From d8dc316cd4ac1b243223b05629b46f0aa8878c13 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 19:46:11 +0700 Subject: [PATCH 001/115] feat(platform): extract calendar evaluation from casework-core Casework clocks and Scheduling openings evaluate the same mathematical object: working days, holiday revisions, and timezone-correct local instants. Move the evaluator into a new registry-platform-calendar crate so both products share one implementation, and add the weekly-opening expansion Scheduling needs: bounded patterns become concrete half-open UTC intervals, daylight-saving gaps and folds are explicit errors rather than silent shifts, and exception layering makes a blocking closure win over an opening while an authorized reopening adds time back only inside the closure it names. registry-casework-core re-exports the platform crate through its flat glob, so its public surface is unchanged. CalendarEvaluationError keeps every original variant verbatim and gains the weekly diagnostics; it loses Copy because the new variants carry identifiers. Signed-off-by: Jeremi Joslin --- Cargo.lock | 10 + Cargo.toml | 2 + crates/registry-casework-core/Cargo.toml | 1 + crates/registry-casework-core/src/lib.rs | 3 +- crates/registry-platform-calendar/Cargo.toml | 18 + crates/registry-platform-calendar/README.md | 21 + crates/registry-platform-calendar/src/lib.rs | 129 +++ .../registry-platform-calendar/src/weekly.rs | 891 ++++++++++++++++++ .../src/working_day.rs} | 78 +- 9 files changed, 1079 insertions(+), 74 deletions(-) create mode 100644 crates/registry-platform-calendar/Cargo.toml create mode 100644 crates/registry-platform-calendar/README.md create mode 100644 crates/registry-platform-calendar/src/lib.rs create mode 100644 crates/registry-platform-calendar/src/weekly.rs rename crates/{registry-casework-core/src/calendar.rs => registry-platform-calendar/src/working_day.rs} (85%) diff --git a/Cargo.lock b/Cargo.lock index 91e1bd4c16..b8a47c1676 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5127,6 +5127,7 @@ dependencies = [ "chrono", "chrono-tz", "jsonschema", + "registry-platform-calendar", "registry-platform-canonical-json", "registry-platform-httputil", "registry-review-protocol", @@ -5665,6 +5666,15 @@ dependencies = [ name = "registry-platform-buildinfo" version = "0.32.0" +[[package]] +name = "registry-platform-calendar" +version = "0.31.0" +dependencies = [ + "chrono", + "chrono-tz", + "thiserror 2.0.20", +] + [[package]] name = "registry-platform-canonical-json" version = "0.32.0" diff --git a/Cargo.toml b/Cargo.toml index fc23ccbff7..33dfe362a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/registry-platform-audit", "crates/registry-platform-authcommon", "crates/registry-platform-buildinfo", + "crates/registry-platform-calendar", "crates/registry-platform-canonical-json", "crates/registry-platform-config", "crates/registry-platform-crypto", @@ -122,6 +123,7 @@ registry-stack-client = { path = "crates/registry-stack-client", version = "0.32 registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.32.0" } registry-platform-authcommon = { path = "crates/registry-platform-authcommon", version = "0.32.0" } registry-platform-buildinfo = { path = "crates/registry-platform-buildinfo", version = "0.32.0" } +registry-platform-calendar = { path = "crates/registry-platform-calendar", version = "0.32.0" } registry-cli-reference = { path = "crates/registry-cli-reference", version = "0.32.0" } registry-platform-canonical-json = { path = "crates/registry-platform-canonical-json", version = "0.32.0" } registry-platform-config = { path = "crates/registry-platform-config", version = "0.32.0" } diff --git a/crates/registry-casework-core/Cargo.toml b/crates/registry-casework-core/Cargo.toml index 7b84bfd64c..ac9cf7abe6 100644 --- a/crates/registry-casework-core/Cargo.toml +++ b/crates/registry-casework-core/Cargo.toml @@ -18,6 +18,7 @@ async-trait.workspace = true chrono = { workspace = true, features = ["serde"] } chrono-tz.workspace = true jsonschema.workspace = true +registry-platform-calendar.workspace = true registry-platform-canonical-json.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-casework-core/src/lib.rs b/crates/registry-casework-core/src/lib.rs index 7dc41f8b7c..2102974389 100644 --- a/crates/registry-casework-core/src/lib.rs +++ b/crates/registry-casework-core/src/lib.rs @@ -7,7 +7,6 @@ mod adapter; mod assignment; mod attempt_settlement; -mod calendar; mod clock_runtime; mod config; mod http; @@ -23,12 +22,12 @@ mod transition; pub use adapter::*; pub use assignment::*; pub use attempt_settlement::*; -pub use calendar::*; pub use clock_runtime::*; pub use config::*; pub use http::*; pub use model::*; pub use policy::*; +pub use registry_platform_calendar::*; pub use registry_review_protocol as review_protocol; pub use registry_review_protocol::*; pub use review::*; diff --git a/crates/registry-platform-calendar/Cargo.toml b/crates/registry-platform-calendar/Cargo.toml new file mode 100644 index 0000000000..b31587f968 --- /dev/null +++ b/crates/registry-platform-calendar/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "registry-platform-calendar" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Timezone-correct working-day and opening-pattern calendar evaluation for Registry Stack." +repository.workspace = true +readme = "README.md" +publish = false + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +chrono-tz.workspace = true +thiserror.workspace = true diff --git a/crates/registry-platform-calendar/README.md b/crates/registry-platform-calendar/README.md new file mode 100644 index 0000000000..b79947b02e --- /dev/null +++ b/crates/registry-platform-calendar/README.md @@ -0,0 +1,21 @@ +# Registry Platform Calendar + +`registry-platform-calendar` is the shared Registry Stack implementation of +timezone-correct calendar evaluation. It takes calendar identifiers, named IANA +timezones, pinned holiday content, and bounded arithmetic, and returns exact +instants or intervals. It performs no input or output and holds no product +semantics, so the same evaluator serves any runtime that must derive a +commitment-bearing time from authored calendar content. + +Two evaluators live here. The working-day evaluator shifts a date across +working weekdays and holidays to a local `dueTime`, as used by Registry +Casework clocks. The weekly-opening evaluator expands a bounded weekly pattern +into concrete half-open UTC intervals, with exception layering where a blocking +closure wins over an ordinary opening and an exceptional reopening requires +explicit authority, names the closure it reopens, and stays inside it. + +A local time that falls in a daylight-saving gap or fold is an explicit error, +never a silent shift to the nearest instant, so no caller can derive an +invisible duplicate or shifted commitment. An interval that spans a transition +keeps its real elapsed length. Authored spans are bounded to ten years so +expansion cannot run away on mistyped effective dates. diff --git a/crates/registry-platform-calendar/src/lib.rs b/crates/registry-platform-calendar/src/lib.rs new file mode 100644 index 0000000000..30dadd8326 --- /dev/null +++ b/crates/registry-platform-calendar/src/lib.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Timezone-correct calendar evaluation for Registry Stack. +//! +//! The crate deliberately contains no I/O and no product semantics: a +//! calendar id, a named IANA timezone, pinned holiday content, and bounded +//! arithmetic in, exact instants or intervals out. Daylight-saving gaps and +//! ambiguous local times are explicit errors, never silent shifts, so a +//! caller can never derive an invisible duplicate or shifted commitment. +//! +//! Two evaluators live here: +//! +//! - [`crate::working_day`]: working-day deadlines pinned to a calendar and +//! holiday-set revision, as used by Registry Casework clocks. +//! - [`crate::weekly`]: expansion of a bounded weekly opening pattern into +//! concrete half-open UTC intervals, with exception layering where a +//! blocking closure wins over an ordinary opening and an exceptional +//! reopening requires explicit authority. + +mod weekly; +mod working_day; + +pub use weekly::*; +pub use working_day::*; + +use chrono::{DateTime, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; +use chrono_tz::Tz; + +/// A calendar evaluation refused the input instead of guessing. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum CalendarEvaluationError { + #[error("the calendar id is empty")] + EmptyCalendarId, + #[error("the holiday-set id is empty")] + EmptyHolidaySetId, + #[error("the holiday-set revision must be positive")] + InvalidHolidayRevision, + #[error("the calendar has no working weekdays")] + NoWorkingWeekdays, + #[error("the calendar timezone is not a named IANA timezone")] + InvalidTimezone, + #[error("holiday date at index {index} is not a valid YYYY-MM-DD date")] + InvalidHolidayDate { index: usize }, + #[error("dueTime is not a valid HH:MM local time")] + InvalidDueTime, + #[error("after.workingDays must be between 1 and 3650")] + InvalidWorkingDayOffset, + #[error("atRisk.workingDaysBefore must be between 1 and 3650")] + InvalidWarningOffset, + #[error("reminders[].workingDaysBefore must be between 1 and 3650")] + InvalidReminderOffset, + #[error("the calculated local due time does not exist in the calendar timezone")] + NonexistentLocalTime, + #[error("the calculated local due time is ambiguous in the calendar timezone")] + AmbiguousLocalTime, + #[error("working-day calendar arithmetic overflowed")] + Overflow, + #[error("the opening-pattern id is empty")] + EmptyPatternId, + #[error("the opening pattern has no weekdays")] + NoPatternWeekdays, + #[error("opening-pattern times must be valid HH:MM local values with start before end")] + InvalidPatternTimes, + #[error("opening-pattern date at index {index} is not a valid YYYY-MM-DD date")] + InvalidPatternDate { index: usize }, + #[error("the opening-pattern effective range must have from on or before until")] + InvalidEffectiveRange, + #[error("the opening-pattern effective range spans more than 3650 days")] + PatternSpanTooLarge, + #[error("an exception id is empty")] + EmptyExceptionId, + #[error("exception times must be valid HH:MM local values with start before end")] + InvalidExceptionTimes, + #[error("exception date for {id} is not a valid YYYY-MM-DD date")] + InvalidExceptionDate { id: String }, + #[error("the exception id {id} appears more than once")] + DuplicateExceptionId { id: String }, + #[error("the blocking closure {id} carries reopening fields")] + ClosureCarriesReopeningFields { id: String }, + #[error("the exceptional reopening {id} does not name an existing closure")] + UnknownReopenedClosure { id: String }, + #[error("the exceptional reopening {id} has no explicit authority")] + ReopenAuthorityMissing { id: String }, + #[error("the exceptional reopening {id} is not contained in the closure it reopens")] + ReopenOutsideClosure { id: String }, + #[error("the exceptional reopening {id} overlaps a closure other than the one it reopens")] + ReopenOverlapsUnrelatedClosure { id: String }, +} + +fn parse_date(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 10 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes + .iter() + .enumerate() + .any(|(index, byte)| index != 4 && index != 7 && !byte.is_ascii_digit()) + { + return None; + } + NaiveDate::parse_from_str(value, "%Y-%m-%d").ok() +} + +fn parse_hh_mm(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 5 + || bytes[2] != b':' + || bytes + .iter() + .enumerate() + .any(|(index, byte)| index != 2 && !byte.is_ascii_digit()) + { + return None; + } + NaiveTime::parse_from_str(value, "%H:%M").ok() +} + +fn local_instant( + date: NaiveDate, + time: NaiveTime, + timezone: Tz, +) -> Result, CalendarEvaluationError> { + match timezone.from_local_datetime(&NaiveDateTime::new(date, time)) { + LocalResult::Single(value) => Ok(value.with_timezone(&Utc)), + LocalResult::None => Err(CalendarEvaluationError::NonexistentLocalTime), + LocalResult::Ambiguous(_, _) => Err(CalendarEvaluationError::AmbiguousLocalTime), + } +} diff --git a/crates/registry-platform-calendar/src/weekly.rs b/crates/registry-platform-calendar/src/weekly.rs new file mode 100644 index 0000000000..720e588329 --- /dev/null +++ b/crates/registry-platform-calendar/src/weekly.rs @@ -0,0 +1,891 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Expansion of a bounded weekly opening pattern into concrete intervals. + +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveTime, Utc, Weekday}; +use chrono_tz::Tz; + +use crate::{local_instant, parse_date, parse_hh_mm, CalendarEvaluationError, HolidaySetRevision}; + +/// Ten years is also the maximum authored opening-pattern span. +pub const MAXIMUM_PATTERN_SPAN_DAYS: u32 = 3_650; + +/// A bounded weekly opening pattern evaluated in a named IANA timezone. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WeeklyOpeningPattern<'a> { + pub id: &'a str, + /// A named IANA timezone such as `Asia/Bangkok`. + pub timezone: &'a str, + pub weekdays: &'a [Weekday], + /// Strict local wall time in `HH:MM` form. + pub start_time: &'a str, + /// Strict local wall time in `HH:MM` form, after `start_time` on the same + /// local date. Patterns do not cross midnight. + pub end_time: &'a str, + /// Strict ISO calendar date in `YYYY-MM-DD` form, inclusive. + pub effective_from: &'a str, + /// Strict ISO calendar date in `YYYY-MM-DD` form, inclusive. + pub effective_until: &'a str, +} + +/// One dated exception over local wall-clock time in the pattern's timezone. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CalendarException<'a> { + pub id: &'a str, + pub kind: CalendarExceptionKind, + /// Strict ISO calendar date in `YYYY-MM-DD` form. + pub date: &'a str, + /// Strict local wall time in `HH:MM` form. + pub start_time: &'a str, + /// Strict local wall time in `HH:MM` form, after `start_time` on the same + /// local date. + pub end_time: &'a str, + /// The closure an exceptional reopening reopens. Required when `kind` is + /// [`CalendarExceptionKind::Opening`], absent otherwise. + pub reopens: Option<&'a str>, + /// Explicit authority for an exceptional reopening. Required when `kind` + /// is [`CalendarExceptionKind::Opening`], absent otherwise. + pub authority: Option<&'a str>, +} + +/// The layer an exception occupies. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CalendarExceptionKind { + /// A blocking closure. It wins over an ordinary opening and over every + /// pattern-generated interval it overlaps. + Closure, + /// An exceptional reopening. It requires explicit authority, must sit + /// inside the one closure it names, and may not overlap any other + /// closure, so it can never silently override unrelated closures. + Opening, +} + +/// A half-open UTC interval `[start, end)`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CalendarInterval { + pub start: DateTime, + pub end: DateTime, +} + +/// Expand a bounded weekly opening pattern into sorted, merged half-open UTC +/// intervals without reading mutable calendar or runtime state. +/// +/// Layering: a holiday date produces no interval; a blocking closure removes +/// opening time; an exceptional reopening adds time back only inside the +/// closure it names and only under explicit authority. A local start or end +/// instant that falls in a daylight-saving gap or fold is an explicit error, +/// never a silent shift; an interval that spans a transition (for example +/// `01:00` to `03:00` across a spring-forward jump) keeps its real elapsed +/// length, so no invisible duplicate or shifted commitment appears. +pub fn expand_weekly_openings( + pattern: &WeeklyOpeningPattern<'_>, + exceptions: &[CalendarException<'_>], + holiday_revision: &HolidaySetRevision<'_>, +) -> Result, CalendarEvaluationError> { + if pattern.id.trim().is_empty() { + return Err(CalendarEvaluationError::EmptyPatternId); + } + if pattern.weekdays.is_empty() { + return Err(CalendarEvaluationError::NoPatternWeekdays); + } + let timezone: Tz = pattern + .timezone + .parse() + .map_err(|_| CalendarEvaluationError::InvalidTimezone)?; + let start_time = + parse_hh_mm(pattern.start_time).ok_or(CalendarEvaluationError::InvalidPatternTimes)?; + let end_time = + parse_hh_mm(pattern.end_time).ok_or(CalendarEvaluationError::InvalidPatternTimes)?; + if start_time >= end_time { + return Err(CalendarEvaluationError::InvalidPatternTimes); + } + let effective_from = parse_date(pattern.effective_from) + .ok_or(CalendarEvaluationError::InvalidPatternDate { index: 0 })?; + let effective_until = parse_date(pattern.effective_until) + .ok_or(CalendarEvaluationError::InvalidPatternDate { index: 1 })?; + if effective_from > effective_until { + return Err(CalendarEvaluationError::InvalidEffectiveRange); + } + if effective_until + .signed_duration_since(effective_from) + .num_days() + > i64::from(MAXIMUM_PATTERN_SPAN_DAYS) + { + return Err(CalendarEvaluationError::PatternSpanTooLarge); + } + + if holiday_revision.holiday_set.trim().is_empty() { + return Err(CalendarEvaluationError::EmptyHolidaySetId); + } + if holiday_revision.revision == 0 { + return Err(CalendarEvaluationError::InvalidHolidayRevision); + } + let holidays: BTreeSet = holiday_revision + .dates + .iter() + .enumerate() + .map(|(index, value)| { + parse_date(value).ok_or(CalendarEvaluationError::InvalidHolidayDate { index }) + }) + .collect::>()?; + + let exceptions_by_id = parse_exceptions(exceptions, timezone)?; + validate_layers(&exceptions_by_id)?; + + let weekdays: BTreeSet = pattern + .weekdays + .iter() + .map(|weekday| weekday.num_days_from_monday()) + .collect(); + let mut openings = Vec::new(); + let mut date = effective_from; + while date <= effective_until { + if weekdays.contains(&date.weekday().num_days_from_monday()) && !holidays.contains(&date) { + openings.push(day_interval(date, start_time, end_time, timezone)?); + } + date = date + .checked_add_days(Days::new(1)) + .ok_or(CalendarEvaluationError::Overflow)?; + } + + // Closures remove pattern time first; authorized reopenings then add time + // back, so a reopening can never be subtracted by a later closure. + for exception in exceptions_by_id.values() { + if exception.kind == CalendarExceptionKind::Closure { + subtract_interval(&mut openings, exception.interval); + } + } + for exception in exceptions_by_id.values() { + if exception.kind == CalendarExceptionKind::Opening { + openings.push(exception.interval); + } + } + Ok(merge_intervals(openings)) +} + +struct ParsedException<'a> { + kind: CalendarExceptionKind, + interval: CalendarInterval, + reopens: Option<&'a str>, + authority: Option<&'a str>, +} + +fn parse_exceptions<'e>( + exceptions: &[CalendarException<'e>], + timezone: Tz, +) -> Result>, CalendarEvaluationError> { + let mut parsed = BTreeMap::new(); + for exception in exceptions { + if exception.id.trim().is_empty() { + return Err(CalendarEvaluationError::EmptyExceptionId); + } + let interval = parse_exception_times(exception, timezone)?; + if parsed + .insert( + exception.id.to_owned(), + ParsedException { + kind: exception.kind, + interval, + reopens: exception.reopens, + authority: exception.authority, + }, + ) + .is_some() + { + return Err(CalendarEvaluationError::DuplicateExceptionId { + id: exception.id.to_owned(), + }); + } + } + Ok(parsed) +} + +fn parse_exception_times( + exception: &CalendarException<'_>, + timezone: Tz, +) -> Result { + let date = parse_date(exception.date).ok_or(CalendarEvaluationError::InvalidExceptionDate { + id: exception.id.to_owned(), + })?; + let start = + parse_hh_mm(exception.start_time).ok_or(CalendarEvaluationError::InvalidExceptionTimes)?; + let end = + parse_hh_mm(exception.end_time).ok_or(CalendarEvaluationError::InvalidExceptionTimes)?; + if start >= end { + return Err(CalendarEvaluationError::InvalidExceptionTimes); + } + day_interval(date, start, end, timezone) +} + +/// Enforce the exception layering rules across the parsed exceptions. +fn validate_layers( + exceptions_by_id: &BTreeMap>, +) -> Result<(), CalendarEvaluationError> { + for (id, exception) in exceptions_by_id { + match exception.kind { + CalendarExceptionKind::Closure => { + if exception.reopens.is_some() || exception.authority.is_some() { + return Err(CalendarEvaluationError::ClosureCarriesReopeningFields { + id: id.clone(), + }); + } + } + CalendarExceptionKind::Opening => { + let named = exception + .reopens + .and_then(|reopens| exceptions_by_id.get(reopens)) + .filter(|named| named.kind == CalendarExceptionKind::Closure) + .ok_or_else(|| CalendarEvaluationError::UnknownReopenedClosure { + id: id.clone(), + })?; + if exception + .authority + .is_none_or(|authority| authority.trim().is_empty()) + { + return Err(CalendarEvaluationError::ReopenAuthorityMissing { id: id.clone() }); + } + if exception.interval.start < named.interval.start + || exception.interval.end > named.interval.end + { + return Err(CalendarEvaluationError::ReopenOutsideClosure { id: id.clone() }); + } + let strays = exceptions_by_id.iter().any(|(other_id, other)| { + other.kind == CalendarExceptionKind::Closure + && Some(other_id.as_str()) != exception.reopens + && exception.interval.start < other.interval.end + && other.interval.start < exception.interval.end + }); + if strays { + return Err(CalendarEvaluationError::ReopenOverlapsUnrelatedClosure { + id: id.clone(), + }); + } + } + } + } + Ok(()) +} + +fn day_interval( + date: NaiveDate, + start: NaiveTime, + end: NaiveTime, + timezone: Tz, +) -> Result { + Ok(CalendarInterval { + start: local_instant(date, start, timezone)?, + end: local_instant(date, end, timezone)?, + }) +} + +/// Remove `closure` from every interval in `openings`, splitting as needed. +fn subtract_interval(openings: &mut Vec, closure: CalendarInterval) { + let mut result = Vec::with_capacity(openings.len() + 1); + for interval in openings.drain(..) { + let overlaps = interval.start < closure.end && closure.start < interval.end; + if !overlaps { + result.push(interval); + continue; + } + if interval.start < closure.start { + result.push(CalendarInterval { + start: interval.start, + end: closure.start, + }); + } + if closure.end < interval.end { + result.push(CalendarInterval { + start: closure.end, + end: interval.end, + }); + } + } + *openings = result; +} + +/// Merge overlapping or adjacent half-open intervals into a canonical form. +fn merge_intervals(mut intervals: Vec) -> Vec { + intervals.sort_by_key(|interval| (interval.start, interval.end)); + let mut merged: Vec = Vec::new(); + for interval in intervals { + match merged.last_mut() { + Some(last) if interval.start <= last.end => { + last.end = last.end.max(interval.end); + } + _ => merged.push(interval), + } + } + merged +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + const WEEKDAYS: &[Weekday] = &[ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + ]; + const ALL_WEEKDAYS: &[Weekday] = &[ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + Weekday::Sat, + Weekday::Sun, + ]; + + fn instant(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .unwrap() + .with_timezone(&Utc) + } + + fn utc(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(year, month, day, hour, minute, 0) + .unwrap() + } + + fn pattern<'a>( + timezone: &'a str, + from: &'a str, + until: &'a str, + start_time: &'a str, + end_time: &'a str, + ) -> WeeklyOpeningPattern<'a> { + pattern_with(WEEKDAYS, timezone, from, until, start_time, end_time) + } + + fn pattern_with<'a>( + weekdays: &'a [Weekday], + timezone: &'a str, + from: &'a str, + until: &'a str, + start_time: &'a str, + end_time: &'a str, + ) -> WeeklyOpeningPattern<'a> { + WeeklyOpeningPattern { + id: "counter-hours", + timezone, + weekdays, + start_time, + end_time, + effective_from: from, + effective_until: until, + } + } + + fn holidays<'a>(dates: &'a [&'a str]) -> HolidaySetRevision<'a> { + HolidaySetRevision { + holiday_set: "office-holidays", + revision: 7, + dates, + } + } + + fn closure<'a>( + id: &'a str, + date: &'a str, + start_time: &'a str, + end_time: &'a str, + ) -> CalendarException<'a> { + CalendarException { + id, + kind: CalendarExceptionKind::Closure, + date, + start_time, + end_time, + reopens: None, + authority: None, + } + } + + fn reopening<'a>( + id: &'a str, + reopens: &'a str, + authority: &'a str, + date: &'a str, + start_time: &'a str, + end_time: &'a str, + ) -> CalendarException<'a> { + CalendarException { + id, + kind: CalendarExceptionKind::Opening, + date, + start_time, + end_time, + reopens: Some(reopens), + authority: Some(authority), + } + } + + fn expand( + pattern: &WeeklyOpeningPattern<'_>, + exceptions: &[CalendarException<'_>], + dates: &[&str], + ) -> Result, CalendarEvaluationError> { + expand_weekly_openings(pattern, exceptions, &holidays(dates)) + } + + #[test] + fn weekday_pattern_expands_to_half_open_instants() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-09", "09:00", "12:00"), + &[], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![ + CalendarInterval { + start: instant("2026-10-05T09:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-06T09:00:00+07:00"), + end: instant("2026-10-06T12:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-07T09:00:00+07:00"), + end: instant("2026-10-07T12:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-08T09:00:00+07:00"), + end: instant("2026-10-08T12:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-09T09:00:00+07:00"), + end: instant("2026-10-09T12:00:00+07:00"), + }, + ] + ); + } + + #[test] + fn holiday_dates_produce_no_interval() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-07", "09:00", "12:00"), + &[], + &["2026-10-06"], + ) + .unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].start, instant("2026-10-05T09:00:00+07:00")); + assert_eq!(result[1].start, instant("2026-10-07T09:00:00+07:00")); + } + + #[test] + fn daylight_saving_gap_and_fold_are_explicit_errors() { + // America/New_York springs forward on 2026-03-08: 02:00 becomes 03:00. + let gap = expand( + &pattern_with( + ALL_WEEKDAYS, + "America/New_York", + "2026-03-08", + "2026-03-08", + "02:00", + "02:30", + ), + &[], + &[], + ); + assert_eq!(gap, Err(CalendarEvaluationError::NonexistentLocalTime)); + + // America/New_York falls back on 2026-11-01: 01:30 occurs twice. + let fold = expand( + &pattern_with( + ALL_WEEKDAYS, + "America/New_York", + "2026-11-01", + "2026-11-01", + "01:30", + "02:00", + ), + &[], + &[], + ); + assert_eq!(fold, Err(CalendarEvaluationError::AmbiguousLocalTime)); + } + + #[test] + fn an_interval_spanning_a_transition_keeps_real_elapsed_time() { + // 01:00 to 03:00 across the 2026-03-08 spring-forward jump is one + // real hour, and both wall-clock endpoints exist. + let result = expand( + &pattern_with( + ALL_WEEKDAYS, + "America/New_York", + "2026-03-08", + "2026-03-08", + "01:00", + "03:00", + ), + &[], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-03-08T01:00:00-05:00"), + end: instant("2026-03-08T03:00:00-04:00"), + }] + ); + assert_eq!((result[0].end - result[0].start).num_minutes(), 60); + } + + #[test] + fn a_blocking_closure_wins_over_an_ordinary_opening() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[closure("staff-meeting", "2026-10-05", "09:00", "10:00")], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-10-05T10:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }] + ); + + // A closure that swallows the whole opening removes it entirely. + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[closure("all-day", "2026-10-05", "08:00", "17:00")], + &[], + ) + .unwrap(); + assert_eq!(result, Vec::::new()); + + // A closure inside the opening splits it in two, never truncates or + // shifts silently. + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[closure("midday", "2026-10-05", "10:00", "11:00")], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![ + CalendarInterval { + start: instant("2026-10-05T09:00:00+07:00"), + end: instant("2026-10-05T10:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-05T11:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }, + ] + ); + } + + #[test] + fn closures_do_not_touch_time_outside_the_opening() { + // A closure outside pattern hours removes nothing and adds nothing. + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[closure("early", "2026-10-05", "07:00", "09:00")], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-10-05T09:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }] + ); + } + + #[test] + fn a_closure_may_not_carry_reopening_fields() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + let mut stray = closure("odd", "2026-10-05", "09:00", "10:00"); + stray.reopens = Some("other"); + assert_eq!( + expand(&base, &[stray], &[]), + Err(CalendarEvaluationError::ClosureCarriesReopeningFields { + id: "odd".to_owned() + }) + ); + } + + #[test] + fn an_exceptional_reopening_requires_authority_and_a_named_closure() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + let staff_meeting = closure("staff-meeting", "2026-10-05", "09:00", "10:00"); + + let mut no_authority = reopening( + "re-open", + "staff-meeting", + "", + "2026-10-05", + "09:15", + "09:45", + ); + no_authority.authority = Some(" "); + assert_eq!( + expand(&base, &[staff_meeting, no_authority], &[]), + Err(CalendarEvaluationError::ReopenAuthorityMissing { + id: "re-open".to_owned() + }) + ); + + assert_eq!( + expand( + &base, + &[ + staff_meeting, + reopening( + "re-open", + "unknown", + "duty-officer", + "2026-10-05", + "09:15", + "09:45" + ) + ], + &[] + ), + Err(CalendarEvaluationError::UnknownReopenedClosure { + id: "re-open".to_owned() + }) + ); + + // Naming another reopening, not a closure, is refused the same way. + assert_eq!( + expand( + &base, + &[ + staff_meeting, + reopening( + "first", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:15", + "09:45" + ), + reopening( + "second", + "first", + "duty-officer", + "2026-10-05", + "09:15", + "09:45" + ), + ], + &[] + ), + Err(CalendarEvaluationError::UnknownReopenedClosure { + id: "second".to_owned() + }) + ); + } + + #[test] + fn an_exceptional_reopening_stays_inside_its_closure() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + assert_eq!( + expand( + &base, + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:30", + "10:30" + ) + ], + &[] + ), + Err(CalendarEvaluationError::ReopenOutsideClosure { + id: "re-open".to_owned() + }) + ); + } + + #[test] + fn an_exceptional_reopening_cannot_override_an_unrelated_closure() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + assert_eq!( + expand( + &base, + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + closure("training", "2026-10-05", "09:40", "10:20"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:30", + "10:00" + ) + ], + &[] + ), + Err(CalendarEvaluationError::ReopenOverlapsUnrelatedClosure { + id: "re-open".to_owned() + }) + ); + } + + #[test] + fn an_authorized_reopening_adds_time_back_inside_its_closure() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:15", + "09:45", + ), + ], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![ + CalendarInterval { + start: instant("2026-10-05T09:15:00+07:00"), + end: instant("2026-10-05T09:45:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-05T10:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }, + ] + ); + } + + #[test] + fn pattern_metadata_and_bounds_are_reported_before_arithmetic() { + let mut base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-09", "09:00", "12:00"); + + base.id = " "; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::EmptyPatternId) + ); + base.id = "counter-hours"; + base.weekdays = &[]; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::NoPatternWeekdays) + ); + base.weekdays = WEEKDAYS; + base.timezone = "Not/A_Zone"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::InvalidTimezone) + ); + base.timezone = "Asia/Bangkok"; + base.end_time = "09:00"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::InvalidPatternTimes) + ); + base.end_time = "12:00"; + base.effective_from = "2026-10-06"; + base.effective_until = "2026-10-05"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::InvalidEffectiveRange) + ); + base.effective_from = "2026-01-01"; + base.effective_until = "2036-01-02"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::PatternSpanTooLarge) + ); + base.effective_from = "2026-13-01"; + base.effective_until = "2026-10-09"; + assert_eq!( + expand(&base, &[], &[]), + Err(CalendarEvaluationError::InvalidPatternDate { index: 0 }) + ); + } + + #[test] + fn exception_identifiers_and_times_are_validated() { + let base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"); + + let empty_id = closure(" ", "2026-10-05", "09:00", "10:00"); + assert_eq!( + expand(&base, &[empty_id], &[]), + Err(CalendarEvaluationError::EmptyExceptionId) + ); + + assert_eq!( + expand( + &base, + &[ + closure("a", "2026-10-05", "09:00", "10:00"), + closure("a", "2026-10-06", "09:00", "10:00"), + ], + &[] + ), + Err(CalendarEvaluationError::DuplicateExceptionId { id: "a".to_owned() }) + ); + + assert_eq!( + expand(&base, &[closure("a", "2026-02-30", "09:00", "10:00")], &[]), + Err(CalendarEvaluationError::InvalidExceptionDate { id: "a".to_owned() }) + ); + + assert_eq!( + expand(&base, &[closure("a", "2026-10-05", "10:00", "09:00")], &[]), + Err(CalendarEvaluationError::InvalidExceptionTimes) + ); + } + + #[test] + fn adjacent_intervals_merge_into_canonical_form() { + let mut merged = merge_intervals(vec![ + CalendarInterval { + start: utc(2026, 10, 5, 3, 0), + end: utc(2026, 10, 5, 4, 0), + }, + CalendarInterval { + start: utc(2026, 10, 5, 4, 0), + end: utc(2026, 10, 5, 5, 0), + }, + ]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].end, utc(2026, 10, 5, 5, 0)); + + merged = merge_intervals(vec![ + CalendarInterval { + start: utc(2026, 10, 5, 4, 30), + end: utc(2026, 10, 5, 6, 0), + }, + CalendarInterval { + start: utc(2026, 10, 5, 3, 0), + end: utc(2026, 10, 5, 5, 0), + }, + ]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].start, utc(2026, 10, 5, 3, 0)); + assert_eq!(merged[0].end, utc(2026, 10, 5, 6, 0)); + } +} diff --git a/crates/registry-casework-core/src/calendar.rs b/crates/registry-platform-calendar/src/working_day.rs similarity index 85% rename from crates/registry-casework-core/src/calendar.rs rename to crates/registry-platform-calendar/src/working_day.rs index baffdb1ccc..e07f2849e7 100644 --- a/crates/registry-casework-core/src/calendar.rs +++ b/crates/registry-platform-calendar/src/working_day.rs @@ -1,13 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 +//! Working-day deadlines pinned to calendar and holiday-set content. + use std::collections::BTreeSet; -use chrono::{ - DateTime, Datelike, Days, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc, - Weekday, -}; +use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveTime, Utc, Weekday}; use chrono_tz::Tz; +use crate::{local_instant, parse_date, CalendarEvaluationError}; + /// Ten years is the maximum authored working-day offset evaluated locally. pub const MAXIMUM_WORKING_DAY_OFFSET: u32 = 3_650; @@ -48,36 +49,6 @@ pub struct WorkingDayDeadline { pub reminder_at: Option>, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] -pub enum CalendarEvaluationError { - #[error("the calendar id is empty")] - EmptyCalendarId, - #[error("the holiday-set id is empty")] - EmptyHolidaySetId, - #[error("the holiday-set revision must be positive")] - InvalidHolidayRevision, - #[error("the calendar has no working weekdays")] - NoWorkingWeekdays, - #[error("the calendar timezone is not a named IANA timezone")] - InvalidTimezone, - #[error("holiday date at index {index} is not a valid YYYY-MM-DD date")] - InvalidHolidayDate { index: usize }, - #[error("dueTime is not a valid HH:MM local time")] - InvalidDueTime, - #[error("after.workingDays must be between 1 and 3650")] - InvalidWorkingDayOffset, - #[error("atRisk.workingDaysBefore must be between 1 and 3650")] - InvalidWarningOffset, - #[error("reminders[].workingDaysBefore must be between 1 and 3650")] - InvalidReminderOffset, - #[error("the calculated local due time does not exist in the calendar timezone")] - NonexistentLocalTime, - #[error("the calculated local due time is ambiguous in the calendar timezone")] - AmbiguousLocalTime, - #[error("working-day calendar arithmetic overflowed")] - Overflow, -} - /// Evaluate a working-day deadline without reading mutable calendar or runtime state. /// /// The offset excludes the anchor's local date. Warning and reminder offsets @@ -184,33 +155,8 @@ fn parse_holidays(dates: &[&str]) -> Result, CalendarEvaluat .collect() } -fn parse_date(value: &str) -> Option { - let bytes = value.as_bytes(); - if bytes.len() != 10 - || bytes[4] != b'-' - || bytes[7] != b'-' - || bytes - .iter() - .enumerate() - .any(|(index, byte)| index != 4 && index != 7 && !byte.is_ascii_digit()) - { - return None; - } - NaiveDate::parse_from_str(value, "%Y-%m-%d").ok() -} - fn parse_due_time(value: &str) -> Result { - let bytes = value.as_bytes(); - if bytes.len() != 5 - || bytes[2] != b':' - || bytes - .iter() - .enumerate() - .any(|(index, byte)| index != 2 && !byte.is_ascii_digit()) - { - return Err(CalendarEvaluationError::InvalidDueTime); - } - NaiveTime::parse_from_str(value, "%H:%M").map_err(|_| CalendarEvaluationError::InvalidDueTime) + crate::parse_hh_mm(value).ok_or(CalendarEvaluationError::InvalidDueTime) } #[derive(Clone, Copy)] @@ -290,18 +236,6 @@ fn is_working_date( is_listed_weekday(date, working_weekdays) && !holidays.contains(&date) } -fn local_instant( - date: NaiveDate, - time: NaiveTime, - timezone: Tz, -) -> Result, CalendarEvaluationError> { - match timezone.from_local_datetime(&NaiveDateTime::new(date, time)) { - LocalResult::Single(value) => Ok(value.with_timezone(&Utc)), - LocalResult::None => Err(CalendarEvaluationError::NonexistentLocalTime), - LocalResult::Ambiguous(_, _) => Err(CalendarEvaluationError::AmbiguousLocalTime), - } -} - #[cfg(test)] mod tests { use super::*; From 291b838010774e372cb2f9e2c5664ee685db3f73 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 20:03:32 +0700 Subject: [PATCH 002/115] feat(auth): add scheduling bounds to task grants GrantBounds gains a closed `scheduling` tag whose permissions bind one service and location pair to a bounded action vocabulary, mirroring the existing BREG bounds shape. Existing evidence and breg accessors return None for it, and a scheduling_permissions accessor joins them. Validation: - Threat: grant bounds carry the cross-product authorization payload inside a signed task grant, so a new variant is where semantics could leak from one product's runtime into another's. The variant stays a closed union member: a verifier that does not know the tag rejects it as a malformed bound at authentication and never interprets it, and the Evidence and BREG consumers read bounds only through their accessors, which return None for scheduling grants. - Wire format: inside the signed registry_grant_bounds claim, {"type":"scheduling","permissions":[{"service":...,"location":..., "actions":[...]}]}. No existing claim changes shape; the union gains a tag and nothing else. - Compatibility: issuers on older code cannot mint the tag and verifiers on older code refuse it as malformed, which is the intended fail-closed behavior; no token signed before this change carries scheduling bounds. - Limits: 64 permissions of 32 actions, service and location values <=512 bytes with no whitespace or wildcard, actions in the existing lowercase operation grammar, (service, location) pairs unique; Debug redacts service, location, and action values. - Tests: registry-platform-oidc 81 passed (variant distinctness, cardinality at and over the limits, wildcard, whitespace, and duplicate refusal, scheduling Debug redaction); cargo fmt/check/clippy --workspace --all-targets -D warnings clean; cargo test --workspace green with BREG_TEST_DATABASE_URL set on a disposable PostGIS instance (registry-breg: 53 suites, 373 passed, 0 failed; the casework clock PostgreSQL journey also passes against the extracted calendar); cargo deny check ok for advisories, bans, licenses, and sources. Signed-off-by: Jeremi Joslin --- crates/registry-platform-oidc/README.md | 6 + .../src/authorization_claims.rs | 237 ++++++++++++++++-- 2 files changed, 224 insertions(+), 19 deletions(-) diff --git a/crates/registry-platform-oidc/README.md b/crates/registry-platform-oidc/README.md index 2f41196c0c..19a0a05a25 100644 --- a/crates/registry-platform-oidc/README.md +++ b/crates/registry-platform-oidc/README.md @@ -75,6 +75,12 @@ async fn build_verifier() -> Result> { present. Once present, every core member, the configured approver claim, and both token and grant deadlines are required. Product runtimes still own trusted source-issuer mappings and the supported operation vocabulary. +- Grant bounds are a closed tagged union: `evidence`, `breg`, or `scheduling`. + A verifier built before a variant existed rejects the unknown tag as a + malformed claim, so a grant minted for one product can never be interpreted + by another product's runtime. `SchedulingPermission` values are scoped to + one service and location pair, carry at most 64 permissions of 32 actions, + and never admit wildcards; their `Debug` output redacts every claim value. - Store replay state, authorization decisions, and tenant boundaries in the consuming service. diff --git a/crates/registry-platform-oidc/src/authorization_claims.rs b/crates/registry-platform-oidc/src/authorization_claims.rs index c35d52cdb3..f45060b3e2 100644 --- a/crates/registry-platform-oidc/src/authorization_claims.rs +++ b/crates/registry-platform-oidc/src/authorization_claims.rs @@ -16,6 +16,8 @@ const MAX_CLAIM_VALUE_BYTES: usize = 512; const MAX_PURPOSE_BYTES: usize = 128; const MAX_BREG_PERMISSIONS: usize = 64; const MAX_BREG_OPERATIONS: usize = 32; +const MAX_SCHEDULING_PERMISSIONS: usize = 64; +const MAX_SCHEDULING_ACTIONS: usize = 32; /// Claim naming the assertion authority that signed the exchanged subject token. /// @@ -164,12 +166,56 @@ impl fmt::Debug for BregPermission { } } +/// A bounded Scheduling permission carried inside a signed grant. +#[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SchedulingPermission { + service: String, + location: String, + actions: Vec, +} + +impl SchedulingPermission { + #[must_use] + pub fn service(&self) -> &str { + &self.service + } + + #[must_use] + pub fn location(&self) -> &str { + &self.location + } + + #[must_use] + pub fn actions(&self) -> &[String] { + &self.actions + } +} + +impl fmt::Debug for SchedulingPermission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchedulingPermission") + .field("service", &"") + .field("location", &"") + .field("action_count", &self.actions.len()) + .finish() + } +} + /// Product-specific bounds carried by a task grant. #[derive(Clone, Eq, PartialEq, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum GrantBounds { - Evidence { requirement: String }, - Breg { permissions: Vec }, + Evidence { + requirement: String, + }, + Breg { + permissions: Vec, + }, + Scheduling { + permissions: Vec, + }, } impl GrantBounds { @@ -177,18 +223,26 @@ impl GrantBounds { pub fn evidence_requirement(&self) -> Option<&str> { match self { Self::Evidence { requirement } => Some(requirement), - Self::Breg { .. } => None, + Self::Breg { .. } | Self::Scheduling { .. } => None, } } #[must_use] pub fn breg_permissions(&self) -> Option<&[BregPermission]> { match self { - Self::Evidence { .. } => None, + Self::Evidence { .. } | Self::Scheduling { .. } => None, Self::Breg { permissions } => Some(permissions), } } + #[must_use] + pub fn scheduling_permissions(&self) -> Option<&[SchedulingPermission]> { + match self { + Self::Evidence { .. } | Self::Breg { .. } => None, + Self::Scheduling { permissions } => Some(permissions), + } + } + fn validate(&self) -> Result<(), ClaimError> { match self { Self::Evidence { requirement } => { @@ -218,6 +272,32 @@ impl GrantBounds { } Ok(()) } + Self::Scheduling { permissions } => { + if permissions.is_empty() || permissions.len() > MAX_SCHEDULING_PERMISSIONS { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + let mut scopes = HashSet::with_capacity(permissions.len()); + for permission in permissions { + if !validate_bound_value(&permission.service, MAX_CLAIM_VALUE_BYTES) + || !validate_bound_value(&permission.location, MAX_CLAIM_VALUE_BYTES) + || !scopes + .insert((permission.service.as_str(), permission.location.as_str())) + || permission.actions.is_empty() + || permission.actions.len() > MAX_SCHEDULING_ACTIONS + { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + let mut actions = HashSet::with_capacity(permission.actions.len()); + if permission + .actions + .iter() + .any(|action| !valid_operation(action) || !actions.insert(action.as_str())) + { + return Err(ClaimError::Malformed(ClaimMember::GrantBounds)); + } + } + Ok(()) + } } } } @@ -233,6 +313,10 @@ impl fmt::Debug for GrantBounds { .debug_struct("Breg") .field("permission_count", &permissions.len()) .finish(), + Self::Scheduling { permissions } => formatter + .debug_struct("Scheduling") + .field("permission_count", &permissions.len()) + .finish(), } } } @@ -933,29 +1017,48 @@ mod tests { } #[test] - fn evidence_and_breg_bounds_are_distinct_and_strict() { + fn grant_bounds_variants_are_distinct_and_strict() { let evidence = claims(complete_grant( json!({"type":"evidence","requirement":"urn:requirement:one"}), )); - assert!(matches!( - grant_claims(&evidence, &ClaimNames::default(), 1_500) - .expect("valid") - .expect("present") - .bounds(), - GrantBounds::Evidence { .. } - )); + let evidence_bounds = grant_claims(&evidence, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present") + .bounds() + .clone(); + assert!(matches!(evidence_bounds, GrantBounds::Evidence { .. })); + assert!(evidence_bounds.breg_permissions().is_none()); + assert!(evidence_bounds.scheduling_permissions().is_none()); let breg = claims(complete_grant(json!({ "type":"breg", "permissions":[{"collection":"person.reviewer","operations":["get","patch"]}] }))); - assert!(matches!( - grant_claims(&breg, &ClaimNames::default(), 1_500) - .expect("valid") - .expect("present") - .bounds(), - GrantBounds::Breg { .. } - )); + let breg_bounds = grant_claims(&breg, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present") + .bounds() + .clone(); + assert!(matches!(breg_bounds, GrantBounds::Breg { .. })); + assert!(breg_bounds.evidence_requirement().is_none()); + assert!(breg_bounds.scheduling_permissions().is_none()); + + let scheduling = claims(complete_grant(json!({ + "type":"scheduling", + "permissions":[{ + "service":"urn:service:intake", + "location":"urn:location:north-counter", + "actions":["book","hold","cancel"] + }] + }))); + let scheduling_bounds = grant_claims(&scheduling, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present") + .bounds() + .clone(); + assert!(matches!(scheduling_bounds, GrantBounds::Scheduling { .. })); + assert!(scheduling_bounds.evidence_requirement().is_none()); + assert!(scheduling_bounds.breg_permissions().is_none()); for invalid in [ json!({"type":"evidence","requirement":"*"}), @@ -964,6 +1067,16 @@ mod tests { json!({"type":"breg","permissions":[{"collection":"person.*","operations":["get"]}]}), json!({"type":"breg","permissions":[{"collection":"person.reviewer","operations":[]}]}), json!({"type":"breg","permissions":[{"collection":"person.reviewer","operations":["*"]}]}), + json!({"type":"scheduling","permissions":[]}), + json!({"type":"scheduling","permissions":[{"service":"intake counter","location":"north","actions":["book"]}]}), + json!({"type":"scheduling","permissions":[{"service":"intake*","location":"north","actions":["book"]}]}), + json!({"type":"scheduling","permissions":[{"service":"intake","location":"north","actions":[]}]}), + json!({"type":"scheduling","permissions":[{"service":"intake","location":"north","actions":["Book"]}]}), + json!({"type":"scheduling","permissions":[ + {"service":"intake","location":"north","actions":["book"]}, + {"service":"intake","location":"north","actions":["hold"]} + ]}), + json!({"type":"scheduling","permissions":[{"service":"intake","location":"north","actions":["book","book"]}]}), ] { assert_eq!( grant_claims( @@ -976,6 +1089,69 @@ mod tests { } } + #[test] + fn scheduling_permission_cardinality_bounds_are_enforced() { + let at_limit = json!({ + "type":"scheduling", + "permissions":(0..MAX_SCHEDULING_PERMISSIONS) + .map(|index| json!({ + "service": format!("urn:service:{index}"), + "location": "north", + "actions": (0..MAX_SCHEDULING_ACTIONS) + .map(|action| format!("book.{action}")) + .collect::>() + })) + .collect::>() + }); + assert_eq!( + grant_claims( + &claims(complete_grant(at_limit)), + &ClaimNames::default(), + 1_500 + ) + .map(|grant| grant.is_some()), + Ok(true) + ); + + let over_limit = json!({ + "type":"scheduling", + "permissions":(0..=MAX_SCHEDULING_PERMISSIONS) + .map(|index| json!({ + "service": format!("urn:service:{index}"), + "location": "north", + "actions": ["book"] + })) + .collect::>() + }); + assert_eq!( + grant_claims( + &claims(complete_grant(over_limit)), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + + let over_actions = json!({ + "type":"scheduling", + "permissions":[{ + "service":"urn:service:intake", + "location":"north", + "actions":(0..=MAX_SCHEDULING_ACTIONS) + .map(|action| format!("book.{action}")) + .collect::>() + }] + }); + assert_eq!( + grant_claims( + &claims(complete_grant(over_actions)), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + } + #[test] fn context_binding_uses_normalized_verified_client_and_exact_resource() { let input = claims(complete_grant(json!({ @@ -1046,4 +1222,27 @@ mod tests { assert!(!rendered.contains(canary)); } } + + #[test] + fn scheduling_debug_redacts_service_location_and_actions() { + let input = claims(complete_grant(json!({ + "type":"scheduling", + "permissions":[{ + "service":"sensitive-service-canary", + "location":"sensitive-location-canary", + "actions":["sensitive-action-canary"] + }] + }))); + let grant = grant_claims(&input, &ClaimNames::default(), 1_500) + .expect("valid") + .expect("present"); + let rendered = format!("{:?}", grant.bounds()); + for canary in [ + "sensitive-service-canary", + "sensitive-location-canary", + "sensitive-action-canary", + ] { + assert!(!rendered.contains(canary)); + } + } } From f1e15f02596b03d01d0339539dde4c31f7ea513e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 22:24:22 +0700 Subject: [PATCH 003/115] fix(platform): keep exceptional reopenings inside published hours A reopening restored the closure it named even when that closure reached outside the pattern's own time, so an operator could extend published hours through the exception layer rather than through the reviewed pattern. A reopening now restores only pattern time its closure removed: it must sit inside the pattern's hours on its date, on a pattern weekday, inside the effective range, and on a non-holiday date, or the evaluation refuses with the reopening's identifier. Dated local intervals gain their own conversion with dedicated error variants: local_interval turns one wall-clock interval on a date into a half-open UTC interval in a named timezone, refusing gap and fold instants explicitly, and InvalidIntervalDate and InvalidIntervalTimes replace the exception-scoped variants a placeholder id used to force. Review notes: the reopening tightening changes what an authorized exception may publish, so the tests pin every refused shape (outside pattern hours, reaching past pattern end, holiday date, non-pattern weekday, out of effective range) beside the restored ones (identity reopening of a whole closure, adjacency to an unrelated closure), plus the fold-spanning interval that keeps real elapsed time. Signed-off-by: Jeremi Joslin --- crates/registry-platform-calendar/src/lib.rs | 104 ++++++ .../registry-platform-calendar/src/weekly.rs | 298 +++++++++++++++++- 2 files changed, 385 insertions(+), 17 deletions(-) diff --git a/crates/registry-platform-calendar/src/lib.rs b/crates/registry-platform-calendar/src/lib.rs index 30dadd8326..cda2ea3423 100644 --- a/crates/registry-platform-calendar/src/lib.rs +++ b/crates/registry-platform-calendar/src/lib.rs @@ -85,6 +85,12 @@ pub enum CalendarEvaluationError { ReopenOutsideClosure { id: String }, #[error("the exceptional reopening {id} overlaps a closure other than the one it reopens")] ReopenOverlapsUnrelatedClosure { id: String }, + #[error("the reopening {id} is not inside the opening pattern's time on its date")] + ReopenOutsidePattern { id: String }, + #[error("the interval date is not a valid YYYY-MM-DD date")] + InvalidIntervalDate, + #[error("interval times must be valid HH:MM local values with start before end")] + InvalidIntervalTimes, } fn parse_date(value: &str) -> Option { @@ -127,3 +133,101 @@ fn local_instant( LocalResult::Ambiguous(_, _) => Err(CalendarEvaluationError::AmbiguousLocalTime), } } + +/// Convert one local wall-clock interval on a date into its half-open UTC +/// interval, in a named IANA timezone. +/// +/// A start or end instant that falls in a daylight-saving gap or fold is an +/// explicit error, never a silent shift. This is the record-side companion of +/// the pattern expansion: a caller holding a dated exception as data converts +/// it the same way the expansion would. +pub fn local_interval( + date: &str, + start_time: &str, + end_time: &str, + timezone: &str, +) -> Result { + let zone: Tz = timezone + .parse() + .map_err(|_| CalendarEvaluationError::InvalidTimezone)?; + let date = parse_date(date).ok_or(CalendarEvaluationError::InvalidIntervalDate)?; + let start = parse_hh_mm(start_time).ok_or(CalendarEvaluationError::InvalidIntervalTimes)?; + let end = parse_hh_mm(end_time).ok_or(CalendarEvaluationError::InvalidIntervalTimes)?; + if start >= end { + return Err(CalendarEvaluationError::InvalidIntervalTimes); + } + day_interval(date, start, end, zone) +} + +fn day_interval( + date: NaiveDate, + start: NaiveTime, + end: NaiveTime, + timezone: Tz, +) -> Result { + Ok(CalendarInterval { + start: local_instant(date, start, timezone)?, + end: local_instant(date, end, timezone)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn instant(value: &str) -> DateTime { + value + .parse::>() + .expect("a valid RFC 3339 instant") + } + + #[test] + fn a_local_interval_converts_to_its_half_open_utc_interval() { + let interval = + local_interval("2026-10-05", "09:00", "12:00", "Asia/Bangkok").expect("converts"); + assert_eq!(interval.start, instant("2026-10-05T02:00:00Z")); + assert_eq!(interval.end, instant("2026-10-05T05:00:00Z")); + } + + #[test] + fn a_local_interval_spanning_a_transition_keeps_real_elapsed_time() { + // 00:00 to 02:00 across the 2026-11-01 fall-back is three real hours. + let interval = + local_interval("2026-11-01", "00:00", "02:00", "America/New_York").expect("converts"); + assert_eq!(interval.start, instant("2026-11-01T04:00:00Z")); + assert_eq!(interval.end, instant("2026-11-01T07:00:00Z")); + assert_eq!((interval.end - interval.start).num_minutes(), 180); + } + + #[test] + fn local_interval_endpoints_inside_a_gap_or_fold_are_explicit_errors() { + assert_eq!( + local_interval("2026-03-08", "02:00", "02:30", "America/New_York"), + Err(CalendarEvaluationError::NonexistentLocalTime) + ); + assert_eq!( + local_interval("2026-11-01", "01:30", "02:00", "America/New_York"), + Err(CalendarEvaluationError::AmbiguousLocalTime) + ); + } + + #[test] + fn local_interval_reports_its_own_invalid_inputs() { + assert_eq!( + local_interval("2026-10-5", "09:00", "12:00", "Asia/Bangkok"), + Err(CalendarEvaluationError::InvalidIntervalDate) + ); + assert_eq!( + local_interval("2026-10-05", "9:00", "12:00", "Asia/Bangkok"), + Err(CalendarEvaluationError::InvalidIntervalTimes) + ); + assert_eq!( + local_interval("2026-10-05", "09:00", "12:00", "Not/A_Zone"), + Err(CalendarEvaluationError::InvalidTimezone) + ); + assert_eq!( + local_interval("2026-10-05", "12:00", "09:00", "Asia/Bangkok"), + Err(CalendarEvaluationError::InvalidIntervalTimes) + ); + } +} diff --git a/crates/registry-platform-calendar/src/weekly.rs b/crates/registry-platform-calendar/src/weekly.rs index 720e588329..40fbb06a5f 100644 --- a/crates/registry-platform-calendar/src/weekly.rs +++ b/crates/registry-platform-calendar/src/weekly.rs @@ -4,10 +4,10 @@ use std::collections::{BTreeMap, BTreeSet}; -use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveTime, Utc, Weekday}; +use chrono::{DateTime, Datelike, Days, NaiveDate, Utc, Weekday}; use chrono_tz::Tz; -use crate::{local_instant, parse_date, parse_hh_mm, CalendarEvaluationError, HolidaySetRevision}; +use crate::{day_interval, parse_date, parse_hh_mm, CalendarEvaluationError, HolidaySetRevision}; /// Ten years is also the maximum authored opening-pattern span. pub const MAXIMUM_PATTERN_SPAN_DAYS: u32 = 3_650; @@ -74,7 +74,9 @@ pub struct CalendarInterval { /// /// Layering: a holiday date produces no interval; a blocking closure removes /// opening time; an exceptional reopening adds time back only inside the -/// closure it names and only under explicit authority. A local start or end +/// closure it names, only under explicit authority, and only within the +/// pattern's own hours on its date, so it can restore lost time but never +/// extend the published hours. A local start or end /// instant that falls in a daylight-saving gap or fold is an explicit error, /// never a silent shift; an interval that spans a transition (for example /// `01:00` to `03:00` across a spring-forward jump) keeps its real elapsed @@ -157,9 +159,27 @@ pub fn expand_weekly_openings( subtract_interval(&mut openings, exception.interval); } } - for exception in exceptions_by_id.values() { + for (id, exception) in &exceptions_by_id { if exception.kind == CalendarExceptionKind::Opening { - openings.push(exception.interval); + // A reopening restores pattern time its closure removed; it may + // not extend hours past what the pattern publishes. Outside the + // effective range, on a holiday, or on a non-pattern weekday + // there is no pattern time to restore. + let pattern_day = exception.date >= effective_from + && exception.date <= effective_until + && weekdays.contains(&exception.date.weekday().num_days_from_monday()) + && !holidays.contains(&exception.date); + if pattern_day { + let pattern_interval = + day_interval(exception.date, start_time, end_time, timezone)?; + if exception.interval.start >= pattern_interval.start + && exception.interval.end <= pattern_interval.end + { + openings.push(exception.interval); + continue; + } + } + return Err(CalendarEvaluationError::ReopenOutsidePattern { id: id.clone() }); } } Ok(merge_intervals(openings)) @@ -168,6 +188,7 @@ pub fn expand_weekly_openings( struct ParsedException<'a> { kind: CalendarExceptionKind, interval: CalendarInterval, + date: NaiveDate, reopens: Option<&'a str>, authority: Option<&'a str>, } @@ -182,12 +203,17 @@ fn parse_exceptions<'e>( return Err(CalendarEvaluationError::EmptyExceptionId); } let interval = parse_exception_times(exception, timezone)?; + let date = + parse_date(exception.date).ok_or(CalendarEvaluationError::InvalidExceptionDate { + id: exception.id.to_owned(), + })?; if parsed .insert( exception.id.to_owned(), ParsedException { kind: exception.kind, interval, + date, reopens: exception.reopens, authority: exception.authority, }, @@ -268,18 +294,6 @@ fn validate_layers( Ok(()) } -fn day_interval( - date: NaiveDate, - start: NaiveTime, - end: NaiveTime, - timezone: Tz, -) -> Result { - Ok(CalendarInterval { - start: local_instant(date, start, timezone)?, - end: local_instant(date, end, timezone)?, - }) -} - /// Remove `closure` from every interval in `openings`, splitting as needed. fn subtract_interval(openings: &mut Vec, closure: CalendarInterval) { let mut result = Vec::with_capacity(openings.len() + 1); @@ -778,6 +792,256 @@ mod tests { ); } + #[test] + fn a_reopening_may_not_extend_hours_past_the_pattern() { + // The closure reaches outside pattern hours, so a reopening inside it + // would open 07:30-08:30 as net-new time. A reopening restores lost + // pattern time; it may not extend the published hours. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("building-work", "2026-10-05", "07:00", "12:00"), + reopening( + "early-open", + "building-work", + "duty-officer", + "2026-10-05", + "07:30", + "08:30" + ), + ], + &[], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "early-open".to_owned() + }) + ); + + // A reopening inside pattern hours but reaching past the pattern's + // end through its closure is refused for the same reason. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("late-work", "2026-10-05", "10:00", "13:00"), + reopening( + "late-open", + "late-work", + "duty-officer", + "2026-10-05", + "12:00", + "13:00" + ), + ], + &[], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "late-open".to_owned() + }) + ); + } + + #[test] + fn a_reopening_on_a_holiday_or_a_non_pattern_weekday_is_refused() { + let exceptions = |date: &'static str| { + [ + closure("staff-meeting", date, "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + date, + "09:15", + "09:45", + ), + ] + }; + + // 2026-10-05 is a Monday inside the pattern, but a holiday. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-06", "09:00", "12:00"), + &exceptions("2026-10-05"), + &["2026-10-05"], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "re-open".to_owned() + }) + ); + + // 2026-10-04 is a Sunday, which the pattern does not cover. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-04", "2026-10-06", "09:00", "12:00"), + &exceptions("2026-10-04"), + &[], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "re-open".to_owned() + }) + ); + } + + #[test] + fn a_reopening_dated_outside_the_effective_range_is_refused() { + // 2026-10-12 is a Monday, but the pattern ends on 2026-10-09. + assert_eq!( + expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-09", "09:00", "12:00"), + &[ + closure("staff-meeting", "2026-10-12", "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-12", + "09:15", + "09:45" + ), + ], + &[], + ), + Err(CalendarEvaluationError::ReopenOutsidePattern { + id: "re-open".to_owned() + }) + ); + } + + #[test] + fn the_identity_reopening_restores_the_whole_closure() { + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:00", + "10:00", + ), + ], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-10-05T09:00:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + }] + ); + } + + #[test] + fn a_reopening_adjacent_to_an_unrelated_closure_is_allowed() { + // The reopened closure runs to 10:00, where the unrelated training + // closure begins, so the reopening touches it at its endpoint without + // overlapping it. + let result = expand( + &pattern("Asia/Bangkok", "2026-10-05", "2026-10-05", "09:00", "12:00"), + &[ + closure("staff-meeting", "2026-10-05", "09:00", "10:00"), + closure("training", "2026-10-05", "10:00", "10:30"), + reopening( + "re-open", + "staff-meeting", + "duty-officer", + "2026-10-05", + "09:30", + "10:00", + ), + ], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![ + CalendarInterval { + start: instant("2026-10-05T09:30:00+07:00"), + end: instant("2026-10-05T10:00:00+07:00"), + }, + CalendarInterval { + start: instant("2026-10-05T10:30:00+07:00"), + end: instant("2026-10-05T12:00:00+07:00"), + } + ] + ); + } + + #[test] + fn duplicate_weekday_entries_collapse_to_one_interval() { + let duplicated = pattern_with( + &[Weekday::Mon, Weekday::Mon], + "Asia/Bangkok", + "2026-10-05", + "2026-10-05", + "09:00", + "12:00", + ); + let single = pattern_with( + &[Weekday::Mon], + "Asia/Bangkok", + "2026-10-05", + "2026-10-05", + "09:00", + "12:00", + ); + assert_eq!( + expand(&duplicated, &[], &[]).unwrap(), + expand(&single, &[], &[]).unwrap() + ); + } + + #[test] + fn an_interval_spanning_the_fold_keeps_real_elapsed_time() { + // 00:00 to 02:00 across the 2026-11-01 fall-back is three real hours: + // the repeated local hour happens twice in UTC. + let result = expand( + &pattern_with( + ALL_WEEKDAYS, + "America/New_York", + "2026-11-01", + "2026-11-01", + "00:00", + "02:00", + ), + &[], + &[], + ) + .unwrap(); + assert_eq!( + result, + vec![CalendarInterval { + start: instant("2026-11-01T00:00:00-04:00"), + end: instant("2026-11-01T02:00:00-05:00"), + }] + ); + assert_eq!((result[0].end - result[0].start).num_minutes(), 180); + } + + #[test] + fn the_exactly_3650_day_span_is_within_bounds() { + // 2020-01-01 plus 3650 days is 2029-12-28; the bound rejects only + // spans strictly beyond it. + let result = expand( + &pattern_with( + &[Weekday::Mon], + "Asia/Bangkok", + "2020-01-01", + "2029-12-28", + "09:00", + "12:00", + ), + &[], + &[], + ); + assert!(result.is_ok()); + } + #[test] fn pattern_metadata_and_bounds_are_reported_before_arithmetic() { let mut base = pattern("Asia/Bangkok", "2026-10-05", "2026-10-09", "09:00", "12:00"); From 83f8b1acf9e3062c3e46ff4bb80d2de0f21e0bfd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 22:24:47 +0700 Subject: [PATCH 004/115] feat(platform): export the scheduling permission and pin its wire form GrantBounds::Scheduling shipped with SchedulingPermission private to the crate, so no relying product could name the permission it verifies. Export it through the crate root and pin the wire form: the scheduling tag round-trips through serde, refuses unknown members at the union and the permission-value level alike, and enforces its value boundaries. The review record for the scheduling bounds moves from the M0b commit message into the crate README, where the BREG and Evidence bounds records already live, so a reviewer of a later change finds the limits and redaction decisions beside the code. Review notes: authorization-relevant but behavior-neutral on the wire. No claim shape changes and no verifier changes; the export makes an existing closed type nameable, and the round-trip test holds the tag spelling so a write-side regression fails before validation runs. Signed-off-by: Jeremi Joslin --- crates/registry-platform-oidc/README.md | 38 ++++++++ .../src/authorization_claims.rs | 95 +++++++++++++++++++ crates/registry-platform-oidc/src/lib.rs | 3 +- 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/crates/registry-platform-oidc/README.md b/crates/registry-platform-oidc/README.md index 19a0a05a25..c6a1adceaf 100644 --- a/crates/registry-platform-oidc/README.md +++ b/crates/registry-platform-oidc/README.md @@ -84,6 +84,44 @@ async fn build_verifier() -> Result> { - Store replay state, authorization decisions, and tenant boundaries in the consuming service. +### Task-grant `scheduling` bounds: review record + +The `scheduling` tag was added to the closed `GrantBounds` union so a task +grant can carry the scheduling permissions a registry booking surface needs. +The four elements of the change: + +- **Threat.** A grant minted for another product being replayed against a + scheduling runtime, or a scheduling grant carrying unbounded, wildcard, or + oversized permission content that a verifier would accept and later fail to + enforce. The closed union answers the first: an unknown tag is a malformed + claim for every verifier, old or new. The per-value bounds answer the + second, and they mirror the BREG bounds exactly (64 permissions, 32 actions + each, 512-byte service and location values, unique `(service, location)` + pairs), so the worst case a signed claim can carry is the same + compositional bound BREG already allows. +- **Wire format.** The claim serializes as `"scheduling": {"permissions": + [{"service": ..., "location": ..., "actions": [...]}]}` with + `deny_unknown_fields` at every level; unknown members are refused, not + ignored. `SchedulingPermission` is exported from the crate root beside + `BregPermission`, so issuers construct scheduling grants programmatically + and relying products can name the type in their signatures. +- **Issuer and verifier compatibility.** Issuers may mint the `scheduling` + tag only once every verifier they target understands it: a verifier built + before this variant rejects the whole grant as malformed (fail-closed, both + for old binaries and for products that never add scheduling support). + Rolling a verifier back to a pre-`scheduling` build therefore hard-fails + scheduling grants; that is the intended direction, but an operator doing a + rollback needs to know it. The first Registry Stack release whose verifier + accepts the tag should be recorded here when the release is cut. +- **Test evidence.** The scheduling-grant tests in `authorization_claims.rs` + cover acceptance of a valid scheduling grant, refusal of unknown fields at + the union and nested-struct levels, the 512/513-byte service and + 128/129-byte action boundary values, the 64-permission and 32-action + limits, unique `(service, location)` enforcement, wildcard refusal, + redacted `Debug` output, round-trip serialization of the tag spelling, and + a downstream case that BREG's binding refuses a scheduling grant because it + carries no BREG permissions. + ## Testing ```sh diff --git a/crates/registry-platform-oidc/src/authorization_claims.rs b/crates/registry-platform-oidc/src/authorization_claims.rs index f45060b3e2..11c72e0ee3 100644 --- a/crates/registry-platform-oidc/src/authorization_claims.rs +++ b/crates/registry-platform-oidc/src/authorization_claims.rs @@ -1152,6 +1152,101 @@ mod tests { ); } + #[test] + fn scheduling_value_boundaries_and_unknown_fields_are_enforced() { + let at_bound = json!({ + "type":"scheduling", + "permissions":[{ + "service":"s".repeat(MAX_CLAIM_VALUE_BYTES), + "location":"l".repeat(MAX_CLAIM_VALUE_BYTES), + "actions":["a".repeat(MAX_PURPOSE_BYTES)] + }] + }); + assert_eq!( + grant_claims( + &claims(complete_grant(at_bound)), + &ClaimNames::default(), + 1_500 + ) + .map(|grant| grant.is_some()), + Ok(true) + ); + + for (over_service, over_location, over_action) in [ + (Some("s".repeat(MAX_CLAIM_VALUE_BYTES + 1)), None, None), + (None, Some("l".repeat(MAX_CLAIM_VALUE_BYTES + 1)), None), + (None, None, Some("a".repeat(MAX_PURPOSE_BYTES + 1))), + ] { + let permission = json!({ + "service": over_service.unwrap_or_else(|| "urn:service:intake".to_owned()), + "location": over_location.unwrap_or_else(|| "north".to_owned()), + "actions": [over_action.unwrap_or_else(|| "book".to_owned())] + }); + assert_eq!( + grant_claims( + &claims(complete_grant(json!({ + "type":"scheduling","permissions":[permission] + }))), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + } + + // Unknown members are refused at the union level and inside a + // permission value alike. + for permission_extra in [ + json!({"type":"scheduling","permissions":[ + {"service":"intake","location":"north","actions":["book"],"extra":true} + ]}), + json!({"type":"scheduling","permissions":[ + {"service":"intake","location":"north","actions":["book"]} + ],"extra":true}), + ] { + assert_eq!( + grant_claims( + &claims(complete_grant(permission_extra)), + &ClaimNames::default(), + 1_500 + ), + Err(ClaimError::Malformed(ClaimMember::GrantBounds)) + ); + } + } + + #[test] + fn the_scheduling_tag_round_trips_through_serde() { + let bounds = GrantBounds::Scheduling { + permissions: vec![SchedulingPermission { + service: "urn:service:intake".to_owned(), + location: "urn:location:north-counter".to_owned(), + actions: vec!["book".to_owned(), "hold.cancel".to_owned()], + }], + }; + let wire = serde_json::to_value(&bounds).expect("serializes"); + assert_eq!( + wire, + json!({ + "type":"scheduling", + "permissions":[{ + "service":"urn:service:intake", + "location":"urn:location:north-counter", + "actions":["book","hold.cancel"] + }] + }) + ); + let parsed = serde_json::from_value::(wire.clone()).expect("round trips"); + assert_eq!(parsed, bounds); + // A write-side regression that changed the tag spelling would stop + // parsing before any validation runs. + assert!(serde_json::from_value::(json!({ + "type":"Scheduling", + "permissions":[{"service":"intake","location":"north","actions":["book"]}] + })) + .is_err()); + } + #[test] fn context_binding_uses_normalized_verified_client_and_exact_resource() { let input = claims(complete_grant(json!({ diff --git a/crates/registry-platform-oidc/src/lib.rs b/crates/registry-platform-oidc/src/lib.rs index 9efa0da3ad..84e7628940 100644 --- a/crates/registry-platform-oidc/src/lib.rs +++ b/crates/registry-platform-oidc/src/lib.rs @@ -9,7 +9,8 @@ mod authorization_claims; pub use authorization_claims::{ actor_kind, grant_claims, ActorKind, BregPermission, ClaimError, ClaimMember, ClaimNames, - GrantBounds, GrantClaims, GrantContextError, MatchedClientError, ASSERTION_ISSUER_CLAIM, + GrantBounds, GrantClaims, GrantContextError, MatchedClientError, SchedulingPermission, + ASSERTION_ISSUER_CLAIM, }; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; From 3c5524ce4bd7aebf582d4c2da1a3ebc9bf878c20 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 22:24:58 +0700 Subject: [PATCH 005/115] test(breg): refuse a scheduling grant in the registry binding The scheduling bounds are a closed union member the BREG binding must never interpret: a task grant carrying them is refused as a malformed bound at authentication, the same fail-closed path as every bound the verifier does not know. Pin that refusal so a future accessor addition cannot silently admit scheduling grants to BREG actions. Signed-off-by: Jeremi Joslin --- crates/registry-breg/src/task_grant/tests.rs | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/registry-breg/src/task_grant/tests.rs b/crates/registry-breg/src/task_grant/tests.rs index 3b08694ad3..966886a194 100644 --- a/crates/registry-breg/src/task_grant/tests.rs +++ b/crates/registry-breg/src/task_grant/tests.rs @@ -187,6 +187,33 @@ fn binding_debug_and_scalar_validation_preserve_privacy_and_exact_subjects() { } } +#[test] +fn a_scheduling_grant_is_refused_by_the_breg_binding() { + // The grant union is closed, so a scheduling grant parses as a grant but + // carries no BREG permissions; the binding refuses it before any + // authorization decision or store access runs. + let scheduling = serde_json::json!({ + "grantId": Uuid::new_v4().to_string(), + "sourceIssuer": "https://casework.test", + "principal": "agent", + "client": "agent-client", + "resource": "urn:breg:test", + "purpose": "review", + "bounds": { + "type": "scheduling", + "permissions": [{ + "service": "urn:service:intake", + "location": "north", + "actions": ["book"] + }] + }, + "subjects": {"subject_reference": "synthetic-subject", "active": true}, + "expiresAt": chrono::Utc::now().timestamp() + 900 + }); + let grant: TaskGrantBinding = serde_json::from_value(scheduling).unwrap(); + assert_eq!(grant.validate(), Err(TaskGrantError::Refused)); +} + #[cfg(feature = "postgres-test")] #[test] fn write_idempotency_separates_grants_without_changing_read_authority() { From 2f1c555ed57bd7d6265412dedacd8ff362e0561e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 22:25:24 +0700 Subject: [PATCH 006/115] feat(scheduling): add the offline core and authoring tooling Registry Scheduling Milestone 1, the offline checkpoint: everything that decides an admission and everything that proves it, with no runtime yet. registry-scheduling-core holds the source-neutral model and the pure evaluators. The authored policy declares services, offerings, opening patterns, holiday sets, published windows, and the hold policy; the environment facts (locations, pools, dated exceptions) stay runtime records the policy references but never embeds, so one published policy governs every environment that resolves its identifiers. The evaluators are total functions of resolved policy, facts, ledger snapshot, request, and observed instant: no clock, no socket, no database. The runtime will call them inside its capacity transaction, the explain endpoint will call them again for an authorized caller, and fixture replay calls them with synthetic facts, so the three cannot disagree about what admits. Every refusal carries two problem codes from the closed 26-code vocabulary: the public one every caller may see and the detailed one only the authorized explain path may see. Exactly one code, resource.unavailable, is detailed-only, because a specific resource's absence can disclose another person's booking or a private staff reason. authorization.refused is an audit reason, never a problem code. Interval arithmetic that leaves representable time refuses as a horizon answer, never a mislabeled capacity refusal and never a clamped buffer that undercounts occupancy; window allocation saturates instead of wrapping past u32. Fixtures replay offline: synthetic facts, a starting ledger, ordered cases with expected outcomes. Replay runs the same evaluators, records admitted cases into the ledger later cases contend against, and refuses a fixture naming a code outside the vocabulary or a detailed-only code before any case runs. Structural accesses are total: a policy whose check did not pass fails the fixture with a typed error naming the case, never a panic. schedulingctl authors and proves: init writes a complete starter project from one of two templates, check validates offline, test replays every fixture, explain publishes what the runtime would serve. check exits zero with findings by default and gains --deny-findings for CI, matching caseworkctl; a fixture run with failing cases always exits nonzero. Reports render in text or JSON with path-addressed diagnostics. products/scheduling carries the dependency-direction gate holding the boundary: the core never references runtime, protocol, or storage types, and no product term leaks into a platform crate. Tests: registry-scheduling-core 71 passed, registry-schedulingctl 22 passed, the template projects init/check/test/explain green end to end; cargo fmt, check, and clippy -D warnings clean across the workspace; cargo test --workspace green with the disposable PostGIS databases set; cargo deny check ok; the scheduling dependency-direction gate passes. Signed-off-by: Jeremi Joslin --- Cargo.lock | 29 +- Cargo.toml | 4 + crates/registry-scheduling-core/Cargo.toml | 23 + .../registry-scheduling-core/src/admission.rs | 1453 ++++++++++++++++ .../src/diagnostics.rs | 124 ++ .../registry-scheduling-core/src/fixture.rs | 1054 ++++++++++++ crates/registry-scheduling-core/src/lib.rs | 38 + crates/registry-scheduling-core/src/model.rs | 636 +++++++ crates/registry-scheduling-core/src/naming.rs | 83 + crates/registry-scheduling-core/src/policy.rs | 1492 +++++++++++++++++ .../registry-scheduling-core/src/problem.rs | 211 +++ crates/registry-scheduling-core/src/units.rs | 350 ++++ crates/registry-schedulingctl/Cargo.toml | 24 + crates/registry-schedulingctl/src/lib.rs | 659 ++++++++ crates/registry-schedulingctl/src/main.rs | 7 + crates/registry-schedulingctl/src/project.rs | 517 ++++++ .../registry-schedulingctl/src/templates.rs | 493 ++++++ products/scheduling/README.md | 23 + .../scripts/check_dependency_direction.py | 158 ++ .../scripts/test_dependency_direction.py | 159 ++ 20 files changed, 7536 insertions(+), 1 deletion(-) create mode 100644 crates/registry-scheduling-core/Cargo.toml create mode 100644 crates/registry-scheduling-core/src/admission.rs create mode 100644 crates/registry-scheduling-core/src/diagnostics.rs create mode 100644 crates/registry-scheduling-core/src/fixture.rs create mode 100644 crates/registry-scheduling-core/src/lib.rs create mode 100644 crates/registry-scheduling-core/src/model.rs create mode 100644 crates/registry-scheduling-core/src/naming.rs create mode 100644 crates/registry-scheduling-core/src/policy.rs create mode 100644 crates/registry-scheduling-core/src/problem.rs create mode 100644 crates/registry-scheduling-core/src/units.rs create mode 100644 crates/registry-schedulingctl/Cargo.toml create mode 100644 crates/registry-schedulingctl/src/lib.rs create mode 100644 crates/registry-schedulingctl/src/main.rs create mode 100644 crates/registry-schedulingctl/src/project.rs create mode 100644 crates/registry-schedulingctl/src/templates.rs create mode 100644 products/scheduling/README.md create mode 100644 products/scheduling/scripts/check_dependency_direction.py create mode 100644 products/scheduling/scripts/test_dependency_direction.py diff --git a/Cargo.lock b/Cargo.lock index b8a47c1676..18c18d2596 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5668,7 +5668,7 @@ version = "0.32.0" [[package]] name = "registry-platform-calendar" -version = "0.31.0" +version = "0.32.0" dependencies = [ "chrono", "chrono-tz", @@ -6088,6 +6088,33 @@ dependencies = [ "uuid", ] +[[package]] +name = "registry-scheduling-core" +version = "0.32.0" +dependencies = [ + "chrono", + "registry-platform-calendar", + "registry-platform-canonical-json", + "serde", + "serde_json", + "serde_norway", + "serde_path_to_error", + "sha2 0.11.0", + "thiserror 2.0.20", +] + +[[package]] +name = "registry-schedulingctl" +version = "0.32.0" +dependencies = [ + "anyhow", + "clap", + "registry-platform-buildinfo", + "registry-scheduling-core", + "serde_json", + "tempfile", +] + [[package]] name = "registry-stack-client" version = "0.32.0" diff --git a/Cargo.toml b/Cargo.toml index 33dfe362a6..e8fc7574fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,8 @@ members = [ "crates/registry-stack-client", "crates/registry-language-server", "crates/registry-linkml", + "crates/registry-scheduling-core", + "crates/registry-schedulingctl", ] exclude = [ "products/breg/wasm-handler-sdk", @@ -117,6 +119,8 @@ registry-relay-client-node = { path = "crates/registry-relay-client-node", versi registry-relay-client-py = { path = "crates/registry-relay-client-py", version = "0.32.0" } registry-relay-v2 = { path = "crates/registry-relay-v2", version = "0.32.0" } registry-relayctl = { path = "crates/registry-relayctl", version = "0.32.0" } +registry-scheduling-core = { path = "crates/registry-scheduling-core", version = "0.32.0" } +registry-schedulingctl = { path = "crates/registry-schedulingctl", version = "0.32.0" } registry-breg = { path = "crates/registry-breg", version = "0.32.0", default-features = false } registry-bregctl = { path = "crates/registry-bregctl", version = "0.32.0" } registry-stack-client = { path = "crates/registry-stack-client", version = "0.32.0" } diff --git a/crates/registry-scheduling-core/Cargo.toml b/crates/registry-scheduling-core/Cargo.toml new file mode 100644 index 0000000000..d4954233b0 --- /dev/null +++ b/crates/registry-scheduling-core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "registry-scheduling-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Source-neutral model, policy types, and pure admission evaluators for Registry Scheduling." +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +chrono = { workspace = true, features = ["serde"] } +registry-platform-calendar.workspace = true +registry-platform-canonical-json.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +serde_path_to_error.workspace = true +sha2.workspace = true +thiserror.workspace = true diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs new file mode 100644 index 0000000000..6070b6243b --- /dev/null +++ b/crates/registry-scheduling-core/src/admission.rs @@ -0,0 +1,1453 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The pure admission evaluators. +//! +//! One admission decision is one function of the resolved policy, the +//! environment facts, the authoritative ledger snapshot, the request, and the +//! observed instant. Nothing here reads a clock, opens a socket, or touches a +//! database: the runtime calls these functions inside its capacity +//! transaction, the explain endpoint calls them again for an authorized +//! caller, and offline fixture replay calls them with synthetic facts. The +//! three callers therefore cannot disagree about what admits. +//! +//! A refusal carries two problem codes: the public one every caller may see, +//! and the detailed one the separately authorized explain path may see. The +//! one place they differ is resource unavailability: the public answer to +//! "why not" is that capacity is exhausted, never which resource was absent +//! or why, because a specific resource's absence can disclose another +//! person's booking or a private staff reason. + +use chrono::{DateTime, Duration, Utc}; + +use registry_platform_calendar::CalendarInterval; + +use crate::model::{AdmissionRequest, LedgerClaim, LedgerKind, LedgerSnapshot, PoolMember}; +use crate::policy::{ExactTimeOffering, OfferingPolicy, PublishedWindow}; +use crate::problem::ProblemCode; +use crate::units::UnitsEvaluationError; + +/// A granted admission and the allocation it would create. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Admission { + pub offering: String, + /// The displayed start. + pub start: DateTime, + /// The displayed end, start plus duration. + pub end: DateTime, + /// The occupied interval including setup and cleanup buffers, the interval + /// the ledger records. + pub occupied_start: DateTime, + pub occupied_end: DateTime, + /// The concrete pool member chosen for an exact-time admission. Pooled + /// supply is its members, so the decision names one. + pub resource: Option, + /// The recipient units consumed. + pub units: u32, +} + +/// Which side of the booking horizon the request fell on. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HorizonKind { + /// The start is closer than the offering's lead time allows. + TooSoon, + /// The start is further ahead than the offering's horizon allows. + TooFar, +} + +/// A refused admission. The variants are the decision; the codes are the +/// disclosure. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum AdmissionRefusal { + #[error("the policy revision changed before the request committed")] + PolicyChanged, + #[error("the window revision changed before the request committed")] + RevisionMismatch { observed: u64 }, + #[error("the start is outside the booking horizon")] + HorizonOutside { kind: HorizonKind }, + #[error("no published schedule serves that start")] + ScheduleUnpublished, + #[error("a closure covers that start")] + LocationClosed, + #[error("the party is larger than the offering can ever serve")] + PartyCapacityInadequate, + #[error("the party is missing required prerequisites")] + PrerequisiteMissing { missing: Vec }, + #[error("no backing member carries the required capabilities")] + CapabilityUnmatched, + #[error("an active booking already holds this party's duplicate key")] + DuplicateActiveBooking, + #[error("the offering keys duplicate actives, so the request must carry a key")] + DuplicateKeyRequired, + #[error("every capable member is unavailable")] + ResourceUnavailable, + #[error("the supply is fully committed for that interval")] + CapacityExhausted, + #[error("the hold expired before confirmation")] + HoldExpired, + #[error("the claim is not an active hold")] + HoldReleased, +} + +impl AdmissionRefusal { + /// The problem code every caller may see. + #[must_use] + pub const fn public_code(&self) -> ProblemCode { + match self { + Self::ResourceUnavailable { .. } => ProblemCode::CapacityExhausted, + Self::PolicyChanged { .. } => ProblemCode::PolicyChanged, + Self::RevisionMismatch { .. } => ProblemCode::RevisionMismatch, + Self::HorizonOutside { .. } => ProblemCode::HorizonOutside, + Self::ScheduleUnpublished { .. } => ProblemCode::ScheduleUnpublished, + Self::LocationClosed { .. } => ProblemCode::LocationClosed, + Self::PartyCapacityInadequate { .. } => ProblemCode::PartyCapacityInadequate, + Self::PrerequisiteMissing { .. } => ProblemCode::PrerequisiteMissing, + Self::CapabilityUnmatched { .. } => ProblemCode::CapabilityUnmatched, + Self::DuplicateActiveBooking { .. } => ProblemCode::BookingDuplicateActive, + Self::DuplicateKeyRequired { .. } => ProblemCode::PreconditionRequired, + Self::CapacityExhausted { .. } => ProblemCode::CapacityExhausted, + Self::HoldExpired { .. } => ProblemCode::HoldExpired, + Self::HoldReleased { .. } => ProblemCode::HoldReleased, + } + } + + /// The problem code only the separately authorized explain path may see. + #[must_use] + pub const fn detailed_code(&self) -> ProblemCode { + match self { + Self::ResourceUnavailable { .. } => ProblemCode::ResourceUnavailable, + other => other.public_code(), + } + } +} + +/// The resolved state an exact-time admission runs against. +pub struct ExactTimeContext<'a> { + pub offering: &'a OfferingPolicy, + pub exact: &'a ExactTimeOffering, + /// The pool's members, as runtime records hold them. + pub members: &'a [PoolMember], + /// The expanded published openings for the offering's location. + pub open: &'a [CalendarInterval], + /// The blocking closures for the offering's location, so a refused start + /// can distinguish a closure from no published schedule. + pub closures: &'a [CalendarInterval], + pub snapshot: &'a LedgerSnapshot, + pub policy_revision: u64, + pub now: DateTime, +} + +/// The resolved state an arrival-window admission runs against. +pub struct WindowContext<'a> { + pub offering: &'a OfferingPolicy, + pub window: &'a PublishedWindow, + /// The arrival block's lead time and horizon, resolved from the offering. + pub lead_time_minutes: u32, + pub horizon_days: u32, + pub snapshot: &'a LedgerSnapshot, + pub policy_revision: u64, + pub now: DateTime, +} + +/// Evaluate one exact-time admission request. +/// +/// The checks run in a fixed order so two callers replaying the same state +/// always see the same first refusal: policy revision, window-agnostic +/// horizon, party bounds, prerequisites, duplicate key, published schedule +/// coverage including the start grid, member capability and availability, +/// then member occupancy including buffers. +pub fn evaluate_exact_time_admission( + context: &ExactTimeContext<'_>, + request: &AdmissionRequest, +) -> Result { + let ExactTimeContext { + offering, + exact, + members, + open, + closures, + snapshot, + policy_revision, + now, + } = context; + + if request.policy_revision != *policy_revision { + return Err(AdmissionRefusal::PolicyChanged); + } + check_horizon( + request.start, + *now, + exact.lead_time_minutes, + exact.horizon_days, + )?; + + if request.party.recipients == 0 || request.party.recipients > exact.max_recipients { + return Err(AdmissionRefusal::PartyCapacityInadequate); + } + check_prerequisites(offering, request)?; + check_duplicate(offering, request, snapshot, *now)?; + + // Interval arithmetic that leaves representable time is a horizon + // refusal, never a mislabeled capacity answer: a start whose service or + // occupied interval cannot exist is outside every bookable horizon. + let end = request + .start + .checked_add_signed(Duration::minutes(i64::from(exact.duration_minutes))) + .ok_or(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar, + })?; + let covered = open + .iter() + .find(|interval| interval.start <= request.start && end <= interval.end); + let Some(covering) = covered else { + let overlaps_closure = closures + .iter() + .any(|closure| request.start < closure.end && closure.start < end); + return Err(if overlaps_closure { + AdmissionRefusal::LocationClosed + } else { + AdmissionRefusal::ScheduleUnpublished + }); + }; + // The start grid is part of the published schedule: the offering serves + // starts at fixed increments from the opening's start. A zero increment + // publishes no grid at all, so every start is off it; the check refuses + // zero, and the evaluator still refuses to divide by it rather than trust + // its caller. + let offset = request + .start + .signed_duration_since(covering.start) + .num_minutes(); + let grid = u64::from(exact.start_increment_minutes); + let on_grid = + offset >= 0 && grid != 0 && u64::try_from(offset).is_ok_and(|minutes| minutes % grid == 0); + if !on_grid { + return Err(AdmissionRefusal::ScheduleUnpublished); + } + + // Buffers reaching past representable time refuse rather than clamp: a + // clamped buffer would silently shrink the occupied interval the ledger + // records. + let occupied_start = request + .start + .checked_sub_signed(Duration::minutes(i64::from(exact.buffer_before_minutes))) + .ok_or(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooSoon, + })?; + let occupied_end = end + .checked_add_signed(Duration::minutes(i64::from(exact.buffer_after_minutes))) + .ok_or(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar, + })?; + + let capable: Vec<&PoolMember> = members + .iter() + .filter(|member| { + offering + .requires_capabilities + .iter() + .all(|wanted| member.capabilities.iter().any(|held| held == wanted)) + }) + .collect(); + if capable.is_empty() { + return Err(AdmissionRefusal::CapabilityUnmatched); + } + let available: Vec<&PoolMember> = capable + .iter() + .copied() + .filter(|member| member.available) + .collect(); + if available.is_empty() { + return Err(AdmissionRefusal::ResourceUnavailable); + } + + let mut free: Vec<&PoolMember> = available + .iter() + .copied() + .filter(|member| { + !snapshot.supply_occupied( + &member.resource_id, + occupied_start, + occupied_end, + request.reschedule_of.as_deref(), + *now, + ) + }) + .collect(); + if free.is_empty() { + return Err(AdmissionRefusal::CapacityExhausted); + } + // Deterministic selection: the same state always picks the same member, + // so replay and explain agree with the runtime's commit. + free.sort_by_key(|member| member.resource_id.clone()); + + let chosen = free[0]; + Ok(Admission { + offering: offering.id.clone(), + start: request.start, + end, + occupied_start, + occupied_end, + resource: Some(chosen.resource_id.clone()), + units: 1, + }) +} + +/// Evaluate one arrival-window admission request. +/// +/// The window's units policy converts the party to recipient units; the +/// channel subquota and the total window capacity are both checked, and a +/// party the published units could never carry is refused as inadequate +/// rather than as exhausted. +pub fn evaluate_window_admission( + context: &WindowContext<'_>, + request: &AdmissionRequest, +) -> Result { + let WindowContext { + offering, + window, + lead_time_minutes, + horizon_days, + snapshot, + policy_revision, + now, + } = context; + + if request.policy_revision != *policy_revision { + return Err(AdmissionRefusal::PolicyChanged); + } + if request.window_revision != Some(window.revision) { + return Err(AdmissionRefusal::RevisionMismatch { + observed: window.revision, + }); + } + check_horizon(window.start, *now, *lead_time_minutes, *horizon_days)?; + + if request.party.recipients == 0 { + return Err(AdmissionRefusal::PartyCapacityInadequate); + } + let required = window + .units_policy + .evaluate(request.party.recipients) + .map_err(|error| match error { + UnitsEvaluationError::RefusedAboveHighestBand | UnitsEvaluationError::Overflow => { + AdmissionRefusal::PartyCapacityInadequate + } + })?; + if required > window.units { + return Err(AdmissionRefusal::PartyCapacityInadequate); + } + check_prerequisites(offering, request)?; + check_duplicate(offering, request, snapshot, *now)?; + + if let Some(channel) = &request.channel { + if let Some(subquota) = window + .subquotas + .iter() + .find(|subquota| subquota.channel.as_str() == channel.as_str()) + { + let allocated = snapshot.window_units_allocated( + &window.id, + Some(channel), + request.reschedule_of.as_deref(), + *now, + ); + if allocated.saturating_add(required) > subquota.units { + return Err(AdmissionRefusal::CapacityExhausted); + } + } + } + let allocated = + snapshot.window_units_allocated(&window.id, None, request.reschedule_of.as_deref(), *now); + if allocated.saturating_add(required) > window.units { + return Err(AdmissionRefusal::CapacityExhausted); + } + + Ok(Admission { + offering: offering.id.clone(), + start: window.start, + end: window.end, + occupied_start: window.start, + occupied_end: window.end, + resource: None, + units: required, + }) +} + +/// Evaluate a hold's state at an instant. +/// +/// An active hold confirms. A hold that expired at or before `now` is +/// expired, whatever the cleanup worker has or has not done. A claim that is +/// not a hold at all — a booking, or a hold already released or consumed — is +/// not confirmable; release and consumption are store states the runtime +/// reports with `hold.released` when it resolves the claim, so seeing one +/// here means the caller passed the wrong claim. +pub fn evaluate_hold_state( + hold: &LedgerClaim, + now: DateTime, +) -> Result<&LedgerClaim, AdmissionRefusal> { + if hold.kind != LedgerKind::Hold { + return Err(AdmissionRefusal::HoldReleased); + } + match hold.expires_at { + Some(expiry) if expiry > now => Ok(hold), + _ => Err(AdmissionRefusal::HoldExpired), + } +} + +fn check_horizon( + start: DateTime, + now: DateTime, + lead_time_minutes: u32, + horizon_days: u32, +) -> Result<(), AdmissionRefusal> { + let lead = start.signed_duration_since(now); + if lead < Duration::minutes(i64::from(lead_time_minutes)) { + return Err(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooSoon, + }); + } + if lead > Duration::minutes(i64::from(horizon_days) * 24 * 60) { + return Err(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar, + }); + } + Ok(()) +} + +fn check_prerequisites( + offering: &OfferingPolicy, + request: &AdmissionRequest, +) -> Result<(), AdmissionRefusal> { + let missing: Vec = offering + .prerequisites + .iter() + .filter(|wanted| !request.prerequisites.contains(wanted)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(AdmissionRefusal::PrerequisiteMissing { missing }) + } +} + +fn check_duplicate( + offering: &OfferingPolicy, + request: &AdmissionRequest, + snapshot: &LedgerSnapshot, + now: DateTime, +) -> Result<(), AdmissionRefusal> { + if offering.duplicate_active_key.is_none() { + return Ok(()); + } + match &request.duplicate_key { + // An offering that keys duplicate actives refuses a request without a + // key: skipping the check would let the same party hold two active + // bookings by omission. + None => Err(AdmissionRefusal::DuplicateKeyRequired), + Some(key) if snapshot.duplicate_active(key, request.reschedule_of.as_deref(), now) => { + Err(AdmissionRefusal::DuplicateActiveBooking) + } + Some(_) => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::PartyCounts; + use crate::policy::{ + ArrivalOffering, Channel, ExactTimeOffering, LeftoverCapacityPolicy, OfferingPolicy, + PublishedWindow, SchedulingMode, WindowSubquota, + }; + use crate::units::{BandedInput, RequiredUnitsPolicy}; + use chrono::TimeZone as _; + use registry_platform_calendar::{ + expand_weekly_openings, CalendarException, CalendarExceptionKind, HolidaySetRevision, + WeeklyOpeningPattern, + }; + + fn utc(day: u32, hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, day, hour, minute, 0) + .unwrap() + } + + /// The shared pool membership, computed once and borrowed for the + /// lifetime of every context the tests build. + fn members() -> &'static [PoolMember] { + static MEMBERS: std::sync::OnceLock> = std::sync::OnceLock::new(); + MEMBERS.get_or_init(|| { + vec![ + PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec![], + available: true, + }, + PoolMember { + resource_id: "station-2".to_owned(), + capabilities: vec![], + available: true, + }, + ] + }) + } + + fn exact_offering() -> (OfferingPolicy, ExactTimeOffering) { + ( + OfferingPolicy { + id: "registry-update-30".to_owned(), + service: "registry-update".to_owned(), + label: "30-minute counter update".to_owned(), + mode: SchedulingMode::ExactTime, + location: "north-counter".to_owned(), + because: "A 30-minute update at an interchangeable station.".to_owned(), + exact_time: None, + arrival: None, + cancellation_cutoff_minutes: 240, + duplicate_active_key: Some("subject".to_owned()), + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + }, + ExactTimeOffering { + duration_minutes: 30, + buffer_before_minutes: 5, + buffer_after_minutes: 5, + lead_time_minutes: 60, + horizon_days: 30, + pool: "update-stations".to_owned(), + start_increment_minutes: 30, + max_recipients: 1, + }, + ) + } + + /// The shared published openings, computed once and borrowed for the + /// lifetime of every context the tests build. + fn open_intervals() -> &'static [CalendarInterval] { + static OPEN: std::sync::OnceLock> = std::sync::OnceLock::new(); + OPEN.get_or_init(|| { + vec![ + CalendarInterval { + start: utc(5, 2, 0), + end: utc(5, 5, 30), + }, + CalendarInterval { + start: utc(6, 2, 0), + end: utc(6, 5, 30), + }, + ] + }) + } + + fn exact_context<'a>( + offering: &'a OfferingPolicy, + exact: &'a ExactTimeOffering, + members: &'a [PoolMember], + snapshot: &'a LedgerSnapshot, + now: DateTime, + ) -> ExactTimeContext<'a> { + ExactTimeContext { + offering, + exact, + members, + open: open_intervals(), + closures: &[], + snapshot, + policy_revision: 1, + now, + } + } + + fn exact_request(start: DateTime) -> AdmissionRequest { + AdmissionRequest { + offering: "registry-update-30".to_owned(), + start, + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: Some("subject:one".to_owned()), + policy_revision: 1, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + reschedule_of: None, + } + } + + fn booking_claim( + id: &str, + supply: &str, + start: DateTime, + end: DateTime, + ) -> LedgerClaim { + LedgerClaim { + id: id.to_owned(), + supply_id: supply.to_owned(), + kind: LedgerKind::Booking, + channel: None, + start, + end, + units: 1, + duplicate_key: None, + expires_at: None, + } + } + + fn window_claim(id: &str, units: u32, channel: &str) -> LedgerClaim { + LedgerClaim { + id: id.to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some(channel.to_owned()), + start: utc(10, 8, 0), + end: utc(10, 10, 0), + units, + duplicate_key: None, + expires_at: None, + } + } + + /// AT-01, pure half: two callers against the last free unit. The first is + /// admitted onto a concrete member; the second, replayed against the + /// ledger the first created, is refused with a recoverable conflict. + #[test] + fn the_second_caller_against_the_last_unit_is_refused() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + let first = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30))) + .expect("the first caller is admitted"); + assert_eq!(first.resource.as_deref(), Some("station-1")); + + let mut after = snapshot.clone(); + after.claims.push(booking_claim( + "claim-1", + "station-1", + first.occupied_start, + first.occupied_end, + )); + let second_context = exact_context(&offering, &exact, members(), &after, now); + let second = evaluate_exact_time_admission(&second_context, &exact_request(utc(5, 2, 30))) + .map(|admission| admission.resource) + .map_err(|refusal| refusal.public_code()); + assert_eq!(second, Ok(Some("station-2".to_owned()))); + + // With one station left occupied at the same displayed time, the + // other station serves; once both are occupied the refusal is a + // recoverable capacity conflict. + after.claims.push(booking_claim( + "claim-2", + "station-2", + utc(5, 2, 25), + utc(5, 3, 5), + )); + let third_context = exact_context(&offering, &exact, members(), &after, now); + let third = evaluate_exact_time_admission(&third_context, &exact_request(utc(5, 2, 30))); + assert_eq!( + third.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + } + + /// AT-09: buffers make touching displayed times conflict. + #[test] + fn buffer_overlap_rejects_touching_displayed_times() { + let (offering, exact) = exact_offering(); + let mut snapshot = LedgerSnapshot::default(); + // Claims on both stations displayed 02:00-02:30, occupied 01:55-02:35. + snapshot.claims.push(booking_claim( + "claim-1", + "station-1", + utc(5, 1, 55), + utc(5, 2, 35), + )); + snapshot.claims.push(booking_claim( + "claim-2", + "station-2", + utc(5, 1, 55), + utc(5, 2, 35), + )); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + // A 02:30 start displays as touching, but its 02:25 buffer start + // overlaps both claims' cleanup, so every member is blocked. + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30))); + assert_eq!( + result.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + + // At 03:00, with the increment grid observed, both stations are free. + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0))); + assert!(result.is_ok()); + } + + /// AT-06, duplicate half: the same subject with an active booking is + /// refused; a reschedule of that booking excludes itself. + #[test] + fn an_active_duplicate_key_is_refused_unless_rescheduling_it() { + let (offering, exact) = exact_offering(); + let mut snapshot = LedgerSnapshot::default(); + let mut existing = booking_claim("claim-1", "station-1", utc(5, 1, 55), utc(5, 2, 35)); + existing.duplicate_key = Some("subject:one".to_owned()); + snapshot.claims.push(existing); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + let fresh = evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0))); + assert_eq!( + fresh.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::BookingDuplicateActive) + ); + + let rescheduling = AdmissionRequest { + reschedule_of: Some("claim-1".to_owned()), + ..exact_request(utc(5, 3, 0)) + }; + let moved = evaluate_exact_time_admission(&context, &rescheduling); + assert!(moved.is_ok(), "{moved:?}"); + } + + /// A keyed offering refuses a request without a duplicate key: the + /// duplicate check may not be skipped by omission. + #[test] + fn a_keyed_offering_refuses_a_request_without_a_duplicate_key() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let request = AdmissionRequest { + duplicate_key: None, + ..exact_request(utc(5, 2, 30)) + }; + let refusal = evaluate_exact_time_admission(&context, &request) + .expect_err("refused for the missing key"); + assert_eq!(refusal.public_code(), ProblemCode::PreconditionRequired); + assert_eq!(refusal.detailed_code(), ProblemCode::PreconditionRequired); + } + + /// Interval arithmetic that leaves representable time refuses as a + /// horizon answer: never a mislabeled capacity refusal, never a silently + /// clamped buffer that undercounts occupancy. + #[test] + fn unrepresentable_intervals_refuse_as_horizon_never_as_capacity() { + let (offering, mut exact) = exact_offering(); + exact.lead_time_minutes = 1; + exact.horizon_days = 3650; + let snapshot = LedgerSnapshot::default(); + + // A start whose displayed end leaves representable time. + let now = DateTime::::MAX_UTC - Duration::minutes(10); + let at_the_edge = now + Duration::minutes(5); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(at_the_edge)).err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar + }) + ); + + // A displayed interval that fits, whose cleanup buffer does not. + let now = DateTime::::MAX_UTC - Duration::minutes(60); + let start = DateTime::::MAX_UTC - Duration::minutes(30); + assert!(start.checked_add_signed(Duration::minutes(30)).is_some()); + exact.buffer_after_minutes = 30; + let open = [CalendarInterval { + start: now, + end: DateTime::::MAX_UTC, + }]; + let context = ExactTimeContext { + open: &open, + ..exact_context(&offering, &exact, members(), &snapshot, now) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(start)).err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar + }) + ); + + // A start whose setup buffer reaches before representable time. + let now = DateTime::::MIN_UTC + Duration::minutes(1); + let start = DateTime::::MIN_UTC + Duration::minutes(2); + let open = [CalendarInterval { + start, + end: start + Duration::minutes(30), + }]; + let context = ExactTimeContext { + open: &open, + ..exact_context(&offering, &exact, members(), &snapshot, now) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(start)).err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooSoon + }) + ); + } + + /// A zero increment publishes no grid at all; the evaluator refuses every + /// start rather than dividing by zero. The policy check refuses zero, and + /// this holds the line for hand-built contexts. + #[test] + fn a_zero_increment_grid_serves_no_start() { + let (offering, mut exact) = exact_offering(); + exact.start_increment_minutes = 0; + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30))) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + } + + /// Self-overlap: a reschedule onto its own current slot succeeds, because + /// its own allocation is excluded, while another caller at that slot is + /// refused. + #[test] + fn a_reschedule_does_not_compete_with_its_own_allocation() { + let (offering, exact) = exact_offering(); + let mut snapshot = LedgerSnapshot::default(); + let mut existing = booking_claim("claim-1", "station-1", utc(5, 1, 55), utc(5, 2, 35)); + existing.duplicate_key = Some("subject:one".to_owned()); + snapshot.claims.push(existing); + // Both stations hold the slot, so only the reschedule's own exclusion + // can get a caller onto 02:00. + snapshot.claims.push(booking_claim( + "claim-2", + "station-2", + utc(5, 1, 55), + utc(5, 2, 35), + )); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + let same_slot = AdmissionRequest { + reschedule_of: Some("claim-1".to_owned()), + ..exact_request(utc(5, 2, 0)) + }; + let result = evaluate_exact_time_admission(&context, &same_slot); + assert!(result.is_ok(), "{result:?}"); + assert_eq!(result.unwrap().resource.as_deref(), Some("station-1")); + + // Another caller at 02:00 without the exclusion is refused. + let other = AdmissionRequest { + duplicate_key: Some("subject:two".to_owned()), + ..exact_request(utc(5, 2, 0)) + }; + let result = evaluate_exact_time_admission(&context, &other); + assert_eq!( + result.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + } + + /// AT-05, pure half: an expired hold consumes nothing, and confirming it + /// late is refused. + #[test] + fn an_expired_hold_consumes_nothing_and_cannot_be_confirmed() { + let now = utc(4, 4, 0); + let expired_hold = LedgerClaim { + id: "hold-1".to_owned(), + supply_id: "station-1".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(5, 1, 55), + end: utc(5, 2, 35), + units: 1, + duplicate_key: Some("subject:one".to_owned()), + expires_at: Some(utc(4, 3, 55)), + }; + let snapshot = LedgerSnapshot { + claims: vec![expired_hold.clone()], + }; + + assert_eq!( + evaluate_hold_state(&expired_hold, now).err(), + Some(AdmissionRefusal::HoldExpired) + ); + + let (offering, exact) = exact_offering(); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + // The expired hold neither blocks the slot nor blocks the duplicate + // key: its capacity is bookable again. + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0))); + assert!(result.is_ok(), "{result:?}"); + } + + #[test] + fn a_live_hold_still_consumes() { + let now = utc(4, 4, 0); + let live_hold = LedgerClaim { + id: "hold-1".to_owned(), + supply_id: "station-1".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(5, 1, 55), + end: utc(5, 2, 35), + units: 1, + duplicate_key: None, + expires_at: Some(utc(4, 4, 30)), + }; + assert!(evaluate_hold_state(&live_hold, now).is_ok()); + + let snapshot = LedgerSnapshot { + claims: vec![live_hold], + }; + let (offering, exact) = exact_offering(); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0))); + // station-1 is held; station-2 still serves the party. + assert_eq!( + result.map(|admission| admission.resource), + Ok(Some("station-2".to_owned())) + ); + } + + /// BKG-02: a public refusal distinguishes horizon, closure, unpublished + /// schedule, capability, and party bounds; and never names an unavailable + /// resource. + #[test] + fn refusals_carry_their_distinguished_public_codes() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + + // Too soon: 02:00 is inside the 60-minute lead time at 04:00? No: it + // is in the past, which is the strongest form of too soon. + let past = evaluate_exact_time_admission(&context, &exact_request(utc(4, 4, 30))); + assert!(matches!( + past.err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooSoon + }) + )); + + // Beyond the 30-day horizon: November 10 is 36 days out. + let far = AdmissionRequest { + start: Utc.with_ymd_and_hms(2026, 11, 10, 2, 30, 0).unwrap(), + ..exact_request(utc(5, 2, 30)) + }; + assert!(matches!( + evaluate_exact_time_admission(&context, &far).err(), + Some(AdmissionRefusal::HorizonOutside { + kind: HorizonKind::TooFar + }) + )); + + // Off-grid start: no published schedule serves 02:15. + let off_grid = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 15))); + assert_eq!( + off_grid.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + + // Off the published day entirely. + let unpublished_day = + evaluate_exact_time_admission(&context, &exact_request(utc(9, 2, 30))); + assert_eq!( + unpublished_day.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + + // A closure over the start is a closure, not an unpublished day. The + // expansion has already removed the closure from the published + // openings, so the uncovered start overlaps the closure interval. + let closures = vec![CalendarInterval { + start: utc(5, 2, 0), + end: utc(5, 3, 0), + }]; + let open_after_closure = [CalendarInterval { + start: utc(5, 3, 0), + end: utc(5, 5, 30), + }]; + let closed_context = ExactTimeContext { + open: &open_after_closure, + closures: &closures, + ..exact_context(&offering, &exact, members(), &snapshot, now) + }; + let closed = evaluate_exact_time_admission(&closed_context, &exact_request(utc(5, 2, 30))); + assert_eq!( + closed.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::LocationClosed) + ); + + // A party larger than the offering can ever serve. + let party = AdmissionRequest { + party: PartyCounts { + recipients: 2, + attendees: 2, + }, + ..exact_request(utc(5, 2, 30)) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &party) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::PartyCapacityInadequate) + ); + + // A required capability no member carries. + let (wanting, exact_wanting) = { + let (mut offering, exact) = exact_offering(); + offering.requires_capabilities = vec!["interpreter".to_owned()]; + (offering, exact) + }; + let capable_members = vec![PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec!["notary".to_owned()], + available: true, + }]; + let wanting_context = + exact_context(&wanting, &exact_wanting, &capable_members, &snapshot, now); + assert_eq!( + evaluate_exact_time_admission(&wanting_context, &exact_request(utc(5, 2, 30))) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::CapabilityUnmatched) + ); + + // Capable members that are all unavailable: the public answer is + // exhausted capacity, the detailed answer names unavailability. + let unavailable = vec![PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec!["interpreter".to_owned()], + available: false, + }]; + let unavailable_context = + exact_context(&wanting, &exact_wanting, &unavailable, &snapshot, now); + let refused = + evaluate_exact_time_admission(&unavailable_context, &exact_request(utc(5, 2, 30))) + .expect_err("refused"); + assert_eq!(refused.public_code(), ProblemCode::CapacityExhausted); + assert_eq!(refused.detailed_code(), ProblemCode::ResourceUnavailable); + } + + #[test] + fn policy_change_outranks_a_window_revision_mismatch() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let stale = AdmissionRequest { + policy_revision: 0, + window_revision: Some(7), + ..exact_request(utc(5, 2, 30)) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &stale).err(), + Some(AdmissionRefusal::PolicyChanged) + ); + } + + #[test] + fn missing_prerequisites_are_named() { + let (mut offering, exact) = exact_offering(); + offering.prerequisites = vec![ + "case:approved-application".to_owned(), + "urn:evidence:residency".to_owned(), + ]; + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let request = AdmissionRequest { + prerequisites: vec!["urn:evidence:residency".to_owned()], + ..exact_request(utc(5, 2, 30)) + }; + assert_eq!( + evaluate_exact_time_admission(&context, &request).err(), + Some(AdmissionRefusal::PrerequisiteMissing { + missing: vec!["case:approved-application".to_owned()] + }) + ); + } + + fn window() -> PublishedWindow { + PublishedWindow { + id: "household-morning-window".to_owned(), + revision: 2, + offering: "household-morning".to_owned(), + location: "civic-hall".to_owned(), + start: utc(10, 8, 0), + end: utc(10, 10, 0), + units: 3, + units_policy: RequiredUnitsPolicy::PerRecipient { + per_recipient: 1, + because: "Each recipient consumes one serving slot.".to_owned(), + }, + subquotas: vec![WindowSubquota { + id: "public-quota".to_owned(), + channel: Channel::Public, + units: 2, + because: "Most households book the public channel.".to_owned(), + }], + leftover: LeftoverCapacityPolicy::BecomesWalkIn, + staffing: None, + because: "The Saturday morning household block.".to_owned(), + } + } + + fn arrival_offering() -> OfferingPolicy { + OfferingPolicy { + id: "household-morning".to_owned(), + service: "household-day".to_owned(), + label: "Morning household block".to_owned(), + mode: SchedulingMode::ArrivalWindow, + location: "civic-hall".to_owned(), + because: "Households arrive in a served morning block.".to_owned(), + exact_time: None, + arrival: Some(ArrivalOffering { + window: "household-morning-window".to_owned(), + lead_time_minutes: 1, + horizon_days: 60, + }), + cancellation_cutoff_minutes: 1440, + duplicate_active_key: None, + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + fn window_request(recipients: u32, attendees: u32) -> AdmissionRequest { + AdmissionRequest { + offering: "household-morning".to_owned(), + start: utc(10, 8, 0), + party: PartyCounts { + recipients, + attendees, + }, + channel: Some("public".to_owned()), + duplicate_key: None, + policy_revision: 1, + window_revision: Some(2), + capabilities: Vec::new(), + prerequisites: Vec::new(), + reschedule_of: None, + } + } + + /// AT-03: three attendees with two recipients consume two units, never + /// three. + #[test] + fn attendee_count_never_becomes_consumed_units() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 9, 0); + let context = WindowContext { + offering: &offering, + window: &window, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + now, + }; + let admission = + evaluate_window_admission(&context, &window_request(2, 3)).expect("admitted"); + assert_eq!(admission.units, 2); + } + + /// AT-02: a two-recipient party against one remaining unit is refused + /// without a partial or undercounted booking. + #[test] + fn a_party_that_does_not_fit_remaining_capacity_is_refused_whole() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot { + claims: vec![window_claim("claim-1", 2, "public")], + }; + let now = utc(4, 9, 0); + let context = WindowContext { + offering: &offering, + window: &window, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + now, + }; + let result = evaluate_window_admission(&context, &window_request(2, 2)); + assert_eq!( + result.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + + // The one remaining unit sits outside the public slice: a caller the + // window does not channel still fits it. + let unchanneled = AdmissionRequest { + channel: None, + ..window_request(1, 1) + }; + let single = evaluate_window_admission(&context, &unchanneled); + assert_eq!(single.map(|admission| admission.units), Ok(1)); + } + + #[test] + fn a_party_the_published_units_could_never_carry_is_inadequate_not_exhausted() { + let offering = arrival_offering(); + let mut window = window(); + window.units = 2; + window.units_policy = RequiredUnitsPolicy::BandedTable { + input: BandedInput::ServiceRecipientCount, + bands: vec![crate::units::UnitBand { + up_to: 2, + units: 2, + because: "A household of two shares one block.".to_owned(), + }], + above_highest_band: crate::units::AboveHighestBand::Refuse, + because: "Household sizing reviewed in 2026.".to_owned(), + }; + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 9, 0); + let context = WindowContext { + offering: &offering, + window: &window, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + now, + }; + // Four recipients are above the highest band: inadequate party. + assert_eq!( + evaluate_window_admission(&context, &window_request(4, 4)) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::PartyCapacityInadequate) + ); + // Two recipients fall in the band at 2 units, exactly the published 2: + // admitted, not inadequate. + assert!(evaluate_window_admission(&context, &window_request(2, 2)).is_ok()); + } + + #[test] + fn subquota_channels_are_checked_before_the_total() { + let offering = arrival_offering(); + let window = window(); + // Public subquota of 2, already fully allocated. + let snapshot = LedgerSnapshot { + claims: vec![window_claim("claim-1", 2, "public")], + }; + let now = utc(4, 9, 0); + let context = WindowContext { + offering: &offering, + window: &window, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + now, + }; + let result = evaluate_window_admission(&context, &window_request(1, 1)); + assert_eq!( + result.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + } + + #[test] + fn window_revisions_must_match_and_policy_change_wins() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 9, 0); + let context = WindowContext { + offering: &offering, + window: &window, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + now, + }; + let stale_window = AdmissionRequest { + window_revision: Some(1), + ..window_request(1, 1) + }; + assert!(matches!( + evaluate_window_admission(&context, &stale_window).err(), + Some(AdmissionRefusal::RevisionMismatch { observed: 2 }) + )); + + let stale_both = AdmissionRequest { + policy_revision: 0, + ..stale_window + }; + assert_eq!( + evaluate_window_admission(&context, &stale_both).err(), + Some(AdmissionRefusal::PolicyChanged) + ); + } + + /// AT-19: opening expansion across a daylight-saving fold feeds admission + /// with real elapsed intervals, and the repeated wall-clock hour yields two + /// distinct bookable instants, never an invisible duplicate. + #[test] + fn the_folded_hour_yields_two_distinct_bookable_instants() { + // America/New_York falls back on 2026-11-01: local 01:30 occurs twice, + // at 05:30Z EDT and 06:30Z EST. A pattern may not start or end inside + // the repeated hour, but it may span the fold: the hall opens + // 00:30-02:30 local, whose endpoints exist unambiguously. The two + // wall-clock hours span three real hours, [04:30Z, 07:30Z). + let pattern = WeeklyOpeningPattern { + id: "fold-day-hours", + timezone: "America/New_York", + weekdays: &[chrono::Weekday::Sun], + start_time: "00:30", + end_time: "02:30", + effective_from: "2026-11-01", + effective_until: "2026-11-01", + }; + let holidays = HolidaySetRevision { + holiday_set: "office-holidays", + revision: 1, + dates: &[], + }; + let open = expand_weekly_openings(&pattern, &[], &holidays).expect("expands"); + assert_eq!(open.len(), 1); + assert_eq!( + open[0].start, + Utc.with_ymd_and_hms(2026, 11, 1, 4, 30, 0).unwrap() + ); + assert_eq!( + open[0].end, + Utc.with_ymd_and_hms(2026, 11, 1, 7, 30, 0).unwrap() + ); + assert_eq!((open[0].end - open[0].start).num_minutes(), 180); + + let (offering, mut exact) = exact_offering(); + exact.lead_time_minutes = 1; + exact.horizon_days = 3650; + exact.start_increment_minutes = 30; + exact.duration_minutes = 30; + let snapshot = LedgerSnapshot::default(); + let now = Utc.with_ymd_and_hms(2026, 11, 1, 4, 0, 0).unwrap(); + let context = ExactTimeContext { + offering: &offering, + exact: &exact, + members: members(), + open: &open, + closures: &[], + snapshot: &snapshot, + policy_revision: 1, + now, + }; + // Both 01:30 instants are on the grid and inside the real interval: + // the first (EDT) ends 06:00Z, the second (EST) ends 07:00Z. + let first = evaluate_exact_time_admission( + &context, + &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 5, 30, 0).unwrap()), + ) + .expect("the first 01:30 admits"); + assert_eq!( + first.end, + Utc.with_ymd_and_hms(2026, 11, 1, 6, 0, 0).unwrap() + ); + let second = evaluate_exact_time_admission( + &context, + &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 6, 30, 0).unwrap()), + ) + .expect("the second 01:30 admits"); + assert_eq!( + second.end, + Utc.with_ymd_and_hms(2026, 11, 1, 7, 0, 0).unwrap() + ); + + // A start at the interval's end is outside the real opening: no + // published schedule serves it, whatever the wall clock says. + let outside = Utc.with_ymd_and_hms(2026, 11, 1, 7, 30, 0).unwrap(); + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(outside)) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + } + + /// A closure applied during expansion removes the bookable interval, and + /// the evaluator reports the closure, not an unpublished schedule. + #[test] + fn a_closure_over_a_fold_day_interval_closes_the_slot() { + // Same fold-day opening, [04:30Z, 07:30Z). A closure before the fold + // whose endpoints sit in one offset, 00:15-00:45 local, removes the + // first half hour: the opening starts 04:45Z, and a request inside the + // closed stretch reports the closure. + let pattern = WeeklyOpeningPattern { + id: "fold-day-hours", + timezone: "America/New_York", + weekdays: &[chrono::Weekday::Sun], + start_time: "00:30", + end_time: "02:30", + effective_from: "2026-11-01", + effective_until: "2026-11-01", + }; + let holidays = HolidaySetRevision { + holiday_set: "office-holidays", + revision: 1, + dates: &[], + }; + let closure = CalendarException { + id: "systems-training", + kind: CalendarExceptionKind::Closure, + date: "2026-11-01", + start_time: "00:15", + end_time: "00:45", + reopens: None, + authority: None, + }; + let open = expand_weekly_openings(&pattern, &[closure], &holidays).expect("expands"); + assert_eq!(open.len(), 1); + assert_eq!( + open[0].start, + Utc.with_ymd_and_hms(2026, 11, 1, 4, 45, 0).unwrap() + ); + + let (offering, mut exact) = exact_offering(); + exact.lead_time_minutes = 1; + exact.horizon_days = 3650; + let snapshot = LedgerSnapshot::default(); + let now = Utc.with_ymd_and_hms(2026, 11, 1, 4, 0, 0).unwrap(); + let closures = vec![CalendarInterval { + start: Utc.with_ymd_and_hms(2026, 11, 1, 4, 15, 0).unwrap(), + end: Utc.with_ymd_and_hms(2026, 11, 1, 4, 45, 0).unwrap(), + }]; + let context = ExactTimeContext { + offering: &offering, + exact: &exact, + members: members(), + open: &open, + closures: &closures, + snapshot: &snapshot, + policy_revision: 1, + now, + }; + // Inside the closed stretch: a closure, not an unpublished schedule. + let closed_start = Utc.with_ymd_and_hms(2026, 11, 1, 4, 30, 0).unwrap(); + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(closed_start)) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::LocationClosed) + ); + // On the moved grid (increments from 04:45Z) the hall still serves. + let served = evaluate_exact_time_admission( + &context, + &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 5, 15, 0).unwrap()), + ) + .expect("the post-closure grid serves"); + assert_eq!( + served.end, + Utc.with_ymd_and_hms(2026, 11, 1, 5, 45, 0).unwrap() + ); + } +} diff --git a/crates/registry-scheduling-core/src/diagnostics.rs b/crates/registry-scheduling-core/src/diagnostics.rs new file mode 100644 index 0000000000..58d5b089ed --- /dev/null +++ b/crates/registry-scheduling-core/src/diagnostics.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Field-addressed diagnostics for scheduling policy checks. +//! +//! A diagnostic names the exact policy path that failed and a closed reason, +//! so an authoring tool can point at the offending line and a report consumer +//! can never mistake one failure family for another. + +use std::fmt; + +/// A closed reason a policy path failed its check. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PolicyCheckReason { + /// The envelope apiVersion is not the scheduling policy apiVersion. + UnsupportedApiVersion, + /// The envelope kind is not the scheduling policy kind. + UnsupportedKind, + /// An identifier is empty or outside the identifier grammar. + InvalidIdentifier, + /// The same identifier appears twice in one collection. + DuplicateIdentifier, + /// A required `because` is missing, blank, or longer than 256 bytes. + InvalidBecause, + /// A collection that must carry at least one entry is empty. + EmptyCollection, + /// A referenced service does not exist. + UnknownService, + /// A referenced offering does not exist. + UnknownOffering, + /// A referenced window does not exist. + UnknownWindow, + /// A referenced holiday set does not exist. + UnknownHolidaySet, + /// A field required by the offering's scheduling mode is absent. + MissingModeField, + /// A field that belongs to the other scheduling mode is present. + WrongModeField, + /// A numeric bound is zero, negative, or inverted. + InvalidBound, + /// The banded table is empty, non-increasing, or carries non-positive + /// units. + InvalidBands, + /// Channel subquotas overlap or sum above the window's published units. + SubquotaOverdrawn, + /// Two declarations draw on the same supply without an attributable + /// partition of it. + SharedSupplyUnpartitioned, + /// A hook declares an ABI other than the one reserved scheduling ABI. + UnsupportedHookAbi, + /// This version has no hook engine, so a declared hook can never run. + HooksUnsupported, +} + +impl PolicyCheckReason { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::UnsupportedApiVersion => "unsupported-api-version", + Self::UnsupportedKind => "unsupported-kind", + Self::InvalidIdentifier => "invalid-identifier", + Self::DuplicateIdentifier => "duplicate-identifier", + Self::InvalidBecause => "invalid-because", + Self::EmptyCollection => "empty-collection", + Self::UnknownService => "unknown-service", + Self::UnknownOffering => "unknown-offering", + Self::UnknownWindow => "unknown-window", + Self::UnknownHolidaySet => "unknown-holiday-set", + Self::MissingModeField => "missing-mode-field", + Self::WrongModeField => "wrong-mode-field", + Self::InvalidBound => "invalid-bound", + Self::InvalidBands => "invalid-bands", + Self::SubquotaOverdrawn => "subquota-overdrawn", + Self::SharedSupplyUnpartitioned => "shared-supply-unpartitioned", + Self::UnsupportedHookAbi => "unsupported-hook-abi", + Self::HooksUnsupported => "hooks-unsupported", + } + } +} + +impl fmt::Display for PolicyCheckReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// One policy check finding: the path that failed, and why. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SchedulingDiagnostic { + pub path: String, + pub reason: PolicyCheckReason, +} + +impl SchedulingDiagnostic { + pub fn new(path: impl Into, reason: PolicyCheckReason) -> Self { + Self { + path: path.into(), + reason, + } + } +} + +impl fmt::Display for SchedulingDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.path, self.reason) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reasons_render_as_closed_slugs() { + assert_eq!( + PolicyCheckReason::SharedSupplyUnpartitioned.as_str(), + "shared-supply-unpartitioned" + ); + assert_eq!( + SchedulingDiagnostic::new("windows[0].offering", PolicyCheckReason::UnknownOffering) + .to_string(), + "windows[0].offering: unknown-offering" + ); + } +} diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs new file mode 100644 index 0000000000..42700c466b --- /dev/null +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -0,0 +1,1054 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Offline replay fixtures and the replay itself. +//! +//! A fixture is synthetic facts, a starting ledger, and an ordered list of +//! cases with their expected outcomes. Replay runs the same pure evaluators +//! the runtime runs, entirely offline: no network, no database, no clock. An +//! admitted case joins the ledger the later cases run against, and an +//! admitted reschedule replaces the allocation it names exactly once, so a +//! fixture can tell the story of several callers in sequence. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use registry_platform_calendar::{ + expand_weekly_openings, local_interval, CalendarEvaluationError, CalendarInterval, + HolidaySetRevision, WeeklyOpeningPattern, +}; + +use crate::admission::{ + evaluate_exact_time_admission, evaluate_window_admission, ExactTimeContext, WindowContext, +}; +use crate::model::{AdmissionRequest, LedgerClaim, LedgerKind, LedgerSnapshot, SchedulingFacts}; +use crate::naming::{SCHEDULING_FIXTURE_API_VERSION, SCHEDULING_FIXTURE_KIND}; +use crate::policy::{SchedulingMode, SchedulingPolicy}; +use crate::problem::ProblemCode; + +/// One case's expected outcome. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "outcome", rename_all = "camelCase", deny_unknown_fields)] +pub enum FixtureExpectation { + Admitted { + units: u32, + #[serde(default)] + resource: Option, + }, + Refused { + /// A public problem code, exactly as a caller would receive it. + code: String, + }, +} + +/// One replayed request and its expectation. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FixtureCase { + pub name: String, + pub request: AdmissionRequest, + pub expect: FixtureExpectation, +} + +/// An offline replay fixture: synthetic facts, a starting ledger, and cases. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingFixture { + pub api_version: String, + pub kind: String, + pub name: String, + /// The single observed instant every case runs at. + pub now: DateTime, + pub facts: SchedulingFacts, + #[serde(default)] + pub initial: Vec, + pub cases: Vec, +} + +/// Parse a fixture from its authored YAML, with the failing path preserved. +pub fn parse_fixture_yaml( + text: &str, +) -> Result> { + let deserializer = serde_norway::Deserializer::from_str(text); + serde_path_to_error::deserialize(deserializer) +} + +/// Whether one replayed case matched its expectation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CaseStatus { + Pass, + Fail, +} + +impl CaseStatus { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Pass => "pass", + Self::Fail => "fail", + } + } +} + +/// The outcome of one replayed case. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CaseOutcome { + pub name: String, + pub status: CaseStatus, + pub detail: Option, +} + +/// Replay stopped before a case could be evaluated: the fixture and policy do +/// not fit together, or an expectation names a code outside the closed +/// vocabulary. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ReplayError { + #[error("the fixture envelope is not a scheduling fixture")] + UnsupportedEnvelope, + #[error("case {case} requests unknown offering {offering}")] + UnknownOffering { case: String, offering: String }, + #[error("case {case} needs pool {pool}, which the facts do not declare")] + UnknownPool { case: String, pool: String }, + #[error("case {case} needs pool {pool}, which has no members")] + EmptyPool { case: String, pool: String }, + #[error("case {case} needs location {location}, which the facts do not declare")] + UnknownLocation { case: String, location: String }, + #[error("case {case} needs window {window}, which the policy does not publish")] + UnknownWindow { case: String, window: String }, + #[error("case {case} expects code {code}, which is outside the closed vocabulary")] + UnknownExpectationCode { case: String, code: String }, + #[error( + "case {case} expects code {code}, which only the authorized explain path discloses; \ + expect the public code instead" + )] + DetailedExpectationCode { case: String, code: String }, + #[error( + "case {case} targets offering {offering}, whose mode block is absent; the policy does \ + not pass its check" + )] + ModeBlockMissing { case: String, offering: String }, + #[error( + "opening {opening} names holiday set {holiday_set}, which the policy does not declare" + )] + HolidaySetMissing { + opening: String, + holiday_set: String, + }, + #[error("window {window} does not sit inside the location's published openings")] + WindowOutsideOpenings { window: String }, + #[error("the calendar refused the configuration: {0}")] + Calendar(#[from] CalendarEvaluationError), +} + +impl SchedulingFixture { + /// Check the fixture envelope and every expectation code before any case + /// runs, so a broken fixture fails fast instead of half-way through. + pub fn check(&self, policy: &SchedulingPolicy) -> Result<(), ReplayError> { + if self.api_version != SCHEDULING_FIXTURE_API_VERSION + || self.kind != SCHEDULING_FIXTURE_KIND + { + return Err(ReplayError::UnsupportedEnvelope); + } + for case in &self.cases { + if let FixtureExpectation::Refused { code } = &case.expect { + match ProblemCode::from_code(code) { + None => { + return Err(ReplayError::UnknownExpectationCode { + case: case.name.clone(), + code: code.clone(), + }); + } + Some(problem) if problem.is_detailed_only() => { + return Err(ReplayError::DetailedExpectationCode { + case: case.name.clone(), + code: code.clone(), + }); + } + Some(_) => {} + } + } + let offering = policy.offering(&case.request.offering).ok_or_else(|| { + ReplayError::UnknownOffering { + case: case.name.clone(), + offering: case.request.offering.clone(), + } + })?; + let location = &offering.location; + if self.facts.location(location).is_none() { + return Err(ReplayError::UnknownLocation { + case: case.name.clone(), + location: location.clone(), + }); + } + match offering.mode { + SchedulingMode::ExactTime => { + // The mode block is a policy check finding, not something + // the fixture harness may assume away: a policy that does + // not pass its check stops here instead of panicking. + let Some(exact) = offering.exact_time.as_ref() else { + return Err(ReplayError::ModeBlockMissing { + case: case.name.clone(), + offering: offering.id.clone(), + }); + }; + let pool_id = exact.pool.clone(); + let pool = + self.facts + .pool(&pool_id) + .ok_or_else(|| ReplayError::UnknownPool { + case: case.name.clone(), + pool: pool_id.clone(), + })?; + if pool.members.is_empty() { + return Err(ReplayError::EmptyPool { + case: case.name.clone(), + pool: pool_id, + }); + } + } + SchedulingMode::ArrivalWindow => { + let Some(arrival) = offering.arrival.as_ref() else { + return Err(ReplayError::ModeBlockMissing { + case: case.name.clone(), + offering: offering.id.clone(), + }); + }; + let window_id = arrival.window.clone(); + let Some(window) = policy.window(&window_id) else { + return Err(ReplayError::UnknownWindow { + case: case.name.clone(), + window: window_id, + }); + }; + // The window must sit inside the location's published + // openings; a window floating outside every opening is a + // fixture telling a story the schedule never publishes. + let timezone = self + .facts + .location(&offering.location) + .expect("the location was resolved above") + .timezone + .clone(); + let exceptions: Vec<_> = self + .facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let open = location_open_intervals( + policy, + &offering.location, + &timezone, + &exceptions, + )?; + if !covers_span(&open, window.start, window.end) { + return Err(ReplayError::WindowOutsideOpenings { window: window_id }); + } + } + } + } + Ok(()) + } + + /// Replay the cases in order against the policy, entirely offline. + /// + /// Every case runs at the fixture's fixed `now` through the same pure + /// evaluators the runtime calls. An admitted case is recorded into the + /// ledger as a confirmed booking; an admitted reschedule removes the + /// allocation it replaces, exactly once. A refused case consumes nothing. + pub fn replay(&self, policy: &SchedulingPolicy) -> Result, ReplayError> { + self.check(policy)?; + let mut snapshot = LedgerSnapshot { + claims: self.initial.clone(), + }; + let mut outcomes = Vec::with_capacity(self.cases.len()); + for case in &self.cases { + let outcome = match replay_case(policy, self, case, &mut snapshot) { + Ok(admitted) => Ok(admitted), + Err(CaseStoppage::Replay(error)) => return Err(error), + Err(CaseStoppage::Refusal(refusal)) => Err(refusal), + }; + let status = match (&case.expect, &outcome) { + (FixtureExpectation::Admitted { units, resource }, Ok(recorded)) => { + let units_match = recorded.units == *units; + let resource_match = match resource { + None => true, + Some(expected) => recorded.resource.as_deref() == Some(expected.as_str()), + }; + if units_match && resource_match { + CaseStatus::Pass + } else { + CaseStatus::Fail + } + } + (FixtureExpectation::Refused { code }, Err(refusal)) => { + if refusal.public_code().code() == code.as_str() { + CaseStatus::Pass + } else { + CaseStatus::Fail + } + } + _ => CaseStatus::Fail, + }; + let detail = match &outcome { + Ok(admitted) => Some(format!( + "admitted onto {} for {} unit(s)", + admitted.resource.as_deref().unwrap_or("the window"), + admitted.units + )), + Err(refusal) => Some(format!( + "refused {} (detailed: {})", + refusal.public_code().code(), + refusal.detailed_code().code() + )), + }; + outcomes.push(CaseOutcome { + name: case.name.clone(), + status, + detail, + }); + } + Ok(outcomes) + } +} + +/// What stopped one replayed case: a replay-level configuration problem, or +/// the admission refusal the evaluators returned. +#[derive(Debug)] +enum CaseStoppage { + Replay(ReplayError), + Refusal(crate::admission::AdmissionRefusal), +} + +impl From for CaseStoppage { + fn from(refusal: crate::admission::AdmissionRefusal) -> Self { + Self::Refusal(refusal) + } +} + +fn replay_case( + policy: &SchedulingPolicy, + fixture: &SchedulingFixture, + case: &FixtureCase, + snapshot: &mut LedgerSnapshot, +) -> Result { + let request = &case.request; + let offering = policy + .offering(&request.offering) + .ok_or(CaseStoppage::Refusal( + crate::admission::AdmissionRefusal::ScheduleUnpublished, + ))?; + let location = fixture + .facts + .location(&offering.location) + .ok_or(CaseStoppage::Replay(ReplayError::UnknownLocation { + case: case.name.clone(), + location: offering.location.clone(), + }))?; + + let revision = policy.scheduling.version; + match offering.mode { + SchedulingMode::ExactTime => { + let exact = offering.exact_time.as_ref().ok_or(CaseStoppage::Replay( + ReplayError::ModeBlockMissing { + case: case.name.clone(), + offering: offering.id.clone(), + }, + ))?; + let pool = fixture.facts.pool(&exact.pool).ok_or(CaseStoppage::Replay( + ReplayError::UnknownPool { + case: case.name.clone(), + pool: exact.pool.clone(), + }, + ))?; + let exceptions: Vec<_> = fixture + .facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let open = location_open_intervals( + policy, + &offering.location, + &location.timezone, + &exceptions, + ) + .map_err(CaseStoppage::Replay)?; + let closures = + location_closure_intervals(&fixture.facts, &offering.location, &location.timezone) + .map_err(CaseStoppage::Replay)?; + let context = ExactTimeContext { + offering, + exact, + members: &pool.members, + open: &open, + closures: &closures, + snapshot, + policy_revision: revision, + now: fixture.now, + }; + let admitted = evaluate_exact_time_admission(&context, request)?; + let supply = admitted + .resource + .clone() + .expect("an exact-time admission names its member"); + record_admission(&case.name, &admitted, &supply, request, snapshot); + Ok(admitted) + } + SchedulingMode::ArrivalWindow => { + let arrival = offering.arrival.as_ref().ok_or(CaseStoppage::Replay( + ReplayError::ModeBlockMissing { + case: case.name.clone(), + offering: offering.id.clone(), + }, + ))?; + let window = policy.window(&arrival.window).ok_or(CaseStoppage::Replay( + ReplayError::UnknownWindow { + case: case.name.clone(), + window: arrival.window.clone(), + }, + ))?; + let context = WindowContext { + offering, + window, + lead_time_minutes: arrival.lead_time_minutes, + horizon_days: arrival.horizon_days, + snapshot, + policy_revision: revision, + now: fixture.now, + }; + let admitted = evaluate_window_admission(&context, request)?; + // A window claim occupies the window itself, so later cases sum + // against the window's published units. + record_admission(&case.name, &admitted, &window.id, request, snapshot); + Ok(admitted) + } + } +} + +/// Record an admitted case into the replay ledger, replacing the allocation a +/// reschedule names exactly once. The recorded claim id is `case:` plus the +/// case name, so a later case can reschedule what an earlier case booked. +fn record_admission( + case: &str, + admitted: &crate::admission::Admission, + supply_id: &str, + request: &AdmissionRequest, + snapshot: &mut LedgerSnapshot, +) { + if let Some(replaced) = &request.reschedule_of { + snapshot.claims.retain(|claim| &claim.id != replaced); + } + snapshot.claims.push(LedgerClaim { + id: format!("case:{case}"), + supply_id: supply_id.to_owned(), + kind: LedgerKind::Booking, + channel: request.channel.clone(), + start: admitted.occupied_start, + end: admitted.occupied_end, + units: admitted.units, + duplicate_key: request.duplicate_key.clone(), + // A booking recorded by replay never expires; holds are exercised + // through the evaluators directly. + expires_at: None, + }); +} + +fn location_open_intervals( + policy: &SchedulingPolicy, + location_id: &str, + timezone: &str, + exceptions: &[registry_platform_calendar::CalendarException<'_>], +) -> Result, ReplayError> { + let mut all = Vec::new(); + for opening in policy.openings.iter().filter(|o| o.location == location_id) { + // A holiday-set reference the policy does not declare is a policy + // check finding; the calendar cannot expand against it, so replay + // stops with the opening that carries it instead of panicking. + let Some(holiday_set) = policy.holiday_set(&opening.holiday_set) else { + return Err(ReplayError::HolidaySetMissing { + opening: opening.id.clone(), + holiday_set: opening.holiday_set.clone(), + }); + }; + let weekdays: Vec = + opening.weekdays.iter().map(|day| day.to_chrono()).collect(); + let pattern = WeeklyOpeningPattern { + id: &opening.id, + timezone, + weekdays: &weekdays, + start_time: &opening.start_time, + end_time: &opening.end_time, + effective_from: &opening.effective_from, + effective_until: &opening.effective_until, + }; + let dates: Vec<&str> = holiday_set.dates.iter().map(String::as_str).collect(); + let revision = HolidaySetRevision { + holiday_set: &holiday_set.id, + revision: holiday_set.revision, + dates: &dates, + }; + all.extend(expand_weekly_openings(&pattern, exceptions, &revision)?); + } + Ok(all) +} + +/// Whether the union of `intervals` covers the whole span `[start, end)`. +fn covers_span( + intervals: &[CalendarInterval], + start: chrono::DateTime, + end: chrono::DateTime, +) -> bool { + let mut overlapping: Vec = intervals + .iter() + .filter(|interval| interval.start < end && start < interval.end) + .copied() + .collect(); + overlapping.sort_by_key(|interval| (interval.start, interval.end)); + + let mut covered_until = start; + for interval in overlapping { + if interval.start > covered_until { + return false; + } + covered_until = covered_until.max(interval.end); + if covered_until >= end { + return true; + } + } + covered_until >= end +} + +fn location_closure_intervals( + facts: &SchedulingFacts, + location_id: &str, + timezone: &str, +) -> Result, ReplayError> { + let mut closures = Vec::new(); + for exception in facts + .exceptions + .iter() + .filter(|exception| exception.location == location_id) + { + if exception.kind == crate::model::ExceptionRecordKind::Closure { + closures.push(local_interval( + &exception.date, + &exception.start_time, + &exception.end_time, + timezone, + )?); + } + } + Ok(closures) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{LocationRecord, PartyCounts}; + + fn exact_time_fixture_yaml() -> &'static str { + r#" +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: last-station +now: 2026-10-05T00:00:00Z +facts: + locations: + - id: north-counter + timezone: Asia/Bangkok + pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true +initial: [] +cases: + - name: first-caller-takes-the-last-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + - name: same-key-retry-is-refused + request: + offering: registry-update-30 + start: 2026-10-05T03:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: booking.duplicate-active +"# + } + + fn policy_for_fixture() -> SchedulingPolicy { + crate::policy::parse_policy_yaml( + r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: A 30-minute update at an interchangeable station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 30 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + duplicateActiveKey: subject + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: counter-hours + location: north-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-05" + effectiveUntil: "2026-10-05" + because: Counter opening hours reviewed by the office manager. +windows: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#, + ) + .expect("the policy parses") + } + + #[test] + fn fixtures_parse_and_refuse_unknown_fields() { + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + assert_eq!(fixture.name, "last-station"); + assert!( + parse_fixture_yaml(&format!("{}stray: true\n", exact_time_fixture_yaml())).is_err() + ); + } + + #[test] + fn replay_threads_admitted_cases_into_the_ledger() { + let policy = policy_for_fixture(); + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + let outcomes = fixture.replay(&policy).expect("replays"); + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0].status, CaseStatus::Pass); + assert_eq!(outcomes[1].status, CaseStatus::Pass); + // The first case's duplicate key is live for the second. + assert!(outcomes[1] + .detail + .as_deref() + .is_some_and(|detail| detail.contains("booking.duplicate-active"))); + } + + #[test] + fn an_admitted_reschedule_replaces_its_allocation_once() { + let policy = policy_for_fixture(); + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: move-one-booking +now: 2026-10-05T00:00:00Z +facts: + locations: + - id: north-counter + timezone: Asia/Bangkok + pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true +initial: + - id: booking-9 + supplyId: station-1 + kind: booking + start: 2026-10-05T02:00:00Z + end: 2026-10-05T02:30:00Z + units: 1 + duplicateKey: subject:one +cases: + - name: reschedule-excludes-and-replaces-its-own-allocation + request: + offering: registry-update-30 + start: 2026-10-05T02:30:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + rescheduleOf: booking-9 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-1 + - name: the-freed-slot-serves-another-caller + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:two + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-1 +"#; + let fixture = parse_fixture_yaml(yaml).expect("parses"); + let outcomes = fixture.replay(&policy).expect("replays"); + assert_eq!(outcomes.len(), 2); + assert!(outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass)); + } + + #[test] + fn broken_fixtures_stop_before_running() { + let policy = policy_for_fixture(); + let mut fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + + fixture.cases[1].expect = FixtureExpectation::Refused { + code: "authorization.refused".to_owned(), + }; + assert_eq!( + fixture.check(&policy), + Err(ReplayError::UnknownExpectationCode { + case: "same-key-retry-is-refused".to_owned(), + code: "authorization.refused".to_owned() + }) + ); + + fixture.cases[1].expect = FixtureExpectation::Refused { + code: "capacity.exhausted".to_owned(), + }; + fixture.cases[0].request.offering = "no-such-offering".to_owned(); + assert!(matches!( + fixture.check(&policy), + Err(ReplayError::UnknownOffering { .. }) + )); + } + + /// A detailed-only expectation code can never match: replay compares the + /// public projection, so the check refuses it up front with the code named. + #[test] + fn a_detailed_only_expectation_code_is_refused_before_any_case_runs() { + let policy = policy_for_fixture(); + let mut fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + fixture.cases[1].expect = FixtureExpectation::Refused { + code: "resource.unavailable".to_owned(), + }; + assert_eq!( + fixture.check(&policy), + Err(ReplayError::DetailedExpectationCode { + case: "same-key-retry-is-refused".to_owned(), + code: "resource.unavailable".to_owned(), + }) + ); + } + + /// A policy whose mode block is missing fails the fixture with a typed + /// error naming the case and the offering, never a panic: replay must not + /// assume the policy passed a check it never ran. + #[test] + fn a_policy_missing_its_mode_block_fails_the_fixture_not_the_process() { + let mut policy = policy_for_fixture(); + policy.offerings[0].exact_time = None; + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + assert_eq!( + fixture.check(&policy), + Err(ReplayError::ModeBlockMissing { + case: "first-caller-takes-the-last-station".to_owned(), + offering: "registry-update-30".to_owned(), + }) + ); + assert_eq!( + fixture.replay(&policy), + Err(ReplayError::ModeBlockMissing { + case: "first-caller-takes-the-last-station".to_owned(), + offering: "registry-update-30".to_owned(), + }) + ); + } + + /// An opening referencing a holiday set the policy does not declare stops + /// replay at the opening that carries it, through both the arrival-window + /// check path and the exact-time expansion path. + #[test] + fn an_opening_referencing_an_undeclared_holiday_set_fails_the_fixture() { + let mut policy = policy_for_fixture(); + policy.openings[0].holiday_set = "no-such-holidays".to_owned(); + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + assert_eq!( + fixture.replay(&policy), + Err(ReplayError::HolidaySetMissing { + opening: "counter-hours".to_owned(), + holiday_set: "no-such-holidays".to_owned(), + }) + ); + } + + #[test] + fn closures_and_openings_come_from_the_facts_for_the_location() { + let policy = policy_for_fixture(); + let exceptions: Vec> = Vec::new(); + let open = location_open_intervals(&policy, "north-counter", "Asia/Bangkok", &exceptions) + .expect("expands"); + // 2026-10-05 is a Monday: one 09:00-12:30 Bangkok interval, and + // 09:00 Bangkok is 02:00Z the same day. + assert_eq!(open.len(), 1); + assert_eq!(open[0].start.to_rfc3339(), "2026-10-05T02:00:00+00:00"); + assert_eq!(open[0].end.to_rfc3339(), "2026-10-05T05:30:00+00:00"); + + let facts = SchedulingFacts { + locations: vec![LocationRecord { + id: "north-counter".to_owned(), + timezone: "Asia/Bangkok".to_owned(), + }], + pools: vec![], + exceptions: vec![crate::model::CalendarExceptionRecord { + id: "systems-training".to_owned(), + location: "north-counter".to_owned(), + kind: crate::model::ExceptionRecordKind::Closure, + date: "2026-10-05".to_owned(), + start_time: "09:00".to_owned(), + end_time: "10:00".to_owned(), + reopens: None, + authority: None, + }], + }; + let closures = + location_closure_intervals(&facts, "north-counter", "Asia/Bangkok").expect("converts"); + assert_eq!(closures.len(), 1); + assert_eq!(closures[0].start.to_rfc3339(), "2026-10-05T02:00:00+00:00"); + + // An exception at another location never closes this one. + let other = + location_closure_intervals(&facts, "south-counter", "Asia/Bangkok").expect("converts"); + assert!(other.is_empty()); + } + + /// A window fixture threads admitted cases into the window's own supply, + /// so the total and the channel subquota both deplete as callers arrive. + #[test] + fn window_cases_deplete_the_published_window() { + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-morning +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok + pools: [] +initial: [] +cases: + - name: first-household + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 2 + attendees: 3 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 + - name: second-household-overdraws-the-public-subquota + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted + - name: assisted-household-fits-the-protected-unit + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: assisted + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 +"#; + let policy = crate::policy::parse_policy_yaml( + r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: household-days + version: 3 +services: + - id: household-day + label: Household registration day +offerings: + - id: household-morning + service: household-day + label: Morning household block + mode: arrival-window + location: civic-hall + because: Households arrive in a served morning block. + arrival: + window: household-morning-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: hall-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "08:00" + endTime: "12:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall opens on Saturday mornings. +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + leftover: becomes-walk-in + because: The Saturday morning household block. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. +"#, + ) + .expect("the policy parses"); + assert!(policy.check().is_empty(), "{:?}", policy.check()); + + let fixture = parse_fixture_yaml(yaml).expect("parses"); + let outcomes = fixture.replay(&policy).expect("replays"); + assert!( + outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass), + "{outcomes:?}" + ); + } + + #[test] + fn party_counts_parse_from_the_fixture_surface() { + let fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + assert_eq!( + fixture.cases[0].request.party, + PartyCounts { + recipients: 1, + attendees: 1, + } + ); + } + + #[test] + fn span_coverage_requires_an_unbroken_union() { + use chrono::TimeZone as _; + let at = + |hour: u32, minute: u32| Utc.with_ymd_and_hms(2026, 10, 10, hour, minute, 0).unwrap(); + let span = |sh: u32, sm: u32, eh: u32, em: u32| CalendarInterval { + start: at(sh, sm), + end: at(eh, em), + }; + + // Two touching halves cover the whole; a gap in the middle does not. + assert!(covers_span( + &[span(1, 0, 2, 0), span(2, 0, 3, 0)], + at(1, 0), + at(3, 0) + )); + assert!(!covers_span( + &[span(1, 0, 2, 0), span(2, 30, 4, 0)], + at(1, 0), + at(3, 0) + )); + assert!(!covers_span(&[span(2, 0, 4, 0)], at(1, 0), at(3, 0))); + assert!(!covers_span(&[], at(1, 0), at(3, 0))); + } +} diff --git a/crates/registry-scheduling-core/src/lib.rs b/crates/registry-scheduling-core/src/lib.rs new file mode 100644 index 0000000000..f1613c0cdf --- /dev/null +++ b/crates/registry-scheduling-core/src/lib.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Source-neutral core of Registry Scheduling. +//! +//! This crate owns the model an adopter authors and the decisions that model +//! implies, and nothing else: the policy types with their validators, the +//! supply ledger and its pure query methods, the admission evaluators, the +//! offline replay fixtures, the closed problem vocabulary, and the fixed +//! artifact names. It deliberately contains no source protocol types, no SQL, +//! no I/O, and no clock: every evaluator takes the observed instant as an +//! explicit parameter, so the runtime inside its capacity transaction, the +//! explain path, and an offline fixture replay all run the same pure functions +//! and cannot disagree about what admits. +//! +//! The runtime crate `registry-scheduling` builds its PostgreSQL store, HTTP +//! surface, and workers on top of this model. Adopter tooling +//! (`registry-schedulingctl`) checks and replays authored projects against it. +//! The dependency runs one way: this crate depends only on shared +//! `registry-platform-*` primitives and never on another product's runtime or +//! protocol types. + +mod admission; +mod diagnostics; +mod fixture; +mod model; +mod naming; +mod policy; +mod problem; +mod units; + +pub use admission::*; +pub use diagnostics::*; +pub use fixture::*; +pub use model::*; +pub use naming::*; +pub use policy::*; +pub use problem::*; +pub use units::*; diff --git a/crates/registry-scheduling-core/src/model.rs b/crates/registry-scheduling-core/src/model.rs new file mode 100644 index 0000000000..ed7fdaa766 --- /dev/null +++ b/crates/registry-scheduling-core/src/model.rs @@ -0,0 +1,636 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The source-neutral scheduling model: lifecycle states, the authoritative +//! supply ledger, the environment records admission runs against, and the +//! admission request with its idempotent request hash. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use registry_platform_calendar::{CalendarException, CalendarExceptionKind}; + +/// Lifecycle of a temporary hold. An expired hold stops consuming capacity the +/// moment it expires, whether or not a cleanup worker has run since. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum HoldState { + Active, + Consumed, + Released, + Expired, +} + +/// Lifecycle of a confirmed booking. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BookingState { + Confirmed, + Cancelled, +} + +/// Recorded attendance of a booking. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum AttendanceState { + NotRecorded, + Arrived, + InService, + Completed, + NoShow, + LeftWithoutService, +} + +/// The party summary capacity is derived from. Recipients are the people the +/// service is delivered to; attendees are everyone present, recipients +/// included. A parent accompanying two children is three attendees and two +/// recipients, and consumes recipient units, never attendee counts. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PartyCounts { + pub recipients: u32, + pub attendees: u32, +} + +/// What a ledger entry allocates supply for. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum LedgerKind { + /// A confirmed booking. + Booking, + /// A temporary hold. + Hold, +} + +/// One entry in the authoritative supply ledger. +/// +/// `start` and `end` are the occupied interval including any setup and cleanup +/// buffers, so two claims that merely touch at their displayed times still +/// conflict when their buffers overlap. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LedgerClaim { + pub id: String, + /// The pool member resource (exact time) or the window (arrival) this + /// claim occupies. + pub supply_id: String, + pub kind: LedgerKind, + /// The authenticated channel the claim was allocated in, when the supply + /// carries channel subquotas. + pub channel: Option, + pub start: DateTime, + pub end: DateTime, + /// Recipient units consumed. Exact-time claims on one member consume one. + pub units: u32, + /// The duplicate-active key this claim holds, when the offering defines + /// one. + pub duplicate_key: Option, + /// Hold expiry. An expired hold stops consuming capacity at this instant + /// even when the cleanup worker is delayed. + pub expires_at: Option>, +} + +/// The authoritative supply ledger at one observed instant, holding exactly +/// the claims that consume capacity now. +/// +/// The store is expected to have filtered released, cancelled, and consumed +/// claims before building a snapshot; the evaluator still re-checks hold +/// expiry against `now` so a delayed cleanup worker can never keep an expired +/// hold alive. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LedgerSnapshot { + pub claims: Vec, +} + +impl LedgerSnapshot { + /// The claims that consume capacity at `now`. + pub fn consuming<'a>( + &'a self, + now: DateTime, + ) -> impl Iterator + 'a { + self.claims + .iter() + .filter(move |claim| claim.consumes_at(now)) + } + + /// Whether any consuming claim occupies `supply_id` in the half-open + /// interval `[start, end)`, ignoring `exclude`. + pub fn supply_occupied( + &self, + supply_id: &str, + start: DateTime, + end: DateTime, + exclude: Option<&str>, + now: DateTime, + ) -> bool { + self.consuming(now).any(|claim| { + claim.supply_id == supply_id + && Some(claim.id.as_str()) != exclude + && claim.start < end + && start < claim.end + }) + } + + /// Recipient units consuming a window's supply at `now`, all channels or + /// one channel only, ignoring `exclude`. + pub fn window_units_allocated( + &self, + window_id: &str, + channel: Option<&str>, + exclude: Option<&str>, + now: DateTime, + ) -> u32 { + self.consuming(now) + .filter(|claim| { + claim.supply_id == window_id + && Some(claim.id.as_str()) != exclude + && channel.is_none_or(|wanted| claim.channel == Some(wanted.to_owned())) + }) + .map(|claim| claim.units) + // A snapshot past u32 units is already broken; saturating keeps + // every later admission refused instead of wrapping toward zero + // and re-admitting capacity no window holds. + .fold(0, u32::saturating_add) + } + + /// Whether a consuming claim already holds `duplicate_key` at `now`, + /// ignoring `exclude`. + pub fn duplicate_active( + &self, + duplicate_key: &str, + exclude: Option<&str>, + now: DateTime, + ) -> bool { + self.consuming(now).any(|claim| { + claim.duplicate_key.as_deref() == Some(duplicate_key) + && Some(claim.id.as_str()) != exclude + }) + } +} + +impl LedgerClaim { + /// A confirmed booking always consumes; a hold consumes only until it + /// expires. + pub fn consumes_at(&self, now: DateTime) -> bool { + match self.kind { + LedgerKind::Booking => true, + LedgerKind::Hold => self.expires_at.is_some_and(|expiry| expiry > now), + } + } +} + +/// A location record: the layer of configuration an operator maintains at +/// runtime, not inside the authored policy. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocationRecord { + pub id: String, + /// A named IANA timezone such as `Asia/Bangkok`. + pub timezone: String, +} + +/// One interchangeable member of a resource pool. +/// +/// `available` carries no reason. Why a member is unavailable is private staff +/// information; the public answer to a caller is that capacity is exhausted. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PoolMember { + pub resource_id: String, + pub capabilities: Vec, + pub available: bool, +} + +/// A pool of interchangeable resources backing exact-time offerings. A pool is +/// its concrete members, never an independent counter. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourcePool { + pub id: String, + pub members: Vec, +} + +/// An owned dated exception over local wall-clock time, the record form of +/// `registry_platform_calendar::CalendarException`. An exception is scoped to +/// one location: a closure at one counter never closes another. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CalendarExceptionRecord { + pub id: String, + /// The location this exception applies to. + pub location: String, + pub kind: ExceptionRecordKind, + pub date: String, + pub start_time: String, + pub end_time: String, + pub reopens: Option, + pub authority: Option, +} + +/// The layer an exception occupies, mirrored for records. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExceptionRecordKind { + Closure, + Opening, +} + +impl CalendarExceptionRecord { + /// Borrow this record for calendar expansion. + pub fn borrowed(&self) -> CalendarException<'_> { + CalendarException { + id: &self.id, + kind: match self.kind { + ExceptionRecordKind::Closure => CalendarExceptionKind::Closure, + ExceptionRecordKind::Opening => CalendarExceptionKind::Opening, + }, + date: &self.date, + start_time: &self.start_time, + end_time: &self.end_time, + reopens: self.reopens.as_deref(), + authority: self.authority.as_deref(), + } + } +} + +/// The environment records an admission runs against: locations, pools, and +/// dated exceptions. In production these are runtime records; in an offline +/// replay they are fixture facts. The evaluator never reads or mutates them +/// directly; its caller resolves them into the context the evaluator takes. +/// A fixture may omit an empty collection; replay fails fast when a case +/// references a record the facts do not carry. +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingFacts { + #[serde(default)] + pub locations: Vec, + #[serde(default)] + pub pools: Vec, + #[serde(default)] + pub exceptions: Vec, +} + +impl SchedulingFacts { + #[must_use] + pub fn location(&self, id: &str) -> Option<&LocationRecord> { + self.locations.iter().find(|location| location.id == id) + } + + #[must_use] + pub fn pool(&self, id: &str) -> Option<&ResourcePool> { + self.pools.iter().find(|pool| pool.id == id) + } +} + +/// A request for one admission decision. The evaluator is pure: it resolves +/// nothing, fetches nothing, and reads exactly what it is handed. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AdmissionRequest { + pub offering: String, + pub start: DateTime, + pub party: PartyCounts, + /// The authenticated channel, when the deployment allocates supply + /// through channel subquotas. + pub channel: Option, + /// The duplicate-active key, derived the same way the offering's policy + /// prescribes. + pub duplicate_key: Option, + /// The policy revision the caller resolved the offering under. + pub policy_revision: u64, + /// The window revision the caller resolved an arrival window under. + pub window_revision: Option, + /// The party's held capability references, matched against the offering's + /// requirements. + pub capabilities: Vec, + /// The party's held prerequisite references, matched against the + /// offering's requirements. + pub prerequisites: Vec, + /// The claim a reschedule replaces. Its allocation is excluded from the + /// conflict checks for this request so a reschedule never competes with + /// the appointment it replaces. + pub reschedule_of: Option, +} + +/// The idempotent hash of an admission request. +/// +/// The tuple is fixed in code, in this order, so a field reorder elsewhere can +/// never change a stored hash. The same request always hashes identically; +/// the same idempotency key with a different payload hashes differently, which +/// is exactly the distinction a retry must be able to make. +#[must_use] +pub fn admission_request_hash(request: &AdmissionRequest) -> String { + let fixed = ( + &request.offering, + request.start.to_rfc3339(), + request.party.recipients, + request.party.attendees, + &request.channel, + &request.duplicate_key, + request.policy_revision, + request.window_revision, + &request.capabilities, + &request.prerequisites, + &request.reschedule_of, + ); + let value = serde_json::to_value(&fixed).expect("the fixed tuple always serializes"); + let canonical = registry_platform_canonical_json::canonicalize_json(&value) + .expect("the fixed tuple always canonicalizes"); + let digest = Sha256::digest(&canonical); + format!("sha256:{}", hex(&digest)) +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut rendered = String::with_capacity(bytes.len() * 2); + for byte in bytes { + rendered.push(HEX[(byte >> 4) as usize] as char); + rendered.push(HEX[(byte & 0x0f) as usize] as char); + } + rendered +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + fn utc(hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 5, hour, minute, 0).unwrap() + } + + fn request() -> AdmissionRequest { + AdmissionRequest { + offering: "registry-update-30".to_owned(), + start: utc(2, 0), + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: Some("public".to_owned()), + duplicate_key: Some("subject:one".to_owned()), + policy_revision: 4, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + reschedule_of: None, + } + } + + #[test] + fn a_confirmed_booking_consumes_but_an_expired_hold_does_not() { + let now = utc(4, 0); + let booking = LedgerClaim { + id: "claim-1".to_owned(), + supply_id: "station-1".to_owned(), + kind: LedgerKind::Booking, + channel: None, + start: utc(2, 0), + end: utc(3, 0), + units: 1, + duplicate_key: None, + expires_at: None, + }; + let live_hold = LedgerClaim { + id: "claim-2".to_owned(), + supply_id: "station-2".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(2, 0), + end: utc(3, 0), + units: 1, + duplicate_key: None, + expires_at: Some(utc(4, 30)), + }; + let expired_hold = LedgerClaim { + id: "claim-3".to_owned(), + supply_id: "station-3".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(2, 0), + end: utc(3, 0), + units: 1, + duplicate_key: None, + expires_at: Some(now), + }; + let snapshot = LedgerSnapshot { + claims: vec![booking, live_hold.clone(), expired_hold], + }; + + let consuming: Vec<&str> = snapshot + .consuming(now) + .map(|claim| claim.id.as_str()) + .collect(); + assert_eq!(consuming, vec!["claim-1", "claim-2"]); + + // A hold that expires exactly at `now` is already expired: expiry is + // exclusive, so the caller can never observe a hold that died while + // being evaluated. + assert!(!live_hold.consumes_at(live_hold.expires_at.unwrap())); + } + + #[test] + fn supply_occupancy_is_half_open_and_honors_the_excluded_claim() { + let now = utc(4, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + supply_id: "station-1".to_owned(), + kind: LedgerKind::Booking, + channel: None, + start: utc(2, 0), + end: utc(3, 0), + units: 1, + duplicate_key: None, + expires_at: None, + }; + let snapshot = LedgerSnapshot { + claims: vec![claim], + }; + + assert!(snapshot.supply_occupied("station-1", utc(1, 30), utc(2, 30), None, now)); + // Touching at an endpoint is not overlapping: [3:00, 3:30) starts + // exactly where the claim ends. + assert!(!snapshot.supply_occupied("station-1", utc(3, 0), utc(3, 30), None, now)); + assert!(!snapshot.supply_occupied("station-2", utc(2, 0), utc(3, 0), None, now)); + // A reschedule excludes its own allocation. + assert!(!snapshot.supply_occupied("station-1", utc(2, 0), utc(3, 0), Some("claim-1"), now)); + } + + #[test] + fn window_allocation_sums_per_channel_or_overall() { + let now = utc(4, 0); + let claim = |id: &str, channel: Option<&str>, units: u32| LedgerClaim { + id: id.to_owned(), + supply_id: "morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: channel.map(str::to_owned), + start: utc(1, 0), + end: utc(4, 0), + units, + duplicate_key: None, + expires_at: None, + }; + let snapshot = LedgerSnapshot { + claims: vec![ + claim("claim-1", Some("public"), 2), + claim("claim-2", Some("assisted"), 1), + ], + }; + + assert_eq!( + snapshot.window_units_allocated("morning-window", None, None, now), + 3 + ); + assert_eq!( + snapshot.window_units_allocated("morning-window", Some("public"), None, now), + 2 + ); + assert_eq!( + snapshot.window_units_allocated("morning-window", Some("walk-in"), None, now), + 0 + ); + assert_eq!( + snapshot.window_units_allocated("morning-window", None, Some("claim-2"), now), + 2 + ); + } + + #[test] + fn window_allocation_saturates_instead_of_wrapping_past_u32() { + let now = utc(4, 0); + let claim = |id: &str, units: u32| LedgerClaim { + id: id.to_owned(), + supply_id: "morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: None, + start: utc(1, 0), + end: utc(4, 0), + units, + duplicate_key: None, + expires_at: None, + }; + let snapshot = LedgerSnapshot { + claims: vec![claim("claim-1", u32::MAX), claim("claim-2", 1)], + }; + // The saturated total keeps refusing; a wrapped one would read as 0 + // and hand the window its whole capacity back. + assert_eq!( + snapshot.window_units_allocated("morning-window", None, None, now), + u32::MAX + ); + } + + #[test] + fn duplicate_keys_are_detected_only_on_consuming_claims() { + let now = utc(4, 0); + let expired_hold = LedgerClaim { + id: "claim-1".to_owned(), + supply_id: "morning-window".to_owned(), + kind: LedgerKind::Hold, + channel: None, + start: utc(1, 0), + end: utc(4, 0), + units: 1, + duplicate_key: Some("subject:one".to_owned()), + expires_at: Some(utc(3, 0)), + }; + let snapshot = LedgerSnapshot { + claims: vec![expired_hold], + }; + assert!(!snapshot.duplicate_active("subject:one", None, now)); + } + + #[test] + fn the_same_request_always_hashes_identically() { + assert_eq!( + admission_request_hash(&request()), + admission_request_hash(&request()) + ); + assert!(admission_request_hash(&request()).starts_with("sha256:")); + assert_eq!( + admission_request_hash(&request()).len(), + "sha256:".len() + 64 + ); + } + + /// AT-06, pure half: a changed payload under the same caller-visible key + /// must hash differently, or a retry could silently return the wrong + /// appointment. + #[test] + fn a_changed_payload_hashes_differently() { + let base = admission_request_hash(&request()); + + let moved_start = AdmissionRequest { + start: utc(2, 30), + ..request() + }; + assert_ne!(base, admission_request_hash(&moved_start)); + + let grew_party = AdmissionRequest { + party: PartyCounts { + recipients: 2, + attendees: 3, + }, + ..request() + }; + assert_ne!(base, admission_request_hash(&grew_party)); + + let other_offering = AdmissionRequest { + offering: "registry-update-45".to_owned(), + ..request() + }; + assert_ne!(base, admission_request_hash(&other_offering)); + + let rescheduling = AdmissionRequest { + reschedule_of: Some("claim-9".to_owned()), + ..request() + }; + assert_ne!(base, admission_request_hash(&rescheduling)); + } + + #[test] + fn exception_records_borrow_into_calendar_expansion_form() { + let record = CalendarExceptionRecord { + id: "staff-meeting".to_owned(), + location: "north-counter".to_owned(), + kind: ExceptionRecordKind::Closure, + date: "2026-10-05".to_owned(), + start_time: "09:00".to_owned(), + end_time: "10:00".to_owned(), + reopens: None, + authority: None, + }; + let borrowed = record.borrowed(); + assert_eq!(borrowed.id, "staff-meeting"); + assert_eq!(borrowed.kind, CalendarExceptionKind::Closure); + assert_eq!(borrowed.date, "2026-10-05"); + } + + #[test] + fn lifecycle_enums_round_trip_and_refuse_unknown_states() { + let hold: HoldState = serde_norway::from_str("active").unwrap(); + assert_eq!(hold, HoldState::Active); + assert!(serde_norway::from_str::("parked").is_err()); + + let attendance: AttendanceState = serde_norway::from_str("leftWithoutService").unwrap(); + assert_eq!(attendance, AttendanceState::LeftWithoutService); + assert!(serde_norway::from_str::("no-show").is_err()); + + let booking: BookingState = serde_norway::from_str("confirmed").unwrap(); + assert_eq!(booking, BookingState::Confirmed); + } + + #[test] + fn ledger_and_request_shapes_refuse_unknown_fields() { + let claim_yaml = "id: claim-1\nsupplyId: station-1\nkind: booking\n\ + start: 2026-10-05T02:00:00Z\nend: 2026-10-05T03:00:00Z\nunits: 1\n"; + assert!(serde_norway::from_str::(claim_yaml).is_ok()); + assert!( + serde_norway::from_str::(&format!("{claim_yaml}note: stray\n")).is_err() + ); + + assert!(serde_norway::from_str::("recipients: 1\nattendees: 2\n").is_ok()); + assert!(serde_norway::from_str::("recipients: 1\n").is_err()); + } +} diff --git a/crates/registry-scheduling-core/src/naming.rs b/crates/registry-scheduling-core/src/naming.rs new file mode 100644 index 0000000000..10c3917160 --- /dev/null +++ b/crates/registry-scheduling-core/src/naming.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Fixed names of the Scheduling authoring and runtime artifacts. + +/// apiVersion of an authored scheduling policy package. +pub const SCHEDULING_POLICY_API_VERSION: &str = + "registry.registrystack.org/scheduling-policy-package/v1alpha1"; + +/// Kind of an authored scheduling policy package. +pub const SCHEDULING_POLICY_KIND: &str = "SchedulingPolicyPackage"; + +/// File name of the package manifest written beside the authored policy. +pub const SCHEDULING_PACKAGE_MANIFEST_FILE: &str = "scheduling.package.json"; + +/// File name of the authored policy inside a scheduling project. +pub const AUTHORED_POLICY_FILE: &str = "scheduling.yaml"; + +/// apiVersion of the operator runtime configuration document. +pub const SCHEDULING_RUNTIME_API_VERSION: &str = + "registry.registrystack.org/scheduling-runtime/v1alpha1"; + +/// Kind of the operator runtime configuration document. +pub const SCHEDULING_RUNTIME_KIND: &str = "SchedulingRuntimeConfig"; + +/// File name of the runtime configuration document. +pub const RUNTIME_SCHEMA_FILE: &str = "runtime.schema.json"; + +/// `$id` of the runtime configuration JSON Schema. +pub const SCHEDULING_RUNTIME_SCHEMA_ID: &str = + "https://id.registrystack.org/schemas/scheduling/runtime/runtime.v1alpha1.schema.json"; + +/// Base of every Scheduling problem type URI. +pub const SCHEDULING_PROBLEM_TYPE_BASE: &str = + "https://id.registrystack.org/problems/registry-scheduling/"; + +/// The one reserved hook ABI value. This milestone has no hook engine: the +/// value may appear on the hook policy type, and nowhere else. +pub const SCHEDULING_HOOK_ABI: &str = "registry.scheduling-hook/v1"; + +/// apiVersion of an offline replay fixture. +pub const SCHEDULING_FIXTURE_API_VERSION: &str = + "registry.registrystack.org/scheduling-fixture/v1alpha1"; + +/// Kind of an offline replay fixture. +pub const SCHEDULING_FIXTURE_KIND: &str = "SchedulingFixture"; + +#[cfg(test)] +mod tests { + use super::*; + + /// The names are contract: a change here is a product decision, never a + /// formatting pass. + #[test] + fn fixed_names_are_pinned() { + assert_eq!( + SCHEDULING_POLICY_API_VERSION, + "registry.registrystack.org/scheduling-policy-package/v1alpha1" + ); + assert_eq!(SCHEDULING_POLICY_KIND, "SchedulingPolicyPackage"); + assert_eq!(SCHEDULING_PACKAGE_MANIFEST_FILE, "scheduling.package.json"); + assert_eq!(AUTHORED_POLICY_FILE, "scheduling.yaml"); + assert_eq!( + SCHEDULING_RUNTIME_API_VERSION, + "registry.registrystack.org/scheduling-runtime/v1alpha1" + ); + assert_eq!(SCHEDULING_RUNTIME_KIND, "SchedulingRuntimeConfig"); + assert_eq!(RUNTIME_SCHEMA_FILE, "runtime.schema.json"); + assert_eq!( + SCHEDULING_RUNTIME_SCHEMA_ID, + "https://id.registrystack.org/schemas/scheduling/runtime/runtime.v1alpha1.schema.json" + ); + assert_eq!( + SCHEDULING_PROBLEM_TYPE_BASE, + "https://id.registrystack.org/problems/registry-scheduling/" + ); + assert_eq!(SCHEDULING_HOOK_ABI, "registry.scheduling-hook/v1"); + assert_eq!( + SCHEDULING_FIXTURE_API_VERSION, + "registry.registrystack.org/scheduling-fixture/v1alpha1" + ); + assert_eq!(SCHEDULING_FIXTURE_KIND, "SchedulingFixture"); + } +} diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs new file mode 100644 index 0000000000..0465898261 --- /dev/null +++ b/crates/registry-scheduling-core/src/policy.rs @@ -0,0 +1,1492 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The authored scheduling policy: services, offerings, opening patterns, +//! holiday sets, published arrival windows, and the hold policy, with the +//! validators a publication gate runs. +//! +//! The policy is layer one of the configuration model. It declares what a +//! deployment publishes and why; locations, resource pools, and dated +//! exceptions are runtime records the policy references by identifier but +//! never embeds, so the same published policy governs every environment that +//! resolves those identifiers. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::diagnostics::{PolicyCheckReason, SchedulingDiagnostic}; +use crate::naming::SCHEDULING_POLICY_API_VERSION; +use crate::naming::SCHEDULING_POLICY_KIND; +use crate::units::{check_because, RequiredUnitsPolicy}; + +/// The maximum number of entries one policy collection may carry. +pub const MAXIMUM_COLLECTION_ENTRIES: usize = 256; + +/// The maximum bytes of a human-facing label. +pub const MAXIMUM_LABEL_BYTES: usize = 128; + +/// One weekday of an opening pattern. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PolicyWeekday { + Mon, + Tue, + Wed, + Thu, + Fri, + Sat, + Sun, +} + +impl PolicyWeekday { + #[must_use] + pub const fn to_chrono(self) -> chrono::Weekday { + match self { + Self::Mon => chrono::Weekday::Mon, + Self::Tue => chrono::Weekday::Tue, + Self::Wed => chrono::Weekday::Wed, + Self::Thu => chrono::Weekday::Thu, + Self::Fri => chrono::Weekday::Fri, + Self::Sat => chrono::Weekday::Sat, + Self::Sun => chrono::Weekday::Sun, + } + } +} + +/// The identity of the deployment the policy governs. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolicyIdentity { + pub id: String, + pub version: u64, +} + +/// A service a deployment schedules. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServicePolicy { + pub id: String, + pub label: String, +} + +/// How an offering delivers its service. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SchedulingMode { + ExactTime, + ArrivalWindow, +} + +/// The exact-time block of an offering: appointment length, protections, and +/// the interchangeable pool that backs it. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExactTimeOffering { + pub duration_minutes: u32, + /// Setup time occupied before the displayed start. + pub buffer_before_minutes: u32, + /// Cleanup time occupied after the displayed end. + pub buffer_after_minutes: u32, + /// The earliest lead time a caller may book at. + pub lead_time_minutes: u32, + /// How far ahead booking is open. + pub horizon_days: u32, + /// The pool of interchangeable members backing this offering. A pool is + /// its members, never an independent counter. + pub pool: String, + /// The grid displayed starts must land on. + pub start_increment_minutes: u32, + /// The largest party the offering serves. + pub max_recipients: u32, +} + +/// The arrival-window block of an offering. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ArrivalOffering { + /// The published window this offering serves arrivals in. + pub window: String, + /// The earliest lead time a caller may book the window at. + pub lead_time_minutes: u32, + /// How far ahead of the window's start booking is open. + pub horizon_days: u32, +} + +/// One bookable thing a deployment offers. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OfferingPolicy { + pub id: String, + pub service: String, + pub label: String, + pub mode: SchedulingMode, + pub location: String, + pub because: String, + /// Present exactly when `mode` is `exact-time`. + pub exact_time: Option, + /// Present exactly when `mode` is `arrival-window`. + pub arrival: Option, + /// How late before the displayed start a booking may still be cancelled. + pub cancellation_cutoff_minutes: u32, + /// The caller attribute an active-booking duplicate check keys on, when + /// the service defines one. Phone numbers and email addresses are never + /// valid duplicate keys. + pub duplicate_active_key: Option, + /// Capabilities every backing member must have for this offering. + pub requires_capabilities: Vec, + /// Prerequisite references a requesting party must hold. + pub prerequisites: Vec, +} + +/// A bounded weekly opening pattern over local wall-clock time. The timezone +/// comes from the location record the pattern references. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OpeningPatternPolicy { + pub id: String, + pub location: String, + pub holiday_set: String, + pub weekdays: Vec, + pub start_time: String, + pub end_time: String, + pub effective_from: String, + pub effective_until: String, + pub because: String, +} + +/// A named, revisioned set of holiday dates. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HolidaySetPolicy { + pub id: String, + pub revision: u64, + pub because: String, + pub dates: Vec, +} + +/// The authenticated channel a subquota reserves capacity for. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Channel { + Public, + Assisted, + Urgent, + WalkIn, +} + +impl Channel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Public => "public", + Self::Assisted => "assisted", + Self::Urgent => "urgent", + Self::WalkIn => "walk-in", + } + } +} + +/// A non-overlapping reserved slice of a window's published capacity. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WindowSubquota { + pub id: String, + pub channel: Channel, + pub units: u32, + pub because: String, +} + +/// What happens to a window's leftover capacity when the window closes. The +/// policy must say; there is no default and no borrowing from the next +/// window. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum LeftoverCapacityPolicy { + /// Leftover units lapse with the window. + ExpiresUnused, + /// Leftover units become available to the walk-in channel. + BecomesWalkIn, +} + +/// The staffing block a window's supply is carved from, when the window is +/// backed by the same concrete resources an exact-time pool sells. +/// +/// `reserved_members` is the partition: how many pool members the window's +/// supply is attributed to. A window that names a pool without a partition +/// claims the whole block, so any exact-time offering on the same pool is +/// selling the same staffing twice and is rejected at publication. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WindowStaffing { + pub pool: String, + pub reserved_members: Option, + pub because: String, +} + +/// A published arrival window with its capacity. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PublishedWindow { + pub id: String, + pub revision: u64, + pub offering: String, + pub location: String, + pub start: DateTime, + pub end: DateTime, + /// Published capacity in the unit basis the units policy defines. + pub units: u32, + pub units_policy: RequiredUnitsPolicy, + pub subquotas: Vec, + pub leftover: LeftoverCapacityPolicy, + pub staffing: Option, + pub because: String, +} + +/// The hold policy every offering inherits. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HoldPolicy { + pub ttl_minutes: u32, + pub max_per_caller: u32, + pub because: String, +} + +/// A declared lifecycle hook. This version has no hook engine, so a declared +/// hook always fails the policy check: refusing what cannot run beats +/// accepting what would silently not run. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HookPolicy { + pub id: String, + /// Must be the one reserved scheduling hook ABI. + pub abi: String, + pub because: String, +} + +/// The authored scheduling policy package. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingPolicy { + pub api_version: String, + pub kind: String, + pub scheduling: PolicyIdentity, + pub services: Vec, + pub offerings: Vec, + pub holiday_sets: Vec, + pub openings: Vec, + pub windows: Vec, + pub hold_policy: HoldPolicy, + #[serde(default)] + pub hooks: Vec, +} + +/// Parse a policy from its authored YAML, with the failing path preserved. +pub fn parse_policy_yaml( + text: &str, +) -> Result> { + let deserializer = serde_norway::Deserializer::from_str(text); + serde_path_to_error::deserialize(deserializer) +} + +impl SchedulingPolicy { + #[must_use] + pub fn offering(&self, id: &str) -> Option<&OfferingPolicy> { + self.offerings.iter().find(|offering| offering.id == id) + } + + #[must_use] + pub fn window(&self, id: &str) -> Option<&PublishedWindow> { + self.windows.iter().find(|window| window.id == id) + } + + #[must_use] + pub fn holiday_set(&self, id: &str) -> Option<&HolidaySetPolicy> { + self.holiday_sets.iter().find(|set| set.id == id) + } + + /// The canonical digest of this policy package: `sha256:` followed by 64 + /// hex digits over the canonical JSON of the envelope and policy. The + /// same policy always digests identically; any authored change does not. + pub fn policy_digest(&self) -> String { + let envelope = serde_json::json!({ + "schema": SCHEDULING_POLICY_API_VERSION, + "policy": self, + }); + let canonical = registry_platform_canonical_json::canonicalize_json(&envelope) + .expect("a policy envelope always canonicalizes"); + let digest = Sha256::digest(&canonical); + format!("sha256:{}", hex(&digest)) + } + + /// Check the whole policy. Every finding names the path that failed. + pub fn check(&self) -> Vec { + let mut findings = Vec::new(); + if self.api_version != SCHEDULING_POLICY_API_VERSION { + findings.push(SchedulingDiagnostic::new( + "apiVersion", + PolicyCheckReason::UnsupportedApiVersion, + )); + } + if self.kind != SCHEDULING_POLICY_KIND { + findings.push(SchedulingDiagnostic::new( + "kind", + PolicyCheckReason::UnsupportedKind, + )); + } + if !valid_identifier(&self.scheduling.id) { + findings.push(SchedulingDiagnostic::new( + "scheduling.id", + PolicyCheckReason::InvalidIdentifier, + )); + } + if self.scheduling.version == 0 { + findings.push(SchedulingDiagnostic::new( + "scheduling.version", + PolicyCheckReason::InvalidBound, + )); + } + + check_collection_non_empty(&self.services, "services", &mut findings); + check_collection_non_empty(&self.offerings, "offerings", &mut findings); + check_collection_non_empty(&self.holiday_sets, "holidaySets", &mut findings); + check_collection_non_empty(&self.openings, "openings", &mut findings); + check_collection_bound(&self.services, "services", &mut findings); + check_collection_bound(&self.offerings, "offerings", &mut findings); + check_collection_bound(&self.holiday_sets, "holidaySets", &mut findings); + check_collection_bound(&self.openings, "openings", &mut findings); + check_collection_bound(&self.windows, "windows", &mut findings); + + let mut service_ids = Vec::new(); + for (index, service) in self.services.iter().enumerate() { + let path = format!("services[{index}]"); + if !valid_identifier(&service.id) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.id"), + PolicyCheckReason::InvalidIdentifier, + )); + } + if service.label.trim().is_empty() || service.label.len() > MAXIMUM_LABEL_BYTES { + findings.push(SchedulingDiagnostic::new( + format!("{path}.label"), + PolicyCheckReason::InvalidBound, + )); + } + push_unique(&mut service_ids, &service.id, &path, &mut findings); + } + + for (index, set) in self.holiday_sets.iter().enumerate() { + let path = format!("holidaySets[{index}]"); + // A holiday set with no dates is complete: it observes none. + if set.revision == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.revision"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(&set.because, &format!("{path}.because"), &mut findings); + check_identifier(&set.id, &format!("{path}.id"), &mut findings); + for (date_index, date) in set.dates.iter().enumerate() { + if !valid_iso_date(date) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.dates[{date_index}]"), + PolicyCheckReason::InvalidBound, + )); + } + } + self.check_unique_id(&set.id, &path, "holidaySets", &mut findings); + } + + for (index, opening) in self.openings.iter().enumerate() { + let path = format!("openings[{index}]"); + check_identifier(&opening.id, &format!("{path}.id"), &mut findings); + check_identifier( + &opening.location, + &format!("{path}.location"), + &mut findings, + ); + if self.holiday_set(&opening.holiday_set).is_none() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.holidaySet"), + PolicyCheckReason::UnknownHolidaySet, + )); + } + check_collection_non_empty( + &opening.weekdays, + &format!("{path}.weekdays"), + &mut findings, + ); + if !valid_hh_mm(&opening.start_time) || !valid_hh_mm(&opening.end_time) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.startTime"), + PolicyCheckReason::InvalidBound, + )); + } else if opening.start_time >= opening.end_time { + findings.push(SchedulingDiagnostic::new( + format!("{path}.endTime"), + PolicyCheckReason::InvalidBound, + )); + } + if !valid_iso_date(&opening.effective_from) + || !valid_iso_date(&opening.effective_until) + || opening.effective_from > opening.effective_until + { + findings.push(SchedulingDiagnostic::new( + format!("{path}.effectiveUntil"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(&opening.because, &format!("{path}.because"), &mut findings); + self.check_unique_id(&opening.id, &path, "openings", &mut findings); + } + + for (index, offering) in self.offerings.iter().enumerate() { + self.check_offering(offering, index, &mut findings); + } + + for (index, window) in self.windows.iter().enumerate() { + self.check_window(window, index, &mut findings); + } + + check_because( + &self.hold_policy.because, + "holdPolicy.because", + &mut findings, + ); + if self.hold_policy.ttl_minutes == 0 || self.hold_policy.max_per_caller == 0 { + findings.push(SchedulingDiagnostic::new( + "holdPolicy", + PolicyCheckReason::InvalidBound, + )); + } + + if !self.hooks.is_empty() { + findings.push(SchedulingDiagnostic::new( + "hooks", + PolicyCheckReason::HooksUnsupported, + )); + } + for (index, hook) in self.hooks.iter().enumerate() { + let path = format!("hooks[{index}]"); + if hook.abi != crate::naming::SCHEDULING_HOOK_ABI { + findings.push(SchedulingDiagnostic::new( + format!("{path}.abi"), + PolicyCheckReason::UnsupportedHookAbi, + )); + } + check_identifier(&hook.id, &format!("{path}.id"), &mut findings); + check_because(&hook.because, &format!("{path}.because"), &mut findings); + } + + findings + } + + fn check_offering( + &self, + offering: &OfferingPolicy, + index: usize, + findings: &mut Vec, + ) { + let path = format!("offerings[{index}]"); + check_identifier(&offering.id, &format!("{path}.id"), findings); + check_identifier(&offering.service, &format!("{path}.service"), findings); + if !service_ids_contains(self, &offering.service) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.service"), + PolicyCheckReason::UnknownService, + )); + } + check_identifier(&offering.location, &format!("{path}.location"), findings); + if offering.label.trim().is_empty() || offering.label.len() > MAXIMUM_LABEL_BYTES { + findings.push(SchedulingDiagnostic::new( + format!("{path}.label"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(&offering.because, &format!("{path}.because"), findings); + self.check_unique_id(&offering.id, &path, "offerings", findings); + + let (block, block_reason_path) = match offering.mode { + SchedulingMode::ExactTime => (offering.exact_time.is_some(), "exactTime"), + SchedulingMode::ArrivalWindow => (offering.arrival.is_some(), "arrival"), + }; + if !block { + findings.push(SchedulingDiagnostic::new( + format!("{path}.{block_reason_path}"), + PolicyCheckReason::MissingModeField, + )); + } + match offering.mode { + SchedulingMode::ExactTime => { + if offering.arrival.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.arrival"), + PolicyCheckReason::WrongModeField, + )); + } + if let Some(exact) = &offering.exact_time { + let block_path = format!("{path}.exactTime"); + if exact.duration_minutes == 0 + || exact.lead_time_minutes == 0 + || exact.horizon_days == 0 + || exact.start_increment_minutes == 0 + || exact.max_recipients == 0 + { + findings.push(SchedulingDiagnostic::new( + block_path.clone(), + PolicyCheckReason::InvalidBound, + )); + } + check_identifier(&exact.pool, &format!("{block_path}.pool"), findings); + let horizon_minutes = u64::from(exact.horizon_days) * 24 * 60; + if u64::from(exact.lead_time_minutes) > horizon_minutes { + findings.push(SchedulingDiagnostic::new( + format!("{block_path}.leadTimeMinutes"), + PolicyCheckReason::InvalidBound, + )); + } + } + } + SchedulingMode::ArrivalWindow => { + if offering.exact_time.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.exactTime"), + PolicyCheckReason::WrongModeField, + )); + } + if let Some(arrival) = &offering.arrival { + let block_path = format!("{path}.arrival"); + if self.window(&arrival.window).is_none() { + findings.push(SchedulingDiagnostic::new( + format!("{block_path}.window"), + PolicyCheckReason::UnknownWindow, + )); + } + if arrival.lead_time_minutes == 0 || arrival.horizon_days == 0 { + findings.push(SchedulingDiagnostic::new( + block_path.clone(), + PolicyCheckReason::InvalidBound, + )); + } + let horizon_minutes = u64::from(arrival.horizon_days) * 24 * 60; + if u64::from(arrival.lead_time_minutes) > horizon_minutes { + findings.push(SchedulingDiagnostic::new( + format!("{block_path}.leadTimeMinutes"), + PolicyCheckReason::InvalidBound, + )); + } + } + } + } + + if let Some(key) = &offering.duplicate_active_key { + check_identifier(key, &format!("{path}.duplicateActiveKey"), findings); + } + for capability in &offering.requires_capabilities { + if !valid_identifier(capability) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.requiresCapabilities"), + PolicyCheckReason::InvalidIdentifier, + )); + } + } + for prerequisite in &offering.prerequisites { + if !valid_reference(prerequisite) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.prerequisites"), + PolicyCheckReason::InvalidIdentifier, + )); + } + } + } + + fn check_window( + &self, + window: &PublishedWindow, + index: usize, + findings: &mut Vec, + ) { + let path = format!("windows[{index}]"); + check_identifier(&window.id, &format!("{path}.id"), findings); + if window.revision == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.revision"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(&window.because, &format!("{path}.because"), findings); + self.check_unique_id(&window.id, &path, "windows", findings); + if window.units == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.units"), + PolicyCheckReason::InvalidBound, + )); + } + if window.end <= window.start { + findings.push(SchedulingDiagnostic::new( + format!("{path}.end"), + PolicyCheckReason::InvalidBound, + )); + } + findings.extend(window.units_policy.check(&format!("{path}.unitsPolicy"))); + + match self.offering(&window.offering) { + None => findings.push(SchedulingDiagnostic::new( + format!("{path}.offering"), + PolicyCheckReason::UnknownOffering, + )), + Some(offering) => { + if offering.mode != SchedulingMode::ArrivalWindow + || offering + .arrival + .as_ref() + .is_none_or(|arrival| arrival.window != window.id) + || offering.location != window.location + { + findings.push(SchedulingDiagnostic::new( + format!("{path}.offering"), + PolicyCheckReason::WrongModeField, + )); + } + } + } + + let mut channels = Vec::new(); + let mut subquota_units = 0_u32; + for (subquota_index, subquota) in window.subquotas.iter().enumerate() { + let subquota_path = format!("{path}.subquotas[{subquota_index}]"); + check_identifier(&subquota.id, &format!("{subquota_path}.id"), findings); + check_because( + &subquota.because, + &format!("{subquota_path}.because"), + findings, + ); + if subquota.units == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{subquota_path}.units"), + PolicyCheckReason::InvalidBound, + )); + } else { + subquota_units = subquota_units.saturating_add(subquota.units); + } + if channels.contains(&subquota.channel) { + findings.push(SchedulingDiagnostic::new( + format!("{subquota_path}.channel"), + PolicyCheckReason::DuplicateIdentifier, + )); + } + channels.push(subquota.channel); + } + if subquota_units > window.units { + findings.push(SchedulingDiagnostic::new( + format!("{path}.subquotas"), + PolicyCheckReason::SubquotaOverdrawn, + )); + } + + if let Some(staffing) = &window.staffing { + check_identifier(&staffing.pool, &format!("{path}.staffing.pool"), findings); + check_because( + &staffing.because, + &format!("{path}.staffing.because"), + findings, + ); + // An unpartitioned staffing claim owns the whole pool block, so + // any exact-time offering on the same pool would be selling the + // same staffing twice. + if staffing.reserved_members.is_none() { + let shared = self.offerings.iter().any(|offering| { + offering + .exact_time + .as_ref() + .is_some_and(|exact| exact.pool == staffing.pool) + }); + let doubly_claimed = self.windows.iter().any(|other| { + other.id != window.id + && other.staffing.as_ref().is_some_and(|other_staffing| { + other_staffing.pool == staffing.pool + && other_staffing.reserved_members.is_none() + && other.start < window.end + && window.start < other.end + }) + }); + if shared || doubly_claimed { + findings.push(SchedulingDiagnostic::new( + format!("{path}.staffing.reservedMembers"), + PolicyCheckReason::SharedSupplyUnpartitioned, + )); + } + } + } + } + + fn check_unique_id( + &self, + id: &str, + path: &str, + collection: &str, + findings: &mut Vec, + ) { + let duplicate = match collection { + "offerings" => { + self.offerings + .iter() + .filter(|offering| offering.id == id) + .count() + > 1 + } + "windows" => self.windows.iter().filter(|window| window.id == id).count() > 1, + "openings" => { + self.openings + .iter() + .filter(|opening| opening.id == id) + .count() + > 1 + } + "holidaySets" => self.holiday_sets.iter().filter(|set| set.id == id).count() > 1, + _ => false, + }; + if duplicate { + findings.push(SchedulingDiagnostic::new( + format!("{path}.id"), + PolicyCheckReason::DuplicateIdentifier, + )); + } + } +} + +/// One window, or one channel slice of a window, whose proposed capacity fell +/// below what is already committed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapacityReduction { + pub window: String, + /// The channel whose subquota the reduction strands, or `None` when the + /// deficit is the window total's. + pub channel: Option, + pub committed_units: u32, + pub proposed_units: u32, +} + +/// Assess a proposed publication against the commitments already standing in +/// a ledger snapshot. +/// +/// A normal capacity reduction that would leave confirmed bookings and live +/// holds above the proposed capacity is rejected: it comes back here as a +/// finding with the deficit, never as a successful validation. Both levels of +/// published capacity are assessed: the window total, and each channel +/// subquota, which is its own committed slice a proposal may strand even when +/// the total is unchanged. An emergency reduction is a different operation +/// entirely, recorded as an incident with its affected bookings, and does not +/// pass through this assessment. +pub fn assess_publication_impact( + current: &SchedulingPolicy, + proposed: &SchedulingPolicy, + snapshot: &crate::model::LedgerSnapshot, + now: DateTime, +) -> Vec { + let mut reductions = Vec::new(); + for window in ¤t.windows { + let proposed_window = proposed.window(&window.id); + let proposed_units = proposed_window.map_or(0, |proposed| proposed.units); + let committed = snapshot.window_units_allocated(&window.id, None, None, now); + if proposed_units < window.units && committed > proposed_units { + reductions.push(CapacityReduction { + window: window.id.clone(), + channel: None, + committed_units: committed, + proposed_units, + }); + } + // A subquota dropped from the proposal proposes zero for its channel. + for subquota in &window.subquotas { + let proposed_subquota = proposed_window + .and_then(|proposed| { + proposed + .subquotas + .iter() + .find(|candidate| candidate.channel == subquota.channel) + }) + .map_or(0, |candidate| candidate.units); + if proposed_subquota >= subquota.units { + continue; + } + let committed_channel = snapshot.window_units_allocated( + &window.id, + Some(subquota.channel.as_str()), + None, + now, + ); + if committed_channel > proposed_subquota { + reductions.push(CapacityReduction { + window: window.id.clone(), + channel: Some(subquota.channel), + committed_units: committed_channel, + proposed_units: proposed_subquota, + }); + } + } + } + reductions +} + +fn service_ids_contains(policy: &SchedulingPolicy, id: &str) -> bool { + policy.services.iter().any(|service| service.id == id) +} + +fn push_unique( + seen: &mut Vec, + id: &str, + path: &str, + findings: &mut Vec, +) { + if seen.iter().any(|known| known == id) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.id"), + PolicyCheckReason::DuplicateIdentifier, + )); + } else { + seen.push(id.to_owned()); + } +} + +fn check_collection_non_empty( + collection: &[T], + path: &str, + findings: &mut Vec, +) { + if collection.is_empty() { + findings.push(SchedulingDiagnostic::new( + path, + PolicyCheckReason::EmptyCollection, + )); + } +} + +fn check_collection_bound( + collection: &[T], + path: &str, + findings: &mut Vec, +) { + if collection.len() > MAXIMUM_COLLECTION_ENTRIES { + findings.push(SchedulingDiagnostic::new( + path, + PolicyCheckReason::InvalidBound, + )); + } +} + +fn check_identifier(id: &str, path: &str, findings: &mut Vec) { + if !valid_identifier(id) { + findings.push(SchedulingDiagnostic::new( + path, + PolicyCheckReason::InvalidIdentifier, + )); + } +} + +/// The identifier grammar shared by every policy id: non-empty, at most 64 +/// bytes, starting with a lowercase letter and continuing with lowercase +/// letters, digits, and hyphens. +#[must_use] +pub fn valid_identifier(value: &str) -> bool { + let bytes = value.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 64 + && matches!(bytes.first(), Some(b'a'..=b'z')) + && bytes[1..] + .iter() + .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-')) +} + +/// A reference an offering may require a party to hold: a stable scoped +/// identifier such as `case:1234` or `urn:...`. +#[must_use] +pub fn valid_reference(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) +} + +fn valid_hh_mm(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 5 + && bytes[2] == b':' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| index == 2 || byte.is_ascii_digit()) + && value[..2].parse::().is_ok_and(|hour| hour < 24) + && value[3..].parse::().is_ok_and(|minute| minute < 60) +} + +fn valid_iso_date(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit()) + && chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok() +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut rendered = String::with_capacity(bytes.len() * 2); + for byte in bytes { + rendered.push(HEX[(byte >> 4) as usize] as char); + rendered.push(HEX[(byte & 0x0f) as usize] as char); + } + rendered +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{LedgerClaim, LedgerKind, PartyCounts}; + use chrono::TimeZone as _; + + fn utc(hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 5, hour, minute, 0).unwrap() + } + + pub fn minimal_exact_time_policy() -> SchedulingPolicy { + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: A 30-minute update at an interchangeable station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: ["2026-12-25"] +openings: + - id: counter-hours + location: north-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. +windows: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#; + serde_norway::from_str(yaml).expect("the minimal policy parses") + } + + pub fn household_window_policy() -> SchedulingPolicy { + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: household-days + version: 3 +services: + - id: household-day + label: Household registration day +offerings: + - id: household-morning + service: household-day + label: Morning household block + mode: arrival-window + location: civic-hall + because: Households arrive in a served morning block. + arrival: + window: household-morning-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: hall-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "08:00" + endTime: "12:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall opens on Saturday mornings. +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T08:00:00Z + end: 2026-10-10T10:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + leftover: becomes-walk-in + because: The Saturday morning household block, sized for two officers. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. +"#; + serde_norway::from_str(yaml).expect("the household policy parses") + } + + #[test] + fn the_minimal_policies_carry_no_findings() { + assert!( + minimal_exact_time_policy().check().is_empty(), + "{:?}", + minimal_exact_time_policy().check() + ); + assert!( + household_window_policy().check().is_empty(), + "{:?}", + household_window_policy().check() + ); + } + + #[test] + fn the_policy_digest_is_stable_and_moves_with_content() { + let policy = minimal_exact_time_policy(); + assert_eq!(policy.policy_digest(), policy.clone().policy_digest()); + assert!(policy.policy_digest().starts_with("sha256:")); + assert_eq!(policy.policy_digest().len(), 71); + + let edited = SchedulingPolicy { + hold_policy: HoldPolicy { + ttl_minutes: 6, + ..policy.hold_policy.clone() + }, + ..policy.clone() + }; + assert_ne!(policy.policy_digest(), edited.policy_digest()); + } + + #[test] + fn envelope_and_identity_findings_are_path_addressed() { + let mut policy = minimal_exact_time_policy(); + policy.api_version = "registry.registrystack.org/other/v1".to_owned(); + policy.kind = "Other".to_owned(); + policy.scheduling.version = 0; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"apiVersion: unsupported-api-version".to_owned())); + assert!(rendered.contains(&"kind: unsupported-kind".to_owned())); + assert!(rendered.contains(&"scheduling.version: invalid-bound".to_owned())); + } + + #[test] + fn mode_fields_must_match_the_declared_mode() { + let mut policy = minimal_exact_time_policy(); + policy.offerings[0].exact_time = None; + policy.offerings[0].arrival = Some(ArrivalOffering { + window: "no-such-window".to_owned(), + lead_time_minutes: 60, + horizon_days: 45, + }); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"offerings[0].exactTime: missing-mode-field".to_owned())); + // The stray arrival block is refused as a whole; its window reference + // is not validated because the block must be removed, not repaired. + assert!(rendered.contains(&"offerings[0].arrival: wrong-mode-field".to_owned())); + } + + #[test] + fn references_into_missing_collections_are_named() { + let mut policy = minimal_exact_time_policy(); + policy.offerings[0].service = "no-such-service".to_owned(); + policy.openings[0].holiday_set = "no-such-holidays".to_owned(); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"offerings[0].service: unknown-service".to_owned())); + assert!(rendered.contains(&"openings[0].holidaySet: unknown-holiday-set".to_owned())); + } + + #[test] + fn duplicate_ids_across_one_collection_are_rejected() { + let mut policy = minimal_exact_time_policy(); + policy.offerings.push(policy.offerings[0].clone()); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert_eq!( + rendered + .iter() + .filter(|f| f.ends_with("duplicate-identifier")) + .count(), + 2 + ); + } + + #[test] + fn subquota_slices_may_not_overdraw_the_window() { + let mut policy = household_window_policy(); + policy.windows[0].subquotas[0].units = 3; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"windows[0].subquotas: subquota-overdrawn".to_owned())); + } + + #[test] + fn one_channel_may_not_hold_two_slices_of_one_window() { + let mut policy = household_window_policy(); + policy.windows[0].subquotas[1].channel = Channel::Public; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"windows[0].subquotas[1].channel: duplicate-identifier".to_owned()) + ); + } + + /// AT-22, static half: an unpartitioned staffing claim over a pool that an + /// exact-time offering also sells is rejected at publication. + #[test] + fn an_unpartitioned_shared_staffing_block_is_rejected() { + let mut policy = household_window_policy(); + policy.windows[0].staffing = Some(WindowStaffing { + pool: "officer-pool".to_owned(), + reserved_members: None, + because: "The morning block is staffed by the duty officers.".to_owned(), + }); + policy.offerings.push(OfferingPolicy { + id: "urgent-five".to_owned(), + service: "household-day".to_owned(), + label: "Urgent five-minute slot".to_owned(), + mode: SchedulingMode::ExactTime, + location: "civic-hall".to_owned(), + because: "Urgent matters get exact five-minute slots.".to_owned(), + exact_time: Some(ExactTimeOffering { + duration_minutes: 5, + buffer_before_minutes: 0, + buffer_after_minutes: 0, + lead_time_minutes: 30, + horizon_days: 14, + pool: "officer-pool".to_owned(), + start_increment_minutes: 5, + max_recipients: 1, + }), + arrival: None, + cancellation_cutoff_minutes: 60, + duplicate_active_key: None, + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + }); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains( + &"windows[0].staffing.reservedMembers: shared-supply-unpartitioned".to_owned() + )); + + // An attributable partition makes the share explicit and passes. + policy.windows[0].staffing = Some(WindowStaffing { + pool: "officer-pool".to_owned(), + reserved_members: Some(2), + because: "Two of the four duty officers staff the block.".to_owned(), + }); + assert!(policy.check().is_empty(), "{:?}", policy.check()); + } + + #[test] + fn two_overlapping_whole_pool_claims_are_double_counting() { + let mut policy = household_window_policy(); + policy.windows[0].staffing = Some(WindowStaffing { + pool: "officer-pool".to_owned(), + reserved_members: None, + because: "The morning block is staffed by the duty officers.".to_owned(), + }); + policy.windows.push(PublishedWindow { + id: "household-noon-window".to_owned(), + revision: 1, + offering: "household-morning".to_owned(), + location: "civic-hall".to_owned(), + start: Utc.with_ymd_and_hms(2026, 10, 10, 9, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2026, 10, 10, 11, 30, 0).unwrap(), + units: 2, + units_policy: RequiredUnitsPolicy::Fixed { + units: 1, + because: "One unit per party.".to_owned(), + }, + subquotas: Vec::new(), + leftover: LeftoverCapacityPolicy::ExpiresUnused, + staffing: Some(WindowStaffing { + pool: "officer-pool".to_owned(), + reserved_members: None, + because: "The noon block claims the same officers.".to_owned(), + }), + because: "A second block in the same hall.".to_owned(), + }); + // The second window's offering linkage is wrong on purpose in this + // fixture; only the supply finding is asserted here. + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered + .iter() + .any(|f| f.ends_with("shared-supply-unpartitioned"))); + + // Disjoint windows may each claim the whole pool. + let disjoint = PublishedWindow { + start: Utc.with_ymd_and_hms(2026, 10, 10, 13, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2026, 10, 10, 15, 0, 0).unwrap(), + ..policy.windows[1].clone() + }; + policy.windows[1] = disjoint; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(!rendered + .iter() + .any(|f| f.ends_with("shared-supply-unpartitioned"))); + } + + /// AT-10, pure half: a normal reduction below standing commitments is + /// rejected with the deficit, while a reduction that keeps commitments + /// covered passes. + #[test] + fn a_reduction_below_standing_commitments_is_rejected_with_its_deficit() { + let current = household_window_policy(); + let now = utc(6, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim], + }; + + let mut reduced = current.clone(); + reduced.windows[0].units = 1; + assert_eq!( + assess_publication_impact(¤t, &reduced, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: None, + committed_units: 2, + proposed_units: 1, + }] + ); + + // Dropping the window entirely reduces both the total and the public + // slice to zero; the total reduction is reported first. + let mut removed = current.clone(); + removed.windows.clear(); + let reductions = assess_publication_impact(¤t, &removed, &snapshot, now); + assert_eq!(reductions[0].proposed_units, 0); + assert_eq!(reductions[0].channel, None); + + // A reduction that still covers commitments, and an increase, pass. + let mut adequate = current.clone(); + adequate.windows[0].units = 2; + assert!(assess_publication_impact(¤t, &adequate, &snapshot, now).is_empty()); + let mut increased = current.clone(); + increased.windows[0].units = 9; + assert!(assess_publication_impact(¤t, &increased, &snapshot, now).is_empty()); + } + + /// AT-10, channel half: a subquota reduced below its channel's standing + /// commitments is rejected even when the window total is unchanged, and a + /// dropped subquota proposes zero for its channel. + #[test] + fn a_subquota_reduction_strands_its_channel_even_at_an_unchanged_total() { + let current = household_window_policy(); + let now = utc(6, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim], + }; + + // The total stays at 3 while the public slice shrinks below the 2 + // units that channel already holds. + let mut narrowed = current.clone(); + narrowed.windows[0].subquotas[0].units = 1; + assert_eq!( + assess_publication_impact(¤t, &narrowed, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: Some(Channel::Public), + committed_units: 2, + proposed_units: 1, + }] + ); + + // Dropping the subquota entirely is a reduction of that channel to + // zero; only the channel strand is reported, the total still covers. + let mut dropped = current.clone(); + dropped.windows[0].subquotas.remove(0); + assert_eq!( + assess_publication_impact(¤t, &dropped, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: Some(Channel::Public), + committed_units: 2, + proposed_units: 0, + }] + ); + + // A channel nothing has committed against never strands, and an + // unchanged slice passes. + let mut assisted_dropped = current.clone(); + assisted_dropped.windows[0].subquotas.remove(1); + assert!(assess_publication_impact(¤t, &assisted_dropped, &snapshot, now).is_empty()); + let mut widened = current.clone(); + widened.windows[0].subquotas[0].units = 2; + assert!(assess_publication_impact(¤t, &widened, &snapshot, now).is_empty()); + } + + #[test] + fn declared_hooks_are_refused_because_nothing_can_run_them() { + let mut policy = minimal_exact_time_policy(); + policy.hooks = vec![HookPolicy { + id: "on-hold".to_owned(), + abi: crate::naming::SCHEDULING_HOOK_ABI.to_owned(), + because: "A future notification hook.".to_owned(), + }]; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"hooks: hooks-unsupported".to_owned())); + + policy.hooks[0].abi = "vendor.hook/v9".to_owned(); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"hooks[0].abi: unsupported-hook-abi".to_owned())); + } + + #[test] + fn identifiers_dates_and_times_follow_their_grammars() { + assert!(valid_identifier("north-counter-2")); + assert!(!valid_identifier("North")); + assert!(!valid_identifier("north counter")); + assert!(!valid_identifier("")); + assert!(!valid_identifier(&"x".repeat(65))); + assert!(valid_hh_mm("09:00")); + assert!(!valid_hh_mm("9:00")); + assert!(!valid_hh_mm("24:00")); + assert!(valid_iso_date("2026-10-05")); + assert!(!valid_iso_date("2026-10-5")); + assert!(!valid_iso_date("2026-02-30")); + } + + #[test] + fn weekdays_and_channels_serialize_in_their_published_spelling() { + assert_eq!( + serde_norway::to_string(&PolicyWeekday::Wed).unwrap(), + "wed\n" + ); + assert_eq!( + serde_norway::from_str::("wed").unwrap(), + PolicyWeekday::Wed + ); + assert_eq!(Channel::WalkIn.as_str(), "walk-in"); + assert_eq!( + serde_norway::from_str::("walk-in").unwrap(), + Channel::WalkIn + ); + assert_eq!(PolicyWeekday::Sun.to_chrono(), chrono::Weekday::Sun); + } + + #[test] + fn party_counts_still_parse_from_the_policy_surface() { + let party = PartyCounts { + recipients: 2, + attendees: 3, + }; + assert_eq!( + serde_json::to_value(party).unwrap(), + serde_json::json!({"recipients": 2, "attendees": 3}) + ); + } + + #[test] + fn unknown_policy_fields_are_refused() { + let yaml = r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: broken + version: 1 +services: [] +offerings: [] +holidaySets: [] +openings: [] +windows: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short. +surprise: true +"#; + assert!(parse_policy_yaml(yaml).is_err()); + } + + #[test] + fn collection_bounds_are_enforced() { + let mut policy = minimal_exact_time_policy(); + policy.services = (0..MAXIMUM_COLLECTION_ENTRIES + 1) + .map(|index| ServicePolicy { + id: format!("service-{index}"), + label: "Filler service".to_owned(), + }) + .collect(); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"services: invalid-bound".to_owned())); + + let at_bound = minimal_exact_time_policy(); + assert!(at_bound.check().is_empty()); + } +} diff --git a/crates/registry-scheduling-core/src/problem.rs b/crates/registry-scheduling-core/src/problem.rs new file mode 100644 index 0000000000..2d6f6f14af --- /dev/null +++ b/crates/registry-scheduling-core/src/problem.rs @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The closed problem vocabulary of Registry Scheduling. +//! +//! The vocabulary is closed on purpose: a caller can enumerate every problem +//! this product can ever return, and a problem code outside this list is a +//! defect, not a compatibility event. `authorization.refused` is deliberately +//! absent everywhere in this file: it is an audit reason family used by other +//! Registry Stack products to describe why a decision was recorded, and audit +//! reasons and problem codes must not be conflated. The authorization refusals +//! callers see here are `authentication.refused`, `operation.not-authorized`, +//! and `profile.not-authorized`. + +use crate::naming::SCHEDULING_PROBLEM_TYPE_BASE; + +pub const AUTHENTICATION_REFUSED_PROBLEM: &str = "authentication.refused"; +pub const BOOKING_DUPLICATE_ACTIVE_PROBLEM: &str = "booking.duplicate-active"; +pub const CANCELLATION_CUTOFF_PASSED_PROBLEM: &str = "cancellation.cutoff-passed"; +pub const CAPABILITY_UNMATCHED_PROBLEM: &str = "capability.unmatched"; +pub const CAPACITY_EXHAUSTED_PROBLEM: &str = "capacity.exhausted"; +pub const CURSOR_EXPIRED_PROBLEM: &str = "cursor.expired"; +pub const CURSOR_INVALID_PROBLEM: &str = "cursor.invalid"; +pub const ELIGIBILITY_UNAVAILABLE_PROBLEM: &str = "eligibility.unavailable"; +pub const HOLD_EXPIRED_PROBLEM: &str = "hold.expired"; +pub const HOLD_RELEASED_PROBLEM: &str = "hold.released"; +pub const HORIZON_OUTSIDE_PROBLEM: &str = "horizon.outside"; +pub const HOOK_UNAVAILABLE_PROBLEM: &str = "hook.unavailable"; +pub const IDEMPOTENCY_EXPIRED_PROBLEM: &str = "idempotency.expired"; +pub const IDEMPOTENCY_KEY_REUSED_PROBLEM: &str = "idempotency.key-reused"; +pub const LOCATION_CLOSED_PROBLEM: &str = "location.closed"; +pub const OPERATION_NOT_AUTHORIZED_PROBLEM: &str = "operation.not-authorized"; +pub const PARTY_CAPACITY_INADEQUATE_PROBLEM: &str = "party.capacity-inadequate"; +pub const POLICY_CHANGED_PROBLEM: &str = "policy.changed"; +pub const PRECONDITION_FAILED_PROBLEM: &str = "precondition.failed"; +pub const PRECONDITION_REQUIRED_PROBLEM: &str = "precondition.required"; +pub const PREREQUISITE_MISSING_PROBLEM: &str = "prerequisite.missing"; +pub const PROFILE_NOT_AUTHORIZED_PROBLEM: &str = "profile.not-authorized"; +pub const RESOURCE_UNAVAILABLE_PROBLEM: &str = "resource.unavailable"; +pub const REVISION_MISMATCH_PROBLEM: &str = "revision.mismatch"; +pub const SCHEDULE_UNPUBLISHED_PROBLEM: &str = "schedule.unpublished"; +pub const SERVICE_UNAVAILABLE_PROBLEM: &str = "service.unavailable"; + +/// Every problem code Registry Scheduling can return, in code-string order. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ProblemCode { + AuthenticationRefused, + BookingDuplicateActive, + CancellationCutoffPassed, + CapabilityUnmatched, + CapacityExhausted, + CursorExpired, + CursorInvalid, + EligibilityUnavailable, + HoldExpired, + HoldReleased, + HookUnavailable, + HorizonOutside, + IdempotencyExpired, + IdempotencyKeyReused, + LocationClosed, + OperationNotAuthorized, + PartyCapacityInadequate, + PolicyChanged, + PreconditionFailed, + PreconditionRequired, + PrerequisiteMissing, + ProfileNotAuthorized, + ResourceUnavailable, + RevisionMismatch, + ScheduleUnpublished, + ServiceUnavailable, +} + +impl ProblemCode { + /// The complete closed vocabulary, in code-string order. + pub const ALL: &'static [Self] = &[ + Self::AuthenticationRefused, + Self::BookingDuplicateActive, + Self::CancellationCutoffPassed, + Self::CapabilityUnmatched, + Self::CapacityExhausted, + Self::CursorExpired, + Self::CursorInvalid, + Self::EligibilityUnavailable, + Self::HoldExpired, + Self::HoldReleased, + Self::HookUnavailable, + Self::HorizonOutside, + Self::IdempotencyExpired, + Self::IdempotencyKeyReused, + Self::LocationClosed, + Self::OperationNotAuthorized, + Self::PartyCapacityInadequate, + Self::PolicyChanged, + Self::PreconditionFailed, + Self::PreconditionRequired, + Self::PrerequisiteMissing, + Self::ProfileNotAuthorized, + Self::ResourceUnavailable, + Self::RevisionMismatch, + Self::ScheduleUnpublished, + Self::ServiceUnavailable, + ]; + + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::AuthenticationRefused => AUTHENTICATION_REFUSED_PROBLEM, + Self::BookingDuplicateActive => BOOKING_DUPLICATE_ACTIVE_PROBLEM, + Self::CancellationCutoffPassed => CANCELLATION_CUTOFF_PASSED_PROBLEM, + Self::CapabilityUnmatched => CAPABILITY_UNMATCHED_PROBLEM, + Self::CapacityExhausted => CAPACITY_EXHAUSTED_PROBLEM, + Self::CursorExpired => CURSOR_EXPIRED_PROBLEM, + Self::CursorInvalid => CURSOR_INVALID_PROBLEM, + Self::EligibilityUnavailable => ELIGIBILITY_UNAVAILABLE_PROBLEM, + Self::HoldExpired => HOLD_EXPIRED_PROBLEM, + Self::HoldReleased => HOLD_RELEASED_PROBLEM, + Self::HorizonOutside => HORIZON_OUTSIDE_PROBLEM, + Self::HookUnavailable => HOOK_UNAVAILABLE_PROBLEM, + Self::IdempotencyExpired => IDEMPOTENCY_EXPIRED_PROBLEM, + Self::IdempotencyKeyReused => IDEMPOTENCY_KEY_REUSED_PROBLEM, + Self::LocationClosed => LOCATION_CLOSED_PROBLEM, + Self::OperationNotAuthorized => OPERATION_NOT_AUTHORIZED_PROBLEM, + Self::PartyCapacityInadequate => PARTY_CAPACITY_INADEQUATE_PROBLEM, + Self::PolicyChanged => POLICY_CHANGED_PROBLEM, + Self::PreconditionFailed => PRECONDITION_FAILED_PROBLEM, + Self::PreconditionRequired => PRECONDITION_REQUIRED_PROBLEM, + Self::PrerequisiteMissing => PREREQUISITE_MISSING_PROBLEM, + Self::ProfileNotAuthorized => PROFILE_NOT_AUTHORIZED_PROBLEM, + Self::ResourceUnavailable => RESOURCE_UNAVAILABLE_PROBLEM, + Self::RevisionMismatch => REVISION_MISMATCH_PROBLEM, + Self::ScheduleUnpublished => SCHEDULE_UNPUBLISHED_PROBLEM, + Self::ServiceUnavailable => SERVICE_UNAVAILABLE_PROBLEM, + } + } + + /// Resolve a code string back to its variant, or `None` for any string + /// outside the closed vocabulary. + #[must_use] + pub fn from_code(code: &str) -> Option { + Self::ALL + .iter() + .copied() + .find(|candidate| candidate.code() == code) + } + + /// Whether this code is disclosed only through the separately authorized + /// explain path, never as the public projection of a refusal. A replay + /// expectation compares the public projection, so a detailed-only code can + /// never match one and is refused before any case runs. + #[must_use] + pub const fn is_detailed_only(self) -> bool { + matches!(self, Self::ResourceUnavailable) + } +} + +/// Expand a dotted problem code into its full problem type URI. +#[must_use] +pub fn type_uri(code: &str) -> String { + format!("{}{}", SCHEDULING_PROBLEM_TYPE_BASE, code.replace('.', "/")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::naming::SCHEDULING_PROBLEM_TYPE_BASE; + + #[test] + fn the_vocabulary_is_complete_and_closed() { + assert_eq!(ProblemCode::ALL.len(), 26); + for code in ProblemCode::ALL { + assert_eq!(ProblemCode::from_code(code.code()), Some(*code)); + } + assert_eq!(ProblemCode::from_code("authorization.refused"), None); + assert_eq!(ProblemCode::from_code("capacity.unexhausted"), None); + assert_eq!(ProblemCode::from_code(""), None); + } + + /// Exactly one code is detailed-only. A second would mean the public and + /// detailed projections diverge in more than the one place the disclosure + /// contract describes, so this count is a decision point, not a tally. + #[test] + fn exactly_one_code_is_detailed_only() { + let detailed: Vec = ProblemCode::ALL + .iter() + .copied() + .filter(|code| code.is_detailed_only()) + .collect(); + assert_eq!(detailed, vec![ProblemCode::ResourceUnavailable]); + } + + #[test] + fn codes_are_listed_in_code_string_order() { + let codes: Vec<&str> = ProblemCode::ALL.iter().map(|code| code.code()).collect(); + let mut sorted = codes.clone(); + sorted.sort(); + assert_eq!(codes, sorted); + } + + #[test] + fn type_uris_expand_dots_into_path_segments() { + assert_eq!( + type_uri(CAPACITY_EXHAUSTED_PROBLEM), + format!("{SCHEDULING_PROBLEM_TYPE_BASE}capacity/exhausted") + ); + assert_eq!( + type_uri(IDEMPOTENCY_KEY_REUSED_PROBLEM), + format!("{SCHEDULING_PROBLEM_TYPE_BASE}idempotency/key-reused") + ); + } +} diff --git a/crates/registry-scheduling-core/src/units.rs b/crates/registry-scheduling-core/src/units.rs new file mode 100644 index 0000000000..7057329088 --- /dev/null +++ b/crates/registry-scheduling-core/src/units.rs @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! How a party maps to the recipient units a capacity window allocates. +//! +//! The grammar is closed: a window either fixes the units, scales them per +//! service recipient, or reads them from a banded table whose only input is +//! the service recipient count. Whichever form is chosen, the function from +//! party to units must be total over every party size the offering accepts, +//! and what happens above the highest band must be declared, never defaulted. + +use serde::{Deserialize, Serialize}; + +use crate::diagnostics::{PolicyCheckReason, SchedulingDiagnostic}; + +/// The closed input set a banded table may read. Tier 1 reads exactly one. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BandedInput { + ServiceRecipientCount, +} + +/// One band of a banded units table: parties up to `up_to` recipients cost +/// `units`. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UnitBand { + pub up_to: u32, + pub units: u32, + pub because: String, +} + +/// What a party above the highest band costs, or that it is refused. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(tag = "policy", rename_all = "camelCase", deny_unknown_fields)] +pub enum AboveHighestBand { + Refuse, + Units { units: u32 }, +} + +/// How an offering converts a party into consumed recipient units. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum RequiredUnitsPolicy { + /// Every party costs the same fixed units. + Fixed { units: u32, because: String }, + /// A party costs its recipient count times `per_recipient`. + PerRecipient { per_recipient: u32, because: String }, + /// A party costs whatever band its recipient count falls in. + BandedTable { + input: BandedInput, + bands: Vec, + above_highest_band: AboveHighestBand, + because: String, + }, +} + +/// A units table refused a party instead of guessing a cost. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum UnitsEvaluationError { + #[error("the recipient count falls above the highest band and the table refuses it")] + RefusedAboveHighestBand, + #[error("the units arithmetic overflowed")] + Overflow, +} + +impl RequiredUnitsPolicy { + /// The total function from service recipient count to consumed units. + pub fn evaluate(&self, recipients: u32) -> Result { + match self { + Self::Fixed { units, .. } => Ok(*units), + Self::PerRecipient { per_recipient, .. } => u64::from(*per_recipient) + .checked_mul(u64::from(recipients)) + .filter(|total| *total <= u64::from(u32::MAX)) + .map(|total| total as u32) + .ok_or(UnitsEvaluationError::Overflow), + Self::BandedTable { + bands, + above_highest_band, + .. + } => { + let band = bands.iter().find(|band| recipients <= band.up_to); + match (band, above_highest_band) { + (Some(band), _) => Ok(band.units), + // Above the highest band the table does what it declares: + // refuse, or cost the declared units. Never a guess. + (None, AboveHighestBand::Refuse) => { + Err(UnitsEvaluationError::RefusedAboveHighestBand) + } + (None, AboveHighestBand::Units { units }) => Ok(*units), + } + } + } + } + + /// Check the authored form: bands must exist, rise strictly, carry + /// positive units, declare their behavior above the highest band, and + /// every `because` must hold its bytes. + pub fn check(&self, path: &str) -> Vec { + let mut findings = Vec::new(); + match self { + Self::Fixed { units, because } => { + if *units == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.units"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(because, &format!("{path}.because"), &mut findings); + } + Self::PerRecipient { + per_recipient, + because, + } => { + if *per_recipient == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.perRecipient"), + PolicyCheckReason::InvalidBound, + )); + } + check_because(because, &format!("{path}.because"), &mut findings); + } + Self::BandedTable { + input: _, + bands, + above_highest_band, + because, + } => { + if bands.is_empty() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.bands"), + PolicyCheckReason::InvalidBands, + )); + } + let mut highest = 0; + for (index, band) in bands.iter().enumerate() { + let band_path = format!("{path}.bands[{index}]"); + if band.up_to <= highest || band.units == 0 { + findings.push(SchedulingDiagnostic::new( + band_path.clone(), + PolicyCheckReason::InvalidBands, + )); + } + highest = highest.max(band.up_to); + check_because( + &band.because, + &format!("{band_path}.because"), + &mut findings, + ); + } + if let AboveHighestBand::Units { units } = above_highest_band { + if *units == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.aboveHighestBand.units"), + PolicyCheckReason::InvalidBound, + )); + } + } + check_because(because, &format!("{path}.because"), &mut findings); + } + } + findings + } +} + +/// A `because` must be present, non-blank, and at most 256 bytes: every rule +/// that decides capacity carries its recorded reason. +pub fn check_because(because: &str, path: &str, findings: &mut Vec) { + if because.trim().is_empty() || because.len() > MAXIMUM_BECAUSE_BYTES { + findings.push(SchedulingDiagnostic::new( + path, + PolicyCheckReason::InvalidBecause, + )); + } +} + +/// The byte bound every recorded `because` obeys. +pub const MAXIMUM_BECAUSE_BYTES: usize = 256; + +#[cfg(test)] +mod tests { + use super::*; + + fn banded() -> RequiredUnitsPolicy { + RequiredUnitsPolicy::BandedTable { + input: BandedInput::ServiceRecipientCount, + bands: vec![ + UnitBand { + up_to: 1, + units: 1, + because: "A single recipient consumes one serving slot.".to_owned(), + }, + UnitBand { + up_to: 4, + units: 2, + because: "A household of up to four shares one officers' block.".to_owned(), + }, + ], + above_highest_band: AboveHighestBand::Refuse, + because: "Household sizing reviewed by the registry office in 2026.".to_owned(), + } + } + + #[test] + fn fixed_per_recipient_and_banded_tables_are_total_over_their_domain() { + let fixed = RequiredUnitsPolicy::Fixed { + units: 1, + because: "One appointment per party.".to_owned(), + }; + assert_eq!(fixed.evaluate(3), Ok(1)); + + let per_recipient = RequiredUnitsPolicy::PerRecipient { + per_recipient: 1, + because: "Each recipient consumes one serving slot.".to_owned(), + }; + assert_eq!(per_recipient.evaluate(3), Ok(3)); + + let table = banded(); + assert_eq!(table.evaluate(1), Ok(1)); + assert_eq!(table.evaluate(2), Ok(2)); + assert_eq!(table.evaluate(4), Ok(2)); + } + + #[test] + fn above_the_highest_band_the_table_does_what_it_declares_and_nothing_else() { + assert_eq!( + banded().evaluate(5), + Err(UnitsEvaluationError::RefusedAboveHighestBand) + ); + + let capped = RequiredUnitsPolicy::BandedTable { + input: BandedInput::ServiceRecipientCount, + bands: vec![UnitBand { + up_to: 2, + units: 1, + because: "A pair shares one block.".to_owned(), + }], + above_highest_band: AboveHighestBand::Units { units: 3 }, + because: "Larger parties consume a full officer block.".to_owned(), + }; + assert_eq!(capped.evaluate(9), Ok(3)); + } + + #[test] + fn per_recipient_arithmetic_refuses_to_overflow_silently() { + let scaling = RequiredUnitsPolicy::PerRecipient { + per_recipient: u32::MAX, + because: "Pathological scaling kept for the bound test.".to_owned(), + }; + assert_eq!(scaling.evaluate(2), Err(UnitsEvaluationError::Overflow)); + } + + #[test] + fn well_formed_policies_carry_no_findings() { + assert!(banded().check("windows[0].unitsPolicy").is_empty()); + + let per_recipient = RequiredUnitsPolicy::PerRecipient { + per_recipient: 1, + because: "Each recipient consumes one serving slot.".to_owned(), + }; + assert!(per_recipient.check("windows[0].unitsPolicy").is_empty()); + } + + #[test] + fn broken_tables_are_named_band_by_band() { + let broken = RequiredUnitsPolicy::BandedTable { + input: BandedInput::ServiceRecipientCount, + bands: vec![ + UnitBand { + up_to: 4, + units: 2, + because: "A household of up to four shares one block.".to_owned(), + }, + UnitBand { + up_to: 4, + units: 0, + because: " ".to_owned(), + }, + ], + above_highest_band: AboveHighestBand::Units { units: 0 }, + because: String::new(), + }; + let findings = broken.check("windows[0].unitsPolicy"); + let rendered: Vec = findings.iter().map(|finding| finding.to_string()).collect(); + // Band 0 declares four first, so band 1 fails both the non-rising and + // the zero-units rule; its blank because is named separately. + assert_eq!( + rendered, + vec![ + "windows[0].unitsPolicy.bands[1]: invalid-bands".to_owned(), + "windows[0].unitsPolicy.bands[1].because: invalid-because".to_owned(), + "windows[0].unitsPolicy.aboveHighestBand.units: invalid-bound".to_owned(), + "windows[0].unitsPolicy.because: invalid-because".to_owned(), + ] + ); + } + + #[test] + fn because_bounds_are_inclusive_at_256_bytes() { + let at_bound = RequiredUnitsPolicy::Fixed { + units: 1, + because: "x".repeat(MAXIMUM_BECAUSE_BYTES), + }; + assert!(at_bound.check("p").is_empty()); + + let over_bound = RequiredUnitsPolicy::Fixed { + units: 1, + because: "x".repeat(MAXIMUM_BECAUSE_BYTES + 1), + }; + assert_eq!( + over_bound.check("p"), + vec![SchedulingDiagnostic::new( + "p.because", + PolicyCheckReason::InvalidBecause + )] + ); + } + + #[test] + fn the_units_grammar_is_closed() { + // Tier 1 reads exactly one input, spelled one way. + assert!(serde_norway::from_str::("serviceRecipientCount").is_ok()); + assert!(serde_norway::from_str::("partyCount").is_err()); + + let yaml = "kind: fixed\nunits: 1\nbecause: One appointment per party.\n"; + assert!(serde_norway::from_str::(yaml).is_ok()); + assert!(serde_norway::from_str::( + "kind: fixed\nunits: 1\nbecause: One appointment per party.\nmystery: true\n" + ) + .is_err()); + + // Above the highest band is declared, never defaulted: dropping the + // field is a deserialization failure, not a silent choice. + let banded_yaml = "kind: bandedTable\ninput: serviceRecipientCount\n\ + bands:\n - upTo: 2\n units: 1\n because: A pair shares one block.\n"; + assert!(serde_norway::from_str::(banded_yaml).is_err()); + assert!(serde_norway::from_str::(&format!( + "{banded_yaml}aboveHighestBand:\n policy: refuse\nbecause: Sizing reviewed.\n" + )) + .is_ok()); + assert!(serde_norway::from_str::(&format!( + "{banded_yaml}aboveHighestBand:\n policy: units\n units: 3\nbecause: Sizing reviewed.\n" + )) + .is_ok()); + } +} diff --git a/crates/registry-schedulingctl/Cargo.toml b/crates/registry-schedulingctl/Cargo.toml new file mode 100644 index 0000000000..fcf189ea86 --- /dev/null +++ b/crates/registry-schedulingctl/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "registry-schedulingctl" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Registry Scheduling adopter and local operator tooling." +repository.workspace = true +publish = false + +[[bin]] +name = "schedulingctl" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +registry-platform-buildinfo.workspace = true +registry-scheduling-core.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs new file mode 100644 index 0000000000..65fc7a09f2 --- /dev/null +++ b/crates/registry-schedulingctl/src/lib.rs @@ -0,0 +1,659 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `schedulingctl`, the Registry Scheduling authoring and local operator +//! tooling: `init` writes a complete starter project, `check` validates the +//! authored policy offline, `test` replays every fixture offline, and +//! `explain` publishes what the runtime would serve. + +mod project; +mod templates; + +use anyhow::Result; +use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use serde_json::{json, Value}; +use std::ffi::{OsStr, OsString}; +use std::io; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Debug, Parser)] +#[command( + name = "schedulingctl", + version = registry_platform_buildinfo::DISPLAY_VERSION, + about = "Registry Scheduling authoring and local operator tooling" +)] +struct Cli { + /// Emit the selected command's report in this format. + #[arg(long, value_enum, global = true, default_value_t)] + format: OutputFormat, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Create a complete Registry Scheduling authoring project in a new directory. + Init(InitArgs), + /// Validate the authored policy offline and print effective defaults. + Check(CheckArgs), + /// Run every fixture's replay cases offline. + Test(ProjectArgs), + /// Explain the checked policy offline: what the runtime would publish. + Explain(ProjectArgs), +} + +#[derive(Debug, Args)] +struct InitArgs { + /// New directory for the authored scheduling project. + #[arg(value_name = "PROJECT")] + project: PathBuf, + /// Project template: standalone-exact-time or standalone-arrival-window. + #[arg(long, value_name = "NAME")] + template: String, +} + +#[derive(Debug, Args)] +struct CheckArgs { + /// Authored scheduling project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, + /// Exit unsuccessfully when the authoring check reports any finding. + #[arg(long)] + deny_findings: bool, +} + +#[derive(Debug, Args)] +struct ProjectArgs { + /// Authored scheduling project directory. + #[arg(value_name = "PROJECT")] + project: PathBuf, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +enum OutputFormat { + #[default] + Text, + Json, +} + +const DOMAIN_REFUSAL_EXIT: u8 = 1; +const OPERATIONAL_FAILURE_EXIT: u8 = 3; + +pub fn main_entry() -> ExitCode { + main_entry_from( + std::env::args_os(), + &mut std::io::stdout().lock(), + &mut std::io::stderr().lock(), + ) +} + +fn main_entry_from( + args: I, + stdout: &mut dyn io::Write, + stderr: &mut dyn io::Write, +) -> ExitCode +where + I: IntoIterator, + T: Into, +{ + let args = args.into_iter().map(Into::into).collect::>(); + let machine_mode = args + .windows(2) + .any(|pair| pair[0] == OsStr::new("--format") && pair[1] == OsStr::new("json")) + || args.iter().any(|arg| arg == OsStr::new("--format=json")); + let cli = match Cli::try_parse_from(args) { + Ok(cli) => cli, + Err(error) + if matches!( + error.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) => + { + let _ = write!(stdout, "{error}"); + return ExitCode::SUCCESS; + } + Err(error) => { + if machine_mode { + write_failure( + &usage_failure(error.to_string()), + OutputFormat::Json, + stdout, + stderr, + ); + } else { + let _ = write!(stderr, "{error}"); + } + return ExitCode::from(2); + } + }; + let format = cli.format; + let deny_findings = matches!(&cli.command, Command::Check(args) if args.deny_findings); + match run(cli) { + Ok(report) => { + let outcome = write_success(&report, format, stdout, stderr); + if outcome != ExitCode::SUCCESS { + return outcome; + } + if report_is_refusal(&report, deny_findings) { + ExitCode::from(DOMAIN_REFUSAL_EXIT) + } else { + ExitCode::SUCCESS + } + } + Err(error) => { + let (exit, diagnostic) = classify_failure(&error); + write_failure( + &json!({"ok": false, "diagnostics": [diagnostic]}), + format, + stdout, + stderr, + ); + ExitCode::from(exit) + } + } +} + +fn run(cli: Cli) -> Result { + match cli.command { + Command::Init(args) => project::init(&args.project, &args.template), + Command::Check(args) => project::check(&args.project), + Command::Test(args) => project::test(&args.project), + Command::Explain(args) => project::explain(&args.project), + } +} + +/// A completed report may still describe a refusal: an authoring check that +/// found something when the caller passed `--deny-findings`, or a fixture run +/// with failing cases, which always fails the build. The report is the +/// evidence; the exit code is the signal. +fn report_is_refusal(report: &Value, deny_findings: bool) -> bool { + if report["command"] == "check" { + return deny_findings && report["status"] == "incomplete"; + } + report["fixtures"] + .as_array() + .is_some_and(|fixtures| fixtures.iter().any(|fixture| fixture["status"] == "failed")) +} + +fn usage_failure(message: String) -> Value { + json!({ + "ok": false, + "command": "usage", + "diagnostics": [{ + "severity": "error", + "code": "schedulingctl.usage-invalid", + "artifact": "command_arguments", + "path": "arguments", + "message": message, + "suggestedAction": "Correct the command arguments and retry.", + }], + }) +} + +fn classify_failure(error: &anyhow::Error) -> (u8, Value) { + let io_failure = error.chain().any(|cause| cause.is::()); + if io_failure { + ( + OPERATIONAL_FAILURE_EXIT, + json!({ + "severity": "error", + "code": "schedulingctl.io-failure", + "artifact": "filesystem", + "path": "filesystem", + "message": "A required filesystem operation failed.", + "suggestedAction": "Correct the path or permissions, then retry.", + }), + ) + } else { + ( + DOMAIN_REFUSAL_EXIT, + json!({ + "severity": "error", + "code": "schedulingctl.refused", + "artifact": "scheduling_project", + "path": "scheduling.yaml", + "message": format!("{error:#}"), + "suggestedAction": "Correct the authored input the message names, then retry.", + }), + ) + } +} + +fn write_success( + report: &Value, + format: OutputFormat, + stdout: &mut dyn io::Write, + stderr: &mut dyn io::Write, +) -> ExitCode { + let result = match format { + OutputFormat::Json => serde_json::to_writer_pretty(&mut *stdout, report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)), + OutputFormat::Text => render_human(report, stdout), + }; + if result.is_ok() { + ExitCode::SUCCESS + } else { + let _ = writeln!(stderr, "schedulingctl: output could not be written"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } +} + +fn write_failure( + report: &Value, + format: OutputFormat, + stdout: &mut dyn io::Write, + stderr: &mut dyn io::Write, +) { + if format == OutputFormat::Json { + let _ = serde_json::to_writer_pretty(&mut *stdout, report); + let _ = writeln!(stdout); + return; + } + if let Some(diagnostics) = report["diagnostics"].as_array() { + for finding in diagnostics { + let _ = writeln!( + stderr, + "{}[{}] {}: {}", + finding["severity"].as_str().unwrap_or("error"), + finding["code"].as_str().unwrap_or("schedulingctl.refused"), + finding["path"].as_str().unwrap_or("command"), + finding["message"].as_str().unwrap_or("command refused"), + ); + if let Some(action) = finding["suggestedAction"].as_str() { + let _ = writeln!(stderr, " next: {action}"); + } + } + } +} + +fn human_lead(report: &Value) -> String { + let command = report["command"].as_str().unwrap_or("command"); + let failed_fixtures = report["fixtures"] + .as_array() + .is_some_and(|fixtures| fixtures.iter().any(|fixture| fixture["status"] == "failed")); + match ( + command, + report["status"].as_str(), + report["authoringStatus"].as_str(), + ) { + ("check", Some("incomplete"), _) => { + "Authoring check completed with incomplete inputs.".to_owned() + } + ("check", Some("complete"), _) => "Authoring check passed with complete inputs.".to_owned(), + ("test", _, _) if failed_fixtures => { + "Offline synthetic fixtures reported failures.".to_owned() + } + ("test", _, Some("incomplete")) => { + "Offline synthetic fixtures passed with incomplete authored inputs.".to_owned() + } + ("test", _, _) => "Offline synthetic fixtures passed.".to_owned(), + _ => format!("{command} succeeded."), + } +} + +fn render_human(report: &Value, stdout: &mut dyn io::Write) -> io::Result<()> { + writeln!(stdout, "{}", human_lead(report))?; + if let Some(fields) = report.as_object() { + for (key, value) in fields { + if matches!(key.as_str(), "ok" | "command" | "findings" | "diagnostics") + || value.is_null() + { + continue; + } + let rendered = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + writeln!(stdout, "{key}: {rendered}")?; + } + } + if let Some(findings) = report["findings"].as_array() { + for finding in findings { + writeln!( + stdout, + "finding {}: {}", + finding["path"].as_str().unwrap_or("scheduling.yaml"), + finding["reason"].as_str().unwrap_or("review required"), + )?; + } + } + Ok(()) +} + +/// Command tree available to command-reference tooling. +#[must_use] +pub fn command() -> clap::Command { + Cli::command() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Run one invocation in JSON mode and return its exit code, its report + /// (null when stdout carried no report), and its stderr. + fn run_json(arguments: &[&str]) -> (ExitCode, Value, Vec) { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut all = vec![ + OsString::from("schedulingctl"), + OsString::from("--format=json"), + ]; + all.extend(arguments.iter().map(OsString::from)); + let exit = main_entry_from(all, &mut stdout, &mut stderr); + let report = serde_json::from_slice(&stdout).unwrap_or(Value::Null); + (exit, report, stderr) + } + + fn initialized(template: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + project::init(&project, template).unwrap(); + (root, project) + } + + #[test] + fn canonical_command_shapes_and_default_format_are_stable() { + let cli = Cli::try_parse_from(["schedulingctl", "check", "/tmp/project"]).unwrap(); + assert_eq!(cli.format, OutputFormat::Text); + let Command::Check(args) = cli.command else { + panic!("expected check") + }; + assert_eq!(args.project, PathBuf::from("/tmp/project")); + assert!(!args.deny_findings); + + let cli = + Cli::try_parse_from(["schedulingctl", "check", "/tmp/project", "--deny-findings"]) + .unwrap(); + let Command::Check(args) = cli.command else { + panic!("expected check") + }; + assert!(args.deny_findings); + + let cli = + Cli::try_parse_from(["schedulingctl", "--format", "json", "test", "/tmp/project"]) + .unwrap(); + assert_eq!(cli.format, OutputFormat::Json); + + let cli = Cli::try_parse_from([ + "schedulingctl", + "init", + "/tmp/project", + "--template", + "standalone-exact-time", + ]) + .unwrap(); + let Command::Init(args) = cli.command else { + panic!("expected init") + }; + assert_eq!(args.template, "standalone-exact-time"); + + for command in ["test", "explain"] { + let cli = Cli::try_parse_from(["schedulingctl", command, "/tmp/project"]).unwrap(); + match command { + "test" => assert!(matches!(cli.command, Command::Test(_))), + _ => assert!(matches!(cli.command, Command::Explain(_))), + } + } + + assert!(Cli::try_parse_from([ + "schedulingctl", + "init", + "--template", + "standalone-exact-time" + ]) + .is_err()); + assert!(Cli::try_parse_from(["schedulingctl", "check"]).is_err()); + } + + #[test] + fn every_template_initializes_checks_tests_and_explains_green() { + for template in ["standalone-exact-time", "standalone-arrival-window"] { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + let project_string = project.to_str().unwrap().to_owned(); + + let (exit, report, stderr) = + run_json(&["init", &project_string, &format!("--template={template}")]); + assert_eq!(exit, ExitCode::SUCCESS, "{template}"); + assert!(stderr.is_empty()); + assert_eq!(report["command"], "init"); + + let (exit, check, stderr) = run_json(&["check", &project_string]); + assert_eq!(exit, ExitCode::SUCCESS, "{template}"); + assert!(stderr.is_empty()); + assert_eq!(check["status"], "complete", "{check}"); + + let (exit, test, stderr) = run_json(&["test", &project_string]); + assert_eq!(exit, ExitCode::SUCCESS, "{template}"); + assert!(stderr.is_empty()); + assert!( + test["fixtures"] + .as_array() + .unwrap() + .iter() + .all(|fixture| fixture["status"] == "passed"), + "{test}" + ); + + let (exit, explain, stderr) = run_json(&["explain", &project_string]); + assert_eq!(exit, ExitCode::SUCCESS, "{template}"); + assert!(stderr.is_empty()); + assert!(explain["policyDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + } + } + + /// A check that completes reports its findings and exits zero, so a human + /// reads the report without parsing an exit code; `--deny-findings` is the + /// CI switch that makes the same finding fail the build. A complete check + /// passes either way. + #[test] + fn an_incomplete_check_exits_zero_unless_findings_are_denied() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "version: 1\n", + "version: 0\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let (exit, report, stderr) = run_json(&["check", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::SUCCESS); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "incomplete"); + assert!(report["findings"] + .as_array() + .unwrap() + .iter() + .any(|finding| { + finding["path"] == "scheduling.version" && finding["reason"] == "invalid-bound" + })); + + let (exit, report, stderr) = + run_json(&["check", "--deny-findings", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "incomplete"); + + let (_root, clean) = initialized("standalone-exact-time"); + let (exit, report, stderr) = + run_json(&["check", "--deny-findings", clean.to_str().unwrap()]); + assert_eq!(exit, ExitCode::SUCCESS); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "complete"); + } + + #[test] + fn a_failing_fixture_case_is_reported_and_exits_nonzero() { + let (_root, project) = initialized("standalone-exact-time"); + let fixture_path = project.join("fixtures/counter-stations.yaml"); + let broken = std::fs::read_to_string(&fixture_path).unwrap().replacen( + "code: booking.duplicate-active", + "code: capacity.exhausted", + 1, + ); + std::fs::write(&fixture_path, broken).unwrap(); + let (exit, report, stderr) = run_json(&["test", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + let fixtures = report["fixtures"].as_array().unwrap(); + let counter = fixtures + .iter() + .find(|fixture| fixture["name"] == "counter-stations") + .unwrap(); + assert_eq!(counter["status"], "failed"); + let case = counter["cases"] + .as_array() + .unwrap() + .iter() + .find(|case| case["name"] == "same-key-retry-is-refused") + .unwrap(); + assert_eq!(case["status"], "fail"); + // The detail reports what actually happened: the duplicate-active + // refusal the broken expectation no longer matches. + assert!(case["detail"] + .as_str() + .unwrap() + .contains("booking.duplicate-active")); + } + + #[test] + fn a_fixture_that_cannot_replay_is_a_domain_refusal_with_a_diagnostic() { + let (_root, project) = initialized("standalone-exact-time"); + let fixture_path = project.join("fixtures/counter-stations.yaml"); + let broken = std::fs::read_to_string(&fixture_path).unwrap().replacen( + "code: booking.duplicate-active", + "code: not-a-problem-code", + 1, + ); + std::fs::write(&fixture_path, broken).unwrap(); + let (exit, report, stderr) = run_json(&["test", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + let diagnostic = &report["diagnostics"][0]; + for field in [ + "severity", + "code", + "artifact", + "path", + "message", + "suggestedAction", + ] { + assert!(diagnostic.get(field).is_some(), "missing {field}"); + } + assert_eq!(diagnostic["code"], "schedulingctl.refused"); + assert!(diagnostic["message"] + .as_str() + .unwrap() + .contains("not-a-problem-code")); + } + + #[test] + fn usage_json_has_the_common_diagnostic_fields_and_exit_two() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "--format", "json", "check"], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::from(2)); + assert!(stderr.is_empty()); + let report: Value = serde_json::from_slice(&stdout).unwrap(); + assert_eq!(report["command"], "usage"); + let diagnostic = &report["diagnostics"][0]; + for field in [ + "severity", + "code", + "artifact", + "path", + "message", + "suggestedAction", + ] { + assert!(diagnostic.get(field).is_some(), "missing {field}"); + } + + stdout.clear(); + let exit = main_entry_from(["schedulingctl", "check"], &mut stdout, &mut stderr); + assert_eq!(exit, ExitCode::from(2)); + assert!(stdout.is_empty()); + assert!(!stderr.is_empty()); + } + + #[test] + fn a_missing_project_is_an_operational_failure_on_the_selected_channel() { + let root = tempfile::tempdir().unwrap(); + let missing = root.path().join("missing").to_str().unwrap().to_owned(); + let (exit, report, stderr) = run_json(&["check", &missing]); + assert_eq!(exit, ExitCode::from(OPERATIONAL_FAILURE_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["diagnostics"][0]["code"], "schedulingctl.io-failure"); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "check", missing.as_str()], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::from(OPERATIONAL_FAILURE_EXIT)); + assert!(stdout.is_empty()); + assert!(String::from_utf8_lossy(&stderr).starts_with("error[schedulingctl.io-failure]")); + } + + #[test] + fn text_reports_render_the_lead_the_proof_boundary_and_findings() { + let (_root, project) = initialized("standalone-exact-time"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "test", project.to_str().unwrap()], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::SUCCESS); + assert!(stderr.is_empty()); + let text = String::from_utf8(stdout).unwrap(); + assert!(text.starts_with("Offline synthetic fixtures passed.")); + assert!(text.contains("proofBoundary: offline_synthetic")); + assert!(text.contains("productionClosure: false")); + assert!(text.contains("networkAccess: false")); + + let mut stdout = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "check", project.to_str().unwrap()], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::SUCCESS); + let text = String::from_utf8(stdout).unwrap(); + assert!(text.starts_with("Authoring check passed with complete inputs.")); + // Nested report objects render as compact JSON. + assert!(text.contains("\"schedulingId\":\"registry-updates\"")); + + // An incomplete check renders its findings in text mode too, and + // exits zero unless findings were denied. + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "version: 1\n", + "version: 0\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let mut stdout = Vec::new(); + let exit = main_entry_from( + ["schedulingctl", "check", project.to_str().unwrap()], + &mut stdout, + &mut stderr, + ); + assert_eq!(exit, ExitCode::SUCCESS); + let text = String::from_utf8(stdout).unwrap(); + assert!(text.starts_with("Authoring check completed with incomplete inputs.")); + assert!(text.contains("finding scheduling.version: invalid-bound")); + } +} diff --git a/crates/registry-schedulingctl/src/main.rs b/crates/registry-schedulingctl/src/main.rs new file mode 100644 index 0000000000..bfd3e73c67 --- /dev/null +++ b/crates/registry-schedulingctl/src/main.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::process::ExitCode; + +fn main() -> ExitCode { + registry_schedulingctl::main_entry() +} diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs new file mode 100644 index 0000000000..72102614b8 --- /dev/null +++ b/crates/registry-schedulingctl/src/project.rs @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Project loading and the offline init, check, test, and explain reports. +//! +//! Everything here runs without a network, a database, or a clock: the +//! authored policy and its fixtures are read from the project directory, +//! checked and replayed through the pure core, and rendered as one JSON +//! report per command. + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use registry_scheduling_core::{ + parse_fixture_yaml, parse_policy_yaml, CaseStatus, SchedulingDiagnostic, SchedulingFixture, + SchedulingPolicy, AUTHORED_POLICY_FILE, +}; +use serde_json::{json, Value}; + +use crate::templates; + +/// The largest policy or fixture an authoring project may carry. +const MAXIMUM_INPUT_BYTES: usize = 1024 * 1024; + +/// The directory an authored project keeps its replay fixtures in. +const FIXTURES_DIRECTORY: &str = "fixtures"; + +pub(super) fn init(project: &Path, template: &str) -> Result { + let Some(files) = templates::template_files(template) else { + bail!( + "unknown template {template:?}; available templates: standalone-exact-time, standalone-arrival-window" + ); + }; + match fs::symlink_metadata(project) { + Ok(_) => bail!("destination already exists; init never overwrites a project"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).context("checking the project destination"), + } + let parent = project + .parent() + .context("project destination has no parent")?; + fs::create_dir_all(parent).context("creating project parent")?; + let staging = tempfile::Builder::new() + .prefix(".scheduling-init-") + .tempdir_in(parent) + .context("creating project staging directory")?; + fs::create_dir(staging.path().join(FIXTURES_DIRECTORY))?; + let mut created = Vec::new(); + for (relative, contents) in &files { + fs::write(staging.path().join(relative), contents)?; + created.push((*relative).to_owned()); + } + let staging_path = staging.keep(); + fs::rename(&staging_path, project) + .context("publishing scheduling project without replacement")?; + Ok(json!({ + "ok": true, + "command": "init", + "template": template, + "project": project, + "created": created, + "next": ["Run schedulingctl check PROJECT, then schedulingctl test PROJECT."], + })) +} + +pub(super) fn check(project: &Path) -> Result { + let policy = load_policy(project)?; + let findings = policy.check(); + let status = if findings.is_empty() { + "complete" + } else { + "incomplete" + }; + Ok(json!({ + "ok": true, + "command": "check", + "status": status, + "project": project, + "findings": findings_json(&findings), + "effective": effective(&policy), + "networkAccess": false, + "databaseAccess": false, + })) +} + +pub(super) fn test(project: &Path) -> Result { + let policy = load_policy(project)?; + let findings = policy.check(); + let authoring_status = if findings.is_empty() { + "complete" + } else { + "incomplete" + }; + let fixture_dir = project.join(FIXTURES_DIRECTORY); + let mut paths = fs::read_dir(&fixture_dir) + .context("reading fixtures directory")? + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("yaml")) + .collect::>(); + paths.sort(); + if paths.is_empty() { + bail!("test requires at least one YAML fixture"); + } + let mut reports = Vec::new(); + for path in paths { + reports.push(run_fixture(project, &policy, &path)?); + } + Ok(json!({ + "ok": true, + "command": "test", + "project": project, + "authoringStatus": authoring_status, + "findings": findings_json(&findings), + "fixtures": reports, + "proofBoundary": "offline_synthetic", + "productionClosure": false, + "networkAccess": false, + "databaseAccess": false, + })) +} + +pub(super) fn explain(project: &Path) -> Result { + let policy = load_policy(project)?; + let findings = policy.check(); + if !findings.is_empty() { + bail!( + "explain requires a policy that passes its check; run schedulingctl check first. Findings: {}", + findings + .iter() + .map(ToString::to_string) + .collect::>() + .join("; ") + ); + } + let offerings = policy + .offerings + .iter() + .map(|offering| { + let mut report = json!({ + "id": offering.id, + "service": offering.service, + "mode": offering.mode, + "location": offering.location, + }); + if let Some(exact) = &offering.exact_time { + report["exactTime"] = serde_json::to_value(exact)?; + } + if let Some(arrival) = &offering.arrival { + report["arrival"] = serde_json::to_value(arrival)?; + } + Ok(report) + }) + .collect::>>()?; + let windows = policy + .windows + .iter() + .map(|window| { + let mut units_policy = serde_json::to_value(&window.units_policy)?; + units_policy["subquotas"] = json!(window + .subquotas + .iter() + .map(|subquota| json!({ + "channel": subquota.channel.as_str(), + "units": subquota.units, + })) + .collect::>()); + Ok(json!({ + "id": window.id, + "revision": window.revision, + "start": window.start, + "end": window.end, + "units": window.units, + "unitsPolicy": units_policy, + })) + }) + .collect::>>()?; + Ok(json!({ + "ok": true, + "command": "explain", + "scheduling": { + "id": policy.scheduling.id, + "version": policy.scheduling.version, + }, + "policyDigest": policy.policy_digest(), + "offerings": offerings, + "windows": windows, + "holdPolicy": { + "ttlMinutes": policy.hold_policy.ttl_minutes, + "maxPerCaller": policy.hold_policy.max_per_caller, + "because": policy.hold_policy.because, + }, + "networkAccess": false, + "databaseAccess": false, + })) +} + +fn run_fixture(project: &Path, policy: &SchedulingPolicy, path: &Path) -> Result { + let relative: PathBuf = path.strip_prefix(project).unwrap_or(path).to_owned(); + let fixture = load_fixture(path)?; + let outcomes = fixture + .replay(policy) + .with_context(|| format!("replaying fixture {}", relative.display()))?; + let cases = outcomes + .iter() + .map(|outcome| { + json!({ + "name": outcome.name, + "status": outcome.status.as_str(), + "detail": outcome.detail, + }) + }) + .collect::>(); + let status = if outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass) + { + "passed" + } else { + "failed" + }; + Ok(json!({ + "name": fixture.name, + "status": status, + "file": relative, + "cases": cases, + })) +} + +fn load_policy(project: &Path) -> Result { + let bytes = read_authoring_input(&project.join(AUTHORED_POLICY_FILE))?; + parse_policy_yaml(&bytes).with_context(|| format!("parsing {AUTHORED_POLICY_FILE}")) +} + +fn load_fixture(path: &Path) -> Result { + let bytes = read_authoring_input(path)?; + parse_fixture_yaml(&bytes).with_context(|| format!("parsing fixture {}", path.display())) +} + +fn read_authoring_input(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?; + if bytes.len() > MAXIMUM_INPUT_BYTES { + bail!("{} exceeds the one MiB authoring limit", path.display()); + } + String::from_utf8(bytes).with_context(|| format!("reading {}", path.display())) +} + +/// Every check finding as its path and closed reason, ready for a report. +fn findings_json(findings: &[SchedulingDiagnostic]) -> Vec { + findings + .iter() + .map(|finding| json!({"path": finding.path, "reason": finding.reason.as_str()})) + .collect() +} + +/// The effective policy a check saw: identity, digest, collection sizes and +/// identifiers, and the hold policy. +fn effective(policy: &SchedulingPolicy) -> Value { + fn summary<'a>(ids: impl Iterator) -> Value { + let all: Vec<&str> = ids.map(String::as_str).collect(); + json!({"count": all.len(), "ids": all}) + } + json!({ + "schedulingId": policy.scheduling.id, + "schedulingVersion": policy.scheduling.version, + "policyDigest": policy.policy_digest(), + "services": summary(policy.services.iter().map(|service| &service.id)), + "offerings": summary(policy.offerings.iter().map(|offering| &offering.id)), + "openings": summary(policy.openings.iter().map(|opening| &opening.id)), + "windows": summary(policy.windows.iter().map(|window| &window.id)), + "holdPolicy": { + "ttlMinutes": policy.hold_policy.ttl_minutes, + "maxPerCaller": policy.hold_policy.max_per_caller, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A tempdir holding one freshly initialized template project, plus the + /// paths the tempdir keeps alive. + fn initialized(template: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + init(&project, template).unwrap(); + (root, project) + } + + #[test] + fn init_writes_every_template_file_without_overwriting() { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + let report = init(&project, "standalone-arrival-window").unwrap(); + assert_eq!(report["command"], "init"); + assert_eq!(report["template"], "standalone-arrival-window"); + assert_eq!( + report["created"], + json!(["scheduling.yaml", "fixtures/household-morning.yaml"]) + ); + assert!(project.join(AUTHORED_POLICY_FILE).is_file()); + assert!(project.join("fixtures/household-morning.yaml").is_file()); + let error = init(&project, "standalone-arrival-window").unwrap_err(); + assert!(error.to_string().contains("never overwrites")); + let error = init(&root.path().join("other"), "no-such-template").unwrap_err(); + assert!(error.to_string().contains("standalone-arrival-window")); + } + + #[test] + fn check_reports_the_effective_policy_and_no_findings_for_a_template() { + let (_root, project) = initialized("standalone-exact-time"); + let report = check(&project).unwrap(); + assert_eq!(report["ok"], true); + assert_eq!(report["command"], "check"); + assert_eq!(report["status"], "complete"); + assert_eq!(report["findings"], json!([])); + assert_eq!(report["networkAccess"], false); + assert_eq!(report["databaseAccess"], false); + let effective = &report["effective"]; + assert_eq!(effective["schedulingId"], "registry-updates"); + assert_eq!(effective["schedulingVersion"], 1); + assert!(effective["policyDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert_eq!( + effective["services"], + json!({"count": 1, "ids": ["registry-update"]}) + ); + assert_eq!(effective["offerings"]["count"], 2); + assert_eq!(effective["openings"]["count"], 2); + assert_eq!(effective["windows"], json!({"count": 0, "ids": []})); + assert_eq!(effective["holdPolicy"]["ttlMinutes"], 5); + assert_eq!(effective["holdPolicy"]["maxPerCaller"], 3); + } + + #[test] + fn a_policy_because_breach_is_a_finding_pair_with_an_incomplete_status() { + let (_root, project) = initialized("standalone-arrival-window"); + let policy_path = project.join(AUTHORED_POLICY_FILE); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "because: The hall opens on Saturday mornings.", + "because: ", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let report = check(&project).unwrap(); + assert_eq!(report["status"], "incomplete"); + assert_eq!( + report["findings"], + json!([{"path": "openings[0].because", "reason": "invalid-because"}]) + ); + } + + #[test] + fn test_reports_per_fixture_and_per_case_outcomes_with_the_proof_boundary() { + let (_root, project) = initialized("standalone-exact-time"); + let report = test(&project).unwrap(); + assert_eq!(report["command"], "test"); + assert_eq!(report["authoringStatus"], "complete"); + assert_eq!(report["proofBoundary"], "offline_synthetic"); + assert_eq!(report["productionClosure"], false); + assert_eq!(report["networkAccess"], false); + assert_eq!(report["databaseAccess"], false); + let fixtures = report["fixtures"].as_array().unwrap(); + let names: Vec<&str> = fixtures + .iter() + .map(|fixture| fixture["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["counter-stations", "fold-day-rebooking"]); + let counter = &fixtures[0]; + assert_eq!(counter["status"], "passed"); + assert_eq!(counter["file"], "fixtures/counter-stations.yaml"); + let cases = counter["cases"].as_array().unwrap(); + assert_eq!(cases.len(), 4); + assert!(cases.iter().all(|case| case["status"] == "pass")); + // A refused case reports the public code it was refused with. + let refused = cases + .iter() + .find(|case| case["name"] == "same-key-retry-is-refused") + .unwrap(); + assert!(refused["detail"] + .as_str() + .unwrap() + .contains("booking.duplicate-active")); + // The fold fixture replays too, including its reschedule case. + let fold = &fixtures[1]; + assert_eq!(fold["status"], "passed"); + let fold_cases = fold["cases"].as_array().unwrap(); + let rescheduled = fold_cases + .iter() + .find(|case| case["name"] == "reschedule-into-the-later-morning") + .unwrap(); + assert_eq!(rescheduled["status"], "pass"); + } + + #[test] + fn an_incomplete_policy_still_runs_its_fixtures_and_reports_the_status() { + let (_root, project) = initialized("standalone-arrival-window"); + let policy_path = project.join(AUTHORED_POLICY_FILE); + // A blank because fails the check but never changes what replay does. + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "because: The hall opens on Saturday mornings.", + "because: ", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let report = test(&project).unwrap(); + assert_eq!(report["authoringStatus"], "incomplete"); + assert_eq!( + report["findings"], + json!([{"path": "openings[0].because", "reason": "invalid-because"}]) + ); + assert!(report["fixtures"] + .as_array() + .unwrap() + .iter() + .all(|fixture| fixture["status"] == "passed")); + } + + #[test] + fn explain_publishes_offerings_windows_hold_policy_and_digest() { + let (_root, project) = initialized("standalone-arrival-window"); + let report = explain(&project).unwrap(); + assert_eq!(report["command"], "explain"); + assert_eq!( + report["scheduling"], + json!({"id": "household-days", "version": 3}) + ); + assert!(report["policyDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + let offerings = report["offerings"].as_array().unwrap(); + assert_eq!(offerings[0]["id"], "household-morning"); + assert_eq!(offerings[0]["mode"], "arrival-window"); + assert_eq!(offerings[0]["location"], "civic-hall"); + assert_eq!( + offerings[0]["arrival"], + json!({"window": "household-morning-window", "leadTimeMinutes": 60, "horizonDays": 45}) + ); + let windows = report["windows"].as_array().unwrap(); + assert_eq!(windows[0]["id"], "household-morning-window"); + assert_eq!(windows[0]["revision"], 2); + assert_eq!(windows[0]["start"], "2026-10-10T01:00:00Z"); + assert_eq!(windows[0]["end"], "2026-10-10T03:00:00Z"); + assert_eq!(windows[0]["units"], 3); + assert_eq!(windows[0]["unitsPolicy"]["kind"], "perRecipient"); + assert_eq!( + windows[0]["unitsPolicy"]["subquotas"], + json!([{"channel": "public", "units": 2}, {"channel": "assisted", "units": 1}]) + ); + assert_eq!(report["holdPolicy"]["ttlMinutes"], 10); + assert_eq!(report["holdPolicy"]["maxPerCaller"], 2); + assert_eq!(report["networkAccess"], false); + assert_eq!(report["databaseAccess"], false); + + // The exact-time template explains its fold-day offering's block. + let (_root, project) = initialized("standalone-exact-time"); + let report = explain(&project).unwrap(); + let offerings = report["offerings"].as_array().unwrap(); + let fold = offerings + .iter() + .find(|offering| offering["id"] == "fold-day-update-30") + .unwrap(); + assert_eq!(fold["exactTime"]["durationMinutes"], 30); + assert_eq!(fold["exactTime"]["startIncrementMinutes"], 30); + assert!(fold["arrival"].is_null()); + } + + #[test] + fn explain_refuses_a_policy_that_fails_its_check() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join(AUTHORED_POLICY_FILE); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "version: 1\n", + "version: 0\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let error = explain(&project).unwrap_err(); + assert!(error.to_string().contains("passes its check")); + } + + #[test] + fn a_project_without_fixtures_cannot_test() { + let (_root, project) = initialized("standalone-exact-time"); + // An empty fixtures directory is the authoring error; a missing one is + // a filesystem failure the CLI reports separately. + let fixtures = project.join(FIXTURES_DIRECTORY); + for entry in std::fs::read_dir(&fixtures).unwrap() { + std::fs::remove_file(entry.unwrap().path()).unwrap(); + } + let error = test(&project).unwrap_err(); + assert!(error.to_string().contains("at least one YAML fixture")); + } + + #[test] + fn a_missing_or_unparsable_policy_is_a_reading_failure() { + let root = tempfile::tempdir().unwrap(); + let error = check(&root.path().join("nowhere")).unwrap_err(); + assert!(error.to_string().contains("nowhere")); + assert!(error.to_string().contains("scheduling.yaml")); + + let project = root.path().join("project"); + init(&project, "standalone-exact-time").unwrap(); + let policy_path = project.join(AUTHORED_POLICY_FILE); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "windows: []\n", + "windows: []\nsurprise: true\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let error = check(&project).unwrap_err(); + assert!(error.to_string().contains("parsing scheduling.yaml")); + } +} diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs new file mode 100644 index 0000000000..d0bf3c19c2 --- /dev/null +++ b/crates/registry-schedulingctl/src/templates.rs @@ -0,0 +1,493 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The complete starter projects `schedulingctl init` writes. +//! +//! Each template is a working project: its policy passes the check with zero +//! findings and its fixtures replay all-Pass offline, which the tests prove +//! end to end. + +/// The `standalone-exact-time` policy: a Bangkok counter with two +/// interchangeable stations, and a New York hall whose fold-day opening +/// spans the repeated hour with unambiguous endpoints. +const EXACT_TIME_POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: bangkok-counter + because: A 30-minute registry update at an interchangeable counter station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + duplicateActiveKey: subject + requiresCapabilities: [] + prerequisites: [] + - id: fold-day-update-30 + service: registry-update + label: 30-minute fold-day update + mode: exact-time + location: new-york-hall + because: A 30-minute update at the hall, published across the autumn time change. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 1 + horizonDays: 3650 + pool: hall-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry offices. + dates: [] +openings: + - id: bangkok-counter-hours + location: bangkok-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. + - id: new-york-hall-hours + location: new-york-hall + holidaySet: office-holidays + weekdays: [sun] + startTime: "00:30" + endTime: "02:30" + effectiveFrom: "2026-11-01" + effectiveUntil: "2026-11-01" + because: Fold-day hours whose endpoints sit outside the repeated local hour. +windows: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#; + +/// The `standalone-exact-time` counter fixture: a first caller takes a +/// station, a same-key retry is refused, a second caller gets the other +/// station, and a fourth finds both busy. +const COUNTER_STATIONS_FIXTURE: &str = r#"apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: counter-stations +now: 2026-10-05T00:00:00Z +facts: + locations: + - id: bangkok-counter + timezone: Asia/Bangkok + pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true +initial: [] +cases: + - name: first-caller-takes-a-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-1 + - name: same-key-retry-is-refused + request: + offering: registry-update-30 + start: 2026-10-05T02:30:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: booking.duplicate-active + - name: second-caller-gets-the-other-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:two + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-2 + - name: both-stations-busy-refuses-another-caller + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:three + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted +"#; + +/// The `standalone-exact-time` fold-day fixture: the pre-opening closure +/// moves the start grid to 04:45Z, a booking on the moved grid is rescheduled +/// into the three-real-hour opening, the freed slot serves another caller, +/// and a wall-clock match the moved grid does not publish is refused. +const FOLD_DAY_REBOOKING_FIXTURE: &str = r#"apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: fold-day-rebooking +now: 2026-11-01T04:00:00Z +# America/New_York falls back on 2026-11-01: local 01:30 occurs twice, and the +# 00:30-02:30 opening spans three real hours, [04:30Z, 07:30Z). The closure +# 00:15-00:45 has unambiguous endpoints in one offset and moves the opening +# start to 04:45Z, so the 30-minute grid anchors there: 04:45, 05:15, 05:45... +facts: + locations: + - id: new-york-hall + timezone: America/New_York + pools: + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true + exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" +initial: [] +cases: + - name: morning-booking-after-the-closure + request: + offering: fold-day-update-30 + start: 2026-11-01T05:15:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: reschedule-into-the-later-morning + request: + offering: fold-day-update-30 + start: 2026-11-01T06:45:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + rescheduleOf: case:morning-booking-after-the-closure + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: the-freed-slot-serves-another-caller + request: + offering: fold-day-update-30 + start: 2026-11-01T05:15:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: a-wall-clock-match-off-the-moved-grid-is-not-served + request: + offering: fold-day-update-30 + start: 2026-11-01T06:30:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: schedule.unpublished +"#; + +/// The `standalone-arrival-window` policy: a Saturday household block with a +/// public and an assisted subquota over a per-recipient units table. +const ARRIVAL_WINDOW_POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: household-days + version: 3 +services: + - id: household-day + label: Household registration day +offerings: + - id: household-morning + service: household-day + label: Morning household block + mode: arrival-window + location: civic-hall + because: Households arrive in a served morning block. + arrival: + window: household-morning-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: hall-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "08:00" + endTime: "12:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall opens on Saturday mornings. +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + leftover: becomes-walk-in + because: The Saturday morning household block, sized for two officers. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. +"#; + +/// The `standalone-arrival-window` fixture: the public subquota depletes +/// while the assisted channel still admits. +const HOUSEHOLD_MORNING_FIXTURE: &str = r#"apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-morning +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok +initial: [] +cases: + - name: first-household-takes-the-public-quota + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 2 + attendees: 3 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 + - name: next-public-household-finds-the-quota-spent + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted + - name: assisted-household-fits-the-protected-unit + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: assisted + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 +"#; + +/// The files one template writes, relative to the project directory, in +/// write order. The first entry is always the authored policy. +pub(super) fn template_files(template: &str) -> Option> { + match template { + "standalone-exact-time" => Some(vec![ + ("scheduling.yaml", EXACT_TIME_POLICY), + ("fixtures/counter-stations.yaml", COUNTER_STATIONS_FIXTURE), + ( + "fixtures/fold-day-rebooking.yaml", + FOLD_DAY_REBOOKING_FIXTURE, + ), + ]), + "standalone-arrival-window" => Some(vec![ + ("scheduling.yaml", ARRIVAL_WINDOW_POLICY), + ("fixtures/household-morning.yaml", HOUSEHOLD_MORNING_FIXTURE), + ]), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use registry_scheduling_core::{ + parse_fixture_yaml, parse_policy_yaml, CaseStatus, AUTHORED_POLICY_FILE, + SCHEDULING_FIXTURE_API_VERSION, SCHEDULING_FIXTURE_KIND, SCHEDULING_POLICY_API_VERSION, + SCHEDULING_POLICY_KIND, + }; + + const TEMPLATE_NAMES: [&str; 2] = ["standalone-exact-time", "standalone-arrival-window"]; + + #[test] + fn the_templates_are_exactly_the_two_standalone_projects() { + for template in TEMPLATE_NAMES { + assert!(template_files(template).is_some(), "{template}"); + } + assert!(template_files("standalone-decision").is_none()); + assert!(template_files("").is_none()); + } + + #[test] + fn every_template_policy_parses_and_checks_clean_with_pinned_names() { + for template in TEMPLATE_NAMES { + let files = template_files(template).unwrap(); + assert_eq!(files[0].0, AUTHORED_POLICY_FILE); + let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); + assert_eq!(policy.api_version, SCHEDULING_POLICY_API_VERSION); + assert_eq!(policy.kind, SCHEDULING_POLICY_KIND); + let findings = policy.check(); + assert!(findings.is_empty(), "{template}: {findings:?}"); + } + } + + #[test] + fn every_template_fixture_parses_and_fits_its_policy() { + for template in TEMPLATE_NAMES { + let files = template_files(template).unwrap(); + let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); + for (relative, contents) in &files[1..] { + assert!( + relative.starts_with("fixtures/") && relative.ends_with(".yaml"), + "{relative}" + ); + let fixture = parse_fixture_yaml(contents).expect("the template fixture parses"); + assert_eq!(fixture.api_version, SCHEDULING_FIXTURE_API_VERSION); + assert_eq!(fixture.kind, SCHEDULING_FIXTURE_KIND); + fixture + .check(&policy) + .expect("the template fixture fits its policy"); + } + } + } + + /// The acceptance proof for both templates: every fixture replays + /// all-Pass against its own template policy. + #[test] + fn every_template_fixture_replays_all_pass() { + for template in TEMPLATE_NAMES { + let files = template_files(template).unwrap(); + let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); + for (relative, contents) in &files[1..] { + let fixture = parse_fixture_yaml(contents).expect("the template fixture parses"); + let outcomes = fixture.replay(&policy).expect("the fixture replays"); + assert!( + outcomes + .iter() + .all(|outcome| outcome.status == CaseStatus::Pass), + "{template}/{relative}: {outcomes:?}" + ); + } + } + } + + #[test] + fn the_exact_time_template_carries_both_counter_and_fold_fixtures() { + let files = template_files("standalone-exact-time").unwrap(); + let names: Vec<&str> = files.into_iter().map(|(relative, _)| relative).collect(); + assert_eq!( + names, + vec![ + "scheduling.yaml", + "fixtures/counter-stations.yaml", + "fixtures/fold-day-rebooking.yaml", + ] + ); + } +} diff --git a/products/scheduling/README.md b/products/scheduling/README.md new file mode 100644 index 0000000000..ba888bfd1c --- /dev/null +++ b/products/scheduling/README.md @@ -0,0 +1,23 @@ +# Registry Scheduling product material + +Registry Scheduling gives a registry a coordinated booking surface: published +openings, exact-time offerings over interchangeable resource pools, published +arrival windows with channel subquotas, holds, and accountable bookings, over +PostgreSQL. The runtime is `crates/registry-scheduling`; the source-neutral +model and evaluators live in `crates/registry-scheduling-core`; adopter +tooling is `crates/registry-schedulingctl` (`schedulingctl`). + +This folder holds the product's own contracts, examples, fixtures, and gates. +It currently carries the dependency-direction gate that holds the product +boundary from the first commit: + +```bash +python3 products/scheduling/scripts/check_dependency_direction.py +python3 -m unittest products/scheduling/scripts/test_dependency_direction.py +``` + +No scheduling crate, directly or through any shared dependency, may reach a +Base Registry Engine, Casework, or Evidence crate, and none of those products +may reach a scheduling crate. The source-neutral core sits at the bottom of +the product depending on no other scheduling crate, and the client shares the +core without ever linking the runtime or adopter tooling. diff --git a/products/scheduling/scripts/check_dependency_direction.py b/products/scheduling/scripts/check_dependency_direction.py new file mode 100644 index 0000000000..ee0f4d3001 --- /dev/null +++ b/products/scheduling/scripts/check_dependency_direction.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Check Scheduling dependency direction from Cargo's resolved graph.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +# The runtime products whose protocol surfaces must never reach a scheduling +# crate, directly or through any shared dependency. +PRODUCT_PREFIXES = ("registry-breg", "registry-casework", "registry-evidence") + +# The source-neutral scheduling crates: the model and evaluators, and the +# caller-facing client. Their dependency closures must stay product-free and, +# for the core, free of every other scheduling crate. +NEUTRAL_CRATES = ("registry-scheduling-core", "registry-scheduling-client") + + +def package_graph(metadata: dict) -> tuple[dict[str, str], dict[str, set[str]]]: + names = {package["id"]: package["name"] for package in metadata["packages"]} + resolve = metadata.get("resolve") + if not resolve: + raise ValueError("cargo metadata did not include a resolved dependency graph") + edges = { + node["id"]: { + dependency["pkg"] if isinstance(dependency, dict) else dependency + for dependency in node.get("deps", node.get("dependencies", [])) + } + for node in resolve["nodes"] + } + return names, edges + + +def closure(start: str, edges: dict[str, set[str]]) -> set[str]: + seen: set[str] = set() + pending = list(edges.get(start, set())) + while pending: + package = pending.pop() + if package in seen: + continue + seen.add(package) + pending.extend(edges.get(package, set()) - seen) + return seen + + +def product_violations(name: str, package_id: str, names: dict[str, str], edges: dict[str, set[str]]) -> list[str]: + forbidden = sorted( + { + names[item] + for item in closure(package_id, edges) + if names[item].startswith(PRODUCT_PREFIXES) + } + ) + if forbidden: + return [ + f"{name} transitively depends on other-product package(s): {', '.join(forbidden)}" + ] + return [] + + +def internal_violations(name: str, package_id: str, names: dict[str, str], edges: dict[str, set[str]]) -> list[str]: + failures: list[str] = [] + reached = closure(package_id, edges) + if name == "registry-scheduling-core": + # The core is the bottom of the product: it may depend on no other + # scheduling crate, or the runtime and every replay would follow it. + internal = sorted( + {names[item] for item in reached if names[item].startswith("registry-scheduling")} + ) + if internal: + failures.append( + f"registry-scheduling-core transitively depends on scheduling crate(s): {', '.join(internal)}" + ) + if name == "registry-scheduling-client": + # The client may share the core, never the runtime or adopter tooling. + reaching_runtime = sorted( + { + names[item] + for item in reached + if names[item] in ("registry-scheduling", "registry-schedulingctl") + } + ) + if reaching_runtime: + failures.append( + f"registry-scheduling-client transitively depends on runtime crate(s): {', '.join(reaching_runtime)}" + ) + return failures + + +def violations(metadata: dict) -> list[str]: + names, edges = package_graph(metadata) + ids_by_name: dict[str, list[str]] = {} + for package_id, name in names.items(): + ids_by_name.setdefault(name, []).append(package_id) + + failures: list[str] = [] + for scheduling_name, package_ids in sorted(ids_by_name.items()): + if not scheduling_name.startswith("registry-scheduling"): + continue + for package_id in package_ids: + failures.extend(product_violations(scheduling_name, package_id, names, edges)) + failures.extend(internal_violations(scheduling_name, package_id, names, edges)) + + # The boundary runs both ways: no other runtime product may reach into + # scheduling through a dependency either. + for package_id, package_name in sorted(names.items(), key=lambda item: item[1]): + if not package_name.startswith(PRODUCT_PREFIXES): + continue + forbidden = sorted( + { + names[item] + for item in closure(package_id, edges) + if names[item].startswith("registry-scheduling") + } + ) + if forbidden: + failures.append( + f"{package_name} transitively depends on Scheduling package(s): {', '.join(forbidden)}" + ) + return failures + + +def load_metadata(repository_root: Path, fixture: Path | None) -> dict: + if fixture: + return json.loads(fixture.read_text(encoding="utf-8")) + completed = subprocess.run( + ["cargo", "metadata", "--locked", "--format-version", "1"], + cwd=repository_root, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--metadata", type=Path, help="read a Cargo metadata fixture") + args = parser.parse_args() + repository_root = Path(__file__).resolve().parents[3] + try: + failures = violations(load_metadata(repository_root, args.metadata)) + except (OSError, ValueError, KeyError, json.JSONDecodeError, subprocess.CalledProcessError) as error: + print(f"dependency-direction check could not inspect Cargo metadata: {error}", file=sys.stderr) + return 2 + if failures: + for failure in failures: + print(f"dependency-direction violation: {failure}", file=sys.stderr) + return 1 + print("Scheduling dependency direction is source-neutral.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/scheduling/scripts/test_dependency_direction.py b/products/scheduling/scripts/test_dependency_direction.py new file mode 100644 index 0000000000..92f7df99ba --- /dev/null +++ b/products/scheduling/scripts/test_dependency_direction.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("check_dependency_direction.py") +SPEC = importlib.util.spec_from_file_location("scheduling_dependency_direction", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def metadata(edges: dict[str, list[str]]) -> dict: + names = { + "core": "registry-scheduling-core", + "client": "registry-scheduling-client", + "runtime": "registry-scheduling", + "ctl": "registry-schedulingctl", + "breg-client": "registry-breg-client", + "casework-core": "registry-casework-core", + "evidence-client": "registry-evidence-client", + "platform": "registry-platform-calendar", + "serde": "serde", + } + return { + "packages": [{"id": package_id, "name": name} for package_id, name in names.items()], + "resolve": { + "nodes": [ + { + "id": package_id, + "deps": [{"pkg": dependency, "name": names[dependency]} for dependency in dependencies], + } + for package_id, dependencies in edges.items() + ] + }, + } + + +class DependencyDirectionTests(unittest.TestCase): + def test_intended_direction_is_accepted(self): + graph = metadata( + { + "core": ["platform", "serde"], + "client": ["core", "serde"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": ["serde"], + "serde": [], + } + ) + self.assertEqual(MODULE.violations(graph), []) + + def test_transitive_product_dependency_from_core_is_rejected(self): + graph = metadata( + { + "core": ["serde"], + "client": ["core"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": ["breg-client"], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn("registry-scheduling-core transitively depends on other-product", failures) + self.assertIn("registry-scheduling-client transitively depends on other-product", failures) + self.assertIn("registry-scheduling transitively depends on other-product", failures) + self.assertIn("registry-schedulingctl transitively depends on other-product", failures) + + def test_evidence_dependency_from_client_is_rejected(self): + graph = metadata( + { + "core": ["serde"], + "client": ["evidence-client"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": [], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn( + "registry-scheduling-client transitively depends on other-product package(s): registry-evidence-client", + failures, + ) + + def test_core_depending_on_the_runtime_is_rejected(self): + graph = metadata( + { + "core": ["runtime"], + "client": ["core"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": [], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn( + "registry-scheduling-core transitively depends on scheduling crate(s): registry-scheduling", + failures, + ) + + def test_client_depending_on_the_runtime_is_rejected(self): + graph = metadata( + { + "core": ["serde"], + "client": ["runtime"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": [], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": [], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn( + "registry-scheduling-client transitively depends on runtime crate(s): registry-scheduling", + failures, + ) + + def test_product_dependency_on_scheduling_is_rejected(self): + graph = metadata( + { + "core": ["serde"], + "client": ["core"], + "runtime": ["core"], + "ctl": ["core"], + "breg-client": ["client"], + "casework-core": [], + "evidence-client": [], + "platform": [], + "serde": [], + } + ) + failures = "\n".join(MODULE.violations(graph)) + self.assertIn( + "registry-breg-client transitively depends on Scheduling package(s): registry-scheduling-client", + failures, + ) + + +if __name__ == "__main__": + unittest.main() From d2e325b22814cd79e99c17715426f44262da2d2a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 22:41:09 +0700 Subject: [PATCH 007/115] feat(scheduling): author reminder offsets on offerings INT-02 makes reminders configurable at the service-design layer and binds each intent to an appointment revision, and AT-08 asserts a reschedule leaves no active reminder for the old revision. Both need an authored reminder schedule to exist, or the invariant is only vacuously true, so the policy gains one reminder offset type on each offering: minutes_before and a review because, validated like every other authored choice (zero, duplicate distances, and blank reasons are findings). Message transport stays external: the runtime mints revision-bound intents from these offsets, and an adopter with no consumer reads them back. The standalone-exact-time template authors two offsets on the counter offering so the runtime demo can show a real intent being suppressed. Signed-off-by: Jeremi Joslin --- .../registry-scheduling-core/src/admission.rs | 2 + crates/registry-scheduling-core/src/policy.rs | 78 +++++++++++++++++++ .../registry-schedulingctl/src/templates.rs | 5 ++ 3 files changed, 85 insertions(+) diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index 6070b6243b..b71fcacede 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -504,6 +504,7 @@ mod tests { exact_time: None, arrival: None, cancellation_cutoff_minutes: 240, + reminders: Vec::new(), duplicate_active_key: Some("subject".to_owned()), requires_capabilities: Vec::new(), prerequisites: Vec::new(), @@ -1111,6 +1112,7 @@ mod tests { horizon_days: 60, }), cancellation_cutoff_minutes: 1440, + reminders: Vec::new(), duplicate_active_key: None, requires_capabilities: Vec::new(), prerequisites: Vec::new(), diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 0465898261..37af2a35a4 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -112,6 +112,15 @@ pub struct ArrivalOffering { pub horizon_days: u32, } +/// One authored reminder offset: a notification intent becomes due this many +/// minutes before the displayed start of an appointment on the offering. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReminderOffsetPolicy { + pub minutes_before: u32, + pub because: String, +} + /// One bookable thing a deployment offers. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -128,6 +137,11 @@ pub struct OfferingPolicy { pub arrival: Option, /// How late before the displayed start a booking may still be cancelled. pub cancellation_cutoff_minutes: u32, + /// Authored reminder offsets for this offering. The runtime mints one + /// revision-bound intent per offset whose due time is still ahead when + /// the appointment commits; message transport stays external (INT-02). + #[serde(default)] + pub reminders: Vec, /// The caller attribute an active-booking duplicate check keys on, when /// the service defines one. Phone numbers and email addresses are never /// valid duplicate keys. @@ -581,6 +595,32 @@ impl SchedulingPolicy { if let Some(key) = &offering.duplicate_active_key { check_identifier(key, &format!("{path}.duplicateActiveKey"), findings); } + check_collection_bound(&offering.reminders, &format!("{path}.reminders"), findings); + let mut reminder_offsets: Vec = Vec::new(); + for (offset_index, reminder) in offering.reminders.iter().enumerate() { + let reminder_path = format!("{path}.reminders[{offset_index}]"); + if reminder.minutes_before == 0 { + findings.push(SchedulingDiagnostic::new( + format!("{reminder_path}.minutesBefore"), + PolicyCheckReason::InvalidBound, + )); + } + // Two offsets at the same distance would mint two intents due at + // the same instant for one appointment: one reminder per moment. + if reminder_offsets.contains(&reminder.minutes_before) { + findings.push(SchedulingDiagnostic::new( + format!("{reminder_path}.minutesBefore"), + PolicyCheckReason::DuplicateIdentifier, + )); + } else { + reminder_offsets.push(reminder.minutes_before); + } + check_because( + &reminder.because, + &format!("{reminder_path}.because"), + findings, + ); + } for capability in &offering.requires_capabilities { if !valid_identifier(capability) { findings.push(SchedulingDiagnostic::new( @@ -1158,6 +1198,43 @@ holdPolicy: ); } + /// Authored reminder offsets are part of the reviewed policy: a zero + /// offset, a blank because, and two offsets at the same distance are all + /// findings, and a well-formed schedule passes the check untouched. + #[test] + fn reminder_offsets_are_validated_like_every_authored_choice() { + let mut policy = minimal_exact_time_policy(); + policy.offerings[0].reminders = vec![ + ReminderOffsetPolicy { + minutes_before: 0, + because: " ".to_owned(), + }, + ReminderOffsetPolicy { + minutes_before: 1440, + because: "One reminder the day before a counter update.".to_owned(), + }, + ReminderOffsetPolicy { + minutes_before: 1440, + because: "A second reminder at the same distance.".to_owned(), + }, + ]; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"offerings[0].reminders[0].minutesBefore: invalid-bound".to_owned()) + ); + assert!(rendered.contains(&"offerings[0].reminders[0].because: invalid-because".to_owned())); + assert!(rendered + .contains(&"offerings[0].reminders[2].minutesBefore: duplicate-identifier".to_owned())); + + policy.offerings[0].reminders.truncate(2); + policy.offerings[0].reminders[0] = ReminderOffsetPolicy { + minutes_before: 60, + because: "One reminder an hour before.".to_owned(), + }; + assert!(policy.check().is_empty(), "{:?}", policy.check()); + } + #[test] fn subquota_slices_may_not_overdraw_the_window() { let mut policy = household_window_policy(); @@ -1207,6 +1284,7 @@ holdPolicy: }), arrival: None, cancellation_cutoff_minutes: 60, + reminders: Vec::new(), duplicate_active_key: None, requires_capabilities: Vec::new(), prerequisites: Vec::new(), diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs index d0bf3c19c2..e5d1cc3aab 100644 --- a/crates/registry-schedulingctl/src/templates.rs +++ b/crates/registry-schedulingctl/src/templates.rs @@ -34,6 +34,11 @@ offerings: startIncrementMinutes: 30 maxRecipients: 1 cancellationCutoffMinutes: 240 + reminders: + - minutesBefore: 1440 + because: One reminder the day before a counter update. + - minutesBefore: 120 + because: A second reminder two hours before, when stations are scarce. duplicateActiveKey: subject requiresCapabilities: [] prerequisites: [] From 54ac41503af38850c053eb0b14db943630000597 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 22:51:08 +0700 Subject: [PATCH 008/115] feat(scheduling): add the wire documents to the core The HTTP documents both the runtime and every client speak: the catalogue (service, offering, window, reminder), availability entries as a kind-tagged union of grid slots and windows, pages with cursors, holds, appointments and their requests, history entries, and the explain document with its public and detailed problem codes side by side. They live in the source-neutral core beside the model they project, the way the Casework DTOs live in registry-casework-core, so the client crate depends on core and never on the runtime. Every document is camelCase and refuses unknown fields. The projections are deliberate: because reasons never publish, callers see displayed times rather than occupied intervals, and the detailed code that names unavailability appears only on the separately authorized explain path. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling-core/src/lib.rs | 7 +- crates/registry-scheduling-core/src/wire.rs | 433 ++++++++++++++++++++ 2 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 crates/registry-scheduling-core/src/wire.rs diff --git a/crates/registry-scheduling-core/src/lib.rs b/crates/registry-scheduling-core/src/lib.rs index f1613c0cdf..fc083ba2ce 100644 --- a/crates/registry-scheduling-core/src/lib.rs +++ b/crates/registry-scheduling-core/src/lib.rs @@ -5,8 +5,9 @@ //! This crate owns the model an adopter authors and the decisions that model //! implies, and nothing else: the policy types with their validators, the //! supply ledger and its pure query methods, the admission evaluators, the -//! offline replay fixtures, the closed problem vocabulary, and the fixed -//! artifact names. It deliberately contains no source protocol types, no SQL, +//! offline replay fixtures, the closed problem vocabulary, the HTTP wire +//! documents, and the fixed artifact names. It deliberately contains no +//! source protocol types, no SQL, //! no I/O, and no clock: every evaluator takes the observed instant as an //! explicit parameter, so the runtime inside its capacity transaction, the //! explain path, and an offline fixture replay all run the same pure functions @@ -27,6 +28,7 @@ mod naming; mod policy; mod problem; mod units; +mod wire; pub use admission::*; pub use diagnostics::*; @@ -36,3 +38,4 @@ pub use naming::*; pub use policy::*; pub use problem::*; pub use units::*; +pub use wire::*; diff --git a/crates/registry-scheduling-core/src/wire.rs b/crates/registry-scheduling-core/src/wire.rs new file mode 100644 index 0000000000..261500735c --- /dev/null +++ b/crates/registry-scheduling-core/src/wire.rs @@ -0,0 +1,433 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The HTTP wire documents of the Scheduling API. +//! +//! These types are the contract between the runtime and every client, so they +//! live in the source-neutral core beside the model they project, the way the +//! Casework DTOs live in `registry-casework-core`. Every document is +//! camelCase on the wire and refuses unknown fields, so an undocumented +//! addition to a response is a compatibility event a caller notices, not a +//! field a lenient parser silently drops. +//! +//! The projections are deliberate: `because` review reasons are authoring +//! material and never published; occupied intervals (with buffers) are the +//! ledger's own view and callers see displayed times; and the one refusal +//! detail that names a resource appears only on the separately authorized +//! explain path. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::model::AdmissionRequest; + +/// What `GET /v1/scheduling` answers: which deployment and which policy. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingServiceDocument { + pub scheduling_id: String, + pub policy_revision: u64, + pub policy_digest: String, +} + +/// One service in the catalogue. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceDocument { + pub id: String, + pub label: String, +} + +/// One offering in the catalogue, the public view of the authored policy. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OfferingDocument { + pub id: String, + pub service: String, + pub label: String, + pub mode: SchedulingModeDocument, + pub location: String, + pub lead_time_minutes: u32, + pub horizon_days: u32, + pub cancellation_cutoff_minutes: u32, + /// Present for exact-time offerings. + pub duration_minutes: Option, + /// Present for exact-time offerings. + pub buffer_before_minutes: Option, + /// Present for exact-time offerings. + pub buffer_after_minutes: Option, + /// Present for exact-time offerings. + pub start_increment_minutes: Option, + /// Present for exact-time offerings. + pub max_recipients: Option, + /// Present for arrival-window offerings. + pub window: Option, + pub reminders: Vec, + pub requires_capabilities: Vec, + pub prerequisites: Vec, +} + +/// How an offering delivers its service, on the wire. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SchedulingModeDocument { + ExactTime, + ArrivalWindow, +} + +/// One authored reminder offset, as the catalogue publishes it. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReminderDocument { + pub minutes_before: u32, +} + +/// A published arrival window in the catalogue. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WindowDocument { + pub id: String, + pub revision: u64, + pub start: DateTime, + pub end: DateTime, + pub units: u32, +} + +/// One availability answer entry. Exact-time offerings answer in grid slots; +/// arrival-window offerings answer in windows. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum AvailabilityEntry { + /// A grid slot and how many pool members are still free across it. + Slot { + start: DateTime, + end: DateTime, + free: u32, + }, + /// A published window and its remaining recipient units, the caller's + /// channel slice included when the window reserves one for it. + Window { + window: String, + start: DateTime, + end: DateTime, + remaining: u32, + channel_remaining: Option, + }, +} + +/// One page of a bounded listing, with the opaque cursor that continues it. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PageDocument { + pub items: Vec, + pub next_cursor: Option, +} + +/// A minted hold, the answer to `POST /v1/holds`. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HoldDocument { + pub hold_id: String, + pub offering: String, + pub start: DateTime, + pub end: DateTime, + /// The pool member held, for exact-time offerings. + pub resource: Option, + pub units: u32, + pub expires_at: DateTime, + pub policy_revision: u64, +} + +/// The lifecycle state of an appointment on the wire. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AppointmentStateDocument { + Confirmed, + Cancelled, +} + +impl AppointmentStateDocument { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Confirmed => "confirmed", + Self::Cancelled => "cancelled", + } + } +} + +/// A confirmed appointment. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AppointmentDocument { + pub appointment_id: String, + pub offering: String, + pub start: DateTime, + pub end: DateTime, + /// The pool member serving the appointment, for exact-time offerings. + pub resource: Option, + pub units: u32, + pub channel: Option, + pub revision: u64, + pub state: AppointmentStateDocument, + pub policy_revision: u64, + pub created_at: DateTime, + pub cancelled_at: Option>, +} + +/// The request behind `POST /v1/appointments`: confirm a held allocation, or +/// create an appointment directly. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateAppointmentRequest { + /// The hold being confirmed. Mutually exclusive with `admission`. + pub hold: Option, + /// The direct-create admission request. Mutually exclusive with `hold`. + pub admission: Option, +} + +/// The request behind `POST /v1/appointments/{id}/reschedule`. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RescheduleAppointmentRequest { + pub observed_revision: u64, + pub admission: AdmissionRequest, +} + +/// The request behind `POST /v1/appointments/{id}/cancel`. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CancelAppointmentRequest { + pub observed_revision: u64, + #[serde(default)] + pub reason: Option, +} + +/// One attributable step in an appointment's history. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AppointmentHistoryEntryDocument { + pub event_id: String, + pub kind: String, + pub revision: u64, + pub occurred_at: DateTime, + /// The pseudonymized actor reference, when the step had one. + pub actor: Option, + pub detail: Value, +} + +/// The separately authorized answer to `GET /v1/availability/explain`: what +/// a refused start would have said, including the one detail the public path +/// never names. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExplainDocument { + pub offering: String, + pub start: DateTime, + /// The problem code every caller may see. + pub public_code: String, + /// The problem code only this path may disclose. + pub detailed_code: String, + /// The refusal in words, as the evaluator states it. + pub explanation: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::PartyCounts; + use chrono::TimeZone as _; + + fn utc(day: u32, hour: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, day, hour, 0, 0).unwrap() + } + + #[test] + fn catalogue_documents_round_trip_and_refuse_unknown_fields() { + let offering = OfferingDocument { + id: "registry-update-30".to_owned(), + service: "registry-update".to_owned(), + label: "30-minute counter update".to_owned(), + mode: SchedulingModeDocument::ExactTime, + location: "north-counter".to_owned(), + lead_time_minutes: 120, + horizon_days: 60, + cancellation_cutoff_minutes: 240, + duration_minutes: Some(30), + buffer_before_minutes: Some(5), + buffer_after_minutes: Some(5), + start_increment_minutes: Some(30), + max_recipients: Some(1), + window: None, + reminders: vec![ReminderDocument { + minutes_before: 1440, + }], + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + }; + let json = serde_json::to_value(&offering).unwrap(); + assert_eq!(json["mode"], "exact-time"); + assert_eq!(json["leadTimeMinutes"], 120); + let parsed: OfferingDocument = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, offering); + assert!(serde_json::from_str::( + &serde_json::to_string(&offering) + .unwrap() + .replace("\"reminders\"", "\"reminders\":{\"x\":1},\"stray\"",) + ) + .is_err()); + } + + #[test] + fn availability_entries_are_kind_tagged() { + let slot = AvailabilityEntry::Slot { + start: utc(5, 2), + end: utc(5, 3), + free: 2, + }; + let json = serde_json::to_value(&slot).unwrap(); + assert_eq!(json["kind"], "slot"); + assert_eq!(json["free"], 2); + + let window = AvailabilityEntry::Window { + window: "household-morning-window".to_owned(), + start: utc(10, 8), + end: utc(10, 10), + remaining: 1, + channel_remaining: Some(0), + }; + let json = serde_json::to_value(&window).unwrap(); + assert_eq!(json["kind"], "window"); + assert_eq!(json["channelRemaining"], 0); + assert_eq!( + serde_json::from_value::(json).unwrap(), + window + ); + } + + #[test] + fn create_requests_enforce_their_exclusive_shape() { + let admission = AdmissionRequest { + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: Some("subject:one".to_owned()), + policy_revision: 1, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + reschedule_of: None, + }; + + let direct = CreateAppointmentRequest { + hold: None, + admission: Some(admission.clone()), + }; + let json = serde_json::to_value(&direct).unwrap(); + assert_eq!(json["admission"]["duplicateKey"], "subject:one"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + direct + ); + + let confirming = CreateAppointmentRequest { + hold: Some("0b2bb6b6-3f3a-4c66-9a5a-2fbd8c72d1ee".to_owned()), + admission: None, + }; + let json = serde_json::to_value(&confirming).unwrap(); + assert_eq!( + serde_json::from_value::(json).unwrap(), + confirming + ); + + // Neither branch present, or a stray field, is refused. + assert!(serde_json::from_str::("{}").is_ok()); + assert!(serde_json::from_str::("{\"hold\":1}").is_err()); + } + + #[test] + fn appointment_and_history_documents_round_trip() { + let appointment = AppointmentDocument { + appointment_id: "6e97f6a3-8524-4a13-9db8-ad0c4eb4d64b".to_owned(), + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + end: utc(5, 3), + resource: Some("station-1".to_owned()), + units: 1, + channel: None, + revision: 2, + state: AppointmentStateDocument::Confirmed, + policy_revision: 1, + created_at: utc(4, 9), + cancelled_at: None, + }; + let json = serde_json::to_value(&appointment).unwrap(); + assert_eq!(json["state"], "confirmed"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + appointment + ); + + let entry = AppointmentHistoryEntryDocument { + event_id: "b31c0f4e-cc7f-4ba8-88f2-9c1a9102f0a1".to_owned(), + kind: "rescheduled".to_owned(), + revision: 2, + occurred_at: utc(4, 10), + actor: Some("hmac-sha256:v2:8f0a".to_owned()), + detail: serde_json::json!({"start": "2026-10-05T03:00:00Z"}), + }; + let json = serde_json::to_value(&entry).unwrap(); + assert_eq!(json["kind"], "rescheduled"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + entry + ); + } + + /// The explain document carries both projections, so a test can pin that + /// the detailed code never equals a detailed-only value in the public + /// position. + #[test] + fn explain_documents_carry_both_projections() { + let explain = ExplainDocument { + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + public_code: "capacity.exhausted".to_owned(), + detailed_code: "resource.unavailable".to_owned(), + explanation: "every capable member is unavailable".to_owned(), + }; + let json = serde_json::to_value(&explain).unwrap(); + assert_eq!(json["publicCode"], "capacity.exhausted"); + assert_eq!(json["detailedCode"], "resource.unavailable"); + assert_eq!( + serde_json::from_value::(json).unwrap(), + explain + ); + } + + #[test] + fn pages_carry_an_optional_cursor() { + let page = PageDocument { + items: vec![ServiceDocument { + id: "registry-update".to_owned(), + label: "Registry record update".to_owned(), + }], + next_cursor: None, + }; + let json = serde_json::to_value(&page).unwrap(); + assert_eq!(json["nextCursor"], Value::Null); + assert_eq!( + serde_json::from_value::>(json).unwrap(), + page + ); + } +} From 6d498545a2dc55f431aa78af949cdede2aeb673c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 22:54:00 +0700 Subject: [PATCH 009/115] feat(scheduling): pin the problem status map in the core Every code in the closed vocabulary now carries its HTTP status, title, and value-free remediation detail beside the code string itself, so the runtime emits and every client validates one pinned mapping rather than each re-deriving its own. The map is part of the wire contract: a caller can enumerate every problem this product can return and know the status it arrives with. Statuses follow the acceptance reasoning: recoverable conflicts answer 409, holds and idempotency receipts that no longer exist answer 410, stale revisions answer 412, a missing precondition answer 428, requests the published policy could never serve answer 422, and checks that could not run answer 503. Transport statuses (404, 405, 413, 415) are deliberately absent: edge rejections belong to the platform's own problem namespace, never to the domain vocabulary. Signed-off-by: Jeremi Joslin --- .../registry-scheduling-core/src/problem.rs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/crates/registry-scheduling-core/src/problem.rs b/crates/registry-scheduling-core/src/problem.rs index 2d6f6f14af..b44c867918 100644 --- a/crates/registry-scheduling-core/src/problem.rs +++ b/crates/registry-scheduling-core/src/problem.rs @@ -152,6 +152,154 @@ impl ProblemCode { pub const fn is_detailed_only(self) -> bool { matches!(self, Self::ResourceUnavailable) } + + /// The HTTP status this problem answers with. The map lives here, beside + /// the vocabulary it belongs to, so the runtime emits and every client + /// validates one pinned status per code rather than each re-deriving its + /// own. + #[must_use] + pub const fn http_status(self) -> u16 { + match self { + Self::CursorInvalid => 400, + Self::AuthenticationRefused => 401, + Self::OperationNotAuthorized | Self::ProfileNotAuthorized => 403, + Self::BookingDuplicateActive + | Self::CancellationCutoffPassed + | Self::CapacityExhausted + | Self::HoldReleased + | Self::IdempotencyKeyReused + | Self::LocationClosed + | Self::ResourceUnavailable => 409, + Self::CursorExpired | Self::HoldExpired | Self::IdempotencyExpired => 410, + Self::PolicyChanged | Self::PreconditionFailed | Self::RevisionMismatch => 412, + Self::CapabilityUnmatched + | Self::HorizonOutside + | Self::PartyCapacityInadequate + | Self::PrerequisiteMissing + | Self::ScheduleUnpublished => 422, + Self::PreconditionRequired => 428, + Self::EligibilityUnavailable | Self::HookUnavailable | Self::ServiceUnavailable => 503, + } + } + + /// The fixed, value-free problem title. Titles and details carry no + /// identifiers, so a problem body never discloses another person's + /// booking, a private staff reason, or restricted eligibility + /// information. + #[must_use] + pub const fn title(self) -> &'static str { + match self { + Self::AuthenticationRefused => "Authentication refused", + Self::BookingDuplicateActive => "Duplicate active booking", + Self::CancellationCutoffPassed => "Cancellation cutoff passed", + Self::CapabilityUnmatched => "Capability unmatched", + Self::CapacityExhausted => "Capacity exhausted", + Self::CursorExpired => "Cursor expired", + Self::CursorInvalid => "Cursor invalid", + Self::EligibilityUnavailable => "Eligibility unavailable", + Self::HoldExpired => "Hold expired", + Self::HoldReleased => "Hold released", + Self::HookUnavailable => "Hook unavailable", + Self::HorizonOutside => "Start outside the booking horizon", + Self::IdempotencyExpired => "Idempotency window expired", + Self::IdempotencyKeyReused => "Idempotency key reused", + Self::LocationClosed => "Location closed", + Self::OperationNotAuthorized => "Operation not authorized", + Self::PartyCapacityInadequate => "Party capacity inadequate", + Self::PolicyChanged => "Policy changed", + Self::PreconditionFailed => "Precondition failed", + Self::PreconditionRequired => "Precondition required", + Self::PrerequisiteMissing => "Prerequisite missing", + Self::ProfileNotAuthorized => "Profile not authorized", + Self::ResourceUnavailable => "Resource unavailable", + Self::RevisionMismatch => "Revision mismatch", + Self::ScheduleUnpublished => "Schedule unpublished", + Self::ServiceUnavailable => "Scheduling service unavailable", + } + } + + /// The fixed remediation sentence, value-free like the title. + #[must_use] + pub const fn detail(self) -> &'static str { + match self { + Self::AuthenticationRefused => { + "The bearer credential is missing, invalid, or expired. Sign in again." + } + Self::BookingDuplicateActive => { + "An active booking already holds this party's duplicate key. Cancel or complete it before booking again." + } + Self::CancellationCutoffPassed => { + "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled." + } + Self::CapabilityUnmatched => { + "No backing member carries every capability this offering requires." + } + Self::CapacityExhausted => { + "The supply is fully committed for the requested interval. Choose another time." + } + Self::CursorExpired => { + "This cursor has expired. Start again without a cursor and deduplicate entries by id." + } + Self::CursorInvalid => "The cursor is invalid for this request.", + Self::EligibilityUnavailable => { + "A required eligibility check could not run. Retry; do not treat this as permission." + } + Self::HoldExpired => { + "The hold expired before confirmation. Its capacity is bookable again; start a new request." + } + Self::HoldReleased => { + "The claim is not an active hold. It may already be confirmed or released." + } + Self::HookUnavailable => { + "A required lifecycle hook could not run, so the request was refused rather than half-applied." + } + Self::HorizonOutside => { + "The requested start is earlier than the lead time allows or further ahead than the horizon allows." + } + Self::IdempotencyExpired => { + "The stored response for this idempotency key has expired. Reconcile the original operation before choosing a new key." + } + Self::IdempotencyKeyReused => { + "This idempotency key was used for a different request." + } + Self::LocationClosed => { + "A closure covers the requested start. Choose a start outside the closure." + } + Self::OperationNotAuthorized => { + "Your current Scheduling authority does not allow this operation." + } + Self::PartyCapacityInadequate => { + "The party is larger than this offering can ever serve, or its size falls outside the published units." + } + Self::PolicyChanged => { + "The policy revision changed before the request committed. Reload the catalogue and try again with the current revision." + } + Self::PreconditionFailed => { + "The appointment changed since you loaded it. Reload and try again." + } + Self::PreconditionRequired => { + "This mutation requires the revision you loaded, or the duplicate key this offering keys on." + } + Self::PrerequisiteMissing => { + "The party is missing a prerequisite this offering requires." + } + Self::ProfileNotAuthorized => { + "The selected Scheduling profile does not authorize this request." + } + Self::ResourceUnavailable => { + "Every capable member is unavailable for this interval." + } + Self::RevisionMismatch => { + "The window revision changed before the request committed. Reload the catalogue and try again." + } + Self::ScheduleUnpublished => { + "No published schedule serves that start. Choose a start on the published grid." + } + Self::ServiceUnavailable => { + "Scheduling storage is unavailable. Try again after the service recovers." + } + } + } } /// Expand a dotted problem code into its full problem type URI. @@ -208,4 +356,43 @@ mod tests { format!("{SCHEDULING_PROBLEM_TYPE_BASE}idempotency/key-reused") ); } + + /// Every code carries one pinned status from the statuses this product + /// answers with, a non-empty title, and a non-empty value-free detail. + /// Transport statuses (404, 405, 413, 415) are absent on purpose: those + /// are edge rejections under the platform's own problem namespace, never + /// domain codes. + #[test] + fn every_code_pins_a_status_title_and_detail() { + let statuses = [400, 401, 403, 409, 410, 412, 422, 428, 503]; + for code in ProblemCode::ALL { + assert!( + statuses.contains(&code.http_status()), + "{}: {}", + code.code(), + code.http_status() + ); + assert!(!code.title().is_empty(), "{}", code.code()); + assert!(!code.detail().is_empty(), "{}", code.code()); + } + } + + /// The statuses the acceptance scenarios reason about: a recoverable + /// capacity conflict, a hold that can no longer be confirmed, the two + /// idempotency answers, and the two precondition answers. + #[test] + fn the_pinned_statuses_match_the_acceptance_reasoning() { + assert_eq!(ProblemCode::CapacityExhausted.http_status(), 409); + assert_eq!(ProblemCode::HoldExpired.http_status(), 410); + assert_eq!(ProblemCode::IdempotencyExpired.http_status(), 410); + assert_eq!(ProblemCode::IdempotencyKeyReused.http_status(), 409); + assert_eq!(ProblemCode::PreconditionFailed.http_status(), 412); + assert_eq!(ProblemCode::PreconditionRequired.http_status(), 428); + // The detailed-only code shares its public projection's status: the + // explain path names the reason, never a different outcome. + assert_eq!( + ProblemCode::ResourceUnavailable.http_status(), + ProblemCode::CapacityExhausted.http_status() + ); + } } From e3a2fed74b95d4334d898e85b2146af477a904c5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 22:55:52 +0700 Subject: [PATCH 010/115] feat(scheduling): pin the HTTP wire names and catalogue listings The route paths, the idempotency-key header, the cursor and limit query parameters, and the idempotency key bound are contract the runtime and every client share, so they live in core beside the artifact names and are pinned by the same style of test. The resource and location listings gain their documents: a resource is a concrete pool member, never an independent counter, and a location carries the IANA timezone its openings expand in. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling-core/src/naming.rs | 62 +++++++++++++++++++ crates/registry-scheduling-core/src/wire.rs | 24 ++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/registry-scheduling-core/src/naming.rs b/crates/registry-scheduling-core/src/naming.rs index 10c3917160..243a14a0c0 100644 --- a/crates/registry-scheduling-core/src/naming.rs +++ b/crates/registry-scheduling-core/src/naming.rs @@ -44,6 +44,49 @@ pub const SCHEDULING_FIXTURE_API_VERSION: &str = /// Kind of an offline replay fixture. pub const SCHEDULING_FIXTURE_KIND: &str = "SchedulingFixture"; +// HTTP wire names the runtime and every client share. They live here, beside +// the artifact names, because they are contract: a route that moves strands +// every caller at once. + +/// Route answering which deployment and which policy revision. +pub const SCHEDULING_PATH: &str = "/v1/scheduling"; + +/// Route listing the service catalogue. +pub const SERVICES_PATH: &str = "/v1/services"; + +/// Route listing the offering catalogue. +pub const OFFERINGS_PATH: &str = "/v1/offerings"; + +/// Route answering bounded availability searches. +pub const AVAILABILITY_PATH: &str = "/v1/availability"; + +/// Route answering the separately authorized explanation of one refusal. +pub const AVAILABILITY_EXPLAIN_PATH: &str = "/v1/availability/explain"; + +/// Route minting holds. +pub const HOLDS_PATH: &str = "/v1/holds"; + +/// Route confirming holds and creating appointments. +pub const APPOINTMENTS_PATH: &str = "/v1/appointments"; + +/// Route listing backing resources. +pub const RESOURCES_PATH: &str = "/v1/resources"; + +/// Route listing locations. +pub const LOCATIONS_PATH: &str = "/v1/locations"; + +/// Header carrying the scoped idempotency key of a mutating command. +pub const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key"; + +/// Query parameter continuing a bounded listing. +pub const CURSOR_QUERY_PARAMETER: &str = "cursor"; + +/// Query parameter capping one page of a bounded listing. +pub const LIMIT_QUERY_PARAMETER: &str = "limit"; + +/// The most bytes of one idempotency key, mirroring the stack convention. +pub const MAXIMUM_IDEMPOTENCY_KEY_BYTES: usize = 128; + #[cfg(test)] mod tests { use super::*; @@ -80,4 +123,23 @@ mod tests { ); assert_eq!(SCHEDULING_FIXTURE_KIND, "SchedulingFixture"); } + + /// The HTTP wire names are contract the same way: routes move only with + /// the product's compatibility decisions, never in a formatting pass. + #[test] + fn http_wire_names_are_pinned() { + assert_eq!(SCHEDULING_PATH, "/v1/scheduling"); + assert_eq!(SERVICES_PATH, "/v1/services"); + assert_eq!(OFFERINGS_PATH, "/v1/offerings"); + assert_eq!(AVAILABILITY_PATH, "/v1/availability"); + assert_eq!(AVAILABILITY_EXPLAIN_PATH, "/v1/availability/explain"); + assert_eq!(HOLDS_PATH, "/v1/holds"); + assert_eq!(APPOINTMENTS_PATH, "/v1/appointments"); + assert_eq!(RESOURCES_PATH, "/v1/resources"); + assert_eq!(LOCATIONS_PATH, "/v1/locations"); + assert_eq!(IDEMPOTENCY_KEY_HEADER, "idempotency-key"); + assert_eq!(CURSOR_QUERY_PARAMETER, "cursor"); + assert_eq!(LIMIT_QUERY_PARAMETER, "limit"); + assert_eq!(MAXIMUM_IDEMPOTENCY_KEY_BYTES, 128); + } } diff --git a/crates/registry-scheduling-core/src/wire.rs b/crates/registry-scheduling-core/src/wire.rs index 261500735c..3a7a73b690 100644 --- a/crates/registry-scheduling-core/src/wire.rs +++ b/crates/registry-scheduling-core/src/wire.rs @@ -93,6 +93,26 @@ pub struct WindowDocument { pub units: u32, } +/// One backing resource in the resource listing: a concrete pool member, +/// because a pool is its members, never an independent counter. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResourceDocument { + pub resource_id: String, + pub pool: String, + pub capabilities: Vec, + pub available: bool, +} + +/// One location in the location listing, with the IANA timezone identifier +/// its published openings expand in. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocationDocument { + pub location_id: String, + pub timezone: String, +} + /// One availability answer entry. Exact-time offerings answer in grid slots; /// arrival-window offerings answer in windows. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -128,7 +148,9 @@ pub struct PageDocument { pub next_cursor: Option, } -/// A minted hold, the answer to `POST /v1/holds`. +/// A minted hold, the answer to `POST /v1/holds`. The hold request body is +/// the same admission request shape a direct create carries: a hold is an +/// admission ask that reserves instead of committing. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct HoldDocument { From a94b64bf27ae30e15a229d9368f99ce700bc28d8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 14 Sep 2026 23:43:23 +0700 Subject: [PATCH 011/115] feat(scheduling): add the bounded Rust client The relying-party client for Registry Scheduling: fourteen methods over the pinned exact-time HTTP contract, each taking a borrowed bearer token by value and answering one completed call with the response document and its validated trace identifier. Every failure lands in one of four matchable shapes: a caller-side configuration or request defect validated before any network input or output, a transport failure, a protocol failure naming the stage where the wire stopped matching the pinned contract, and an exactly validated product problem surfaced as the core's closed ProblemCode. A problem document only becomes a domain problem when its status, type URI, title, detail, code, and trace identifier all equal the pinned definition; any mismatch, and any platform-owned transport problem, is edge talk. Mutating calls carry a caller-supplied idempotency key validated against the pinned bound. Route identifiers are validated as one path segment before interpolation, so an opaque id can never silently detour to a different route. Bodies are read under a bounded byte ceiling and the core documents refuse unknown fields, so a response is exactly the contract or a protocol failure. The client never retains a token, never follows a redirect, and never retries a mutation. Boundary: the client depends on registry-scheduling-core and the shared registry-platform-httpsec and registry-platform-httputil primitives only, never on the Scheduling runtime or its operator tooling, and adds no scheduling semantics of its own. Signed-off-by: Jeremi Joslin --- Cargo.lock | 17 + Cargo.toml | 1 + crates/registry-scheduling-client/Cargo.toml | 28 + crates/registry-scheduling-client/README.md | 16 + .../registry-scheduling-client/src/client.rs | 668 ++++++++++++++ .../registry-scheduling-client/src/config.rs | 161 ++++ .../registry-scheduling-client/src/error.rs | 221 +++++ crates/registry-scheduling-client/src/lib.rs | 49 ++ .../registry-scheduling-client/src/model.rs | 55 ++ .../tests/http_boundary.rs | 833 ++++++++++++++++++ 10 files changed, 2049 insertions(+) create mode 100644 crates/registry-scheduling-client/Cargo.toml create mode 100644 crates/registry-scheduling-client/README.md create mode 100644 crates/registry-scheduling-client/src/client.rs create mode 100644 crates/registry-scheduling-client/src/config.rs create mode 100644 crates/registry-scheduling-client/src/error.rs create mode 100644 crates/registry-scheduling-client/src/lib.rs create mode 100644 crates/registry-scheduling-client/src/model.rs create mode 100644 crates/registry-scheduling-client/tests/http_boundary.rs diff --git a/Cargo.lock b/Cargo.lock index 18c18d2596..893544d390 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6088,6 +6088,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "registry-scheduling-client" +version = "0.32.0" +dependencies = [ + "axum", + "chrono", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-scheduling-core", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "url", +] + [[package]] name = "registry-scheduling-core" version = "0.32.0" diff --git a/Cargo.toml b/Cargo.toml index e8fc7574fc..aa8052d487 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ members = [ "crates/registry-linkml", "crates/registry-scheduling-core", "crates/registry-schedulingctl", + "crates/registry-scheduling-client", ] exclude = [ "products/breg/wasm-handler-sdk", diff --git a/crates/registry-scheduling-client/Cargo.toml b/crates/registry-scheduling-client/Cargo.toml new file mode 100644 index 0000000000..23d6ba099d --- /dev/null +++ b/crates/registry-scheduling-client/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "registry-scheduling-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Canonical bounded Rust client for Registry Scheduling." +readme = "README.md" +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +registry-platform-httpsec.workspace = true +registry-platform-httputil.workspace = true +registry-scheduling-core.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +url.workspace = true + +[dev-dependencies] +axum.workspace = true +tokio.workspace = true diff --git a/crates/registry-scheduling-client/README.md b/crates/registry-scheduling-client/README.md new file mode 100644 index 0000000000..ef01df0c67 --- /dev/null +++ b/crates/registry-scheduling-client/README.md @@ -0,0 +1,16 @@ +# Registry Scheduling client + +`registry-scheduling-client` is the canonical bounded Rust client for Registry +Scheduling. It exposes Scheduling's exact-time and arrival-window HTTP +contract over the wire documents and closed problem vocabulary of +`registry-scheduling-core`. It does not contain the Scheduling runtime, its +PostgreSQL store, or its operator tooling, and it depends on no other +product's runtime or protocol types. + +The client takes a borrowed bearer token for each call, the only credential +Scheduling accepts. It never retains the token, follows redirects, or +retries a mutation. Mutating calls carry a caller-supplied idempotency key, +validated against the pinned bound before any network input or output. +Responses are read under a bounded byte ceiling, and exactly validated +product problems surface as their typed `ProblemCode`; every other failure +is a caller-side request defect, a transport failure, or a protocol failure. diff --git a/crates/registry-scheduling-client/src/client.rs b/crates/registry-scheduling-client/src/client.rs new file mode 100644 index 0000000000..7db96c896d --- /dev/null +++ b/crates/registry-scheduling-client/src/client.rs @@ -0,0 +1,668 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use chrono::{DateTime, SecondsFormat, Utc}; +use registry_platform_httpsec::{response_trace_id, ProblemDocument}; +use registry_platform_httputil::client::{ + build_client, read_failure_kind, send_failure_kind, OutboundOptions, ServiceBaseUrl, +}; +use registry_platform_httputil::{ + read_bounded, url::append_path_segments, validate_response_headers, +}; +use registry_scheduling_core::{ + type_uri, AdmissionRequest, AppointmentDocument, AppointmentHistoryEntryDocument, + AvailabilityEntry, CancelAppointmentRequest, CreateAppointmentRequest, ExplainDocument, + HoldDocument, LocationDocument, OfferingDocument, PageDocument, ProblemCode, + RescheduleAppointmentRequest, ResourceDocument, SchedulingServiceDocument, ServiceDocument, + APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, CURSOR_QUERY_PARAMETER, + HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, LOCATIONS_PATH, + MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, +}; +use reqwest::header::{HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::{RequestBuilder, Response, StatusCode, Url}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::{ + SchedulingAuth, SchedulingClientConfig, SchedulingClientError, SchedulingComplete, + SchedulingProtocolFailure, +}; + +const JSON_MEDIA_TYPE: &str = "application/json"; +const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; +const MAXIMUM_PROBLEM_BYTES: u64 = 8 * 1024; +const MAXIMUM_CURSOR_BYTES: usize = 4096; +const MAXIMUM_IDENTIFIER_BYTES: usize = 128; +const OFFERING_QUERY_PARAMETER: &str = "offering"; +const START_QUERY_PARAMETER: &str = "start"; +const END_QUERY_PARAMETER: &str = "end"; + +pub struct SchedulingClient { + http: reqwest::Client, + base_url: ServiceBaseUrl, + max_response_bytes: u64, +} + +impl fmt::Debug for SchedulingClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchedulingClient") + .field("base_url", &"") + .field("max_response_bytes", &self.max_response_bytes) + .finish_non_exhaustive() + } +} + +impl SchedulingClient { + pub fn new(config: SchedulingClientConfig) -> Result { + let base_url = config.validate()?; + let http = build_client(OutboundOptions { + request_timeout: config.request_timeout, + connect_timeout: config.connect_timeout, + user_agent: config.user_agent.as_deref(), + trusted_root_certificates: config.trusted_root_certificates.as_deref(), + }) + .map_err(|_| SchedulingClientError::configuration("the HTTP client could not be built"))?; + Ok(Self { + http, + base_url, + max_response_bytes: config.max_response_bytes, + }) + } + + /// Which deployment and which policy this service answers with. + pub async fn get_scheduling( + &self, + auth: SchedulingAuth<'_>, + ) -> Result, SchedulingClientError> { + self.get_json(&auth, SCHEDULING_PATH, &[]).await + } + + pub async fn list_services( + &self, + auth: SchedulingAuth<'_>, + cursor: Option<&str>, + ) -> Result>, SchedulingClientError> { + validate_cursor(cursor)?; + self.get_json(&auth, SERVICES_PATH, &cursor_query(cursor)) + .await + } + + pub async fn list_offerings( + &self, + auth: SchedulingAuth<'_>, + cursor: Option<&str>, + ) -> Result>, SchedulingClientError> { + validate_cursor(cursor)?; + self.get_json(&auth, OFFERINGS_PATH, &cursor_query(cursor)) + .await + } + + /// Bounded availability. Exact-time offerings answer in grid slots and + /// arrival-window offerings in windows; `start` and `end` bound the + /// searched interval, `cursor` and `limit` bound the page. + pub async fn availability( + &self, + auth: SchedulingAuth<'_>, + offering: &str, + start: Option>, + end: Option>, + cursor: Option<&str>, + limit: Option, + ) -> Result>, SchedulingClientError> { + validate_availability(offering, start, end, cursor, limit)?; + let mut query: Vec<(&str, String)> = Vec::new(); + query.push((OFFERING_QUERY_PARAMETER, offering.to_owned())); + if let Some(start) = start { + query.push((START_QUERY_PARAMETER, rfc3339(start))); + } + if let Some(end) = end { + query.push((END_QUERY_PARAMETER, rfc3339(end))); + } + if let Some(cursor) = cursor { + query.push((CURSOR_QUERY_PARAMETER, cursor.to_owned())); + } + if let Some(limit) = limit { + query.push((LIMIT_QUERY_PARAMETER, limit.to_string())); + } + let request = self.authorized( + self.http + .get(self.url_from_constant(AVAILABILITY_PATH)?) + .query(&query), + &auth, + ); + self.send_json(request, StatusCode::OK).await + } + + /// The separately authorized explanation of one refused start. + pub async fn explain( + &self, + auth: SchedulingAuth<'_>, + offering: &str, + start: DateTime, + ) -> Result, SchedulingClientError> { + if offering.is_empty() { + return Err(SchedulingClientError::invalid_request( + "the offering selector is invalid", + )); + } + let query = [ + (OFFERING_QUERY_PARAMETER, offering.to_owned()), + (START_QUERY_PARAMETER, rfc3339(start)), + ]; + let request = self.authorized( + self.http + .get(self.url_from_constant(AVAILABILITY_EXPLAIN_PATH)?) + .query(&query), + &auth, + ); + self.send_json(request, StatusCode::OK).await + } + + /// Reserve an admission instead of committing it. The request body is + /// the same admission ask a direct create carries. + pub async fn create_hold( + &self, + auth: SchedulingAuth<'_>, + idempotency_key: &str, + request: &AdmissionRequest, + ) -> Result, SchedulingClientError> { + self.post_json( + &auth, + HOLDS_PATH, + idempotency_key, + request, + StatusCode::CREATED, + ) + .await + } + + /// Give a hold's capacity back. The hold identifier is opaque and is + /// never invented here: it comes from a minted hold document. + pub async fn release_hold( + &self, + auth: SchedulingAuth<'_>, + hold_id: &str, + ) -> Result, SchedulingClientError> { + validate_identifier(hold_id, "the hold identifier is invalid")?; + let url = self.url_from_constant(&format!("{HOLDS_PATH}/{hold_id}"))?; + let request = self.authorized(self.http.delete(url), &auth); + self.send_empty(request, StatusCode::NO_CONTENT).await + } + + /// Confirm a hold or create an appointment directly; the two request + /// shapes are mutually exclusive by the core's own contract. + pub async fn create_appointment( + &self, + auth: SchedulingAuth<'_>, + idempotency_key: &str, + request: &CreateAppointmentRequest, + ) -> Result, SchedulingClientError> { + self.post_json( + &auth, + APPOINTMENTS_PATH, + idempotency_key, + request, + StatusCode::CREATED, + ) + .await + } + + pub async fn get_appointment( + &self, + auth: SchedulingAuth<'_>, + appointment_id: &str, + ) -> Result, SchedulingClientError> { + validate_identifier(appointment_id, "the appointment identifier is invalid")?; + self.get_json(&auth, &format!("{APPOINTMENTS_PATH}/{appointment_id}"), &[]) + .await + } + + pub async fn reschedule_appointment( + &self, + auth: SchedulingAuth<'_>, + appointment_id: &str, + idempotency_key: &str, + request: &RescheduleAppointmentRequest, + ) -> Result, SchedulingClientError> { + validate_identifier(appointment_id, "the appointment identifier is invalid")?; + self.post_json( + &auth, + &format!("{APPOINTMENTS_PATH}/{appointment_id}/reschedule"), + idempotency_key, + request, + StatusCode::OK, + ) + .await + } + + pub async fn cancel_appointment( + &self, + auth: SchedulingAuth<'_>, + appointment_id: &str, + idempotency_key: &str, + request: &CancelAppointmentRequest, + ) -> Result, SchedulingClientError> { + validate_identifier(appointment_id, "the appointment identifier is invalid")?; + self.post_json( + &auth, + &format!("{APPOINTMENTS_PATH}/{appointment_id}/cancel"), + idempotency_key, + request, + StatusCode::OK, + ) + .await + } + + pub async fn appointment_history( + &self, + auth: SchedulingAuth<'_>, + appointment_id: &str, + cursor: Option<&str>, + ) -> Result< + SchedulingComplete>, + SchedulingClientError, + > { + validate_identifier(appointment_id, "the appointment identifier is invalid")?; + validate_cursor(cursor)?; + self.get_json( + &auth, + &format!("{APPOINTMENTS_PATH}/{appointment_id}/history"), + &cursor_query(cursor), + ) + .await + } + + pub async fn list_resources( + &self, + auth: SchedulingAuth<'_>, + cursor: Option<&str>, + ) -> Result>, SchedulingClientError> { + validate_cursor(cursor)?; + self.get_json(&auth, RESOURCES_PATH, &cursor_query(cursor)) + .await + } + + pub async fn list_locations( + &self, + auth: SchedulingAuth<'_>, + cursor: Option<&str>, + ) -> Result>, SchedulingClientError> { + validate_cursor(cursor)?; + self.get_json(&auth, LOCATIONS_PATH, &cursor_query(cursor)) + .await + } + + async fn get_json( + &self, + auth: &SchedulingAuth<'_>, + path: &str, + query: &[(&str, &str)], + ) -> Result, SchedulingClientError> { + let request = self.authorized( + self.http.get(self.url_from_constant(path)?).query(query), + auth, + ); + self.send_json(request, StatusCode::OK).await + } + + async fn post_json( + &self, + auth: &SchedulingAuth<'_>, + path: &str, + idempotency_key: &str, + body: &B, + expected_status: StatusCode, + ) -> Result, SchedulingClientError> { + validate_idempotency_key(idempotency_key)?; + let request = self + .authorized( + self.http.post(self.url_from_constant(path)?).json(body), + auth, + ) + .header( + HeaderName::from_static(IDEMPOTENCY_KEY_HEADER), + HeaderValue::from_str(idempotency_key).map_err(|_| { + SchedulingClientError::invalid_request("the idempotency key is invalid") + })?, + ); + self.send_json(request, expected_status).await + } + + fn authorized(&self, request: RequestBuilder, auth: &SchedulingAuth<'_>) -> RequestBuilder { + request + .header(AUTHORIZATION, auth.token.authorization_header_value()) + .header(ACCEPT, JSON_MEDIA_TYPE) + } + + fn url_from_constant(&self, path: &str) -> Result { + let segments = path.trim_start_matches('/').split('/').collect::>(); + append_path_segments(self.base_url.as_url(), &segments) + .map_err(|_| SchedulingClientError::invalid_request("a route identifier is invalid")) + } + + async fn send_json( + &self, + request: RequestBuilder, + expected_status: StatusCode, + ) -> Result, SchedulingClientError> { + let response = self.send(request).await?; + let status = response.status(); + if status != expected_status { + return Err(self.problem_or_status(response).await); + } + let trace_id = response_trace(status, response.headers())?; + if !exact_media_type(response.headers(), JSON_MEDIA_TYPE) { + return Err(protocol( + status, + SchedulingProtocolFailure::MediaType, + Some(trace_id), + )); + } + let body = read_bounded(response, self.max_response_bytes) + .await + .map_err(|error| SchedulingClientError::Transport { + kind: read_failure_kind(&error), + })?; + let value = serde_json::from_slice(&body).map_err(|_| { + protocol( + status, + SchedulingProtocolFailure::Body, + Some(trace_id.clone()), + ) + })?; + Ok(SchedulingComplete { value, trace_id }) + } + + async fn send_empty( + &self, + request: RequestBuilder, + expected_status: StatusCode, + ) -> Result, SchedulingClientError> { + let response = self.send(request).await?; + let status = response.status(); + if status != expected_status { + return Err(self.problem_or_status(response).await); + } + let trace_id = response_trace(status, response.headers())?; + let body = + read_bounded(response, 1) + .await + .map_err(|error| SchedulingClientError::Transport { + kind: read_failure_kind(&error), + })?; + if !body.is_empty() { + return Err(protocol( + status, + SchedulingProtocolFailure::Body, + Some(trace_id), + )); + } + Ok(SchedulingComplete { + value: (), + trace_id, + }) + } + + async fn send(&self, request: RequestBuilder) -> Result { + let response = request + .send() + .await + .map_err(|error| SchedulingClientError::Transport { + kind: send_failure_kind(&error), + })?; + validate_response_headers(response.headers()).map_err(|_| { + protocol( + response.status(), + SchedulingProtocolFailure::HeaderBounds, + None, + ) + })?; + Ok(response) + } + + async fn problem_or_status(&self, response: Response) -> SchedulingClientError { + let status = response.status(); + let trace = response_trace(status, response.headers()).ok(); + if !exact_media_type(response.headers(), PROBLEM_MEDIA_TYPE) { + return protocol(status, SchedulingProtocolFailure::Status, trace); + } + let body = match read_bounded(response, MAXIMUM_PROBLEM_BYTES).await { + Ok(value) => value, + Err(error) => { + return SchedulingClientError::Transport { + kind: read_failure_kind(&error), + } + } + }; + let document = match ProblemDocument::parse_exact(&body, MAXIMUM_PROBLEM_BYTES as usize) { + Ok(value) => value, + Err(_) => return protocol(status, SchedulingProtocolFailure::Problem, trace), + }; + domain_problem(status, trace.as_deref(), &document) + } +} + +/// Decide between a typed domain problem and a protocol failure. +/// +/// A domain problem requires every one of these to hold: the document's +/// status equals the response status, the code is in the closed vocabulary +/// and pins that same status, the response trace header matches the +/// document's trace identifier, and the type URI, title, and detail equal +/// the pinned definition. Any mismatch, and any code outside the closed +/// vocabulary (a platform-owned transport problem carrying a different type +/// base, for example), is the edge talking: a protocol failure, never a +/// domain problem. +pub(crate) fn domain_problem( + status: StatusCode, + header_trace: Option<&str>, + document: &ProblemDocument, +) -> SchedulingClientError { + let trace_id = header_trace.map(str::to_owned); + let Some(code) = ProblemCode::from_code(&document.code) else { + return protocol(status, SchedulingProtocolFailure::Problem, trace_id); + }; + if document.status != status.as_u16() + || code.http_status() != status.as_u16() + || header_trace != Some(document.trace_id.as_str()) + || document.type_uri != type_uri(code.code()) + || document.title != code.title() + || document.detail != code.detail() + { + return protocol(status, SchedulingProtocolFailure::Problem, trace_id); + } + SchedulingClientError::Problem { + status: status.as_u16(), + code, + trace_id, + } +} + +fn response_trace( + status: StatusCode, + headers: &reqwest::header::HeaderMap, +) -> Result { + response_trace_id(headers) + .map(|value| value.as_str().to_owned()) + .map_err(|_| protocol(status, SchedulingProtocolFailure::TraceContext, None)) +} + +fn exact_media_type(headers: &reqwest::header::HeaderMap, expected: &str) -> bool { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + matches!( + (values.next(), values.next()), + (Some(value), None) if value.as_bytes() == expected.as_bytes() + ) +} + +fn protocol( + status: StatusCode, + failure: SchedulingProtocolFailure, + trace_id: Option, +) -> SchedulingClientError { + SchedulingClientError::Protocol { + status: status.as_u16(), + failure, + trace_id, + } +} + +fn cursor_query(cursor: Option<&str>) -> Vec<(&'static str, &str)> { + cursor + .map(|value| vec![(CURSOR_QUERY_PARAMETER, value)]) + .unwrap_or_default() +} + +fn rfc3339(instant: DateTime) -> String { + instant.to_rfc3339_opts(SecondsFormat::AutoSi, true) +} + +fn validate_idempotency_key(idempotency_key: &str) -> Result<(), SchedulingClientError> { + if idempotency_key.is_empty() + || idempotency_key.len() > MAXIMUM_IDEMPOTENCY_KEY_BYTES + || !idempotency_key.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(SchedulingClientError::invalid_request( + "the idempotency key is invalid", + )); + } + Ok(()) +} + +fn validate_cursor(cursor: Option<&str>) -> Result<(), SchedulingClientError> { + if cursor.is_some_and(|value| value.is_empty() || value.len() > MAXIMUM_CURSOR_BYTES) { + return Err(SchedulingClientError::invalid_request( + "the cursor is invalid", + )); + } + Ok(()) +} + +/// A route identifier is an opaque string a minted document carried, so it is +/// validated as a whole before it becomes a path segment: anything that would +/// split into several segments, or leave the segment alphabet the runtime's +/// own identifiers live in, is a caller defect, never a silent detour to a +/// different route. +fn validate_identifier( + identifier: &str, + reason: &'static str, +) -> Result<(), SchedulingClientError> { + if identifier.is_empty() + || identifier.len() > MAXIMUM_IDENTIFIER_BYTES + || !identifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(SchedulingClientError::invalid_request(reason)); + } + Ok(()) +} + +fn validate_availability( + offering: &str, + start: Option>, + end: Option>, + cursor: Option<&str>, + limit: Option, +) -> Result<(), SchedulingClientError> { + if offering.is_empty() { + return Err(SchedulingClientError::invalid_request( + "the offering selector is invalid", + )); + } + validate_cursor(cursor)?; + if limit.is_some_and(|value| value == 0) { + return Err(SchedulingClientError::invalid_request( + "the page size is outside the accepted range", + )); + } + if let (Some(start), Some(end)) = (start, end) { + if end <= start { + return Err(SchedulingClientError::invalid_request( + "the availability interval ends before it starts", + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + #[test] + fn idempotency_keys_are_bounded_before_io() { + assert!(validate_idempotency_key("hold-7").is_ok()); + assert!(validate_idempotency_key(&"x".repeat(MAXIMUM_IDEMPOTENCY_KEY_BYTES)).is_ok()); + assert!(validate_idempotency_key("").is_err()); + assert!(validate_idempotency_key(&"x".repeat(MAXIMUM_IDEMPOTENCY_KEY_BYTES + 1)).is_err()); + assert!(validate_idempotency_key("line\nbreak").is_err()); + } + + #[test] + fn cursors_are_bounded_before_io() { + assert!(validate_cursor(None).is_ok()); + assert!(validate_cursor(Some("opaque-cursor")).is_ok()); + assert!(validate_cursor(Some("")).is_err()); + assert!(validate_cursor(Some(&"x".repeat(MAXIMUM_CURSOR_BYTES + 1))).is_err()); + } + + #[test] + fn route_identifiers_stay_one_path_segment() { + let reason = "the appointment identifier is invalid"; + let uuid = "0199d0e0-8f2a-7c3b-9d4e-5f6a7b8c9d0e"; + assert!(validate_identifier(uuid, reason).is_ok()); + assert!(validate_identifier("appt-1", reason).is_ok()); + assert!(validate_identifier("claim_appt.1", reason).is_ok()); + for foreign in [ + "", + "appt/1", + "appt?x", + "appt#y", + "appt%2F1", + "space id", + "../appt", + &"x".repeat(MAXIMUM_IDENTIFIER_BYTES + 1), + ] { + assert!( + validate_identifier(foreign, reason).is_err(), + "{foreign:?} must be refused" + ); + } + } + + #[test] + fn availability_selectors_are_validated_before_io() { + let start = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + let end = Utc.with_ymd_and_hms(2026, 10, 5, 2, 30, 0).unwrap(); + assert!(validate_availability( + "registry-update-30", + Some(start), + Some(end), + None, + Some(25) + ) + .is_ok()); + assert!(validate_availability("", None, None, None, None).is_err()); + assert!(validate_availability("registry-update-30", None, None, Some(""), None).is_err()); + assert!(validate_availability("registry-update-30", None, None, None, Some(0)).is_err()); + assert!( + validate_availability("registry-update-30", Some(end), Some(start), None, None) + .is_err() + ); + } + + /// Times render exactly as the runtime answers them: UTC instants with + /// no sub-second part carry the `Z` designator, and a sub-second part + /// keeps its precision instead of being truncated. + #[test] + fn query_instants_render_as_whole_second_rfc3339() { + let whole = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + assert_eq!(rfc3339(whole), "2026-10-05T02:00:00Z"); + let precise = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap() + + chrono::Duration::milliseconds(250); + assert_eq!(rfc3339(precise), "2026-10-05T02:00:00.250Z"); + } +} diff --git a/crates/registry-scheduling-client/src/config.rs b/crates/registry-scheduling-client/src/config.rs new file mode 100644 index 0000000000..fa4bb91ea3 --- /dev/null +++ b/crates/registry-scheduling-client/src/config.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{fmt, time::Duration}; + +use registry_platform_httputil::client::{ + ServiceBaseUrl, DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, + MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES, +}; +use url::Url; + +use crate::SchedulingClientError; + +const DEFAULT_MAXIMUM_RESPONSE_BYTES: u64 = 4 * 1024 * 1024; +const MAXIMUM_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; + +#[derive(Clone)] +pub struct SchedulingClientConfig { + pub(crate) base_url: Url, + pub(crate) request_timeout: Duration, + pub(crate) connect_timeout: Duration, + pub(crate) max_response_bytes: u64, + pub(crate) user_agent: Option, + pub(crate) trusted_root_certificates: Option>, +} + +impl SchedulingClientConfig { + #[must_use] + pub fn new(base_url: Url) -> Self { + Self { + base_url, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + max_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, + user_agent: None, + trusted_root_certificates: None, + } + } + + #[must_use] + pub fn with_request_timeout(mut self, value: Duration) -> Self { + self.request_timeout = value; + self + } + + #[must_use] + pub fn with_connect_timeout(mut self, value: Duration) -> Self { + self.connect_timeout = value; + self + } + + #[must_use] + pub fn with_max_response_bytes(mut self, value: u64) -> Self { + self.max_response_bytes = value; + self + } + + #[must_use] + pub fn with_user_agent(mut self, value: impl Into) -> Self { + self.user_agent = Some(value.into()); + self + } + + #[must_use] + pub fn with_trusted_root_certificates(mut self, value: Vec) -> Self { + self.trusted_root_certificates = Some(value); + self + } + + pub(crate) fn validate(&self) -> Result { + let base_url = ServiceBaseUrl::new(self.base_url.clone()).map_err(|_| { + SchedulingClientError::configuration("the service base URL is not usable") + })?; + if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + return Err(SchedulingClientError::configuration( + "client timeouts must be greater than zero", + )); + } + if self.max_response_bytes == 0 || self.max_response_bytes > MAXIMUM_RESPONSE_BYTES { + return Err(SchedulingClientError::configuration( + "the response body bound is outside the accepted range", + )); + } + if self + .trusted_root_certificates + .as_ref() + .is_some_and(|value| value.len() > MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES) + { + return Err(SchedulingClientError::configuration( + "the trusted root certificate bundle exceeds the accepted bound", + )); + } + Ok(base_url) + } +} + +impl fmt::Debug for SchedulingClientConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchedulingClientConfig") + .field("base_url", &"") + .field("request_timeout", &self.request_timeout) + .field("connect_timeout", &self.connect_timeout) + .field("max_response_bytes", &self.max_response_bytes) + .field("user_agent_present", &self.user_agent.is_some()) + .field( + "trusted_root_certificates_present", + &self.trusted_root_certificates.is_some(), + ) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_withholds_url_and_certificate_material() { + let config = SchedulingClientConfig::new( + Url::parse("https://secret.example.invalid/private").expect("fixture URL"), + ) + .with_user_agent("secret-agent") + .with_trusted_root_certificates(b"secret-certificate".to_vec()); + let debug = format!("{config:?}"); + assert!(!debug.contains("secret.example")); + assert!(!debug.contains("private")); + assert!(!debug.contains("secret-agent")); + assert!(!debug.contains("secret-certificate")); + } + + #[test] + fn configuration_bounds_are_enforced_before_any_client_exists() { + let url = Url::parse("https://scheduling.example.invalid/").expect("fixture URL"); + let zero_timeout = + SchedulingClientConfig::new(url.clone()).with_request_timeout(Duration::ZERO); + assert!(matches!( + zero_timeout.validate(), + Err(SchedulingClientError::Configuration { .. }) + )); + let zero_body_bound = SchedulingClientConfig::new(url.clone()).with_max_response_bytes(0); + assert!(matches!( + zero_body_bound.validate(), + Err(SchedulingClientError::Configuration { .. }) + )); + let oversized_body_bound = SchedulingClientConfig::new(url.clone()) + .with_max_response_bytes(MAXIMUM_RESPONSE_BYTES + 1); + assert!(matches!( + oversized_body_bound.validate(), + Err(SchedulingClientError::Configuration { .. }) + )); + let oversized_certificate_bundle = SchedulingClientConfig::new(url) + .with_trusted_root_certificates(vec![ + 0; + MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES + 1 + ]); + assert!(matches!( + oversized_certificate_bundle.validate(), + Err(SchedulingClientError::Configuration { .. }) + )); + } +} diff --git a/crates/registry-scheduling-client/src/error.rs b/crates/registry-scheduling-client/src/error.rs new file mode 100644 index 0000000000..ce78f1901f --- /dev/null +++ b/crates/registry-scheduling-client/src/error.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The client's error taxonomy: caller-side request defects, transport +//! failures, protocol failures, and exactly validated product problems. +//! +//! The taxonomy is the caller's recovery contract: every outcome is +//! matchable without string inspection, and a product problem carries the +//! core's typed `ProblemCode`, whose pinned title and remediation detail the +//! caller reads with `code.title()` and `code.detail()`. The client +//! validates a problem against that pinned definition before surfacing it, +//! so a refused answer can never carry text this client has not already +//! pinned in the core. + +use registry_platform_httputil::client::TransportKind; +use registry_scheduling_core::ProblemCode; +use thiserror::Error; + +/// The stage inside the exchange where the wire stopped matching the pinned +/// contract. Every variant is a protocol failure: the caller cannot repair +/// it by editing the request, only by stopping or reporting. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum SchedulingProtocolFailure { + /// The response headers exceed the shared client bounds. + HeaderBounds, + /// The response carries no single canonical W3C Trace Context field. + TraceContext, + /// A JSON answer was not exactly `application/json`. + MediaType, + /// The body was not empty where emptiness was required, was + /// unparseable, or carried a field the contract refuses. + Body, + /// The problem document did not match the closed problem definition its + /// code names, or named no code in the closed vocabulary. + Problem, + /// A failure answer was not a problem document at all. + Status, +} + +/// Every failure a Scheduling call can produce. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum SchedulingClientError { + #[error("Registry Scheduling client configuration is invalid: {reason}")] + Configuration { reason: &'static str }, + #[error("Registry Scheduling client request is invalid: {reason}")] + InvalidRequest { reason: &'static str }, + #[error("Registry Scheduling exchange did not complete: {kind}")] + Transport { kind: TransportKind }, + #[error( + "Registry Scheduling refused the request (HTTP {status}, problem {})", + .code.code() + )] + Problem { + status: u16, + code: ProblemCode, + trace_id: Option, + }, + #[error("Registry Scheduling returned an invalid response")] + Protocol { + status: u16, + failure: SchedulingProtocolFailure, + trace_id: Option, + }, +} + +impl SchedulingClientError { + pub(crate) fn configuration(reason: &'static str) -> Self { + Self::Configuration { reason } + } + + pub(crate) fn invalid_request(reason: &'static str) -> Self { + Self::InvalidRequest { reason } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::domain_problem; + use registry_platform_httpsec::{ProblemDocument, TraceId}; + use registry_scheduling_core::type_uri; + use reqwest::StatusCode; + + const TRACE_ID: &str = "0123456789abcdef0123456789abcdef"; + + fn exhaustive_document() -> ProblemDocument { + let code = ProblemCode::CapacityExhausted; + ProblemDocument { + type_uri: type_uri(code.code()), + title: code.title().to_owned(), + status: code.http_status(), + detail: code.detail().to_owned(), + code: code.code().to_owned(), + trace_id: TraceId::parse(TRACE_ID).expect("fixture trace identifier"), + } + } + + #[test] + fn an_exact_problem_document_is_a_typed_domain_problem() { + let document = exhaustive_document(); + let trace = Some(document.trace_id.as_str()); + match domain_problem(StatusCode::CONFLICT, trace, &document) { + SchedulingClientError::Problem { + status: 409, + code: ProblemCode::CapacityExhausted, + trace_id, + } => assert_eq!(trace_id.as_deref(), trace), + other => panic!("expected a typed domain problem, got {other:?}"), + } + } + + #[test] + fn every_problem_mismatch_degrades_to_a_protocol_failure() { + let document = exhaustive_document(); + let trace = Some(document.trace_id.as_str()); + + let mismatches: Vec = vec![ + ProblemDocument { + status: 500, + ..document.clone() + }, + ProblemDocument { + title: "Wrong title".to_owned(), + ..document.clone() + }, + ProblemDocument { + detail: "Wrong detail.".to_owned(), + ..document.clone() + }, + ProblemDocument { + type_uri: format!( + "https://id.registrystack.org/problems/registry-platform/{}", + document.code.replace('.', "/") + ), + ..document.clone() + }, + ProblemDocument { + code: "capacity.unexhausted".to_owned(), + ..document.clone() + }, + ]; + for mismatch in mismatches { + assert!( + matches!( + domain_problem(StatusCode::CONFLICT, trace, &mismatch), + SchedulingClientError::Protocol { + status: 409, + failure: SchedulingProtocolFailure::Problem, + .. + } + ), + "{mismatch:?}" + ); + } + + // The response trace header must match the document's own trace + // identifier; a missing or different one is edge talk, never a + // domain problem. + assert!(matches!( + domain_problem( + StatusCode::CONFLICT, + Some("ffffffffffffffffffffffffffffffff"), + &document + ), + SchedulingClientError::Protocol { + status: 409, + failure: SchedulingProtocolFailure::Problem, + .. + } + )); + assert!(matches!( + domain_problem(StatusCode::CONFLICT, None, &document), + SchedulingClientError::Protocol { + status: 409, + failure: SchedulingProtocolFailure::Problem, + .. + } + )); + } + + #[test] + fn display_names_the_product_the_stage_and_the_typed_code() { + let document = exhaustive_document(); + let problem = domain_problem( + StatusCode::CONFLICT, + Some(document.trace_id.as_str()), + &document, + ); + assert_eq!( + problem.to_string(), + "Registry Scheduling refused the request (HTTP 409, problem capacity.exhausted)" + ); + let configuration = SchedulingClientError::configuration("fixture reason"); + assert_eq!( + configuration.to_string(), + "Registry Scheduling client configuration is invalid: fixture reason" + ); + let invalid_request = SchedulingClientError::invalid_request("fixture reason"); + assert_eq!( + invalid_request.to_string(), + "Registry Scheduling client request is invalid: fixture reason" + ); + let transport = SchedulingClientError::Transport { + kind: TransportKind::Timeout, + }; + assert_eq!( + transport.to_string(), + "Registry Scheduling exchange did not complete: the configured timeout elapsed" + ); + let protocol = SchedulingClientError::Protocol { + status: 200, + failure: SchedulingProtocolFailure::MediaType, + trace_id: None, + }; + assert_eq!( + protocol.to_string(), + "Registry Scheduling returned an invalid response" + ); + } +} diff --git a/crates/registry-scheduling-client/src/lib.rs b/crates/registry-scheduling-client/src/lib.rs new file mode 100644 index 0000000000..7b87eeb2b7 --- /dev/null +++ b/crates/registry-scheduling-client/src/lib.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Canonical, bounded client for Registry Scheduling. +//! +//! A thin relying-party client over the pinned Scheduling HTTP contract. The +//! wire documents, the closed problem vocabulary, and every route, query, and +//! header name come from `registry-scheduling-core`; the shared transport +//! primitives, the exact problem document shape, and trace correlation come +//! from `registry-platform-httpsec` and `registry-platform-httputil`. This +//! crate never depends on the Scheduling runtime or its operator tooling, +//! adds no scheduling semantics of its own, and never re-declares a name the +//! core already owns: a caller matches recoverable outcomes against the +//! core's `ProblemCode`, not a second vocabulary. +//! +//! Authentication is a borrowed bearer token per call, the only credential +//! Scheduling accepts. The client never retains the token, follows +//! redirects, or retries a mutation. Mutating calls carry a caller-supplied +//! idempotency key, validated against the pinned bound before any network +//! input or output. Responses are read under a bounded byte ceiling, the +//! core documents themselves refuse unknown fields, and every failure lands +//! in one of three named shapes: a caller-side request defect, a transport +//! failure, or a protocol failure, with exactly validated product problems +//! surfaced as their typed code. +//! +//! Re-exports are flat, so a caller names a document, a route constant, or a +//! vocabulary entry through this crate exactly as the core spells it. + +#![deny(unsafe_code)] + +mod client; +mod config; +mod error; +mod model; + +pub use client::SchedulingClient; +pub use config::SchedulingClientConfig; +pub use error::{SchedulingClientError, SchedulingProtocolFailure}; +pub use model::{SchedulingAuth, SchedulingComplete}; +pub use registry_platform_httputil::client::{BearerToken, TransportKind}; +pub use registry_scheduling_core::{ + type_uri, AdmissionRequest, AppointmentDocument, AppointmentHistoryEntryDocument, + AppointmentStateDocument, AvailabilityEntry, CancelAppointmentRequest, + CreateAppointmentRequest, ExplainDocument, HoldDocument, LocationDocument, OfferingDocument, + PageDocument, PartyCounts, ProblemCode, ReminderDocument, RescheduleAppointmentRequest, + ResourceDocument, SchedulingModeDocument, SchedulingServiceDocument, ServiceDocument, + WindowDocument, APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, + CURSOR_QUERY_PARAMETER, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LIMIT_QUERY_PARAMETER, + LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, + SCHEDULING_PROBLEM_TYPE_BASE, SERVICES_PATH, +}; diff --git a/crates/registry-scheduling-client/src/model.rs b/crates/registry-scheduling-client/src/model.rs new file mode 100644 index 0000000000..c17c0a9815 --- /dev/null +++ b/crates/registry-scheduling-client/src/model.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt; + +use registry_platform_httputil::client::BearerToken; +use serde::{Deserialize, Serialize}; + +/// Authentication for exactly one call: a borrowed bearer token, the only +/// credential Registry Scheduling accepts. Scheduling declares no profile +/// header, so there is nothing else to select. +/// +/// The client borrows the token, attaches it to one request, and never +/// retains it in client state or a result. +pub struct SchedulingAuth<'a> { + pub token: &'a BearerToken, +} + +impl<'a> SchedulingAuth<'a> { + #[must_use] + pub fn new(token: &'a BearerToken) -> Self { + Self { token } + } +} + +impl fmt::Debug for SchedulingAuth<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SchedulingAuth") + .field("token", &"") + .finish() + } +} + +/// One completed call: the response document and the response's validated +/// trace identifier, so a caller correlates the answer with its own +/// trace context without re-reading headers. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingComplete { + pub value: T, + pub trace_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_debug_redacts_the_token() { + let token = BearerToken::new("secret-token").expect("fixture token"); + let auth = SchedulingAuth::new(&token); + let debug = format!("{auth:?}"); + assert!(!debug.contains("secret-token")); + } +} diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs new file mode 100644 index 0000000000..5a9900eb2d --- /dev/null +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -0,0 +1,833 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::{Arc, Mutex}; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; +use axum::response::IntoResponse; +use axum::routing::{delete, get, post}; +use axum::Router; +use chrono::{TimeZone as _, Utc}; +use registry_scheduling_client::{ + type_uri, AdmissionRequest, AvailabilityEntry, BearerToken, CancelAppointmentRequest, + CreateAppointmentRequest, PartyCounts, ProblemCode, RescheduleAppointmentRequest, + SchedulingAuth, SchedulingClient, SchedulingClientConfig, SchedulingClientError, + SchedulingProtocolFailure, TransportKind, +}; +use url::Url; + +const TRACEPARENT: &str = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"; +const TRACE_ID: &str = "0123456789abcdef0123456789abcdef"; + +#[derive(Clone)] +struct Captured { + method: String, + uri: String, + headers: HeaderMap, + body: Bytes, +} + +type Observations = Arc>>; + +/// One route's canned answer, plus the record of every request it received. +#[derive(Clone)] +struct Fixture { + observations: Observations, + status: StatusCode, + content_type: &'static str, + body: String, + /// Answered when the request carries a cursor, for round-trip checks. + continuation: Option, + traced: bool, +} + +impl Fixture { + fn json(observations: &Observations, status: StatusCode, body: &str) -> Self { + Self { + observations: observations.clone(), + status, + content_type: "application/json", + body: body.to_owned(), + continuation: None, + traced: true, + } + } + + fn with_content_type(mut self, content_type: &'static str) -> Self { + self.content_type = content_type; + self + } + + fn observe(&self, method: Method, uri: Uri, headers: HeaderMap, body: Bytes) { + self.observations + .lock() + .expect("observations") + .push(Captured { + method: method.to_string(), + uri: uri.to_string(), + headers, + body, + }); + } + + fn respond(&self, uri: &Uri) -> (StatusCode, HeaderMap, String) { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + self.content_type.parse().expect("fixture content type"), + ); + if self.traced { + headers.insert("traceparent", HeaderValue::from_static(TRACEPARENT)); + } + let body = match ( + uri.query().unwrap_or_default().contains("cursor="), + &self.continuation, + ) { + (true, Some(continuation)) => continuation.clone(), + _ => self.body.clone(), + }; + (self.status, headers, body) + } +} + +async fn capture_get( + State(fixture): State, + method: Method, + uri: Uri, + headers: HeaderMap, +) -> impl IntoResponse { + fixture.observe(method, uri.clone(), headers, Bytes::new()); + fixture.respond(&uri) +} + +async fn capture_call( + State(fixture): State, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + fixture.observe(method, uri.clone(), headers, body); + fixture.respond(&uri) +} + +async fn spawn(app: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fixture"); + let address = listener.local_addr().expect("fixture address").to_string(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve fixture"); + }); + (address, server) +} + +fn client(address: &str) -> SchedulingClient { + SchedulingClient::new(SchedulingClientConfig::new( + Url::parse(&format!("http://{address}/")).expect("fixture URL"), + )) + .expect("client") +} + +fn auth(token: &BearerToken) -> SchedulingAuth<'_> { + SchedulingAuth::new(token) +} + +fn admission() -> AdmissionRequest { + AdmissionRequest { + offering: "registry-update-30".to_owned(), + start: Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(), + party: PartyCounts { + recipients: 1, + attendees: 2, + }, + channel: Some("public".to_owned()), + duplicate_key: Some("subject:one".to_owned()), + policy_revision: 4, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + reschedule_of: None, + } +} + +const SCHEDULING_DOCUMENT: &str = + r#"{"schedulingId":"scheduling-1","policyRevision":4,"policyDigest":"sha256:aa"}"#; + +const APPOINTMENT_DOCUMENT: &str = concat!( + r#"{"appointmentId":"appt-1","offering":"registry-update-30","#, + r#""start":"2026-10-05T02:00:00Z","end":"2026-10-05T02:30:00Z","#, + r#""resource":"station-1","units":1,"channel":null,"revision":2,"state":"confirmed","#, + r#""policyRevision":4,"createdAt":"2026-10-04T09:00:00Z","cancelledAt":null}"# +); + +#[tokio::test] +async fn scheduling_document_answers_with_the_parsed_service_and_trace() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route("/v1/scheduling", get(capture_get)) + .with_state(Fixture::json( + &observations, + StatusCode::OK, + SCHEDULING_DOCUMENT, + )); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let complete = client(&address) + .get_scheduling(auth(&token)) + .await + .expect("scheduling document"); + + assert_eq!(complete.value.scheduling_id, "scheduling-1"); + assert_eq!(complete.value.policy_revision, 4); + assert_eq!(complete.value.policy_digest, "sha256:aa"); + assert_eq!(complete.trace_id, TRACE_ID); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + let captured = &observations[0]; + assert_eq!(captured.method, "GET"); + assert_eq!(captured.uri, "/v1/scheduling"); + assert_eq!(captured.headers["authorization"], "Bearer fixture-secret"); + assert_eq!(captured.headers["accept"], "application/json"); + assert!(!captured.headers.contains_key("registry-scheduling-profile")); + assert!(!captured.headers.contains_key("idempotency-key")); + server.abort(); +} + +#[tokio::test] +async fn catalogue_listings_forward_and_round_trip_the_cursor() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let page = r#"{"items":[],"nextCursor":"next"}"#; + let app = Router::new() + .route("/v1/services", get(capture_get)) + .route("/v1/offerings", get(capture_get)) + .route("/v1/resources", get(capture_get)) + .route("/v1/locations", get(capture_get)) + .with_state(Fixture::json(&observations, StatusCode::OK, page)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + + let services = client + .list_services(auth(&token), None) + .await + .expect("first services page"); + assert_eq!(services.value.next_cursor.as_deref(), Some("next")); + let services = client + .list_services(auth(&token), services.value.next_cursor.as_deref()) + .await + .expect("second services page"); + assert!(services.value.items.is_empty()); + + let offerings = client + .list_offerings(auth(&token), None) + .await + .expect("first offerings page"); + assert_eq!(offerings.value.next_cursor.as_deref(), Some("next")); + let offerings = client + .list_offerings(auth(&token), offerings.value.next_cursor.as_deref()) + .await + .expect("second offerings page"); + assert!(offerings.value.items.is_empty()); + + let resources = client + .list_resources(auth(&token), None) + .await + .expect("first resources page"); + assert_eq!(resources.value.next_cursor.as_deref(), Some("next")); + let resources = client + .list_resources(auth(&token), resources.value.next_cursor.as_deref()) + .await + .expect("second resources page"); + assert!(resources.value.items.is_empty()); + + let locations = client + .list_locations(auth(&token), None) + .await + .expect("first locations page"); + assert_eq!(locations.value.next_cursor.as_deref(), Some("next")); + let locations = client + .list_locations(auth(&token), locations.value.next_cursor.as_deref()) + .await + .expect("second locations page"); + assert!(locations.value.items.is_empty()); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 8); + assert_eq!(observations[0].uri, "/v1/services"); + assert_eq!(observations[1].uri, "/v1/services?cursor=next"); + assert_eq!(observations[2].uri, "/v1/offerings"); + assert_eq!(observations[3].uri, "/v1/offerings?cursor=next"); + assert_eq!(observations[4].uri, "/v1/resources"); + assert_eq!(observations[5].uri, "/v1/resources?cursor=next"); + assert_eq!(observations[6].uri, "/v1/locations"); + assert_eq!(observations[7].uri, "/v1/locations?cursor=next"); + server.abort(); +} + +#[tokio::test] +async fn availability_forwards_the_exact_query_and_validates_its_selectors_locally() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let first_page = concat!( + r#"{"items":[{"kind":"slot","start":"2026-10-05T02:00:00Z","#, + r#""end":"2026-10-05T02:30:00Z","free":2}],"nextCursor":"next"}"# + ); + // The continuation page answers in windows: the kind tag travels + // untouched through the same bounded page envelope. + let continuation = concat!( + r#"{"items":[{"kind":"window","window":"household-morning-window","#, + r#""start":"2026-10-10T08:00:00Z","end":"2026-10-10T10:00:00Z","#, + r#""remaining":3,"channelRemaining":null}],"nextCursor":null}"# + ); + let mut fixture = Fixture::json(&observations, StatusCode::OK, first_page); + fixture.continuation = Some(continuation.to_owned()); + let app = Router::new() + .route("/v1/availability", get(capture_get)) + .with_state(fixture); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + let start = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + let end = Utc.with_ymd_and_hms(2026, 10, 5, 2, 30, 0).unwrap(); + + let first = client + .availability( + auth(&token), + "registry-update-30", + Some(start), + Some(end), + None, + Some(25), + ) + .await + .expect("first availability page"); + assert_eq!( + first.value.items, + vec![AvailabilityEntry::Slot { + start, + end, + free: 2, + }] + ); + let second = client + .availability( + auth(&token), + "registry-update-30", + Some(start), + Some(end), + first.value.next_cursor.as_deref(), + Some(25), + ) + .await + .expect("second availability page"); + assert_eq!( + second.value.items, + vec![AvailabilityEntry::Window { + window: "household-morning-window".to_owned(), + start: Utc.with_ymd_and_hms(2026, 10, 10, 8, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2026, 10, 10, 10, 0, 0).unwrap(), + remaining: 3, + channel_remaining: None, + }] + ); + assert_eq!(second.trace_id, TRACE_ID); + + assert!(matches!( + client + .availability(auth(&token), "", None, None, None, None) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client + .availability( + auth(&token), + "registry-update-30", + None, + None, + Some(""), + None + ) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client + .availability( + auth(&token), + "registry-update-30", + None, + None, + None, + Some(0) + ) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client + .availability( + auth(&token), + "registry-update-30", + Some(end), + Some(start), + None, + None + ) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 2); + assert_eq!( + observations[0].uri, + "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z&end=2026-10-05T02%3A30%3A00Z&limit=25" + ); + assert_eq!( + observations[1].uri, + "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z&end=2026-10-05T02%3A30%3A00Z&cursor=next&limit=25" + ); + server.abort(); +} + +#[tokio::test] +async fn explain_forwards_offering_and_start() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let explain = concat!( + r#"{"offering":"registry-update-30","start":"2026-10-05T02:00:00Z","#, + r#""publicCode":"capacity.exhausted","detailedCode":"resource.unavailable","#, + r#""explanation":"every capable member is unavailable"}"# + ); + let app = Router::new() + .route("/v1/availability/explain", get(capture_get)) + .with_state(Fixture::json(&observations, StatusCode::OK, explain)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let start = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + let complete = client(&address) + .explain(auth(&token), "registry-update-30", start) + .await + .expect("explain document"); + assert_eq!(complete.value.public_code, "capacity.exhausted"); + assert_eq!(complete.value.detailed_code, "resource.unavailable"); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + assert_eq!( + observations[0].uri, + "/v1/availability/explain?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z" + ); + server.abort(); +} + +#[tokio::test] +async fn create_hold_sends_the_admission_body_under_its_idempotency_key() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let hold = concat!( + r#"{"holdId":"hold-7","offering":"registry-update-30","#, + r#""start":"2026-10-05T02:00:00Z","end":"2026-10-05T02:30:00Z","#, + r#""resource":"station-1","units":1,"expiresAt":"2026-10-05T01:45:00Z","#, + r#""policyRevision":4}"# + ); + let app = Router::new() + .route("/v1/holds", post(capture_call)) + .with_state(Fixture::json(&observations, StatusCode::CREATED, hold)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let request = admission(); + let complete = client(&address) + .create_hold(auth(&token), "hold-7", &request) + .await + .expect("minted hold"); + assert_eq!(complete.value.hold_id, "hold-7"); + assert_eq!(complete.value.resource.as_deref(), Some("station-1")); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1, "a hold is never retried"); + let captured = &observations[0]; + assert_eq!(captured.method, "POST"); + assert_eq!(captured.uri, "/v1/holds"); + assert_eq!(captured.headers["idempotency-key"], "hold-7"); + assert_eq!(captured.headers["authorization"], "Bearer fixture-secret"); + let sent: AdmissionRequest = serde_json::from_slice(&captured.body).expect("sent admission"); + assert_eq!(sent, request); + server.abort(); +} + +#[tokio::test] +async fn release_hold_deletes_and_accepts_an_exactly_empty_answer() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let app = Router::new() + .route("/v1/holds/hold-7", delete(capture_call)) + .with_state(Fixture::json(&observations, StatusCode::NO_CONTENT, "")); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let complete = client(&address) + .release_hold(auth(&token), "hold-7") + .await + .expect("released hold"); + assert_eq!(complete.value, ()); + assert_eq!(complete.trace_id, TRACE_ID); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + let captured = &observations[0]; + assert_eq!(captured.method, "DELETE"); + assert_eq!(captured.uri, "/v1/holds/hold-7"); + assert!(captured.body.is_empty()); + assert!(!captured.headers.contains_key("idempotency-key")); + server.abort(); +} + +#[tokio::test] +async fn appointment_commands_carry_their_keys_and_exact_bodies() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let history = concat!( + r#"{"items":[{"eventId":"event-1","kind":"created","revision":1,"occurredAt":"2026-10-04T09:00:00Z","#, + r#""actor":"hmac-sha256:v2:8f0a","detail":{}}],"nextCursor":null}"# + ); + let created = Fixture::json(&observations, StatusCode::CREATED, APPOINTMENT_DOCUMENT); + let shown = Fixture::json(&observations, StatusCode::OK, APPOINTMENT_DOCUMENT); + let moved = Fixture::json(&observations, StatusCode::OK, APPOINTMENT_DOCUMENT); + let cancelled = Fixture::json(&observations, StatusCode::OK, APPOINTMENT_DOCUMENT); + let history_page = Fixture::json(&observations, StatusCode::OK, history); + let app = Router::new() + .route("/v1/appointments", post(capture_call).with_state(created)) + .route( + "/v1/appointments/appt-1", + get(capture_get).with_state(shown), + ) + .route( + "/v1/appointments/appt-1/reschedule", + post(capture_call).with_state(moved), + ) + .route( + "/v1/appointments/appt-1/cancel", + post(capture_call).with_state(cancelled), + ) + .route( + "/v1/appointments/appt-1/history", + get(capture_get).with_state(history_page), + ); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + + let create = CreateAppointmentRequest { + hold: Some("hold-7".to_owned()), + admission: None, + }; + let confirmed = client + .create_appointment(auth(&token), "create-1", &create) + .await + .expect("confirmed appointment"); + assert_eq!(confirmed.value.appointment_id, "appt-1"); + assert_eq!(confirmed.value.state.as_str(), "confirmed"); + + let read = client + .get_appointment(auth(&token), "appt-1") + .await + .expect("appointment"); + assert_eq!(read.value.revision, 2); + + let mut reschedule_admission = admission(); + reschedule_admission.reschedule_of = Some("claim-appt-1".to_owned()); + let moved = client + .reschedule_appointment( + auth(&token), + "appt-1", + "move-1", + &RescheduleAppointmentRequest { + observed_revision: 2, + admission: reschedule_admission.clone(), + }, + ) + .await + .expect("rescheduled appointment"); + assert_eq!(moved.trace_id, TRACE_ID); + + let cancelled = client + .cancel_appointment( + auth(&token), + "appt-1", + "cancel-1", + &CancelAppointmentRequest { + observed_revision: 2, + reason: Some("plans changed".to_owned()), + }, + ) + .await + .expect("cancelled appointment"); + assert_eq!(cancelled.value.appointment_id, "appt-1"); + + let history = client + .appointment_history(auth(&token), "appt-1", Some("h-next")) + .await + .expect("history page"); + assert_eq!(history.value.items[0].event_id, "event-1"); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 5); + assert_eq!(observations[0].method, "POST"); + assert_eq!(observations[0].uri, "/v1/appointments"); + assert_eq!(observations[0].headers["idempotency-key"], "create-1"); + let sent_create: CreateAppointmentRequest = + serde_json::from_slice(&observations[0].body).expect("sent create"); + assert_eq!(sent_create, create); + assert_eq!(observations[1].method, "GET"); + assert_eq!(observations[1].uri, "/v1/appointments/appt-1"); + assert!(!observations[1].headers.contains_key("idempotency-key")); + assert_eq!(observations[2].uri, "/v1/appointments/appt-1/reschedule"); + assert_eq!(observations[2].headers["idempotency-key"], "move-1"); + let sent_reschedule: RescheduleAppointmentRequest = + serde_json::from_slice(&observations[2].body).expect("sent reschedule"); + assert_eq!(sent_reschedule.admission, reschedule_admission); + assert_eq!(sent_reschedule.observed_revision, 2); + assert_eq!(observations[3].uri, "/v1/appointments/appt-1/cancel"); + assert_eq!(observations[3].headers["idempotency-key"], "cancel-1"); + let sent_cancel: CancelAppointmentRequest = + serde_json::from_slice(&observations[3].body).expect("sent cancel"); + assert_eq!(sent_cancel.observed_revision, 2); + assert_eq!(sent_cancel.reason.as_deref(), Some("plans changed")); + assert_eq!( + observations[4].uri, + "/v1/appointments/appt-1/history?cursor=h-next" + ); + server.abort(); +} + +#[tokio::test] +async fn an_exact_problem_document_maps_to_the_typed_runtime_code() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let problem = serde_json::json!({ + "type": type_uri(ProblemCode::CapacityExhausted.code()), + "title": ProblemCode::CapacityExhausted.title(), + "status": ProblemCode::CapacityExhausted.http_status(), + "detail": ProblemCode::CapacityExhausted.detail(), + "code": ProblemCode::CapacityExhausted.code(), + "traceId": TRACE_ID, + }) + .to_string(); + let app = Router::new() + .route("/v1/holds", post(capture_call)) + .with_state( + Fixture::json(&observations, StatusCode::CONFLICT, &problem) + .with_content_type("application/problem+json"), + ); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let result = client(&address) + .create_hold(auth(&token), "hold-7", &admission()) + .await; + assert_eq!( + result.as_ref().expect_err("refused hold").to_string(), + "Registry Scheduling refused the request (HTTP 409, problem capacity.exhausted)" + ); + match result { + Err(SchedulingClientError::Problem { + status: 409, + code: ProblemCode::CapacityExhausted, + trace_id, + }) => assert_eq!(trace_id.as_deref(), Some(TRACE_ID)), + other => panic!("expected a typed capacity problem, got {other:?}"), + } + server.abort(); +} + +#[tokio::test] +async fn edge_answers_stay_edge_talk() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let platform_problem = serde_json::json!({ + "type": "https://id.registrystack.org/problems/registry-platform/request/not-found", + "title": "Route not found", + "status": 404, + "detail": "The requested route does not exist.", + "code": "request.not-found", + "traceId": TRACE_ID, + }) + .to_string(); + let mut untraced = Fixture::json(&observations, StatusCode::OK, SCHEDULING_DOCUMENT); + untraced.traced = false; + let not_found = Fixture::json(&observations, StatusCode::NOT_FOUND, &platform_problem) + .with_content_type("application/problem+json"); + let plain_json = Fixture::json( + &observations, + StatusCode::INTERNAL_SERVER_ERROR, + r#"{"nope":true}"#, + ); + let garbage_problem = Fixture::json( + &observations, + StatusCode::INTERNAL_SERVER_ERROR, + "this is not a problem document", + ) + .with_content_type("application/problem+json"); + let wrong_media = Fixture::json(&observations, StatusCode::OK, r#"{"items":[]}"#) + .with_content_type("text/plain"); + let unparseable = Fixture::json(&observations, StatusCode::CREATED, "not json"); + let app = Router::new() + .route("/v1/scheduling", get(capture_get).with_state(untraced)) + .route("/v1/services", get(capture_get).with_state(not_found)) + .route("/v1/offerings", get(capture_get).with_state(plain_json)) + .route( + "/v1/locations", + get(capture_get).with_state(garbage_problem), + ) + .route("/v1/resources", get(capture_get).with_state(wrong_media)) + .route("/v1/holds", post(capture_call).with_state(unparseable)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let client = client(&address); + + // A platform-owned route-not-found problem carries a different type base + // and a code outside the closed vocabulary: the edge talking. + assert!(matches!( + client.list_services(auth(&token), None).await, + Err(SchedulingClientError::Protocol { + status: 404, + failure: SchedulingProtocolFailure::Problem, + .. + }) + )); + // A failure answer that is not a problem document at all. + assert!(matches!( + client.list_offerings(auth(&token), None).await, + Err(SchedulingClientError::Protocol { + status: 500, + failure: SchedulingProtocolFailure::Status, + .. + }) + )); + // A success answer in the wrong media type. + assert!(matches!( + client.list_resources(auth(&token), None).await, + Err(SchedulingClientError::Protocol { + status: 200, + failure: SchedulingProtocolFailure::MediaType, + .. + }) + )); + // A success answer with no trace context. + assert!(matches!( + client.get_scheduling(auth(&token)).await, + Err(SchedulingClientError::Protocol { + status: 200, + failure: SchedulingProtocolFailure::TraceContext, + .. + }) + )); + // A problem answer whose body is not an exact problem document. + assert!(matches!( + client.list_locations(auth(&token), None).await, + Err(SchedulingClientError::Protocol { + status: 500, + failure: SchedulingProtocolFailure::Problem, + .. + }) + )); + // A success answer whose body does not parse. + assert!(matches!( + client + .create_hold(auth(&token), "hold-7", &admission()) + .await, + Err(SchedulingClientError::Protocol { + status: 201, + failure: SchedulingProtocolFailure::Body, + .. + }) + )); + + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 6, "no failure is ever retried"); + server.abort(); +} + +#[tokio::test] +async fn oversized_answers_stay_bounded() { + let observations: Observations = Arc::new(Mutex::new(Vec::new())); + let page = format!("{{\"items\":[],\"nextCursor\":\"{}\"}}", "x".repeat(200)); + let app = Router::new() + .route("/v1/resources", get(capture_get)) + .with_state(Fixture::json(&observations, StatusCode::OK, &page)); + let (address, server) = spawn(app).await; + + let token = BearerToken::new("fixture-secret").expect("fixture token"); + let bounded = SchedulingClient::new( + SchedulingClientConfig::new( + Url::parse(&format!("http://{address}/")).expect("fixture URL"), + ) + .with_max_response_bytes(64), + ) + .expect("client"); + match bounded.list_resources(auth(&token), None).await { + Err(SchedulingClientError::Transport { + kind: TransportKind::ResponseTooLarge, + }) => {} + other => panic!("expected a bounded-read refusal, got {other:?}"), + } + let observations = observations.lock().expect("observations"); + assert_eq!(observations.len(), 1); + server.abort(); +} + +#[tokio::test] +async fn invalid_client_input_never_reaches_the_wire() { + // Port 1 refuses every connection, so any attempted round trip would + // surface as a transport failure instead of a request defect. + let client = SchedulingClient::new(SchedulingClientConfig::new( + Url::parse("http://127.0.0.1:1/").expect("fixture URL"), + )) + .expect("client"); + let token = BearerToken::new("fixture-secret").expect("fixture token"); + + assert!(matches!( + client.create_hold(auth(&token), "", &admission()).await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + let oversized_key = "x".repeat(129); + assert!(matches!( + client + .create_hold(auth(&token), &oversized_key, &admission()) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client + .cancel_appointment( + auth(&token), + "appt-1", + "line\nbreak", + &CancelAppointmentRequest { + observed_revision: 2, + reason: None, + }, + ) + .await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client.list_services(auth(&token), Some("")).await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + // An identifier that would split into several path segments never reaches + // the wire as a different route. + assert!(matches!( + client.get_appointment(auth(&token), "appt/1").await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); + assert!(matches!( + client.release_hold(auth(&token), "../holds").await, + Err(SchedulingClientError::InvalidRequest { .. }) + )); +} From 38804499769159db07f2e7b848ecf8f2b604ad3b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 00:28:31 +0700 Subject: [PATCH 012/115] feat(scheduling): add the service layer over the capacity store The service owns the three decisions the store cannot. Authorization: every mutating call must carry a task grant whose scheduling permissions name the offering's service, location, and the operation, and the store re-checks the grant's expiry inside the capacity transaction. Disclosure: every refusal projects to its public problem code, and only the separately authorized explain path sees the detailed one; an admitted start carries no codes at all, because there is no refusal to explain. Attributability: the commitment's audit record carries pseudonymized principal, client, grant, and approver references, and a refused commitment records its receipt under the caller's idempotency key so a replay answers exactly as the first attempt did. Availability is bounded: exact-time offerings walk their authored grid inside the published openings, clear of closures, past lead time, inside the horizon, and only list a slot at least one available member can still serve; arrival windows list their remaining units. A stored success receipt carries the minted claim itself, so a replayed response is the original response through the same projection, not a re-derivation from live state. The policy-to-context resolution moves from the replay fixtures into the core beside the evaluators, so the offline replay and the live runtime expand the same openings and closures and cannot disagree about what a location published. Signed-off-by: Jeremi Joslin --- Cargo.lock | 45 + Cargo.toml | 1 + .../tests/http_boundary.rs | 10 +- .../registry-scheduling-core/src/fixture.rs | 108 +- crates/registry-scheduling-core/src/lib.rs | 2 + .../registry-scheduling-core/src/resolve.rs | 124 + crates/registry-scheduling-core/src/wire.rs | 41 +- crates/registry-scheduling/Cargo.toml | 64 + .../migrations/0001_scheduling.sql | 157 ++ crates/registry-scheduling/src/config.rs | 1047 ++++++++ crates/registry-scheduling/src/cursors.rs | 245 ++ crates/registry-scheduling/src/lib.rs | 22 + crates/registry-scheduling/src/main.rs | 7 + crates/registry-scheduling/src/service.rs | 2006 +++++++++++++++ crates/registry-scheduling/src/store.rs | 2263 +++++++++++++++++ 15 files changed, 6040 insertions(+), 102 deletions(-) create mode 100644 crates/registry-scheduling-core/src/resolve.rs create mode 100644 crates/registry-scheduling/Cargo.toml create mode 100644 crates/registry-scheduling/migrations/0001_scheduling.sql create mode 100644 crates/registry-scheduling/src/config.rs create mode 100644 crates/registry-scheduling/src/cursors.rs create mode 100644 crates/registry-scheduling/src/lib.rs create mode 100644 crates/registry-scheduling/src/main.rs create mode 100644 crates/registry-scheduling/src/service.rs create mode 100644 crates/registry-scheduling/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index 893544d390..af9b6ad80f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6088,6 +6088,51 @@ dependencies = [ "uuid", ] +[[package]] +name = "registry-scheduling" +version = "0.32.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "clap", + "deadpool-postgres", + "futures", + "http", + "jsonwebtoken", + "registry-platform-audit", + "registry-platform-authcommon", + "registry-platform-buildinfo", + "registry-platform-calendar", + "registry-platform-canonical-json", + "registry-platform-config", + "registry-platform-crypto", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-platform-oidc", + "registry-scheduling-core", + "reqwest", + "rustls", + "schemars", + "serde", + "serde_json", + "serde_norway", + "serde_path_to_error", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tower", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "wiremock", +] + [[package]] name = "registry-scheduling-client" version = "0.32.0" diff --git a/Cargo.toml b/Cargo.toml index aa8052d487..852f3d50de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ members = [ "crates/registry-language-server", "crates/registry-linkml", "crates/registry-scheduling-core", + "crates/registry-scheduling", "crates/registry-schedulingctl", "crates/registry-scheduling-client", ] diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs index 5a9900eb2d..2d62e3dd6e 100644 --- a/crates/registry-scheduling-client/tests/http_boundary.rs +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -415,8 +415,14 @@ async fn explain_forwards_offering_and_start() { .explain(auth(&token), "registry-update-30", start) .await .expect("explain document"); - assert_eq!(complete.value.public_code, "capacity.exhausted"); - assert_eq!(complete.value.detailed_code, "resource.unavailable"); + assert_eq!( + complete.value.public_code.as_deref(), + Some("capacity.exhausted") + ); + assert_eq!( + complete.value.detailed_code.as_deref(), + Some("resource.unavailable") + ); let observations = observations.lock().expect("observations"); assert_eq!(observations.len(), 1); diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs index 42700c466b..55c433b832 100644 --- a/crates/registry-scheduling-core/src/fixture.rs +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -12,10 +12,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use registry_platform_calendar::{ - expand_weekly_openings, local_interval, CalendarEvaluationError, CalendarInterval, - HolidaySetRevision, WeeklyOpeningPattern, -}; +use registry_platform_calendar::CalendarEvaluationError; use crate::admission::{ evaluate_exact_time_admission, evaluate_window_admission, ExactTimeContext, WindowContext, @@ -24,6 +21,9 @@ use crate::model::{AdmissionRequest, LedgerClaim, LedgerKind, LedgerSnapshot, Sc use crate::naming::{SCHEDULING_FIXTURE_API_VERSION, SCHEDULING_FIXTURE_KIND}; use crate::policy::{SchedulingMode, SchedulingPolicy}; use crate::problem::ProblemCode; +use crate::resolve::{ + covers_span, location_closure_intervals, location_open_intervals, ResolveError, +}; /// One case's expected outcome. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -374,10 +374,10 @@ fn replay_case( &location.timezone, &exceptions, ) - .map_err(CaseStoppage::Replay)?; + .map_err(|error| CaseStoppage::Replay(error.into()))?; let closures = location_closure_intervals(&fixture.facts, &offering.location, &location.timezone) - .map_err(CaseStoppage::Replay)?; + .map_err(|error| CaseStoppage::Replay(error.into()))?; let context = ExactTimeContext { offering, exact, @@ -455,98 +455,26 @@ fn record_admission( }); } -fn location_open_intervals( - policy: &SchedulingPolicy, - location_id: &str, - timezone: &str, - exceptions: &[registry_platform_calendar::CalendarException<'_>], -) -> Result, ReplayError> { - let mut all = Vec::new(); - for opening in policy.openings.iter().filter(|o| o.location == location_id) { - // A holiday-set reference the policy does not declare is a policy - // check finding; the calendar cannot expand against it, so replay - // stops with the opening that carries it instead of panicking. - let Some(holiday_set) = policy.holiday_set(&opening.holiday_set) else { - return Err(ReplayError::HolidaySetMissing { - opening: opening.id.clone(), - holiday_set: opening.holiday_set.clone(), - }); - }; - let weekdays: Vec = - opening.weekdays.iter().map(|day| day.to_chrono()).collect(); - let pattern = WeeklyOpeningPattern { - id: &opening.id, - timezone, - weekdays: &weekdays, - start_time: &opening.start_time, - end_time: &opening.end_time, - effective_from: &opening.effective_from, - effective_until: &opening.effective_until, - }; - let dates: Vec<&str> = holiday_set.dates.iter().map(String::as_str).collect(); - let revision = HolidaySetRevision { - holiday_set: &holiday_set.id, - revision: holiday_set.revision, - dates: &dates, - }; - all.extend(expand_weekly_openings(&pattern, exceptions, &revision)?); - } - Ok(all) -} - -/// Whether the union of `intervals` covers the whole span `[start, end)`. -fn covers_span( - intervals: &[CalendarInterval], - start: chrono::DateTime, - end: chrono::DateTime, -) -> bool { - let mut overlapping: Vec = intervals - .iter() - .filter(|interval| interval.start < end && start < interval.end) - .copied() - .collect(); - overlapping.sort_by_key(|interval| (interval.start, interval.end)); - - let mut covered_until = start; - for interval in overlapping { - if interval.start > covered_until { - return false; - } - covered_until = covered_until.max(interval.end); - if covered_until >= end { - return true; - } - } - covered_until >= end -} - -fn location_closure_intervals( - facts: &SchedulingFacts, - location_id: &str, - timezone: &str, -) -> Result, ReplayError> { - let mut closures = Vec::new(); - for exception in facts - .exceptions - .iter() - .filter(|exception| exception.location == location_id) - { - if exception.kind == crate::model::ExceptionRecordKind::Closure { - closures.push(local_interval( - &exception.date, - &exception.start_time, - &exception.end_time, - timezone, - )?); +impl From for ReplayError { + fn from(error: ResolveError) -> Self { + match error { + ResolveError::HolidaySetMissing { + opening, + holiday_set, + } => Self::HolidaySetMissing { + opening, + holiday_set, + }, + ResolveError::Calendar(error) => Self::Calendar(error), } } - Ok(closures) } #[cfg(test)] mod tests { use super::*; use crate::model::{LocationRecord, PartyCounts}; + use registry_platform_calendar::CalendarInterval; fn exact_time_fixture_yaml() -> &'static str { r#" diff --git a/crates/registry-scheduling-core/src/lib.rs b/crates/registry-scheduling-core/src/lib.rs index fc083ba2ce..133eb5e715 100644 --- a/crates/registry-scheduling-core/src/lib.rs +++ b/crates/registry-scheduling-core/src/lib.rs @@ -27,6 +27,7 @@ mod model; mod naming; mod policy; mod problem; +mod resolve; mod units; mod wire; @@ -37,5 +38,6 @@ pub use model::*; pub use naming::*; pub use policy::*; pub use problem::*; +pub use resolve::*; pub use units::*; pub use wire::*; diff --git a/crates/registry-scheduling-core/src/resolve.rs b/crates/registry-scheduling-core/src/resolve.rs new file mode 100644 index 0000000000..f6a5cb04c5 --- /dev/null +++ b/crates/registry-scheduling-core/src/resolve.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Policy-context resolution: the authored policy and the environment +//! records resolved into the concrete calendar intervals an evaluation, an +//! availability walk, or an offline replay runs against. +//! +//! This is the one place the product turns policy plus facts into openings +//! and closures, so the offline fixture replay and the live runtime expand +//! exactly the same intervals and can never disagree about what a location +//! published. + +use chrono::{DateTime, Utc}; +use registry_platform_calendar::{ + expand_weekly_openings, local_interval, CalendarEvaluationError, CalendarInterval, + HolidaySetRevision, WeeklyOpeningPattern, +}; + +use crate::model::{ExceptionRecordKind, SchedulingFacts}; +use crate::policy::SchedulingPolicy; + +/// Resolution stopped because the policy and the facts do not fit together. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +pub enum ResolveError { + #[error( + "opening {opening} names holiday set {holiday_set}, which the policy does not declare" + )] + HolidaySetMissing { + opening: String, + holiday_set: String, + }, + #[error("the calendar refused the configuration: {0}")] + Calendar(#[from] CalendarEvaluationError), +} + +/// Expand every authored opening at one location into concrete UTC intervals, +/// layering the location's dated exceptions and each opening's holiday set. +pub fn location_open_intervals( + policy: &SchedulingPolicy, + location_id: &str, + timezone: &str, + exceptions: &[registry_platform_calendar::CalendarException<'_>], +) -> Result, ResolveError> { + let mut all = Vec::new(); + for opening in policy.openings.iter().filter(|o| o.location == location_id) { + // A holiday-set reference the policy does not declare is a policy + // check finding; the calendar cannot expand against it, so resolution + // stops with the opening that carries it instead of panicking. + let Some(holiday_set) = policy.holiday_set(&opening.holiday_set) else { + return Err(ResolveError::HolidaySetMissing { + opening: opening.id.clone(), + holiday_set: opening.holiday_set.clone(), + }); + }; + let weekdays: Vec = + opening.weekdays.iter().map(|day| day.to_chrono()).collect(); + let pattern = WeeklyOpeningPattern { + id: &opening.id, + timezone, + weekdays: &weekdays, + start_time: &opening.start_time, + end_time: &opening.end_time, + effective_from: &opening.effective_from, + effective_until: &opening.effective_until, + }; + let dates: Vec<&str> = holiday_set.dates.iter().map(String::as_str).collect(); + let revision = HolidaySetRevision { + holiday_set: &holiday_set.id, + revision: holiday_set.revision, + dates: &dates, + }; + all.extend(expand_weekly_openings(&pattern, exceptions, &revision)?); + } + Ok(all) +} + +/// Whether the union of `intervals` covers the whole span `[start, end)`. +pub fn covers_span( + intervals: &[CalendarInterval], + start: DateTime, + end: DateTime, +) -> bool { + let mut overlapping: Vec = intervals + .iter() + .filter(|interval| interval.start < end && start < interval.end) + .copied() + .collect(); + overlapping.sort_by_key(|interval| (interval.start, interval.end)); + + let mut covered_until = start; + for interval in overlapping { + if interval.start > covered_until { + return false; + } + covered_until = covered_until.max(interval.end); + if covered_until >= end { + return true; + } + } + covered_until >= end +} + +/// The dated closures recorded against one location, as concrete intervals. +pub fn location_closure_intervals( + facts: &SchedulingFacts, + location_id: &str, + timezone: &str, +) -> Result, ResolveError> { + let mut closures = Vec::new(); + for exception in facts + .exceptions + .iter() + .filter(|exception| exception.location == location_id) + { + if exception.kind == ExceptionRecordKind::Closure { + closures.push(local_interval( + &exception.date, + &exception.start_time, + &exception.end_time, + timezone, + )?); + } + } + Ok(closures) +} diff --git a/crates/registry-scheduling-core/src/wire.rs b/crates/registry-scheduling-core/src/wire.rs index 3a7a73b690..9d0f852c8d 100644 --- a/crates/registry-scheduling-core/src/wire.rs +++ b/crates/registry-scheduling-core/src/wire.rs @@ -251,12 +251,18 @@ pub struct AppointmentHistoryEntryDocument { pub struct ExplainDocument { pub offering: String, pub start: DateTime, - /// The problem code every caller may see. - pub public_code: String, - /// The problem code only this path may disclose. - pub detailed_code: String, - /// The refusal in words, as the evaluator states it. - pub explanation: String, + /// The problem code every caller may see. Absent when the start admits + /// as things stand: there is no refusal to explain. + #[serde(skip_serializing_if = "Option::is_none")] + pub public_code: Option, + /// The problem code only this path may disclose. Absent with + /// `public_code`. + #[serde(skip_serializing_if = "Option::is_none")] + pub detailed_code: Option, + /// The refusal in words, as the evaluator states it. Absent with + /// `public_code`. + #[serde(skip_serializing_if = "Option::is_none")] + pub explanation: Option, } #[cfg(test)] @@ -417,15 +423,15 @@ mod tests { /// The explain document carries both projections, so a test can pin that /// the detailed code never equals a detailed-only value in the public - /// position. + /// position, and that an admitted start carries neither. #[test] fn explain_documents_carry_both_projections() { let explain = ExplainDocument { offering: "registry-update-30".to_owned(), start: utc(5, 2), - public_code: "capacity.exhausted".to_owned(), - detailed_code: "resource.unavailable".to_owned(), - explanation: "every capable member is unavailable".to_owned(), + public_code: Some("capacity.exhausted".to_owned()), + detailed_code: Some("resource.unavailable".to_owned()), + explanation: Some("every capable member is unavailable".to_owned()), }; let json = serde_json::to_value(&explain).unwrap(); assert_eq!(json["publicCode"], "capacity.exhausted"); @@ -434,6 +440,21 @@ mod tests { serde_json::from_value::(json).unwrap(), explain ); + + let admitted = ExplainDocument { + public_code: None, + detailed_code: None, + explanation: None, + ..explain + }; + let json = serde_json::to_value(&admitted).unwrap(); + assert!(json.get("publicCode").is_none()); + assert!(json.get("detailedCode").is_none()); + assert!(json.get("explanation").is_none()); + assert_eq!( + serde_json::from_value::(json).unwrap(), + admitted + ); } #[test] diff --git a/crates/registry-scheduling/Cargo.toml b/crates/registry-scheduling/Cargo.toml new file mode 100644 index 0000000000..a00a619be1 --- /dev/null +++ b/crates/registry-scheduling/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "registry-scheduling" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Registry Scheduling HTTP service and PostgreSQL runtime." +repository.workspace = true +publish = false + +[[bin]] +name = "scheduling" +path = "src/main.rs" + +[features] +default = [] +postgres-test = [] +schema = ["dep:schemars"] + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +axum.workspace = true +base64.workspace = true +chrono = { workspace = true, features = ["serde"] } +clap.workspace = true +deadpool-postgres.workspace = true +futures.workspace = true +http.workspace = true +jsonwebtoken.workspace = true +registry-scheduling-core.workspace = true +registry-platform-audit.workspace = true +registry-platform-authcommon.workspace = true +registry-platform-buildinfo.workspace = true +registry-platform-calendar.workspace = true +registry-platform-canonical-json.workspace = true +registry-platform-config.workspace = true +registry-platform-crypto.workspace = true +registry-platform-httpsec = { workspace = true, features = ["server"] } +registry-platform-httputil.workspace = true +registry-platform-oidc.workspace = true +rustls.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +serde_path_to_error.workspace = true +schemars = { workspace = true, optional = true } +sha2.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-postgres.workspace = true +tokio-postgres-rustls.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +url.workspace = true +uuid = { workspace = true, features = ["serde"] } + +[dev-dependencies] +reqwest.workspace = true +tempfile.workspace = true +tower.workspace = true +wiremock.workspace = true diff --git a/crates/registry-scheduling/migrations/0001_scheduling.sql b/crates/registry-scheduling/migrations/0001_scheduling.sql new file mode 100644 index 0000000000..f2833f5741 --- /dev/null +++ b/crates/registry-scheduling/migrations/0001_scheduling.sql @@ -0,0 +1,157 @@ +CREATE TABLE IF NOT EXISTS scheduling_meta ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + scheduling_id text NOT NULL, + policy_revision bigint NOT NULL CHECK (policy_revision > 0), + policy_digest text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); +INSERT INTO scheduling_meta (singleton, scheduling_id, policy_revision, policy_digest) +VALUES (true, '', 1, '') +ON CONFLICT (singleton) DO NOTHING; + +CREATE TABLE IF NOT EXISTS scheduling_policy_revisions ( + policy_revision bigint PRIMARY KEY CHECK (policy_revision > 0), + policy_digest text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS scheduling_locations ( + location_id text PRIMARY KEY, + timezone text NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS scheduling_pools ( + pool_id text PRIMARY KEY, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS scheduling_pool_members ( + resource_id text PRIMARY KEY, + pool_id text NOT NULL REFERENCES scheduling_pools(pool_id), + capabilities text[] NOT NULL DEFAULT '{}', + available boolean NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS scheduling_pool_members_pool_idx + ON scheduling_pool_members(pool_id); + +CREATE TABLE IF NOT EXISTS scheduling_exceptions ( + exception_id text PRIMARY KEY, + location text NOT NULL, + kind text NOT NULL CHECK (kind IN ('closure','opening')), + date date NOT NULL, + start_time text NOT NULL, + end_time text NOT NULL, + reopens text, + authority text, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS scheduling_exceptions_location_idx + ON scheduling_exceptions(location, date); + +-- The capacity transaction's lock anchor. One row per separately locked +-- supply: every resource pool backing exact-time offerings, and every +-- published window. The anchor is the pool, not the offering, because two +-- offerings on one pool sell the same members and must serialize against +-- each other. +CREATE TABLE IF NOT EXISTS scheduling_supply ( + supply_id text PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('pool','window')) +); + +CREATE TABLE IF NOT EXISTS scheduling_claims ( + claim_id uuid PRIMARY KEY, + kind text NOT NULL CHECK (kind IN ('booking','hold')), + state text NOT NULL CHECK (state IN ('active','released','cancelled','consumed','expired')), + offering text NOT NULL, + supply_id text NOT NULL, + channel text, + displayed_start timestamptz NOT NULL, + displayed_end timestamptz NOT NULL, + occupied_start timestamptz NOT NULL, + occupied_end timestamptz NOT NULL, + units integer NOT NULL CHECK (units > 0), + duplicate_key text, + hold_expires_at timestamptz, + revision bigint NOT NULL CHECK (revision > 0), + policy_revision bigint NOT NULL CHECK (policy_revision > 0), + occurrence_id uuid, + actor text NOT NULL, + reason text CHECK (reason IS NULL OR octet_length(reason) <= 4096), + created_at timestamptz NOT NULL DEFAULT now(), + changed_at timestamptz NOT NULL DEFAULT now(), + closed_at timestamptz +); +CREATE INDEX IF NOT EXISTS scheduling_claims_supply_active_idx + ON scheduling_claims(supply_id, occupied_start, occupied_end) + WHERE state = 'active'; +CREATE INDEX IF NOT EXISTS scheduling_claims_duplicate_idx + ON scheduling_claims(duplicate_key) + WHERE state = 'active' AND duplicate_key IS NOT NULL; +CREATE INDEX IF NOT EXISTS scheduling_claims_expiry_idx + ON scheduling_claims(hold_expires_at) + WHERE state = 'active' AND kind = 'hold'; + +CREATE TABLE IF NOT EXISTS scheduling_history ( + event_id uuid PRIMARY KEY, + claim_id uuid NOT NULL REFERENCES scheduling_claims(claim_id), + revision bigint NOT NULL, + kind text NOT NULL, + occurred_at timestamptz NOT NULL, + actor text NOT NULL, + detail jsonb NOT NULL +); +CREATE INDEX IF NOT EXISTS scheduling_history_claim_idx + ON scheduling_history(claim_id, occurred_at, event_id); + +CREATE TABLE IF NOT EXISTS scheduling_outbox ( + outbox_id uuid PRIMARY KEY, + purpose text NOT NULL CHECK (purpose IN ('confirmation','change','cancellation','reminder')), + claim_id uuid NOT NULL REFERENCES scheduling_claims(claim_id), + appointment_revision bigint NOT NULL, + due_at timestamptz NOT NULL, + delivery_state text NOT NULL CHECK (delivery_state IN ('pending','delivered','failed','local')), + attempts integer NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL, + payload jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + delivered_at timestamptz +); +CREATE INDEX IF NOT EXISTS scheduling_outbox_due_idx + ON scheduling_outbox(due_at, next_attempt_at) + WHERE delivery_state = 'pending'; +CREATE INDEX IF NOT EXISTS scheduling_outbox_claim_idx + ON scheduling_outbox(claim_id, purpose); + +CREATE TABLE IF NOT EXISTS scheduling_audit_outbox ( + event_id uuid PRIMARY KEY, + audit_record jsonb NOT NULL, + published_at timestamptz +); + +CREATE TABLE IF NOT EXISTS scheduling_attempts ( + attempt_id uuid PRIMARY KEY, + actor_issuer text NOT NULL, + actor_subject text NOT NULL, + scope text NOT NULL, + idempotency_key text NOT NULL, + request_hash text NOT NULL, + state text NOT NULL CHECK (state IN ('completed','refused')), + status_code integer NOT NULL, + receipt jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + UNIQUE (actor_issuer, actor_subject, scope, idempotency_key) +); +CREATE INDEX IF NOT EXISTS scheduling_attempts_expiry_idx + ON scheduling_attempts(expires_at); + +CREATE TABLE IF NOT EXISTS scheduling_cursors ( + cursor_id uuid PRIMARY KEY, + context text NOT NULL, + position jsonb NOT NULL, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS scheduling_cursors_expiry_idx + ON scheduling_cursors(expires_at); diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs new file mode 100644 index 0000000000..e144bc718e --- /dev/null +++ b/crates/registry-scheduling/src/config.rs @@ -0,0 +1,1047 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The operator runtime configuration document and the policy package +//! identity it verifies. + +use std::collections::BTreeSet; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet}; +use jsonwebtoken::Algorithm; +use registry_platform_config::{ + SecretError, SecretProvider, SecretReference, SecretResolver, MAX_SECRET_BYTES, +}; +use registry_platform_oidc::{ + fetch_discovery, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, TokenVerifierConfig, +}; +use registry_scheduling_core::{ + parse_policy_yaml, SchedulingPolicy, AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, + SCHEDULING_POLICY_API_VERSION, SCHEDULING_POLICY_KIND, SCHEDULING_RUNTIME_API_VERSION, + SCHEDULING_RUNTIME_KIND, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use thiserror::Error; + +/// Explain one refused secret reference without disclosing what it protects. +/// +/// A startup refusal reaches an operator as a single line, and the resolver +/// reports only which rule broke. A valid reference is safe and useful to name, +/// but invalid operator-authored text might itself be a literal credential, so +/// only its field is named. The resolved bytes and opened path never appear. +pub(crate) fn describe_secret_failure( + field: &'static str, + reference: &str, + error: &SecretError, +) -> String { + let reason = match error { + SecretError::InvalidReference => { + "it is not an exact secret:env/NAME or secret:file/name reference".to_owned() + } + SecretError::ProviderDisabled => "its provider is not enabled for this runtime".to_owned(), + SecretError::InvalidProviderConfiguration => { + "the secret provider configuration is invalid".to_owned() + } + SecretError::Unavailable => { + "no readable secret of that name exists under the configured provider".to_owned() + } + SecretError::UnsafeFile => concat!( + "the secret file must be a regular file owned by the runtime user, ", + "with mode 0400 or 0600, and exactly one hard link" + ) + .to_owned(), + SecretError::Read => "the secret could not be read".to_owned(), + SecretError::InvalidValue => format!( + "the secret value must be non-empty text of at most {MAX_SECRET_BYTES} bytes \ + without NUL bytes" + ), + }; + if error == &SecretError::InvalidReference { + format!("the secret reference configured at {field} could not be resolved: {reason}") + } else { + format!("the secret reference {reference} could not be resolved: {reason}") + } +} + +const MAXIMUM_POLICY_FILE_BYTES: usize = 1024 * 1024; +const MAXIMUM_PACKAGE_MANIFEST_BYTES: usize = 1024 * 1024; + +/// The default number of days a stored idempotency receipt is replayable +/// before the retention sweep erases it. The default is a floor, not a +/// recommendation: a jurisdiction's retention schedule approves the deployed +/// value, and the deployed value is what the audit journal records. +pub const DEFAULT_ATTEMPT_RECEIPT_DAYS: u16 = 7; + +/// The operator runtime configuration document. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeConfig { + pub api_version: String, + pub kind: String, + pub package: RuntimePackageConfig, + pub listener: ListenerConfig, + pub secret_providers: SecretProvidersConfig, + pub database: DatabaseConfig, + pub authentication: AuthenticationConfig, + pub audit: AuditConfig, + #[serde(default)] + pub destinations: DestinationsConfig, + pub retention: RetentionConfig, +} + +/// The root of an authored scheduling project, holding `scheduling.yaml` and +/// its optional package manifest. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimePackageConfig { + pub root: PathBuf, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListenerConfig { + #[serde(default = "default_listener_bind")] + #[cfg_attr(feature = "schema", schemars(with = "String"))] + pub bind: SocketAddr, + pub tls_termination: TlsTermination, + #[serde(default)] + pub network_exposure: ListenerNetworkExposure, +} + +fn default_listener_bind() -> SocketAddr { + "127.0.0.1:8105" + .parse() + .expect("valid Scheduling listener default") +} + +/// Declares the trusted transport boundary for the runtime's plaintext HTTP +/// listener. Production listeners require operator-controlled upstream TLS +/// termination; direct plaintext is limited to the explicit loopback-only +/// development mode. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum TlsTermination { + OperatorControlledUpstream, + DevelopmentLoopback, +} + +/// The operator-declared private network placement of the HTTP listener. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum ListenerNetworkExposure { + #[default] + PrivateAddress, + ContainerPrivate, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SecretProvidersConfig { + #[serde(default)] + pub file: Option, + #[serde(default)] + pub environment: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentSecretProviderConfig {} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FileSecretProviderConfig { + pub root: PathBuf, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthenticationConfig { + pub oidc: OidcConfig, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DatabaseConfig { + pub runtime_url_ref: String, + pub migration_url_ref: String, + #[serde(default)] + pub trusted_root_certificate_ref: Option, + #[serde(default)] + pub test_only_plaintext: bool, +} + +impl std::fmt::Debug for DatabaseConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DatabaseConfig") + .field("runtime_url_ref", &"") + .field("migration_url_ref", &"") + .field( + "trusted_root_certificate_ref", + &self + .trusted_root_certificate_ref + .as_ref() + .map(|_| ""), + ) + .field("test_only_plaintext", &self.test_only_plaintext) + .finish() + } +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OidcConfig { + #[serde(default)] + pub allowed_clients: Vec, + pub issuer: String, + pub audience: String, + #[serde(default)] + pub jwks_uri: Option, + #[serde(default)] + pub jwks_source: OidcJwksSource, + #[serde(default = "default_scope_claim")] + pub scope_claim: String, + /// The scope every listing and availability read requires. + #[serde(default = "default_reads_scope")] + pub reads_scope: String, + /// The scope the separately authorized explain path requires. + #[serde(default = "default_explain_scope")] + pub explain_scope: String, +} + +fn default_scope_claim() -> String { + "registry_scopes".to_owned() +} +fn default_reads_scope() -> String { + "scheduling-read".to_owned() +} +fn default_explain_scope() -> String { + "scheduling-explain".to_owned() +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum OidcJwksSource { + #[default] + Discovery, + Static { + #[serde(rename = "documentRef")] + document_ref: String, + }, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuditConfig { + pub path: PathBuf, + pub hash_key_ref: String, +} + +/// Where due reminder intents are dispatched. An absent reminders destination +/// is a supported deployment: intents are written and stay local, so an +/// operator can adopt the product before wiring a notification bus. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DestinationsConfig { + #[serde(default)] + pub reminders: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReminderDestinationConfig { + pub url: String, + #[serde(default)] + pub bearer_token_ref: Option, +} + +/// Retention periods the retention sweep enforces. Every value is deployment +/// configuration, never policy: a jurisdiction's retention schedule approves +/// the deployed numbers, and changing one is an operational event. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RetentionConfig { + /// How long a stored idempotency receipt stays replayable. + #[serde(default = "default_attempt_receipt_days")] + pub attempt_receipt_days: u16, +} + +impl Default for RetentionConfig { + fn default() -> Self { + Self { + attempt_receipt_days: DEFAULT_ATTEMPT_RECEIPT_DAYS, + } + } +} + +fn default_attempt_receipt_days() -> u16 { + DEFAULT_ATTEMPT_RECEIPT_DAYS +} + +/// The immutable identity of a packaged policy: the digest of the package +/// envelope over its exact authored files. A scheduling package is one +/// authored policy file, so the manifest is small, but it is still a +/// separate document the runtime verifies rather than trusts. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolicyPackageManifest { + pub api_version: String, + pub kind: String, + pub policy_digest: String, + pub files: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PolicyPackageFile { + pub path: String, + pub sha256: String, + pub bytes: u64, +} + +impl PolicyPackageManifest { + /// Build the immutable identity for an already validated policy text. + pub fn build(policy_text: &str) -> Result { + let bytes = policy_text.as_bytes(); + if bytes.len() > MAXIMUM_POLICY_FILE_BYTES { + return Err(PolicyPackageError::Invalid); + } + let files = vec![PolicyPackageFile { + path: AUTHORED_POLICY_FILE.to_owned(), + sha256: sha256_bytes(bytes), + bytes: u64::try_from(bytes.len()).map_err(|_| PolicyPackageError::Invalid)?, + }]; + Ok(Self { + api_version: SCHEDULING_POLICY_API_VERSION.to_owned(), + kind: SCHEDULING_POLICY_KIND.to_owned(), + policy_digest: package_digest(&files)?, + files, + }) + } + + fn verify(&self, policy_text: &str) -> Result<(), PolicyPackageError> { + let expected = Self::build(policy_text)?; + if self.api_version != expected.api_version + || self.kind != expected.kind + || self.files != expected.files + || self.policy_digest != expected.policy_digest + { + return Err(PolicyPackageError::Invalid); + } + Ok(()) + } +} + +/// Verify the package beside `scheduling.yaml`. An absent manifest is +/// distinguished so local authored development remains usable. +pub fn verify_policy_package( + policy_path: &Path, + policy_text: &str, +) -> Result, PolicyPackageError> { + if policy_path.file_name().and_then(|name| name.to_str()) != Some(AUTHORED_POLICY_FILE) { + return Err(PolicyPackageError::Invalid); + } + let root = policy_path.parent().ok_or(PolicyPackageError::Invalid)?; + let manifest_path = root.join(SCHEDULING_PACKAGE_MANIFEST_FILE); + if !manifest_path.exists() { + return Ok(None); + } + let metadata = std::fs::symlink_metadata(&manifest_path).map_err(PolicyPackageError::Read)?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.len() > MAXIMUM_PACKAGE_MANIFEST_BYTES as u64 + { + return Err(PolicyPackageError::Invalid); + } + let bytes = std::fs::read(&manifest_path).map_err(PolicyPackageError::Read)?; + let manifest: PolicyPackageManifest = + serde_json::from_slice(&bytes).map_err(|_| PolicyPackageError::Invalid)?; + manifest.verify(policy_text)?; + Ok(Some(manifest.policy_digest)) +} + +fn package_digest(files: &[PolicyPackageFile]) -> Result { + let identity = serde_json::json!({ + "apiVersion": SCHEDULING_POLICY_API_VERSION, + "kind": SCHEDULING_POLICY_KIND, + "files": files, + }); + let canonical = registry_platform_canonical_json::canonicalize_json(&identity) + .map_err(|_| PolicyPackageError::Invalid)?; + Ok(sha256_bytes(&canonical)) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} + +impl RuntimeConfig { + /// Load and validate the operator document, reading the authored policy + /// beside it. The bytes never re-enter a parser after startup. + pub fn load(path: impl AsRef) -> Result { + if !path.as_ref().is_absolute() { + return Err(RuntimeConfigError::RelativeRuntimePath); + } + let bytes = std::fs::read(path.as_ref()).map_err(RuntimeConfigError::Read)?; + let deserializer = serde_norway::Deserializer::from_slice(&bytes); + let config: Self = serde_path_to_error::deserialize(deserializer).map_err(|error| { + let path = error.path().to_string(); + RuntimeConfigError::Parse { + path: if path.is_empty() { + "/".to_owned() + } else { + path + }, + source: error.into_inner(), + } + })?; + config.check()?; + Ok(config) + } + + #[must_use] + pub fn policy_path(&self) -> PathBuf { + self.package.root.join(AUTHORED_POLICY_FILE) + } + + /// Read and validate the authored policy this deployment runs. + pub fn load_policy(&self) -> Result { + let policy_text = + std::fs::read_to_string(self.policy_path()).map_err(RuntimeConfigError::PolicyRead)?; + let policy = parse_policy_yaml(&policy_text).map_err(|error| { + let path = error.path().to_string(); + RuntimeConfigError::PolicyParse { + path: if path.is_empty() { + "/".to_owned() + } else { + path + }, + } + })?; + if !policy.check().is_empty() { + return Err(RuntimeConfigError::PolicyFindings); + } + // A present manifest is verified against the exact policy text; the + // digest the runtime publishes is the policy's own, not the manifest's. + verify_policy_package(&self.policy_path(), &policy_text) + .map_err(RuntimeConfigError::PolicyPackage)?; + Ok(policy) + } + + /// Return the verified package identity, if this is a packaged + /// deployment. Production configurations always have one. + pub fn policy_package_digest(&self) -> Result, RuntimeConfigError> { + let policy_text = + std::fs::read_to_string(self.policy_path()).map_err(RuntimeConfigError::PolicyRead)?; + verify_policy_package(&self.policy_path(), &policy_text) + .map_err(RuntimeConfigError::PolicyPackage) + } + + pub fn check(&self) -> Result<(), RuntimeConfigError> { + if self.api_version != SCHEDULING_RUNTIME_API_VERSION { + return Err(RuntimeConfigError::InvalidApiVersion); + } + if self.kind != SCHEDULING_RUNTIME_KIND { + return Err(RuntimeConfigError::InvalidKind); + } + if !self.package.root.is_absolute() { + return Err(RuntimeConfigError::RelativeOperatedPath("package.root")); + } + if self + .secret_providers + .file + .as_ref() + .is_some_and(|file| !file.root.is_absolute()) + { + return Err(RuntimeConfigError::RelativeOperatedPath( + "secretProviders.file.root", + )); + } + if !self.audit.path.is_absolute() { + return Err(RuntimeConfigError::RelativeOperatedPath("audit.path")); + } + if self.secret_providers.file.is_none() && self.secret_providers.environment.is_none() { + return Err(RuntimeConfigError::InvalidSecretProviders); + } + if !valid_listener( + self.listener.bind.ip(), + self.listener.network_exposure, + self.listener.tls_termination, + ) { + return Err(RuntimeConfigError::InvalidListener); + } + if self.authentication.oidc.issuer.is_empty() + || self.authentication.oidc.audience.is_empty() + || self.authentication.oidc.scope_claim.is_empty() + || self.authentication.oidc.reads_scope.is_empty() + || self.authentication.oidc.explain_scope.is_empty() + || self.authentication.oidc.explain_scope == self.authentication.oidc.reads_scope + { + return Err(RuntimeConfigError::InvalidOidc); + } + if self.database.runtime_url_ref.is_empty() || self.database.migration_url_ref.is_empty() { + return Err(RuntimeConfigError::InvalidDatabaseReference); + } + if self.audit.hash_key_ref.is_empty() { + return Err(RuntimeConfigError::InvalidAuditReference); + } + if self.retention.attempt_receipt_days == 0 { + return Err(RuntimeConfigError::InvalidRetention); + } + if let Some(reminders) = &self.destinations.reminders { + if !valid_destination_url(&reminders.url) { + return Err(RuntimeConfigError::InvalidDestination); + } + } + self.validate_secret_references()?; + + self.load_policy()?; + if self.listener.tls_termination == TlsTermination::OperatorControlledUpstream + && self.policy_package_digest()?.is_none() + { + return Err(RuntimeConfigError::ProductionPolicyPackageRequired); + } + #[cfg(not(feature = "postgres-test"))] + if self.database.test_only_plaintext { + return Err(RuntimeConfigError::PlaintextDatabase); + } + Ok(()) + } + + fn validate_secret_references(&self) -> Result<(), RuntimeConfigError> { + let mut references = vec![ + ( + "database.runtimeUrlRef".to_owned(), + &self.database.runtime_url_ref, + ), + ( + "database.migrationUrlRef".to_owned(), + &self.database.migration_url_ref, + ), + ("audit.hashKeyRef".to_owned(), &self.audit.hash_key_ref), + ]; + if let Some(reference) = &self.database.trusted_root_certificate_ref { + references.push(("database.trustedRootCertificateRef".to_owned(), reference)); + } + if let OidcJwksSource::Static { document_ref } = &self.authentication.oidc.jwks_source { + references.push(( + "authentication.oidc.jwksSource.documentRef".to_owned(), + document_ref, + )); + } + if let Some(reminders) = &self.destinations.reminders { + if let Some(reference) = &reminders.bearer_token_ref { + references.push(( + "destinations.reminders.bearerTokenRef".to_owned(), + reference, + )); + } + } + for (path, raw) in references { + let reference = SecretReference::parse(raw.clone()) + .map_err(|_| RuntimeConfigError::InvalidSecretReference { path: path.clone() })?; + let enabled = match reference.provider() { + SecretProvider::File => self.secret_providers.file.is_some(), + SecretProvider::Environment => self.secret_providers.environment.is_some(), + }; + if !enabled { + return Err(RuntimeConfigError::SecretProviderRequired { path }); + } + } + Ok(()) + } + + pub async fn oidc_verifier( + &self, + secrets: &SecretResolver, + ) -> Result<(TokenVerifierConfig, std::sync::Arc), RuntimeConfigError> { + let discovery_config = OidcDiscoveryConfig { + issuer: self.authentication.oidc.issuer.clone(), + jwks_uri_override: self.authentication.oidc.jwks_uri.clone(), + discovery_timeout: Duration::from_secs(5), + max_doc_bytes: 1024 * 1024, + }; + let fetcher = match &self.authentication.oidc.jwks_source { + OidcJwksSource::Discovery => { + let discovery = fetch_discovery(&discovery_config) + .await + .map_err(|_| RuntimeConfigError::Oidc)?; + JwksFetcher::new(discovery.jwks_uri, JwksFetcherConfig::defaults()) + } + OidcJwksSource::Static { document_ref } => { + let document = secrets.resolve(document_ref).map_err(|error| { + RuntimeConfigError::OidcJwksSecret(describe_secret_failure( + "authentication.oidc.jwksSource.documentRef", + document_ref, + &error, + )) + })?; + let jwks = parse_static_jwks(document.expose_secret())?; + JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults()) + } + }; + let verifier = TokenVerifierConfig::access_token_profile( + self.authentication.oidc.issuer.clone(), + vec![self.authentication.oidc.audience.clone()], + vec![Algorithm::RS256, Algorithm::ES256], + vec!["at+jwt".to_owned(), "JWT".to_owned()], + ) + .with_scope_claim(self.authentication.oidc.scope_claim.clone()) + .with_allowed_clients(self.authentication.oidc.allowed_clients.clone()); + Ok((verifier, std::sync::Arc::new(fetcher))) + } +} + +/// A reminder destination URL is an absolute http(s) URL without userinfo. +/// Plain http is accepted only for an explicit loopback host, so a plaintext +/// destination can never silently point at another machine. Full network +/// egress policy is the deployment's perimeter concern, the same boundary +/// BREG draws for its webhook origins. +fn valid_destination_url(value: &str) -> bool { + let Ok(parsed) = url::Url::parse(value) else { + return false; + }; + if parsed.username().is_empty() + && parsed.password().is_none() + && parsed.host().is_some() + && parsed.port_or_known_default().is_some_and(|port| port != 0) + { + match parsed.scheme() { + "https" => true, + "http" => matches!( + parsed.host_str(), + Some("127.0.0.1") | Some("localhost") | Some("[::1]") + ), + _ => false, + } + } else { + false + } +} + +fn valid_listener( + address: IpAddr, + exposure: ListenerNetworkExposure, + tls_termination: TlsTermination, +) -> bool { + if address.is_multicast() { + return false; + } + if tls_termination == TlsTermination::DevelopmentLoopback { + return exposure == ListenerNetworkExposure::PrivateAddress && address.is_loopback(); + } + match (address, exposure) { + (IpAddr::V4(address), ListenerNetworkExposure::PrivateAddress) => { + address.is_loopback() || address.is_private() + } + (IpAddr::V6(address), ListenerNetworkExposure::PrivateAddress) => { + address.is_loopback() || is_unique_local(address) + } + (IpAddr::V4(address), ListenerNetworkExposure::ContainerPrivate) => { + address.is_unspecified() || address.is_loopback() || address.is_private() + } + (IpAddr::V6(address), ListenerNetworkExposure::ContainerPrivate) => { + address.is_unspecified() || address.is_loopback() || is_unique_local(address) + } + } +} + +fn is_unique_local(address: Ipv6Addr) -> bool { + address.octets()[0] & 0xfe == 0xfc +} + +fn parse_static_jwks(bytes: &[u8]) -> Result { + let jwks: JwkSet = serde_json::from_slice(bytes).map_err(|_| RuntimeConfigError::Oidc)?; + let mut kids = BTreeSet::new(); + if jwks.keys.is_empty() + || jwks.keys.iter().any(|key| { + !matches!( + key.algorithm, + AlgorithmParameters::RSA(_) | AlgorithmParameters::EllipticCurve(_) + ) || key + .common + .key_id + .as_ref() + .is_none_or(|kid| kid.is_empty() || !kids.insert(kid.clone())) + }) + { + return Err(RuntimeConfigError::Oidc); + } + Ok(jwks) +} + +#[derive(Debug, Error)] +pub enum PolicyPackageError { + #[error("the Scheduling policy package could not be read")] + Read(#[source] std::io::Error), + #[error("the Scheduling policy package is invalid or does not match its exact inputs")] + Invalid, +} + +#[derive(Debug, Error)] +pub enum RuntimeConfigError { + #[error("the Scheduling runtime configuration could not be read")] + Read(#[source] std::io::Error), + #[error("the Scheduling runtime configuration is not valid YAML at {path}")] + Parse { + path: String, + #[source] + source: serde_norway::Error, + }, + #[error( + "unsupported Scheduling runtime apiVersion; expected registry.registrystack.org/scheduling-runtime/v1alpha1" + )] + InvalidApiVersion, + #[error("unsupported Scheduling runtime kind; expected SchedulingRuntimeConfig")] + InvalidKind, + #[error("the operated runtime path {0} must be absolute")] + RelativeOperatedPath(&'static str), + #[error("the selected Scheduling runtime configuration path must be absolute")] + RelativeRuntimePath, + #[error("secretProviders must explicitly enable file, environment, or both")] + InvalidSecretProviders, + #[error("{path} is not a valid secret reference")] + InvalidSecretReference { path: String }, + #[error("{path} uses a secret provider that is not explicitly enabled")] + SecretProviderRequired { path: String }, + #[error("the authored scheduling policy could not be read")] + PolicyRead(#[source] std::io::Error), + #[error("the authored scheduling policy is not valid YAML at {path}")] + PolicyParse { path: String }, + #[error("the authored scheduling policy does not pass its checks")] + PolicyFindings, + #[error("the Scheduling policy package is invalid")] + PolicyPackage(#[source] PolicyPackageError), + #[error("operator-controlled production requires a verified Scheduling policy package")] + ProductionPolicyPackageRequired, + #[error("listener is not valid for its declared TLS termination and network exposure")] + InvalidListener, + #[error("authentication.oidc is invalid")] + InvalidOidc, + #[error( + "database.runtimeUrlRef and database.migrationUrlRef must be non-empty secret references" + )] + InvalidDatabaseReference, + #[error("audit.hashKeyRef must be a non-empty secret reference")] + InvalidAuditReference, + #[error("retention.attemptReceiptDays must be at least one day")] + InvalidRetention, + #[error("destinations.reminders.url is not a valid destination URL")] + InvalidDestination, + #[error("plaintext PostgreSQL is test-only")] + PlaintextDatabase, + #[error("the OIDC issuer could not be initialized")] + Oidc, + #[error("the static OIDC signing keys could not be loaded: {0}")] + OidcJwksSecret(String), +} + +impl RuntimeConfigError { + #[must_use] + pub fn path(&self) -> &str { + match self { + Self::InvalidApiVersion => "apiVersion", + Self::InvalidKind => "kind", + Self::RelativeOperatedPath(path) => path, + Self::RelativeRuntimePath | Self::Read(_) => "/", + Self::Parse { path, .. } => path, + Self::InvalidSecretProviders => "secretProviders", + Self::InvalidSecretReference { path } | Self::SecretProviderRequired { path } => path, + Self::InvalidOidc | Self::Oidc => "authentication.oidc", + Self::OidcJwksSecret(_) => "authentication.oidc.jwksSource.documentRef", + Self::InvalidListener => "listener", + Self::InvalidDatabaseReference | Self::PlaintextDatabase => "database", + Self::InvalidAuditReference => "audit.hashKeyRef", + Self::InvalidRetention => "retention.attemptReceiptDays", + Self::InvalidDestination => "destinations.reminders.url", + Self::PolicyRead(_) | Self::PolicyParse { .. } => "package.root/scheduling.yaml", + Self::PolicyFindings | Self::PolicyPackage(_) => "package.root", + Self::ProductionPolicyPackageRequired => "package.root", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: {id: standalone-exact-time, version: 1} +services: [{id: registry-update, label: Registry record update}] +holidaySets: [{id: none, revision: 1, because: test, dates: []}] +openings: + - id: weekdays + location: north-counter + holidaySet: none + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:00" + effectiveFrom: "2026-01-01" + effectiveUntil: "2027-01-01" + because: test +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: test + cancellationCutoffMinutes: 240 + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: north-counter + startIncrementMinutes: 30 + maxRecipients: 1 + requiresCapabilities: [] + prerequisites: [] +windows: [] +holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} +"#; + + fn write_policy(root: &Path) { + std::fs::create_dir_all(root).unwrap(); + std::fs::write(root.join(AUTHORED_POLICY_FILE), POLICY).unwrap(); + } + + fn operator_value(package: &Path, tls: &str) -> serde_json::Value { + let root = package.parent().expect("package parent"); + serde_json::json!({ + "apiVersion": SCHEDULING_RUNTIME_API_VERSION, + "kind": SCHEDULING_RUNTIME_KIND, + "package": {"root": package}, + "listener": {"bind": "127.0.0.1:8105", "tlsTermination": tls}, + "secretProviders": {"file": {"root": root.join("secrets")}, "environment": {}}, + "database": { + "runtimeUrlRef": "secret:env/RUNTIME", + "migrationUrlRef": "secret:env/MIGRATION" + }, + "authentication": {"oidc": { + "issuer": "https://identity.example.test", + "audience": "urn:example:scheduling" + }}, + "audit": {"path": root.join("audit.ndjson"), "hashKeyRef": "secret:file/audit"}, + "destinations": {}, + "retention": {"attemptReceiptDays": 7} + }) + } + + fn write_operator(root: &Path, document: serde_json::Value) -> PathBuf { + let operator = root.join("runtime.yaml"); + std::fs::write(&operator, serde_norway::to_string(&document).unwrap()).unwrap(); + operator + } + + #[test] + fn a_development_configuration_loads_and_exposes_its_defaults() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let operator = write_operator( + root.path(), + operator_value(&package, "development-loopback"), + ); + let config = RuntimeConfig::load(&operator).expect("configuration is accepted"); + assert_eq!(config.authentication.oidc.scope_claim, "registry_scopes"); + assert_eq!(config.authentication.oidc.reads_scope, "scheduling-read"); + assert_eq!( + config.authentication.oidc.explain_scope, + "scheduling-explain" + ); + assert_eq!(config.retention.attempt_receipt_days, 7); + assert!(config.destinations.reminders.is_none()); + let policy = config.load_policy().expect("policy loads"); + assert_eq!(policy.scheduling.id, "standalone-exact-time"); + } + + #[test] + fn production_requires_a_verified_package_while_loopback_accepts_authoring() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let operator = write_operator( + root.path(), + operator_value(&package, "operator-controlled-upstream"), + ); + assert!(matches!( + RuntimeConfig::load(&operator), + Err(RuntimeConfigError::ProductionPolicyPackageRequired) + )); + + let manifest = PolicyPackageManifest::build(POLICY).unwrap(); + std::fs::write( + package.join(SCHEDULING_PACKAGE_MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + let config = RuntimeConfig::load(&operator).expect("packaged production is accepted"); + assert_eq!( + config.policy_package_digest().unwrap(), + Some(manifest.policy_digest) + ); + + std::fs::write( + package.join(AUTHORED_POLICY_FILE), + POLICY.replace("30-minute", "31-minute"), + ) + .unwrap(); + assert!(matches!( + RuntimeConfig::load(&operator), + Err(RuntimeConfigError::PolicyPackage(_)) + )); + } + + #[test] + fn explain_and_read_scopes_must_differ_and_retention_must_be_positive() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + for (patch, expected) in [ + ( + serde_json::json!({"explainScope": "scheduling-read"}), + RuntimeConfigError::InvalidOidc, + ), + ( + serde_json::json!({"attemptReceiptDays": 0}), + RuntimeConfigError::InvalidRetention, + ), + ( + serde_json::json!({"reminders": {"url": "not-a-url"}}), + RuntimeConfigError::InvalidDestination, + ), + ] { + let mut document = operator_value(&package, "development-loopback"); + if patch.get("attemptReceiptDays").is_some() { + document["retention"] = patch; + } else if patch.get("reminders").is_some() { + document["destinations"] = patch; + } else { + document["authentication"]["oidc"] + .as_object_mut() + .unwrap() + .extend( + patch + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.clone())), + ); + } + let operator = write_operator(root.path(), document); + let outcome = RuntimeConfig::load(&operator); + assert!( + matches!(&outcome, Err(error) if std::mem::discriminant(error) == std::mem::discriminant(&expected)), + "expected {expected:?}, got {outcome:?}" + ); + } + } + + #[test] + fn a_policy_that_fails_its_checks_never_reaches_the_runtime() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + // A hook is the one authored shape that always fails the check: this + // milestone has no engine, so accepting it would be a silent no-op. + let hooked = format!( + "{POLICY}hooks:\n - {{id: h, abi: registry.scheduling-hook/v1, because: test}}\n" + ); + std::fs::write(package.join(AUTHORED_POLICY_FILE), hooked).unwrap(); + let operator = write_operator( + root.path(), + operator_value(&package, "development-loopback"), + ); + assert!(matches!( + RuntimeConfig::load(&operator), + Err(RuntimeConfigError::PolicyFindings) + )); + } + + #[test] + fn typed_parse_path_does_not_echo_the_rejected_value() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let canary = "DO_NOT_DISCLOSE_RUNTIME_VALUE"; + let mut document = operator_value(&package, "development-loopback"); + document["database"]["testOnlyPlaintext"] = serde_json::json!(canary); + let operator = write_operator(root.path(), document); + #[cfg(not(feature = "postgres-test"))] + { + let error = RuntimeConfig::load(&operator).unwrap_err(); + assert_eq!(error.path(), "database.testOnlyPlaintext"); + assert!(!error.to_string().contains(canary)); + } + } + + #[test] + fn listener_requires_a_private_address_or_explicit_container_network() { + use std::net::{Ipv4Addr, Ipv6Addr}; + assert!(valid_listener( + IpAddr::V4(Ipv4Addr::LOCALHOST), + ListenerNetworkExposure::PrivateAddress, + TlsTermination::OperatorControlledUpstream, + )); + assert!(!valid_listener( + "203.0.113.10".parse::().unwrap(), + ListenerNetworkExposure::ContainerPrivate, + TlsTermination::OperatorControlledUpstream, + )); + assert!(valid_listener( + IpAddr::V6(Ipv6Addr::UNSPECIFIED), + ListenerNetworkExposure::ContainerPrivate, + TlsTermination::OperatorControlledUpstream, + )); + assert!(!valid_listener( + "10.20.30.40".parse::().unwrap(), + ListenerNetworkExposure::PrivateAddress, + TlsTermination::DevelopmentLoopback, + )); + } + + #[test] + fn static_jwks_requires_unique_named_asymmetric_keys() { + assert!(parse_static_jwks( + br#"{"keys":[{"kty":"RSA","kid":"one","n":"AQAB","e":"AQAB"}]}"# + ) + .is_ok()); + for invalid in [ + br#"{}"#.as_slice(), + br#"{"keys":[]}"#, + br#"{"keys":[{"kty":"oct","kid":"one","k":"AA"}]}"#, + br#"{"keys":[{"kty":"RSA","kid":"one","n":"AQAB","e":"AQAB"},{"kty":"RSA","kid":"one","n":"AQAB","e":"AQAB"}]}"#, + b"not-json", + ] { + assert!(parse_static_jwks(invalid).is_err()); + } + } +} diff --git a/crates/registry-scheduling/src/cursors.rs b/crates/registry-scheduling/src/cursors.rs new file mode 100644 index 0000000000..855b103303 --- /dev/null +++ b/crates/registry-scheduling/src/cursors.rs @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The 15-minute context-bound listing cursors. +//! +//! A cursor is an opaque token carrying one server-stored cursor id. The row +//! it names holds the listing context it was minted for and the position the +//! next page resumes from, and it expires after fifteen minutes: a listing is +//! a bounded view of a live ledger, not a snapshot a caller may walk forever. +//! +//! Context binding is the load-bearing part. The context string names the +//! exact listing and every parameter that defines its result set, so a cursor +//! replayed against a different listing, a different offering, or a different +//! range is refused as invalid rather than silently returning rows from the +//! wrong view. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, TimeDelta, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +/// How long a cursor stays resolvable. The bound is part of the listing +/// contract: past it, a caller restarts the listing from its first page. +pub const CURSOR_LIFETIME_MINUTES: i64 = 15; + +/// The most bytes of one encoded cursor a caller may present. +const MAXIMUM_CURSOR_BYTES: usize = 256; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum CursorError { + #[error("the cursor is not a valid cursor of this listing")] + Invalid, + #[error("the cursor expired; restart the listing from its first page")] + Expired, +} + +/// The decoded wire form of one cursor. +#[derive(Serialize, Deserialize)] +struct CursorToken { + v: u8, + id: Uuid, +} + +/// One stored cursor row. +#[derive(Clone, Debug)] +pub struct StoredCursor { + pub cursor_id: Uuid, + pub context: String, + pub position: Value, + pub expires_at: DateTime, +} + +/// Encode a cursor id as the opaque token a caller carries. +#[must_use] +pub fn encode_cursor(cursor_id: Uuid) -> String { + let token = CursorToken { + v: 1, + id: cursor_id, + }; + let bytes = serde_json::to_vec(&token).expect("a cursor token always serializes"); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Decode an opaque token back to its cursor id, refusing anything that is +/// not exactly a cursor of this wire form. +pub fn decode_cursor(encoded: &str) -> Result { + if encoded.len() > MAXIMUM_CURSOR_BYTES { + return Err(CursorError::Invalid); + } + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| CursorError::Invalid)?; + let token: CursorToken = serde_json::from_slice(&bytes).map_err(|_| CursorError::Invalid)?; + if token.v != 1 { + return Err(CursorError::Invalid); + } + Ok(token.id) +} + +/// The expiry a newly minted cursor carries. +#[must_use] +pub fn cursor_expiry(now: DateTime) -> DateTime { + now.checked_add_signed(TimeDelta::minutes(CURSOR_LIFETIME_MINUTES)) + .expect("fifteen minutes is always representable") +} + +/// The position a listing resumes from, typed by what each listing walks. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ListingPosition { + /// Resume strictly after this catalogue id. + AfterId { last_id: String }, + /// Resume strictly after this UTC instant. + FromInstant { after: DateTime }, + /// Resume strictly before this instant and id, walking newest first: the + /// position one history page leaves behind. + BeforeInstantAndId { + before: DateTime, + last_id: String, + }, +} + +impl ListingPosition { + /// The JSON the stored row carries. The shape is internal: callers only + /// ever see the opaque token. + #[must_use] + pub fn to_json(&self) -> Value { + match self { + Self::AfterId { last_id } => serde_json::json!({"afterId": last_id}), + Self::FromInstant { after } => { + serde_json::json!({"after": after.to_rfc3339()}) + } + Self::BeforeInstantAndId { before, last_id } => { + serde_json::json!({"before": before.to_rfc3339(), "lastId": last_id}) + } + } + } + + /// Read back a stored position, refusing one a different listing shape + /// wrote. A position that does not parse is a corrupt row, not a caller + /// error, so it surfaces as invalid the same way a foreign cursor does. + #[must_use] + pub fn from_json(value: &Value) -> Option { + if let Some(last_id) = value.get("afterId").and_then(Value::as_str) { + return Some(Self::AfterId { + last_id: last_id.to_owned(), + }); + } + if let (Some(before), Some(last_id)) = ( + value.get("before").and_then(Value::as_str), + value.get("lastId").and_then(Value::as_str), + ) { + return DateTime::parse_from_rfc3339(before).ok().map(|before| { + Self::BeforeInstantAndId { + before: before.with_timezone(&Utc), + last_id: last_id.to_owned(), + } + }); + } + value + .get("after") + .and_then(Value::as_str) + .and_then(|after| DateTime::parse_from_rfc3339(after).ok()) + .map(|after| Self::FromInstant { + after: after.with_timezone(&Utc), + }) + } +} + +/// Check a resolved cursor row against the listing trying to continue it. +pub fn bind_stored( + stored: &StoredCursor, + context: &str, + now: DateTime, +) -> Result { + if stored.context != context { + return Err(CursorError::Invalid); + } + if stored.expires_at <= now { + return Err(CursorError::Expired); + } + ListingPosition::from_json(&stored.position).ok_or(CursorError::Invalid) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stored(context: &str, position: Value, minutes_left: i64) -> StoredCursor { + StoredCursor { + cursor_id: Uuid::new_v4(), + context: context.to_owned(), + position, + expires_at: Utc::now() + TimeDelta::minutes(minutes_left), + } + } + + #[test] + fn a_cursor_round_trips_through_its_opaque_form() { + let cursor_id = Uuid::new_v4(); + let encoded = encode_cursor(cursor_id); + assert_eq!(decode_cursor(&encoded), Ok(cursor_id)); + // Overwriting the final character with a byte outside the base64url + // alphabet corrupts the token deterministically, whatever the id. + let corrupted = format!("{}!", &encoded[..encoded.len() - 1]); + for foreign in ["", "not-a-cursor", "AAAA", &"A".repeat(512), &corrupted] { + assert_eq!(decode_cursor(foreign), Err(CursorError::Invalid)); + } + } + + #[test] + fn a_cursor_binds_to_its_listing_context_and_expires() { + let now = Utc::now(); + let position = ListingPosition::AfterId { + last_id: "registry-update-30".to_owned(), + }; + let row = stored("offerings", position.to_json(), 10); + assert_eq!( + bind_stored(&row, "offerings", now), + Ok(ListingPosition::AfterId { + last_id: "registry-update-30".to_owned() + }) + ); + assert_eq!( + bind_stored(&row, "services", now), + Err(CursorError::Invalid) + ); + assert_eq!( + bind_stored(&row, "offerings", row.expires_at), + Err(CursorError::Expired) + ); + } + + #[test] + fn positions_round_trip_and_refuse_foreign_shapes() { + for position in [ + ListingPosition::AfterId { + last_id: "w1".to_owned(), + }, + ListingPosition::FromInstant { after: Utc::now() }, + ListingPosition::BeforeInstantAndId { + before: Utc::now(), + last_id: "event-1".to_owned(), + }, + ] { + assert_eq!( + ListingPosition::from_json(&position.to_json()), + Some(position.clone()) + ); + } + assert_eq!( + ListingPosition::from_json(&serde_json::json!({"x": 1})), + None + ); + } + + #[test] + fn cursor_expiry_is_fifteen_minutes_out() { + let now = Utc::now(); + assert_eq!( + cursor_expiry(now).signed_duration_since(now).num_minutes(), + CURSOR_LIFETIME_MINUTES + ); + } +} diff --git a/crates/registry-scheduling/src/lib.rs b/crates/registry-scheduling/src/lib.rs new file mode 100644 index 0000000000..0a13f44e3b --- /dev/null +++ b/crates/registry-scheduling/src/lib.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Registry Scheduling runtime: the PostgreSQL store, the HTTP surface, and +//! the supervised workers. +//! +//! The runtime owns commitment: every booking, hold, release, and cancellation +//! lands through one capacity-transaction shape — lock the supply row, read a +//! ledger snapshot whose hold expiries are evaluated in the query, run the +//! pure evaluators from `registry-scheduling-core` in memory, and write the +//! claim, the idempotency attempt, the history event, and the outbox rows in +//! the same transaction. Three supervised workers run beside it: hold expiry, +//! reminder-intent dispatch, and retention sweeps. A dead worker stops the +//! process rather than silently overselling. +//! +//! The dependency runs one way: this crate builds on the source-neutral core +//! and shared `registry-platform-*` primitives, and nothing here may grow a +//! second product's protocol types. + +pub mod config; +pub mod cursors; +pub mod service; +pub mod store; diff --git a/crates/registry-scheduling/src/main.rs b/crates/registry-scheduling/src/main.rs new file mode 100644 index 0000000000..08322a2988 --- /dev/null +++ b/crates/registry-scheduling/src/main.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The `scheduling` binary entry point. + +fn main() { + println!("{}", registry_platform_buildinfo::DISPLAY_VERSION); +} diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs new file mode 100644 index 0000000000..0fecaa3b99 --- /dev/null +++ b/crates/registry-scheduling/src/service.rs @@ -0,0 +1,2006 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The HTTP-facing service: catalogue projections, availability, explain, and +//! the six commitments, each driving the store's one capacity transaction. +//! +//! The service owns three decisions the store cannot. Authorization: a +//! mutating call must carry a task grant whose scheduling permissions cover +//! the offering's service, location, and the operation, and the store +//! re-checks the grant's expiry inside the transaction. Disclosure: every +//! refusal projects to its public problem code here, and only the separately +//! authorized explain path sees the detailed one. Attributability: the +//! commitment's audit record is built here with pseudonymized principal, +//! client, grant, and approver references, and refused commitments record +//! their receipt so a replayed idempotency key answers as it first did. + +use chrono::{DateTime, TimeDelta, Utc}; +use registry_platform_audit::{AuditKeyHasher, AuthorizationAuditEvent, AuthorizationOutcome}; +use registry_platform_calendar::CalendarInterval; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_oidc::GrantClaims; +use registry_scheduling_core::{ + admission_request_hash, evaluate_exact_time_admission, evaluate_window_admission, + location_closure_intervals, location_open_intervals, type_uri, AdmissionRequest, + AppointmentDocument, AppointmentHistoryEntryDocument, AppointmentStateDocument, + AvailabilityEntry, CancelAppointmentRequest, CreateAppointmentRequest, ExactTimeContext, + ExactTimeOffering, LedgerKind, LedgerSnapshot, OfferingDocument, OfferingPolicy, PageDocument, + PoolMember, ProblemCode, PublishedWindow, ReminderDocument, RescheduleAppointmentRequest, + ResourceDocument, SchedulingFacts, SchedulingMode, SchedulingModeDocument, SchedulingPolicy, + SchedulingServiceDocument, ServiceDocument, WindowContext, WindowDocument, +}; +use serde_json::{json, Value}; +use sha2::{Digest as _, Sha256}; +use uuid::Uuid; + +use crate::cursors::{ + bind_stored, cursor_expiry, decode_cursor, encode_cursor, CursorError, ListingPosition, + StoredCursor, +}; +use crate::store::{ + ClaimRow, CommitError, CommitOutcome, Commitment, PostgresStore, StoreError, SupplyContext, +}; + +/// The closed action vocabulary a task grant's scheduling permissions carry. +/// A permission names a service, a location, and the actions it allows there; +/// these are the actions the runtime knows. +pub const HOLD_CREATE_ACTION: &str = "hold.create"; +pub const HOLD_RELEASE_ACTION: &str = "hold.release"; +pub const APPOINTMENT_CREATE_ACTION: &str = "appointment.create"; +pub const APPOINTMENT_RESCHEDULE_ACTION: &str = "appointment.reschedule"; +pub const APPOINTMENT_CANCEL_ACTION: &str = "appointment.cancel"; + +/// The default and maximum page sizes for every listing. +pub const DEFAULT_PAGE_LIMIT: usize = 50; +pub const MAXIMUM_PAGE_LIMIT: usize = 200; + +/// How far one availability search may span. A search is bounded (BKG-01); +/// a wider ask is served from the first 62 days and continues by cursor. +const MAXIMUM_AVAILABILITY_SPAN_DAYS: i64 = 62; + +/// The audit reference class for a booking caller, hashed over the verified +/// issuer and subject pair. Claims, history, and the idempotency ceiling all +/// key on this pseudonym, never on the raw identity. +const PRINCIPAL_CLASS: &str = "scheduling-principal-v1"; + +/// One verified caller, as the authenticator resolved them. +pub struct Caller { + /// The verified actor kind: `human`, `agent`, or `service`. + pub actor_kind: String, + /// The verified token issuer and subject. The idempotency attempts key + /// on this pair; every other record carries the pseudonym. + pub issuer: String, + pub subject: String, + /// The verified task grant, when the token carried one. + pub grant: Option, +} + +impl Caller { + /// The pseudonymized actor reference claims and history carry. + pub fn actor_pseudonym( + &self, + hasher: &AuditKeyHasher, + scope: &str, + ) -> Result { + let canonical = serde_json::to_string(&(&self.issuer, &self.subject)).map_err(|_| { + ServiceError::internal("the caller identity could not be canonicalized") + })?; + hasher + .audit_reference_hash(PRINCIPAL_CLASS, scope, &canonical) + .map_err(|_| ServiceError::internal("the caller identity could not be pseudonymized")) + } +} + +/// What a commitment answered: a minted document, or the stored receipt a +/// replayed idempotency key must receive again. +pub enum CommitmentAnswer { + Minted(T), + Replay { status_code: u16, receipt: Value }, +} + +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + /// The refusal the caller sees, in its public projection. Every service + /// failure, internal bound, and cursor verdict lands here too: the edge + /// renders one problem shape. + #[error("the request was refused: {}", .0.code())] + Problem(ProblemCode), +} + +impl ServiceError { + fn internal(reason: &'static str) -> Self { + tracing::error!(reason, "the Scheduling service hit an internal bound"); + Self::Problem(ProblemCode::ServiceUnavailable) + } +} + +impl From for ServiceError { + fn from(error: StoreError) -> Self { + tracing::error!(%error, "the Scheduling store failed"); + Self::Problem(ProblemCode::ServiceUnavailable) + } +} + +impl From for ServiceError { + fn from(error: CursorError) -> Self { + match error { + CursorError::Invalid => Self::Problem(ProblemCode::CursorInvalid), + CursorError::Expired => Self::Problem(ProblemCode::CursorExpired), + } + } +} + +impl From for ServiceError { + fn from(error: registry_scheduling_core::ResolveError) -> Self { + // The policy and the facts do not fit together: an operator state + // mismatch, never a caller defect, and never disclosed in detail. + tracing::error!(%error, "the policy and the environment records do not fit together"); + Self::Problem(ProblemCode::ServiceUnavailable) + } +} + +pub struct SchedulingService { + store: PostgresStore, + policy: SchedulingPolicy, + scheduling_id: String, + policy_revision: u64, + policy_digest: String, + hasher: AuditKeyHasher, + attempt_receipt_days: i64, +} + +impl SchedulingService { + #[allow(clippy::too_many_arguments)] + pub fn new( + store: PostgresStore, + policy: SchedulingPolicy, + scheduling_id: String, + policy_revision: i64, + policy_digest: String, + hasher: AuditKeyHasher, + attempt_receipt_days: u16, + ) -> Self { + Self { + store, + policy, + scheduling_id, + policy_revision: u64::try_from(policy_revision).unwrap_or(u64::MAX), + policy_digest, + hasher, + attempt_receipt_days: i64::from(attempt_receipt_days), + } + } + + fn revision(&self) -> u64 { + self.policy_revision + } + + /// Which deployment and which policy this service answers with. + pub fn scheduling(&self) -> SchedulingServiceDocument { + SchedulingServiceDocument { + scheduling_id: self.scheduling_id.clone(), + policy_revision: self.policy_revision, + policy_digest: self.policy_digest.clone(), + } + } + + pub async fn list_services( + &self, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let position = self.resolve_position(cursor, "services", now).await?; + let mut services: Vec<_> = self + .policy + .services + .iter() + .map(|service| ServiceDocument { + id: service.id.clone(), + label: service.label.clone(), + }) + .collect(); + services.sort_by(|a, b| a.id.cmp(&b.id)); + page_of(self, services, position, limit, "services", now).await + } + + pub async fn list_offerings( + &self, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let position = self.resolve_position(cursor, "offerings", now).await?; + let mut offerings: Vec<_> = self + .policy + .offerings + .iter() + .map(|offering| self.offering_document(offering)) + .collect(); + offerings.sort_by(|a, b| a.id.cmp(&b.id)); + page_of(self, offerings, position, limit, "offerings", now).await + } + + pub async fn list_resources( + &self, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let position = self.resolve_position(cursor, "resources", now).await?; + let after = id_position(position)?; + let rows = self + .store + .list_resources(after.as_deref(), limit as i64 + 1) + .await?; + let more = rows.len() > limit; + let items: Vec<_> = rows + .into_iter() + .take(limit) + .map( + |(resource_id, pool, capabilities, available)| ResourceDocument { + resource_id, + pool, + capabilities, + available, + }, + ) + .collect(); + let next_cursor = if more { + Some( + self.mint_cursor("resources", &position_of_last_id(&items), now) + .await?, + ) + } else { + None + }; + Ok(PageDocument { items, next_cursor }) + } + + pub async fn list_locations( + &self, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let position = self.resolve_position(cursor, "locations", now).await?; + let after = id_position(position)?; + let rows = self + .store + .list_locations(after.as_deref(), limit as i64 + 1) + .await?; + let more = rows.len() > limit; + let items: Vec<_> = rows + .into_iter() + .take(limit) + .map( + |(location_id, timezone)| registry_scheduling_core::LocationDocument { + location_id, + timezone, + }, + ) + .collect(); + let next_cursor = if more { + Some( + self.mint_cursor("locations", &position_of_last_id(&items), now) + .await?, + ) + } else { + None + }; + Ok(PageDocument { items, next_cursor }) + } + + /// Bounded availability. Exact-time offerings answer in grid slots, + /// arrival-window offerings in windows. Only entries with capacity left + /// are listed: a slot nobody can serve or a window with no units left is + /// not availability. + pub async fn availability( + &self, + offering_id: &str, + start: Option>, + end: Option>, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + let offering = self + .policy + .offering(offering_id) + .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; + let from = start.unwrap_or(now); + let span = TimeDelta::days(MAXIMUM_AVAILABILITY_SPAN_DAYS); + let to = match end { + Some(end) => end.max(from + TimeDelta::minutes(1)).min(from + span), + None => from + span, + }; + let context = format!("availability:{offering_id}:{}:{to}", from.to_rfc3339()); + let position = self.resolve_position(cursor, &context, now).await?; + let after = match position { + Some(ListingPosition::FromInstant { after }) => Some(after), + Some(_) => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + None => None, + }; + let entries = match self.supply(offering).await? { + ResolvedSupply::ExactTime { + exact, + members, + open, + closures, + } => { + let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); + let buffer = TimeDelta::minutes(i64::from( + exact.buffer_before_minutes + exact.buffer_after_minutes, + )); + let ids: Vec = members.iter().map(|m| m.resource_id.clone()).collect(); + // The snapshot window widens by the duration and both buffers: + // a booking that intrudes on any listed slot's occupied span + // must be in the snapshot, wherever it starts. + let snapshot = self + .store + .member_snapshot(&ids, from - buffer, to + duration + buffer, now) + .await?; + exact_time_slots(&exact, &members, &open, &closures, &snapshot, from, to, now) + } + ResolvedSupply::Window { + window, + lead_time_minutes, + horizon_days, + } => { + let snapshot = self.store.window_snapshot(&window.id, now).await?; + window_entries( + &window, + &snapshot, + from, + to, + now, + lead_time_minutes, + horizon_days, + ) + } + }; + let selected: Vec<_> = entries + .into_iter() + .filter(|entry| match &after { + Some(after) => entry_instant(entry) > *after, + None => true, + }) + .collect(); + let more = selected.len() > limit; + let items: Vec<_> = selected.into_iter().take(limit).collect(); + let next_cursor = if more { + let last = entry_instant(items.last().expect("a limited page is not empty")); + let position = ListingPosition::FromInstant { after: last }; + Some(self.mint_cursor(&context, &position, now).await?) + } else { + None + }; + Ok(PageDocument { items, next_cursor }) + } + + /// The separately authorized explanation of one start: the public and the + /// detailed code of the refusal a booking would receive, or no codes at + /// all when the start admits as things stand. The probe is a minimal + /// party, so the codes explain the calendar and the capacity, never + /// another caller's booking. + pub async fn explain( + &self, + offering_id: &str, + start: DateTime, + now: DateTime, + ) -> Result { + let offering = self + .policy + .offering(offering_id) + .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; + let probe = AdmissionRequest { + offering: offering.id.clone(), + start, + party: registry_scheduling_core::PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: None, + policy_revision: self.revision(), + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + reschedule_of: None, + }; + let refusal = match self.supply(offering).await? { + ResolvedSupply::ExactTime { + exact, + members, + open, + closures, + } => { + let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); + let buffer = TimeDelta::minutes(i64::from( + exact.buffer_before_minutes + exact.buffer_after_minutes, + )); + let ids: Vec = members.iter().map(|m| m.resource_id.clone()).collect(); + let snapshot = self + .store + .member_snapshot( + &ids, + start - buffer - duration, + start + duration + buffer, + now, + ) + .await?; + evaluate_exact_time_admission( + &ExactTimeContext { + offering, + exact: &exact, + members: &members, + open: &open, + closures: &closures, + snapshot: &snapshot, + policy_revision: self.revision(), + now, + }, + &probe, + ) + .err() + } + ResolvedSupply::Window { + window, + lead_time_minutes, + horizon_days, + } => { + let snapshot = self.store.window_snapshot(&window.id, now).await?; + evaluate_window_admission( + &WindowContext { + offering, + window: &window, + lead_time_minutes, + horizon_days, + snapshot: &snapshot, + policy_revision: self.revision(), + now, + }, + &probe, + ) + .err() + } + }; + Ok(registry_scheduling_core::ExplainDocument { + offering: offering.id.clone(), + start, + public_code: refusal + .as_ref() + .map(|refusal| refusal.public_code().code().to_owned()), + detailed_code: refusal + .as_ref() + .map(|refusal| refusal.detailed_code().code().to_owned()), + explanation: refusal.as_ref().map(ToString::to_string), + }) + } + + /// Reserve an admission instead of committing it. + pub async fn create_hold( + &self, + caller: &Caller, + idempotency_key: &str, + request: &AdmissionRequest, + now: DateTime, + ) -> Result, ServiceError> { + let offering = self + .policy + .offering(&request.offering) + .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; + let grant = self.require_permission(caller, offering, HOLD_CREATE_ACTION)?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let supply = self.supply(offering).await?; + let request_hash = admission_request_hash(request); + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + HOLD_CREATE_ACTION, + now, + )?; + let outcome = self + .store + .create_hold( + offering, + &supply.context(), + request, + self.policy.hold_policy.ttl_minutes, + self.policy.hold_policy.max_per_caller, + commitment, + ) + .await; + let answer = self + .commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + "hold:create", + HOLD_CREATE_ACTION, + now, + ) + .await?; + Ok(claim_or_replay(answer, |claim| { + hold_document(claim, self.revision()) + })) + } + + /// Give a hold's capacity back. The release carries no caller-chosen + /// idempotency key on the wire, so the key is the hold's own id: a + /// retried release of the same hold replays the first answer. + pub async fn release_hold( + &self, + caller: &Caller, + hold_id: Uuid, + now: DateTime, + ) -> Result, ServiceError> { + let hold = self + .store + .claim(hold_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::HoldReleased))?; + if hold.kind != LedgerKind::Hold { + return Err(ServiceError::Problem(ProblemCode::HoldReleased)); + } + let offering = self + .policy + .offering(&hold.offering) + .ok_or(ServiceError::internal( + "a committed hold names no offering in the policy", + ))?; + let grant = self.require_permission(caller, offering, HOLD_RELEASE_ACTION)?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let request_hash = canonical_hash(&json!({"hold": hold_id}))?; + // The release carries no caller-chosen key, so the hold's own id is + // the idempotency key: a retried release replays the first answer. + let release_key = hold_id.to_string(); + let commitment = self.commitment( + caller, + &actor, + &grant, + &release_key, + &request_hash, + HOLD_RELEASE_ACTION, + now, + )?; + let outcome = self.store.release_hold(hold_id, commitment).await; + self.commitment_outcome( + outcome, + caller, + &grant, + &actor, + &release_key, + &request_hash, + &format!("hold:{hold_id}:release"), + HOLD_RELEASE_ACTION, + now, + ) + .await + } + + /// Confirm a held allocation, or create an appointment directly. + pub async fn create_appointment( + &self, + caller: &Caller, + idempotency_key: &str, + request: &CreateAppointmentRequest, + now: DateTime, + ) -> Result, ServiceError> { + match (&request.hold, &request.admission) { + (Some(_), Some(_)) | (None, None) => { + return Err(ServiceError::Problem(ProblemCode::PreconditionFailed)); + } + _ => {} + } + let answer = match &request.hold { + Some(hold) => { + let hold_id = Uuid::parse_str(hold) + .map_err(|_| ServiceError::Problem(ProblemCode::PreconditionFailed))?; + self.confirm_appointment(caller, idempotency_key, hold_id, now) + .await + } + None => { + let admission = request.admission.as_ref().expect("checked above"); + self.direct_create_appointment(caller, idempotency_key, admission, now) + .await + } + }?; + Ok(claim_or_replay(answer, |claim| { + appointment_document(claim, self.revision()) + })) + } + + async fn confirm_appointment( + &self, + caller: &Caller, + idempotency_key: &str, + hold_id: Uuid, + now: DateTime, + ) -> Result, ServiceError> { + let hold = self + .store + .claim(hold_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::HoldReleased))?; + if hold.kind != LedgerKind::Hold { + return Err(ServiceError::Problem(ProblemCode::HoldReleased)); + } + let offering = self + .policy + .offering(&hold.offering) + .ok_or(ServiceError::internal( + "a committed hold names no offering in the policy", + ))?; + let grant = self.require_permission(caller, offering, APPOINTMENT_CREATE_ACTION)?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let supply = self.supply(offering).await?; + let request_hash = canonical_hash(&json!({"hold": hold_id}))?; + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + APPOINTMENT_CREATE_ACTION, + now, + )?; + let outcome = self + .store + .confirm_hold(hold_id, offering, &supply.context(), commitment) + .await; + self.commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + &format!("hold:{hold_id}:confirm"), + APPOINTMENT_CREATE_ACTION, + now, + ) + .await + } + + async fn direct_create_appointment( + &self, + caller: &Caller, + idempotency_key: &str, + request: &AdmissionRequest, + now: DateTime, + ) -> Result, ServiceError> { + let offering = self + .policy + .offering(&request.offering) + .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; + let grant = self.require_permission(caller, offering, APPOINTMENT_CREATE_ACTION)?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let supply = self.supply(offering).await?; + let request_hash = admission_request_hash(request); + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + APPOINTMENT_CREATE_ACTION, + now, + )?; + let outcome = self + .store + .create_appointment(offering, &supply.context(), request, commitment) + .await; + self.commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + "appointment:create", + APPOINTMENT_CREATE_ACTION, + now, + ) + .await + } + + /// One appointment by id, visible to the caller that owns it. + pub async fn get_appointment( + &self, + caller: &Caller, + appointment_id: Uuid, + ) -> Result { + let claim = self + .owned_booking(caller, appointment_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; + Ok(appointment_document(&claim, self.revision())) + } + + pub async fn reschedule_appointment( + &self, + caller: &Caller, + appointment_id: Uuid, + idempotency_key: &str, + request: &RescheduleAppointmentRequest, + now: DateTime, + ) -> Result, ServiceError> { + // The pre-read resolves the offering for the permission check; kind, + // state, revision, and ownership are re-checked inside the + // transaction, where a refusal is an auditable decision. + let appointment = self + .booking(appointment_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; + let offering = + self.policy + .offering(&appointment.offering) + .ok_or(ServiceError::internal( + "a committed appointment names no offering in the policy", + ))?; + let grant = self.require_permission(caller, offering, APPOINTMENT_RESCHEDULE_ACTION)?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let supply = self.supply(offering).await?; + let request_hash = canonical_hash(&json!({ + "observedRevision": request.observed_revision, + "admissionHash": admission_request_hash(&request.admission), + }))?; + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + APPOINTMENT_RESCHEDULE_ACTION, + now, + )?; + let outcome = self + .store + .reschedule_appointment( + appointment_id, + offering, + &supply.context(), + &request.admission, + request.observed_revision, + commitment, + ) + .await; + let answer = self + .commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + &format!("appointment:{appointment_id}:reschedule"), + APPOINTMENT_RESCHEDULE_ACTION, + now, + ) + .await?; + Ok(claim_or_replay(answer, |claim| { + appointment_document(claim, self.revision()) + })) + } + + pub async fn cancel_appointment( + &self, + caller: &Caller, + appointment_id: Uuid, + idempotency_key: &str, + request: &CancelAppointmentRequest, + now: DateTime, + ) -> Result, ServiceError> { + let appointment = self + .booking(appointment_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; + let offering = + self.policy + .offering(&appointment.offering) + .ok_or(ServiceError::internal( + "a committed appointment names no offering in the policy", + ))?; + let grant = self.require_permission(caller, offering, APPOINTMENT_CANCEL_ACTION)?; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + let request_hash = canonical_hash(&json!({ + "observedRevision": request.observed_revision, + "reason": request.reason, + }))?; + let commitment = self.commitment( + caller, + &actor, + &grant, + idempotency_key, + &request_hash, + APPOINTMENT_CANCEL_ACTION, + now, + )?; + let outcome = self + .store + .cancel_appointment( + appointment_id, + request.observed_revision, + Some(offering.cancellation_cutoff_minutes), + request.reason.as_deref(), + commitment, + ) + .await; + let answer = self + .commitment_outcome( + outcome, + caller, + &grant, + &actor, + idempotency_key, + &request_hash, + &format!("appointment:{appointment_id}:cancel"), + APPOINTMENT_CANCEL_ACTION, + now, + ) + .await?; + Ok(claim_or_replay(answer, |claim| { + appointment_document(claim, self.revision()) + })) + } + + /// The attributable history of one appointment, newest first, visible to + /// the caller that owns it. + pub async fn appointment_history( + &self, + caller: &Caller, + appointment_id: Uuid, + cursor: Option<&str>, + limit: Option, + now: DateTime, + ) -> Result, ServiceError> { + let limit = page_limit(limit); + self.owned_booking(caller, appointment_id) + .await? + .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; + let context = format!("history:{appointment_id}"); + let position = self.resolve_position(cursor, &context, now).await?; + let before = match position { + Some(ListingPosition::BeforeInstantAndId { before, last_id }) => Some(( + before, + Uuid::parse_str(&last_id) + .map_err(|_| ServiceError::internal("a stored history position is corrupt"))?, + )), + Some(_) => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + None => None, + }; + let rows = self + .store + .claim_history(appointment_id, before, limit as i64 + 1) + .await?; + let more = rows.len() > limit; + let page: Vec<_> = rows.into_iter().take(limit).collect(); + let items = page + .iter() + .map(history_entry) + .collect::, _>>()?; + let next_cursor = if more { + let last = items.last().expect("a limited page is not empty"); + let position = ListingPosition::BeforeInstantAndId { + before: last.occurred_at, + last_id: last.event_id.clone(), + }; + Some(self.mint_cursor(&context, &position, now).await?) + } else { + None + }; + Ok(PageDocument { items, next_cursor }) + } + + /// The booking a claim names, when it is one. `None` covers a claim the + /// caller may not act on as a booking: unknown, not a booking, or + /// another caller's. Mutating paths take the ownership refusal from the + /// store, where it is auditable; read paths take it here, where hiding + /// existence is the contract. + async fn booking(&self, appointment_id: Uuid) -> Result, ServiceError> { + let Some(claim) = self.store.claim(appointment_id).await? else { + return Ok(None); + }; + if claim.kind != LedgerKind::Booking { + return Ok(None); + } + Ok(Some(claim)) + } + + /// `booking`, and the caller owns it. + async fn owned_booking( + &self, + caller: &Caller, + appointment_id: Uuid, + ) -> Result, ServiceError> { + let Some(claim) = self.booking(appointment_id).await? else { + return Ok(None); + }; + let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; + if claim.actor != actor { + return Ok(None); + } + Ok(Some(claim)) + } + + fn offering_document(&self, offering: &OfferingPolicy) -> OfferingDocument { + let mode = match offering.mode { + SchedulingMode::ExactTime => SchedulingModeDocument::ExactTime, + SchedulingMode::ArrivalWindow => SchedulingModeDocument::ArrivalWindow, + }; + let (duration, buffer_before, buffer_after, increment, max_recipients) = + match &offering.exact_time { + Some(exact) => ( + Some(exact.duration_minutes), + Some(exact.buffer_before_minutes), + Some(exact.buffer_after_minutes), + Some(exact.start_increment_minutes), + Some(exact.max_recipients), + ), + None => (None, None, None, None, None), + }; + let window = offering.arrival.as_ref().map(|arrival| { + let window = self + .policy + .window(&arrival.window) + .expect("a published offering names a declared window"); + WindowDocument { + id: window.id.clone(), + revision: window.revision, + start: window.start, + end: window.end, + units: window.units, + } + }); + let (lead_time_minutes, horizon_days) = match (&offering.exact_time, &offering.arrival) { + (Some(exact), None) => (exact.lead_time_minutes, exact.horizon_days), + (None, Some(arrival)) => (arrival.lead_time_minutes, arrival.horizon_days), + _ => (0, 0), + }; + OfferingDocument { + id: offering.id.clone(), + service: offering.service.clone(), + label: offering.label.clone(), + mode, + location: offering.location.clone(), + lead_time_minutes, + horizon_days, + cancellation_cutoff_minutes: offering.cancellation_cutoff_minutes, + duration_minutes: duration, + buffer_before_minutes: buffer_before, + buffer_after_minutes: buffer_after, + start_increment_minutes: increment, + max_recipients, + window, + reminders: offering + .reminders + .iter() + .map(|reminder| ReminderDocument { + minutes_before: reminder.minutes_before, + }) + .collect(), + requires_capabilities: offering.requires_capabilities.clone(), + prerequisites: offering.prerequisites.clone(), + } + } + + /// A grant that covers this offering's service, location, and action, or + /// the refusal. Readable availability is not authority to book: the + /// permission must name all three. + fn require_permission( + &self, + caller: &Caller, + offering: &OfferingPolicy, + action: &str, + ) -> Result { + let Some(grant) = &caller.grant else { + return Err(ServiceError::Problem(ProblemCode::OperationNotAuthorized)); + }; + let allowed = grant + .bounds() + .scheduling_permissions() + .is_some_and(|permissions| { + permissions.iter().any(|permission| { + permission.service() == offering.service.as_str() + && permission.location() == offering.location.as_str() + && permission + .actions() + .iter() + .any(|allowed| allowed.as_str() == action) + }) + }); + if allowed { + Ok(grant.clone()) + } else { + Err(ServiceError::Problem(ProblemCode::OperationNotAuthorized)) + } + } + + /// The policy-resolved supply an offering runs against, read from the + /// live facts the operator's records apply wrote. + async fn supply(&self, offering: &OfferingPolicy) -> Result { + let facts = self.store.facts().await?; + ResolvedSupply::resolve(&self.policy, &facts, offering) + } + + #[allow(clippy::too_many_arguments)] + fn commitment<'a>( + &self, + caller: &'a Caller, + actor: &'a str, + grant: &GrantClaims, + idempotency_key: &'a str, + request_hash: &'a str, + operation: &str, + now: DateTime, + ) -> Result, ServiceError> { + let attempt_expires_at = now + .checked_add_signed(TimeDelta::days(self.attempt_receipt_days)) + .ok_or_else(|| { + ServiceError::internal("the attempt retention horizon is not representable") + })?; + let audit_record = audit_record( + &self.hasher, + &self.scheduling_id, + caller, + grant, + operation, + AuthorizationOutcome::Allowed, + "authorization.allowed", + )?; + Ok(Commitment { + now, + policy_revision: i64::try_from(self.policy_revision).unwrap_or(i64::MAX), + actor, + actor_issuer: &caller.issuer, + actor_subject: &caller.subject, + idempotency_key, + request_hash, + attempt_expires_at, + grant_exp_unix: Some(grant.exp()), + audit_event: Uuid::new_v4(), + audit_record, + }) + } + + /// Translate one store outcome: minted, replayed, or refused. A refusal + /// writes its receipt under the caller's idempotency key so a replay of + /// that key answers the same, and writes its audit row when the refusal + /// was an authorization decision. + #[allow(clippy::too_many_arguments)] + async fn commitment_outcome( + &self, + outcome: Result, + caller: &Caller, + grant: &GrantClaims, + actor: &str, + idempotency_key: &str, + request_hash: &str, + scope: &str, + operation: &str, + now: DateTime, + ) -> Result, ServiceError> + where + T: FromMinted, + { + match outcome { + Ok(CommitOutcome::Replay { + status_code, + receipt, + }) => Ok(CommitmentAnswer::Replay { + status_code, + receipt, + }), + Ok(minted) => Ok(CommitmentAnswer::Minted(T::from_minted(minted))), + Err(error) => { + if matches!(error, CommitError::Store(_) | CommitError::Query(_)) { + // Nothing was decided: no receipt, no audit row, and the + // caller sees no detail. + tracing::error!(%error, "the Scheduling store failed mid-commitment"); + return Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)); + } + let problem = problem_of(&error); + // Key misuse keeps the stored attempt as the answer and + // records nothing new. + if !matches!(error, CommitError::KeyReused | CommitError::KeyExpired) { + let receipt = self.commitment( + caller, + actor, + grant, + idempotency_key, + request_hash, + operation, + now, + ); + match receipt { + Ok(commitment) => { + if let Err(failure) = self + .store + .record_refused_attempt( + &commitment, + scope, + problem.http_status(), + problem_receipt(problem), + ) + .await + { + tracing::error!(%failure, "the refused attempt receipt could not be recorded"); + } + } + Err(refused) => { + tracing::error!(error = %refused, "the refused attempt could not be recorded"); + } + } + } + if matches!( + error, + CommitError::Refused(_) | CommitError::HoldCeiling | CommitError::Unauthorized + ) { + let reason = match error { + CommitError::Unauthorized => "authorization.refused", + _ => "authorization.profile", + }; + match audit_record( + &self.hasher, + &self.scheduling_id, + caller, + grant, + operation, + AuthorizationOutcome::Denied, + reason, + ) { + Ok(record) => { + if let Err(failure) = self + .store + .record_refusal_audit(Uuid::new_v4(), record) + .await + { + tracing::error!(%failure, "the refusal audit row could not be recorded"); + } + } + Err(refused) => { + tracing::error!(error = %refused, "the refusal audit row could not be built"); + } + } + } + Err(ServiceError::Problem(problem)) + } + } + } + + async fn resolve_position( + &self, + cursor: Option<&str>, + context: &str, + now: DateTime, + ) -> Result, ServiceError> { + let Some(encoded) = cursor else { + return Ok(None); + }; + let cursor_id = decode_cursor(encoded)?; + let stored = self + .store + .cursor(cursor_id) + .await? + .ok_or(CursorError::Invalid)?; + let stored = StoredCursor { + cursor_id, + context: stored["context"].as_str().unwrap_or_default().to_owned(), + position: stored["position"].clone(), + expires_at: serde_json::from_value(stored["expiresAt"].clone()) + .map_err(|_| CursorError::Invalid)?, + }; + Ok(Some(bind_stored(&stored, context, now)?)) + } + + async fn mint_cursor( + &self, + context: &str, + position: &ListingPosition, + now: DateTime, + ) -> Result { + let cursor_id = Uuid::new_v4(); + self.store + .insert_cursor(cursor_id, context, position.to_json(), cursor_expiry(now)) + .await?; + Ok(encode_cursor(cursor_id)) + } +} + +/// The minted-claim projection each commitment answer carries. +pub trait FromMinted { + fn from_minted(outcome: CommitOutcome) -> Self; +} + +impl FromMinted for ClaimRow { + fn from_minted(outcome: CommitOutcome) -> Self { + match outcome { + CommitOutcome::Booking(claim) | CommitOutcome::Hold(claim) => claim, + CommitOutcome::Cancelled(claim) => claim, + CommitOutcome::Released => unreachable!("a release is not a minted claim"), + CommitOutcome::Replay { .. } => unreachable!("a replay is not a minted claim"), + } + } +} + +impl FromMinted for () { + fn from_minted(outcome: CommitOutcome) -> Self { + match outcome { + CommitOutcome::Released => (), + _ => unreachable!("a claim minting is not an empty answer"), + } + } +} + +/// A stored success receipt carries the claim the first answer minted; the +/// replay answers with that same claim through the same projection, so a +/// retried request reads the original response, not a re-derivation. A +/// refusal receipt carries no claim and flows to the edge untouched. +fn claim_or_replay( + answer: CommitmentAnswer, + project: impl FnOnce(&ClaimRow) -> T, +) -> CommitmentAnswer { + match answer { + CommitmentAnswer::Minted(claim) => CommitmentAnswer::Minted(project(&claim)), + CommitmentAnswer::Replay { + status_code, + receipt, + } => { + if let Some(claim) = receipt + .get("claim") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + { + CommitmentAnswer::Minted(project(&claim)) + } else { + CommitmentAnswer::Replay { + status_code, + receipt, + } + } + } + } +} + +/// The resolved supply an offering runs against, owned here and borrowed into +/// the store's context. +enum ResolvedSupply { + ExactTime { + exact: ExactTimeOffering, + members: Vec, + open: Vec, + closures: Vec, + }, + Window { + window: PublishedWindow, + lead_time_minutes: u32, + horizon_days: u32, + }, +} + +impl ResolvedSupply { + fn resolve( + policy: &SchedulingPolicy, + facts: &SchedulingFacts, + offering: &OfferingPolicy, + ) -> Result { + // Every reference the policy names must exist in the live records. + // A gap is operator state the caller cannot see or fix, disclosed as + // no availability and logged for the operator. + let operator_gap = |reference: &str| { + tracing::error!( + offering = %offering.id, + reference, + "the offering names a reference the records do not carry" + ); + ServiceError::Problem(ProblemCode::ServiceUnavailable) + }; + let location = facts + .location(&offering.location) + .ok_or_else(|| operator_gap(&offering.location))?; + let exceptions: Vec<_> = facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + match (&offering.exact_time, &offering.arrival) { + (Some(exact), None) => { + let pool = facts + .pool(&exact.pool) + .ok_or_else(|| operator_gap(&exact.pool))?; + Ok(Self::ExactTime { + exact: exact.clone(), + members: pool.members.clone(), + open: location_open_intervals( + policy, + &offering.location, + &location.timezone, + &exceptions, + )?, + closures: location_closure_intervals( + facts, + &offering.location, + &location.timezone, + )?, + }) + } + (None, Some(arrival)) => { + let window = policy + .window(&arrival.window) + .ok_or_else(|| operator_gap(&arrival.window))?; + Ok(Self::Window { + window: window.clone(), + lead_time_minutes: arrival.lead_time_minutes, + horizon_days: arrival.horizon_days, + }) + } + _ => Err(operator_gap("one scheduling mode")), + } + } + + fn context(&self) -> SupplyContext<'_> { + match self { + Self::ExactTime { + exact, + members, + open, + closures, + } => SupplyContext::ExactTime { + exact, + members, + open, + closures, + }, + Self::Window { + window, + lead_time_minutes, + horizon_days, + } => SupplyContext::Window { + window, + lead_time_minutes: *lead_time_minutes, + horizon_days: *horizon_days, + }, + } + } +} + +/// The exact-time availability walk: one entry per grid slot that lies inside +/// the published openings, clear of closures, inside the booking horizon, and +/// that at least one available member can still serve. +#[allow(clippy::too_many_arguments)] +fn exact_time_slots( + exact: &ExactTimeOffering, + members: &[PoolMember], + open: &[CalendarInterval], + closures: &[CalendarInterval], + snapshot: &LedgerSnapshot, + from: DateTime, + to: DateTime, + now: DateTime, +) -> Vec { + let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); + let increment = TimeDelta::minutes(i64::from(exact.start_increment_minutes).max(1)); + let earliest = now + TimeDelta::minutes(i64::from(exact.lead_time_minutes)); + let latest = now + TimeDelta::days(i64::from(exact.horizon_days)); + let mut entries = Vec::new(); + for interval in open { + // Slots anchor on each published opening's own start and step the + // authored grid; a start that leaves the opening or lands in a + // closure is not offered. + let mut slot_start = interval.start; + while slot_start + duration <= interval.end { + let slot_end = slot_start + duration; + let occupied_start = + slot_start - TimeDelta::minutes(i64::from(exact.buffer_before_minutes)); + let occupied_end = slot_end + TimeDelta::minutes(i64::from(exact.buffer_after_minutes)); + let in_range = slot_start >= from && slot_start < to; + let in_horizon = slot_start >= earliest && slot_start <= latest; + let clear_of_closures = closures + .iter() + .all(|closure| closure.end <= slot_start || slot_end <= closure.start); + if in_range && in_horizon && clear_of_closures { + let free = members + .iter() + .filter(|member| member.available) + .filter(|member| { + snapshot.claims.iter().all(|claim| { + claim.supply_id != member.resource_id + || claim.end <= occupied_start + || occupied_end <= claim.start + }) + }) + .count(); + if free > 0 { + entries.push(AvailabilityEntry::Slot { + start: slot_start, + end: slot_end, + free: u32::try_from(free).unwrap_or(u32::MAX), + }); + } + } + slot_start += increment; + if slot_start >= interval.end { + break; + } + } + } + entries.sort_by_key(entry_instant); + entries.dedup_by_key(|entry| entry_instant(entry)); + entries +} + +/// The arrival-window availability walk: one entry per published window in +/// range with units left, after lead time and inside the horizon. +fn window_entries( + window: &PublishedWindow, + snapshot: &LedgerSnapshot, + from: DateTime, + to: DateTime, + now: DateTime, + lead_time_minutes: u32, + horizon_days: u32, +) -> Vec { + let consumed: u32 = snapshot.claims.iter().map(|claim| claim.units).sum(); + let remaining = window.units.saturating_sub(consumed); + let earliest = now + TimeDelta::minutes(i64::from(lead_time_minutes)); + let latest = now + TimeDelta::days(i64::from(horizon_days)); + let in_range = window.start >= from && window.start < to; + let in_horizon = window.start >= earliest && window.start <= latest; + if in_range && in_horizon && remaining > 0 { + vec![AvailabilityEntry::Window { + window: window.id.clone(), + start: window.start, + end: window.end, + remaining, + // The caller's channel slice is a booking-time fact; a public + // availability read carries no channel. + channel_remaining: None, + }] + } else { + Vec::new() + } +} + +fn entry_instant(entry: &AvailabilityEntry) -> DateTime { + match entry { + AvailabilityEntry::Slot { start, .. } | AvailabilityEntry::Window { start, .. } => *start, + } +} + +fn page_limit(limit: Option) -> usize { + limit + .unwrap_or(DEFAULT_PAGE_LIMIT) + .clamp(1, MAXIMUM_PAGE_LIMIT) +} + +/// The `after` id of a position a by-id listing carries, or the invalid-cursor +/// refusal for a foreign position shape. +fn id_position(position: Option) -> Result, ServiceError> { + match position { + Some(ListingPosition::AfterId { last_id }) => Ok(Some(last_id)), + Some(_) => Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + None => Ok(None), + } +} + +trait LastId { + fn last_id(&self) -> String; +} + +macro_rules! last_id_via { + ($name:ident) => { + impl LastId for $name { + fn last_id(&self) -> String { + self.id.clone() + } + } + }; +} +last_id_via!(ServiceDocument); +last_id_via!(OfferingDocument); + +impl LastId for ResourceDocument { + fn last_id(&self) -> String { + self.resource_id.clone() + } +} + +impl LastId for registry_scheduling_core::LocationDocument { + fn last_id(&self) -> String { + self.location_id.clone() + } +} + +fn position_of_last_id(items: &[T]) -> ListingPosition { + ListingPosition::AfterId { + last_id: items.last().expect("a limited page is not empty").last_id(), + } +} + +async fn page_of( + service: &SchedulingService, + items: Vec, + position: Option, + limit: usize, + context: &str, + now: DateTime, +) -> Result, ServiceError> { + let after = match position { + Some(ListingPosition::AfterId { last_id }) => Some(last_id), + Some(_) => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + None => None, + }; + let selected: Vec<_> = items + .into_iter() + .filter(|item| after.as_ref().is_none_or(|after| &item.last_id() > after)) + .collect(); + let more = selected.len() > limit; + let page: Vec<_> = selected.into_iter().take(limit).collect(); + let next_cursor = if more { + Some( + service + .mint_cursor(context, &position_of_last_id(&page), now) + .await?, + ) + } else { + None + }; + Ok(PageDocument { + items: page, + next_cursor, + }) +} + +/// One stored history row as its published document. Every field is strict: +/// a row that does not carry the shape the store writes is corruption, and +/// the caller sees no part of it. +fn history_entry(row: &Value) -> Result { + let corrupt = || ServiceError::internal("a stored history row is corrupt"); + Ok(AppointmentHistoryEntryDocument { + event_id: row["eventId"].as_str().ok_or_else(corrupt)?.to_owned(), + kind: row["kind"].as_str().ok_or_else(corrupt)?.to_owned(), + revision: u64::try_from(row["revision"].as_i64().ok_or_else(corrupt)?) + .map_err(|_| corrupt())?, + occurred_at: serde_json::from_value(row["occurredAt"].clone()).map_err(|_| corrupt())?, + actor: row["actor"].as_str().map(str::to_owned), + detail: row.get("detail").cloned().unwrap_or(Value::Null), + }) +} + +fn hold_document(claim: &ClaimRow, policy_revision: u64) -> registry_scheduling_core::HoldDocument { + registry_scheduling_core::HoldDocument { + hold_id: claim.claim_id.to_string(), + offering: claim.offering.clone(), + start: claim.displayed_start, + end: claim.displayed_end, + resource: claim.supply_id_is_member().then(|| claim.supply_id.clone()), + units: u32::try_from(claim.units).unwrap_or(u32::MAX), + expires_at: claim.hold_expires_at.unwrap_or(claim.created_at), + policy_revision, + } +} + +fn appointment_document(claim: &ClaimRow, policy_revision: u64) -> AppointmentDocument { + AppointmentDocument { + appointment_id: claim.claim_id.to_string(), + offering: claim.offering.clone(), + start: claim.displayed_start, + end: claim.displayed_end, + resource: claim.supply_id_is_member().then(|| claim.supply_id.clone()), + units: u32::try_from(claim.units).unwrap_or(u32::MAX), + channel: claim.channel.clone(), + revision: u64::try_from(claim.revision).unwrap_or(u64::MAX), + state: if claim.state == crate::store::ClaimState::Cancelled { + AppointmentStateDocument::Cancelled + } else { + AppointmentStateDocument::Confirmed + }, + policy_revision, + created_at: claim.created_at, + cancelled_at: claim.closed_at, + } +} + +impl ClaimRow { + /// Whether the claim's supply id names a pool member (an exact-time + /// booking) rather than a window. + fn supply_id_is_member(&self) -> bool { + self.kind == LedgerKind::Booking || self.kind == LedgerKind::Hold + } +} + +fn problem_of(error: &CommitError) -> ProblemCode { + match error { + CommitError::Store(_) | CommitError::Query(_) => ProblemCode::ServiceUnavailable, + CommitError::Refused(refusal) => refusal.public_code(), + CommitError::KeyReused => ProblemCode::IdempotencyKeyReused, + CommitError::KeyExpired => ProblemCode::IdempotencyExpired, + CommitError::HoldCeiling => ProblemCode::CapacityExhausted, + CommitError::Unauthorized => ProblemCode::OperationNotAuthorized, + CommitError::RevisionMismatch => ProblemCode::RevisionMismatch, + CommitError::CutoffPassed => ProblemCode::CancellationCutoffPassed, + } +} + +/// The stored body a replayed refusal answers with: the pinned problem +/// without a trace id, so the edge stamps the answering request's own trace. +fn problem_receipt(problem: ProblemCode) -> Value { + json!({ + "problem": { + "type": type_uri(problem.code()), + "title": problem.title(), + "status": problem.http_status(), + "detail": problem.detail(), + "code": problem.code(), + } + }) +} + +fn canonical_hash(value: &Value) -> Result { + let canonical = canonicalize_json(value) + .map_err(|_| ServiceError::internal("a request body could not be canonicalized"))?; + let digest = Sha256::digest(&canonical); + Ok(format!("sha256:{}", hex(&digest))) +} + +fn hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut rendered = String::with_capacity(bytes.len() * 2); + for byte in bytes { + rendered.push(HEX[(byte >> 4) as usize] as char); + rendered.push(HEX[(byte & 0x0f) as usize] as char); + } + rendered +} + +/// The attributable authorization record of one commitment, with the +/// principal, client, grant, and approver pseudonymized and the purpose +/// recorded as presence only, the way the stack's other runtimes publish it. +#[allow(clippy::too_many_arguments)] +fn audit_record( + hasher: &AuditKeyHasher, + scope: &str, + caller: &Caller, + grant: &GrantClaims, + operation: &str, + outcome: AuthorizationOutcome, + reason: &str, +) -> Result { + let pseudonym = |class: &str, value: &str| -> Result { + hasher + .audit_reference_hash(class, scope, value) + .map_err(|_| ServiceError::internal("an audit reference could not be pseudonymized")) + }; + let event = AuthorizationAuditEvent::new( + caller.actor_kind.clone(), + pseudonym("scheduling-principal-v1", grant.principal())?, + pseudonym("scheduling-client-v1", grant.client())?, + Some(pseudonym("scheduling-grant-v1", grant.id())?), + Some(pseudonym("scheduling-approver-v1", grant.approver())?), + grant.purpose(), + operation, + outcome, + reason, + ) + .map_err(|_| ServiceError::internal("the authorization audit event is not publishable"))?; + let mut value = serde_json::to_value(event) + .map_err(|_| ServiceError::internal("the authorization audit event is not publishable"))?; + let Some(object) = value.as_object_mut() else { + return Err(ServiceError::internal( + "the authorization audit event is not an object", + )); + }; + // Purpose presence is recorded, never the purpose value. + object.remove("purpose"); + value["sourceIssuer"] = json!(grant.source_issuer()); + value["expiresAt"] = json!(grant.exp()); + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + fn interval(start_hour: u32, end_hour: u32) -> CalendarInterval { + let day = Utc.with_ymd_and_hms(2026, 10, 5, 0, 0, 0).unwrap(); + CalendarInterval { + start: day + TimeDelta::hours(i64::from(start_hour)), + end: day + TimeDelta::hours(i64::from(end_hour)), + } + } + + fn exact() -> ExactTimeOffering { + ExactTimeOffering { + duration_minutes: 30, + buffer_before_minutes: 5, + buffer_after_minutes: 5, + lead_time_minutes: 60, + horizon_days: 30, + pool: "north-stations".to_owned(), + start_increment_minutes: 30, + max_recipients: 2, + } + } + + fn members(count: usize) -> Vec { + (1..=count) + .map(|index| PoolMember { + resource_id: format!("station-{index}"), + capabilities: Vec::new(), + available: true, + }) + .collect() + } + + fn at(hour: u32, minute: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 5, hour, minute, 0).unwrap() + } + + fn booking_of( + id: &str, + supply_id: &str, + start: u32, + end: u32, + ) -> registry_scheduling_core::LedgerClaim { + registry_scheduling_core::LedgerClaim { + id: id.to_owned(), + supply_id: supply_id.to_owned(), + kind: LedgerKind::Booking, + channel: None, + start: at(start, 50), + end: at(end, 10), + units: 1, + duplicate_key: None, + expires_at: None, + } + } + + #[test] + fn slots_walk_the_grid_inside_the_opening() { + let now = at(0, 0); + let entries = exact_time_slots( + &exact(), + &members(2), + &[interval(2, 4)], + &[], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + now, + ); + let starts: Vec<_> = entries.iter().map(entry_instant).collect(); + assert_eq!(starts, vec![at(2, 0), at(2, 30), at(3, 0), at(3, 30)]); + assert!(entries.iter().all(|entry| match entry { + AvailabilityEntry::Slot { free, .. } => *free == 2, + _ => false, + })); + } + + #[test] + fn a_slot_the_lead_time_excludes_is_not_listed() { + let now = at(2, 15); + let entries = exact_time_slots( + &exact(), + &members(1), + &[interval(2, 4)], + &[], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + now, + ); + // The earliest bookable start is 03:15 on the grid, so 03:30 is first. + assert_eq!( + entries.iter().map(entry_instant).collect::>(), + vec![at(3, 30)] + ); + } + + #[test] + fn a_booking_occupying_a_member_reduces_its_free_count() { + let snapshot = LedgerSnapshot { + claims: vec![booking_of("booking-1", "station-1", 2, 3)], + }; + let entries = exact_time_slots( + &exact(), + &members(2), + &[interval(2, 4)], + &[], + &snapshot, + at(1, 0), + at(5, 0), + at(0, 0), + ); + let by_start = |start: DateTime| { + entries + .iter() + .find(|entry| entry_instant(entry) == start) + .expect("the slot is listed") + }; + match by_start(at(3, 0)) { + AvailabilityEntry::Slot { free, .. } => assert_eq!(*free, 1), + _ => panic!("expected a slot"), + } + match by_start(at(2, 0)) { + AvailabilityEntry::Slot { free, .. } => assert_eq!(*free, 2), + _ => panic!("expected a slot"), + } + } + + #[test] + fn a_closure_removes_the_slots_it_covers() { + let closures = vec![CalendarInterval { + start: at(2, 30), + end: at(3, 30), + }]; + let entries = exact_time_slots( + &exact(), + &members(1), + &[interval(2, 4)], + &closures, + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert_eq!( + entries.iter().map(entry_instant).collect::>(), + vec![at(2, 0), at(3, 30)] + ); + } + + #[test] + fn a_fully_occupied_slot_is_not_availability() { + let snapshot = LedgerSnapshot { + claims: vec![ + booking_of("booking-1", "station-1", 2, 3), + booking_of("booking-2", "station-2", 2, 3), + ], + }; + let entries = exact_time_slots( + &exact(), + &members(2), + &[interval(2, 4)], + &[], + &snapshot, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert!(entries.iter().all(|entry| entry_instant(entry) != at(3, 0))); + } + + #[test] + fn slots_anchor_on_each_opening_not_a_global_grid() { + // Two openings whose starts are off each other's grid: each anchors + // its own slots. + let entries = exact_time_slots( + &exact(), + &members(1), + &[ + interval(2, 3), + CalendarInterval { + start: at(3, 15), + end: at(4, 15), + }, + ], + &[], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert_eq!( + entries.iter().map(entry_instant).collect::>(), + vec![at(2, 0), at(2, 30), at(3, 15), at(3, 45)] + ); + } + + #[test] + fn page_limits_clamp_to_the_published_bound() { + assert_eq!(page_limit(None), DEFAULT_PAGE_LIMIT); + assert_eq!(page_limit(Some(0)), 1); + assert_eq!(page_limit(Some(10_000)), MAXIMUM_PAGE_LIMIT); + } + + #[test] + fn problem_receipts_carry_the_pinned_problem_without_a_trace() { + let receipt = problem_receipt(ProblemCode::CapacityExhausted); + let problem = &receipt["problem"]; + assert_eq!(problem["code"], "capacity.exhausted"); + assert_eq!(problem["status"], 409); + assert!(problem.get("traceId").is_none()); + } + + #[test] + fn commit_errors_project_to_their_public_codes() { + assert_eq!( + problem_of(&CommitError::HoldCeiling), + ProblemCode::CapacityExhausted + ); + assert_eq!( + problem_of(&CommitError::Unauthorized), + ProblemCode::OperationNotAuthorized + ); + assert_eq!( + problem_of(&CommitError::CutoffPassed), + ProblemCode::CancellationCutoffPassed + ); + assert_eq!( + problem_of(&CommitError::KeyReused), + ProblemCode::IdempotencyKeyReused + ); + } + + #[test] + fn the_detailed_code_stays_behind_the_explain_path() { + let refusal = registry_scheduling_core::AdmissionRefusal::ResourceUnavailable; + assert_eq!(refusal.public_code(), ProblemCode::CapacityExhausted); + assert_eq!(refusal.detailed_code(), ProblemCode::ResourceUnavailable); + // Every other refusal discloses nothing extra. + assert_eq!( + registry_scheduling_core::AdmissionRefusal::CapacityExhausted.detailed_code(), + registry_scheduling_core::AdmissionRefusal::CapacityExhausted.public_code() + ); + } + + #[test] + fn a_success_replay_receipt_projects_through_the_same_document() { + let claim = ClaimRow { + claim_id: Uuid::new_v4(), + kind: LedgerKind::Booking, + state: crate::store::ClaimState::Active, + offering: "registry-update-30".to_owned(), + supply_id: "station-1".to_owned(), + channel: None, + displayed_start: at(2, 0), + displayed_end: at(2, 30), + occupied_start: at(1, 55), + occupied_end: at(2, 35), + units: 1, + duplicate_key: None, + hold_expires_at: None, + revision: 1, + policy_revision: 4, + actor: "actor-pseudonym".to_owned(), + reason: None, + created_at: at(0, 0), + closed_at: None, + }; + let receipt = json!({"kind": "booking", "claim": claim.clone()}); + let answer: CommitmentAnswer = CommitmentAnswer::Replay { + status_code: 201, + receipt, + }; + let replayed = claim_or_replay(answer, |replayed| replayed.clone()); + match replayed { + CommitmentAnswer::Minted(replayed) => assert_eq!(replayed, claim), + CommitmentAnswer::Replay { .. } => panic!("a claim receipt is a minted answer"), + } + + let refused: CommitmentAnswer = CommitmentAnswer::Replay { + status_code: 409, + receipt: problem_receipt(ProblemCode::CapacityExhausted), + }; + assert!(matches!( + claim_or_replay(refused, |claim| claim.clone()), + CommitmentAnswer::Replay { + status_code: 409, + .. + } + )); + } +} diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs new file mode 100644 index 0000000000..ab4d435727 --- /dev/null +++ b/crates/registry-scheduling/src/store.rs @@ -0,0 +1,2263 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The PostgreSQL store: schema, the capacity transactions, and the worker +//! queries. +//! +//! Every commitment lands through one transaction shape: +//! +//! 1. `SELECT ... FOR UPDATE` on the supply row that anchors the decision: +//! the resource pool for an exact-time offering, the published window for +//! an arrival window. +//! 2. A ledger snapshot whose hold expiry is evaluated *in the query* — a +//! claim consumes capacity when it is an active booking, or an active +//! hold whose `hold_expires_at` is still ahead of the observed now. A +//! delayed cleanup worker therefore cannot keep an expired hold alive. +//! 3. The revision and policy-revision guards, with `policy.changed` +//! winning. +//! 4. The pure evaluators from `registry-scheduling-core`, in memory, inside +//! the lock but never doing I/O. The task grant's expiry is re-checked +//! here too, against the same now, so a grant that lapses before the +//! commit never books. +//! 5. The claim, the idempotency attempt, the history event, and the outbox +//! rows, written in that same transaction. +//! 6. Commit. +//! +//! Workers claim due work with `FOR UPDATE SKIP LOCKED` and a limit, the +//! idiom the stack already uses for due clocks and retention sweeps. + +use std::str::FromStr; +use std::time::Duration; + +use chrono::{DateTime, TimeDelta, Utc}; +use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime}; +use registry_platform_calendar::CalendarInterval; +use registry_platform_config::SecretResolver; +use registry_scheduling_core::{ + evaluate_exact_time_admission, evaluate_hold_state, evaluate_window_admission, + AdmissionRefusal, ExactTimeContext, LedgerClaim, LedgerKind, LedgerSnapshot, PoolMember, + SchedulingFacts, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use thiserror::Error; +use tokio_postgres::{Config as PgConfig, Row}; +use uuid::Uuid; + +use crate::config::{describe_secret_failure, DatabaseConfig}; + +const SCHEDULING_MIGRATION: &str = include_str!("../migrations/0001_scheduling.sql"); + +/// Every schema version in ledger order. +const MIGRATIONS: [(i64, &str); 1] = [(1, SCHEDULING_MIGRATION)]; + +/// Serializes operator-run migrations on one session lock. A second migrator +/// waits here instead of racing the ledger primary key. The key spells the +/// ASCII bytes of "sched". +const MIGRATION_LOCK_KEY: i64 = 0x7363_6865_6475_6c65; + +#[derive(Debug, Error)] +pub enum StoreError { + #[error("the Scheduling database configuration is invalid")] + Configuration, + #[error("the Scheduling database secret could not be resolved: {0}")] + SecretConfiguration(String), + #[error("the Scheduling database is not in the state this runtime expects")] + Corrupt, + #[error("the Scheduling query failed")] + Query(#[from] tokio_postgres::Error), + #[error("the pooled Scheduling connection could not be built or leased")] + Pool(#[from] deadpool_postgres::PoolError), +} + +#[derive(Clone)] +pub struct PostgresStore { + pool: Pool, +} + +impl std::fmt::Debug for PostgresStore { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PostgresStore") + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AttemptState { + Completed, + Refused, +} + +impl AttemptState { + const fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::Refused => "refused", + } + } +} + +/// One appointment or hold claim row. This is also the replay receipt body: +/// a stored attempt carries these fields, so a replayed response is the +/// original response, not a re-derivation from live state. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimRow { + pub claim_id: Uuid, + pub kind: LedgerKind, + pub state: ClaimState, + pub offering: String, + pub supply_id: String, + pub channel: Option, + pub displayed_start: DateTime, + pub displayed_end: DateTime, + pub occupied_start: DateTime, + pub occupied_end: DateTime, + pub units: i32, + pub duplicate_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hold_expires_at: Option>, + pub revision: i64, + pub policy_revision: i64, + pub actor: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub closed_at: Option>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ClaimState { + Active, + Released, + Cancelled, + Consumed, + Expired, +} + +impl ClaimState { + const fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Released => "released", + Self::Cancelled => "cancelled", + Self::Consumed => "consumed", + Self::Expired => "expired", + } + } +} + +/// The caller-side facts every commitment carries. `now` is observed once +/// per request and used for every decision inside it, including the grant +/// re-check, so a transaction never mixes two observations of time. +pub struct Commitment<'c> { + pub now: DateTime, + pub policy_revision: i64, + /// The pseudonymized actor reference history and audit carry. + pub actor: &'c str, + pub actor_issuer: &'c str, + pub actor_subject: &'c str, + pub idempotency_key: &'c str, + /// The idempotent hash of the request payload. + pub request_hash: &'c str, + pub attempt_expires_at: DateTime, + /// The task grant's `exp`, re-checked inside the capacity transaction. + pub grant_exp_unix: Option, + /// The audit identity of the commitment, written in the same + /// transaction as the state change. + pub audit_event: Uuid, + pub audit_record: Value, +} + +/// The policy-resolved supply an admission runs against. +pub enum SupplyContext<'p> { + ExactTime { + exact: &'p registry_scheduling_core::ExactTimeOffering, + members: &'p [PoolMember], + open: &'p [CalendarInterval], + closures: &'p [CalendarInterval], + }, + Window { + window: &'p registry_scheduling_core::PublishedWindow, + lead_time_minutes: u32, + horizon_days: u32, + }, +} + +/// What one committed operation produced. +#[derive(Clone, Debug)] +pub enum CommitOutcome { + Booking(ClaimRow), + Hold(ClaimRow), + Cancelled(ClaimRow), + Released, + /// A stored attempt answered instead: replay its receipt exactly. + Replay { + status_code: u16, + receipt: Value, + }, +} + +/// What resolving a stored attempt decided. +enum ReplayOutcome { + Replay { + status_code: u16, + receipt: Value, + }, + /// The key is being misused, or its receipt has been erased past + /// retention. Either way this request does not proceed to a commitment. + Refused(CommitError), +} + +#[derive(Debug, Error)] +pub enum CommitError { + #[error(transparent)] + Store(#[from] StoreError), + /// A statement inside the capacity transaction itself failed. + #[error("the Scheduling query failed")] + Query(#[from] tokio_postgres::Error), + #[error("the admission was refused")] + Refused(#[from] AdmissionRefusal), + #[error("the idempotency key was reused with a different request")] + KeyReused, + #[error("the stored response for this idempotency key has expired")] + KeyExpired, + #[error("the caller holds more active holds than the hold policy allows")] + HoldCeiling, + #[error("the task grant lapsed before the commitment")] + Unauthorized, + #[error("the observed revision does not match the current revision")] + RevisionMismatch, + #[error("the cancellation cutoff has passed")] + CutoffPassed, +} + +/// One due or delivered outbox intent. +#[derive(Clone, Debug)] +pub struct OutboxRow { + pub outbox_id: Uuid, + pub purpose: String, + pub claim_id: Uuid, + pub appointment_revision: i64, + pub due_at: DateTime, + pub attempts: i32, + pub payload: Value, +} + +impl PostgresStore { + pub fn connect_runtime( + config: &DatabaseConfig, + secrets: &SecretResolver, + ) -> Result { + Self::connect_reference( + config, + secrets, + "database.runtimeUrlRef", + &config.runtime_url_ref, + ) + } + + pub fn connect_migration( + config: &DatabaseConfig, + secrets: &SecretResolver, + ) -> Result { + Self::connect_reference( + config, + secrets, + "database.migrationUrlRef", + &config.migration_url_ref, + ) + } + + fn connect_reference( + config: &DatabaseConfig, + secrets: &SecretResolver, + field: &'static str, + reference: &str, + ) -> Result { + let protected = secrets.resolve(reference).map_err(|error| { + StoreError::SecretConfiguration(describe_secret_failure(field, reference, &error)) + })?; + let url = std::str::from_utf8(protected.expose_secret()) + .map_err(|_| StoreError::Configuration)?; + let mut postgres = PgConfig::from_str(url).map_err(|_| StoreError::Configuration)?; + if postgres.get_user().is_none() || postgres.get_dbname().is_none() { + return Err(StoreError::Configuration); + } + let manager_config = ManagerConfig { + recycling_method: RecyclingMethod::Verified, + }; + #[cfg(feature = "postgres-test")] + let manager = if config.test_only_plaintext { + postgres.ssl_mode(tokio_postgres::config::SslMode::Disable); + Manager::from_config(postgres, tokio_postgres::NoTls, manager_config) + } else { + postgres.ssl_mode(tokio_postgres::config::SslMode::Require); + let connector = tls_connector(config, secrets)?; + Manager::from_config(postgres, connector, manager_config) + }; + #[cfg(not(feature = "postgres-test"))] + let manager = { + if config.test_only_plaintext { + return Err(StoreError::Configuration); + } + postgres.ssl_mode(tokio_postgres::config::SslMode::Require); + let connector = tls_connector(config, secrets)?; + Manager::from_config(postgres, connector, manager_config) + }; + let pool = Pool::builder(manager) + .max_size(32) + .wait_timeout(Some(Duration::from_secs(5))) + .create_timeout(Some(Duration::from_secs(5))) + .recycle_timeout(Some(Duration::from_secs(5))) + .runtime(Runtime::Tokio1) + .build() + .map_err(|_| StoreError::Configuration)?; + Ok(Self { pool }) + } + + async fn client(&self) -> Result { + self.pool.get().await.map_err(StoreError::Pool) + } + + pub async fn migrate(&self) -> Result<(), StoreError> { + let mut client = self.client().await?; + client + .query_one("SELECT pg_advisory_lock($1)", &[&MIGRATION_LOCK_KEY]) + .await?; + let applied = Self::apply_migrations(&mut client).await; + let released = client + .query_one("SELECT pg_advisory_unlock($1)", &[&MIGRATION_LOCK_KEY]) + .await; + applied?; + if released?.get::<_, bool>(0) { + Ok(()) + } else { + Err(StoreError::Corrupt) + } + } + + async fn apply_migrations(client: &mut deadpool_postgres::Client) -> Result<(), StoreError> { + let transaction = client.transaction().await?; + transaction + .batch_execute( + "CREATE TABLE IF NOT EXISTS scheduling_schema_migrations (\ + version bigint PRIMARY KEY CHECK (version > 0),\ + applied_at timestamptz NOT NULL);", + ) + .await?; + transaction.commit().await?; + + for (version, migration) in MIGRATIONS { + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&version], + ) + .await? + .get(0); + if !applied { + transaction.batch_execute(migration).await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&version], + ) + .await?; + } + transaction.commit().await?; + } + Ok(()) + } + + pub async fn ready(&self) -> Result<(), StoreError> { + let client = self.client().await?; + let applied = client + .query( + "SELECT version FROM scheduling_schema_migrations ORDER BY version", + &[], + ) + .await?; + let schema_is_current = applied.len() == MIGRATIONS.len() + && applied + .iter() + .zip(MIGRATIONS.iter()) + .all(|(row, (expected, _))| { + row.try_get::<_, i64>(0) + .is_ok_and(|version| version == *expected) + }); + if schema_is_current { + Ok(()) + } else { + Err(StoreError::Corrupt) + } + } + + /// The deployment identity and current policy revision. + pub async fn scheduling_meta(&self) -> Result<(String, i64, String), StoreError> { + let client = self.client().await?; + let row = client + .query_one( + "SELECT scheduling_id, policy_revision, policy_digest FROM scheduling_meta WHERE singleton", + &[], + ) + .await?; + Ok(( + row.get::<_, String>(0), + row.get::<_, i64>(1), + row.get::<_, String>(2), + )) + } + + /// Publish a policy: bump the revision when the digest changed, and make + /// sure every supply anchor the policy needs exists. Re-applying the + /// same policy is a no-op that still verifies the anchors. + pub async fn apply_policy( + &self, + scheduling_id: &str, + policy_digest: &str, + pool_ids: &[String], + window_ids: &[String], + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let row = transaction + .query_one( + "SELECT scheduling_id, policy_revision, policy_digest FROM scheduling_meta WHERE singleton FOR UPDATE", + &[], + ) + .await?; + if row.get::<_, String>(0) != scheduling_id { + return Err(StoreError::Corrupt); + } + let revision; + if row.get::<_, String>(2) != policy_digest { + revision = row.get::<_, i64>(1) + 1; + transaction + .execute( + "INSERT INTO scheduling_policy_revisions(policy_revision, policy_digest) \ + VALUES($1,$2)", + &[&revision, &policy_digest], + ) + .await?; + transaction + .execute( + "UPDATE scheduling_meta SET policy_revision=$1, policy_digest=$2, \ + updated_at=now() WHERE singleton", + &[&revision, &policy_digest], + ) + .await?; + } else { + revision = row.get(1); + } + for (id, kind) in pool_ids + .iter() + .map(|id| (id, "pool")) + .chain(window_ids.iter().map(|id| (id, "window"))) + { + transaction + .execute( + "INSERT INTO scheduling_supply(supply_id, kind) VALUES($1,$2) \ + ON CONFLICT(supply_id) DO NOTHING", + &[id, &kind], + ) + .await?; + } + transaction.commit().await?; + Ok(revision) + } + + /// The environment records an admission runs against. + pub async fn facts(&self) -> Result { + let client = self.client().await?; + let locations = client + .query( + "SELECT location_id, timezone FROM scheduling_locations", + &[], + ) + .await?; + let members = client + .query( + "SELECT resource_id, pool_id, capabilities, available \ + FROM scheduling_pool_members ORDER BY pool_id, resource_id", + &[], + ) + .await?; + let pools = client + .query("SELECT pool_id FROM scheduling_pools ORDER BY pool_id", &[]) + .await?; + let exceptions = client + .query( + "SELECT exception_id, location, kind, date::text, start_time, end_time, \ + reopens, authority FROM scheduling_exceptions", + &[], + ) + .await?; + Ok(SchedulingFacts { + locations: locations + .iter() + .map(|row| registry_scheduling_core::LocationRecord { + id: row.get(0), + timezone: row.get(1), + }) + .collect(), + pools: pools + .iter() + .map(|pool| registry_scheduling_core::ResourcePool { + id: pool.get(0), + members: members + .iter() + .filter(|member| member.get::<_, String>(1) == pool.get::<_, String>(0)) + .map(|member| registry_scheduling_core::PoolMember { + resource_id: member.get(0), + capabilities: member.get(2), + available: member.get(3), + }) + .collect(), + }) + .collect(), + exceptions: exceptions + .iter() + .map(|row| registry_scheduling_core::CalendarExceptionRecord { + id: row.get(0), + location: row.get(1), + kind: match row.get::<_, String>(2).as_str() { + "closure" => registry_scheduling_core::ExceptionRecordKind::Closure, + _ => registry_scheduling_core::ExceptionRecordKind::Opening, + }, + date: row.get(3), + start_time: row.get(4), + end_time: row.get(5), + reopens: row.get(6), + authority: row.get(7), + }) + .collect(), + }) + } + + /// Replace the environment records wholesale. This is the operator + /// tooling path (`schedulingctl records apply`): one attributable write + /// that swaps locations, pools, members, and exceptions atomically. + pub async fn replace_facts( + &self, + facts: &SchedulingFacts, + audit_event: Uuid, + audit_record: Value, + ) -> Result<(), StoreError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + replace_facts_in_transaction(&transaction, facts, audit_event, audit_record).await?; + transaction.commit().await?; + Ok(()) + } + + /// Mint a listing cursor. + pub async fn insert_cursor( + &self, + cursor_id: Uuid, + context: &str, + position: Value, + expires_at: DateTime, + ) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "INSERT INTO scheduling_cursors(cursor_id, context, position, expires_at) \ + VALUES($1,$2,$3,$4)", + &[&cursor_id, &context, &position, &expires_at], + ) + .await?; + Ok(()) + } + + /// Resolve one listing cursor by id. + pub async fn cursor(&self, cursor_id: Uuid) -> Result, StoreError> { + let client = self.client().await?; + let row = client + .query_opt( + "SELECT context, position, expires_at FROM scheduling_cursors WHERE cursor_id=$1", + &[&cursor_id], + ) + .await?; + Ok(row.map(|row| { + json!({ + "context": row.get::<_, String>(0), + "position": row.get::<_, Value>(1), + "expiresAt": row.get::<_, DateTime>(2), + }) + })) + } + + /// One appointment or hold claim by id. + pub async fn claim(&self, claim_id: Uuid) -> Result, StoreError> { + let client = self.client().await?; + let row = client.query_opt(SELECT_CLAIM, &[&claim_id]).await?; + row.map(map_claim_row).transpose() + } + + /// The history of one claim, newest first, bounded by the listing limit. + /// A page resumes strictly before the (occurred_at, event_id) pair its + /// last row carries, so two events sharing an instant still page + /// deterministically. + pub async fn claim_history( + &self, + claim_id: Uuid, + before: Option<(DateTime, Uuid)>, + limit: i64, + ) -> Result, StoreError> { + let (before_at, before_id) = match before { + Some((at, id)) => (Some(at), Some(id)), + None => (None, None), + }; + let client = self.client().await?; + let rows = client + .query( + "SELECT event_id, revision, kind, occurred_at, actor, detail \ + FROM scheduling_history WHERE claim_id=$1 \ + AND ($3::timestamptz IS NULL OR (occurred_at, event_id) < ($3::timestamptz, $4::uuid)) \ + ORDER BY occurred_at DESC, event_id DESC LIMIT $2", + &[&claim_id, &limit, &before_at, &before_id], + ) + .await?; + Ok(rows + .iter() + .map(|row| { + json!({ + "eventId": row.get::<_, Uuid>(0), + "revision": row.get::<_, i64>(1), + "kind": row.get::<_, String>(2), + "occurredAt": row.get::<_, DateTime>(3), + "actor": row.get::<_, String>(4), + "detail": row.get::<_, Value>(5), + }) + }) + .collect()) + } + + /// List pool members page-wise, resuming after `after_id`. + pub async fn list_resources( + &self, + after_id: Option<&str>, + limit: i64, + ) -> Result, bool)>, StoreError> { + let client = self.client().await?; + let rows = client + .query( + "SELECT resource_id, pool_id, capabilities, available \ + FROM scheduling_pool_members \ + WHERE ($1::text IS NULL OR resource_id > $1) \ + ORDER BY resource_id LIMIT $2", + &[&after_id, &limit], + ) + .await?; + Ok(rows + .iter() + .map(|row| (row.get(0), row.get(1), row.get(2), row.get(3))) + .collect()) + } + + /// List locations page-wise, resuming after `after_id`. + pub async fn list_locations( + &self, + after_id: Option<&str>, + limit: i64, + ) -> Result, StoreError> { + let client = self.client().await?; + let rows = client + .query( + "SELECT location_id, timezone FROM scheduling_locations \ + WHERE ($1::text IS NULL OR location_id > $1) \ + ORDER BY location_id LIMIT $2", + &[&after_id, &limit], + ) + .await?; + Ok(rows.iter().map(|row| (row.get(0), row.get(1))).collect()) + } + + /// A read-only member snapshot for availability and explain. No lock: + /// nothing here commits, so committed rows are read as they stand. + pub async fn member_snapshot( + &self, + members: &[String], + from: DateTime, + to: DateTime, + now: DateTime, + ) -> Result { + let client = self.client().await?; + let rows = client + .query(CONSUMING_CLAUSES, &[&members, &from, &to, &now]) + .await?; + Ok(snapshot_from_rows(rows)) + } + + /// A read-only window snapshot for availability and explain. + pub async fn window_snapshot( + &self, + window_id: &str, + now: DateTime, + ) -> Result { + let client = self.client().await?; + let rows = client + .query( + "SELECT claim_id, supply_id, kind, channel, occupied_start, occupied_end, \ + units, duplicate_key, hold_expires_at \ + FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > $2)) \ + AND supply_id = $1 \ + ORDER BY occupied_start", + &[&window_id, &now], + ) + .await?; + Ok(snapshot_from_rows(rows)) + } + + /// Mint a hold through the full capacity transaction. + pub async fn create_hold( + &self, + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + request: ®istry_scheduling_core::AdmissionRequest, + ttl_minutes: u32, + max_per_caller: u32, + commitment: Commitment<'_>, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, "hold:create").await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + if transaction + .active_holds_by_caller(commitment.actor, commitment.now) + .await? + >= i64::from(max_per_caller) + { + return Err(CommitError::HoldCeiling); + } + let admission = evaluate(offering, supply, request, &snapshot, &commitment)?; + let expires_at = commitment + .now + .checked_add_signed(TimeDelta::minutes(i64::from(ttl_minutes))) + .ok_or(AdmissionRefusal::ScheduleUnpublished)?; + let hold_id = Uuid::new_v4(); + let claim = transaction + .insert_claim(&NewClaim { + claim_id: hold_id, + kind: LedgerKind::Hold, + state: ClaimState::Active, + offering: &admission.offering, + supply_id: admission + .resource + .as_deref() + .unwrap_or(window_supply_id(supply)), + channel: request.channel.as_deref(), + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + units: i32::try_from(admission.units).unwrap_or(i32::MAX), + duplicate_key: request.duplicate_key.as_deref(), + hold_expires_at: Some(expires_at), + revision: 1, + policy_revision: commitment.policy_revision, + actor: commitment.actor, + reason: None, + }) + .await?; + transaction + .insert_history( + hold_id, + 1, + "held", + commitment.now, + commitment.actor, + json!({"expiresAt": expires_at, "offering": admission.offering}), + ) + .await?; + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + "hold:create", + AttemptState::Completed, + 201, + json!({"kind": "hold", "claim": claim}), + ) + .await?; + transaction.commit().await?; + Ok(CommitOutcome::Hold(claim)) + } + + /// Create an appointment directly, through the full capacity + /// transaction. + pub async fn create_appointment( + &self, + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + request: ®istry_scheduling_core::AdmissionRequest, + commitment: Commitment<'_>, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, "appointment:create").await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + let admission = evaluate(offering, supply, request, &snapshot, &commitment)?; + let appointment_id = Uuid::new_v4(); + let claim = transaction + .insert_claim(&NewClaim { + claim_id: appointment_id, + kind: LedgerKind::Booking, + state: ClaimState::Active, + offering: &admission.offering, + supply_id: admission + .resource + .as_deref() + .unwrap_or(window_supply_id(supply)), + channel: request.channel.as_deref(), + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + units: i32::try_from(admission.units).unwrap_or(i32::MAX), + duplicate_key: request.duplicate_key.as_deref(), + hold_expires_at: None, + revision: 1, + policy_revision: commitment.policy_revision, + actor: commitment.actor, + reason: None, + }) + .await?; + transaction + .insert_history( + appointment_id, + 1, + "confirmed", + commitment.now, + commitment.actor, + json!({"offering": admission.offering, "start": admission.start}), + ) + .await?; + transaction + .insert_outbox( + "confirmation", + appointment_id, + 1, + commitment.now, + confirmation_payload(&claim, commitment.policy_revision), + ) + .await?; + mint_reminders(&transaction, &claim, offering, commitment.now).await?; + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + "appointment:create", + AttemptState::Completed, + 201, + json!({"kind": "booking", "claim": claim}), + ) + .await?; + transaction.commit().await?; + Ok(CommitOutcome::Booking(claim)) + } + + /// Confirm a held allocation: the hold converts to an appointment in one + /// transaction. The hold's own allocation is the reservation; the + /// confirm re-checks expiry, policy identity, and the grant, then + /// consumes the hold and writes the booking beside it. + pub async fn confirm_hold( + &self, + hold_id: Uuid, + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + commitment: Commitment<'_>, + ) -> Result { + let scope = format!("hold:{hold_id}:confirm"); + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, &scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + // The snapshot is read under the lock for serialization order, but + // admission is not re-evaluated: the hold's own reservation transfers + // to the booking in this same transaction, so capacity does not + // change. What must hold is identity — the policy revision the hold + // was admitted under is still current — checked below. + let _snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + let hold = transaction + .claim_in_transaction(hold_id) + .await? + .ok_or(AdmissionRefusal::HoldReleased)?; + if hold.kind != LedgerKind::Hold || hold.state != ClaimState::Active { + return Err(AdmissionRefusal::HoldReleased.into()); + } + // The expiry check stays here so a hold whose TTL lapsed inside this + // very transaction is refused by the same clock the snapshot used. + evaluate_hold_state(&hold_ledger_claim(&hold), commitment.now)?; + if hold.policy_revision != commitment.policy_revision { + return Err(AdmissionRefusal::PolicyChanged.into()); + } + if hold.actor != commitment.actor { + // Confirming another caller's hold is never a state error: it is + // an authorization refusal the audit journal records. + return Err(CommitError::Unauthorized); + } + let appointment_id = Uuid::new_v4(); + let claim = transaction + .insert_claim(&NewClaim { + claim_id: appointment_id, + kind: LedgerKind::Booking, + state: ClaimState::Active, + offering: &hold.offering, + supply_id: &hold.supply_id, + channel: hold.channel.as_deref(), + displayed_start: hold.displayed_start, + displayed_end: hold.displayed_end, + occupied_start: hold.occupied_start, + occupied_end: hold.occupied_end, + units: hold.units, + duplicate_key: hold.duplicate_key.as_deref(), + hold_expires_at: None, + revision: 1, + policy_revision: commitment.policy_revision, + actor: commitment.actor, + reason: None, + }) + .await?; + transaction + .close_claim(hold_id, ClaimState::Consumed, None, hold.revision + 1) + .await?; + transaction + .insert_history( + appointment_id, + 1, + "confirmed", + commitment.now, + commitment.actor, + json!({"confirmedFrom": hold_id, "start": claim.displayed_start}), + ) + .await?; + transaction + .insert_history( + hold_id, + hold.revision + 1, + "consumed", + commitment.now, + commitment.actor, + json!({"appointment": appointment_id}), + ) + .await?; + transaction + .insert_outbox( + "confirmation", + appointment_id, + 1, + commitment.now, + confirmation_payload(&claim, commitment.policy_revision), + ) + .await?; + mint_reminders(&transaction, &claim, offering, commitment.now).await?; + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + &scope, + AttemptState::Completed, + 201, + json!({"kind": "booking", "claim": claim}), + ) + .await?; + transaction.commit().await?; + Ok(CommitOutcome::Booking(claim)) + } + + /// Release a hold early. + pub async fn release_hold( + &self, + hold_id: Uuid, + commitment: Commitment<'_>, + ) -> Result { + let scope = format!("hold:{hold_id}:release"); + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, &scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let hold = transaction + .claim_in_transaction(hold_id) + .await? + .ok_or(AdmissionRefusal::HoldReleased)?; + if hold.kind != LedgerKind::Hold || hold.state != ClaimState::Active { + return Err(AdmissionRefusal::HoldReleased.into()); + } + if hold.actor != commitment.actor { + return Err(CommitError::Unauthorized); + } + transaction + .close_claim(hold_id, ClaimState::Released, None, hold.revision + 1) + .await?; + transaction + .insert_history( + hold_id, + hold.revision + 1, + "released", + commitment.now, + commitment.actor, + json!({}), + ) + .await?; + transaction.suppress_pending_reminders(hold_id).await?; + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + &scope, + AttemptState::Completed, + 204, + Value::Null, + ) + .await?; + transaction.commit().await?; + Ok(CommitOutcome::Released) + } + + /// Reschedule an appointment to a new time, keeping its identity. + pub async fn reschedule_appointment( + &self, + appointment_id: Uuid, + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + request: ®istry_scheduling_core::AdmissionRequest, + observed_revision: u64, + commitment: Commitment<'_>, + ) -> Result { + let scope = format!("appointment:{appointment_id}:reschedule"); + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, &scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + let appointment = transaction + .claim_in_transaction(appointment_id) + .await? + .ok_or(AdmissionRefusal::HoldReleased)?; + if appointment.kind != LedgerKind::Booking || appointment.state != ClaimState::Active { + return Err(AdmissionRefusal::HoldReleased.into()); + } + // The policy guard wins over the revision guard: a caller whose + // observed revision is also stale learns the policy moved first. + if request.policy_revision != u64::try_from(commitment.policy_revision).unwrap_or(u64::MAX) + { + return Err(AdmissionRefusal::PolicyChanged.into()); + } + if u64::try_from(appointment.revision) != Ok(observed_revision) { + return Err(CommitError::RevisionMismatch); + } + if appointment.actor != commitment.actor { + return Err(CommitError::Unauthorized); + } + let admission = evaluate(offering, supply, request, &snapshot, &commitment)?; + let next_revision = appointment.revision + 1; + transaction + .move_claim(&ClaimMove { + claim_id: appointment_id, + supply_id: admission + .resource + .as_deref() + .unwrap_or(&appointment.supply_id), + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + policy_revision: commitment.policy_revision, + next_revision, + }) + .await?; + transaction + .insert_history(appointment_id, next_revision, "rescheduled", commitment.now, + commitment.actor, + json!({ + "from": {"start": appointment.displayed_start, "end": appointment.displayed_end}, + "to": {"start": admission.start, "end": admission.end}, + })) + .await?; + // A stale unsent reminder describes an appointment that no longer + // exists in this form; it is suppressed, never sent (INT-02). + transaction + .suppress_pending_reminders(appointment_id) + .await?; + let moved = ClaimRow { + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + supply_id: admission.resource.unwrap_or(appointment.supply_id.clone()), + revision: next_revision, + policy_revision: commitment.policy_revision, + ..appointment.clone() + }; + mint_reminders(&transaction, &moved, offering, commitment.now).await?; + transaction + .insert_outbox( + "change", + appointment_id, + next_revision, + commitment.now, + confirmation_payload(&moved, commitment.policy_revision), + ) + .await?; + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + &scope, + AttemptState::Completed, + 200, + json!({"kind": "booking", "claim": moved}), + ) + .await?; + transaction.commit().await?; + Ok(CommitOutcome::Booking(moved)) + } + + /// Cancel an appointment. Cancellation is a release of capacity: it is + /// guarded by the observed revision and the cutoff, never by the policy + /// revision, so an appointment is always cancellable under the policy + /// that currently governs its offering. + pub async fn cancel_appointment( + &self, + appointment_id: Uuid, + observed_revision: u64, + cancellation_cutoff_minutes: Option, + reason: Option<&str>, + commitment: Commitment<'_>, + ) -> Result { + let scope = format!("appointment:{appointment_id}:cancel"); + let mut client = self.client().await?; + let transaction = client.transaction().await?; + match replay_stored_attempt(&transaction, &commitment, &scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + return Ok(CommitOutcome::Replay { + status_code, + receipt, + }); + } + Some(ReplayOutcome::Refused(error)) => return Err(error), + None => {} + } + check_grant_current(&commitment)?; + let appointment = transaction + .claim_in_transaction(appointment_id) + .await? + .ok_or(AdmissionRefusal::HoldReleased)?; + if appointment.kind != LedgerKind::Booking || appointment.state != ClaimState::Active { + return Err(AdmissionRefusal::HoldReleased.into()); + } + if u64::try_from(appointment.revision) != Ok(observed_revision) { + return Err(CommitError::RevisionMismatch); + } + if appointment.actor != commitment.actor { + return Err(CommitError::Unauthorized); + } + if let Some(cutoff) = cancellation_cutoff_minutes { + let earliest_cancel_end = appointment + .displayed_start + .checked_sub_signed(TimeDelta::minutes(i64::from(cutoff))) + .ok_or(CommitError::CutoffPassed)?; + if commitment.now >= earliest_cancel_end { + return Err(CommitError::CutoffPassed); + } + } + let next_revision = appointment.revision + 1; + transaction + .close_claim(appointment_id, ClaimState::Cancelled, reason, next_revision) + .await?; + transaction + .insert_history( + appointment_id, + next_revision, + "cancelled", + commitment.now, + commitment.actor, + json!({"reason": reason}), + ) + .await?; + transaction + .suppress_pending_reminders(appointment_id) + .await?; + transaction + .insert_outbox( + "cancellation", + appointment_id, + next_revision, + commitment.now, + json!({ + "appointmentId": appointment_id, + "revision": next_revision, + "reason": reason, + }), + ) + .await?; + let cancelled = ClaimRow { + state: ClaimState::Cancelled, + revision: next_revision, + reason: reason.map(str::to_owned), + closed_at: Some(commitment.now), + ..appointment + }; + transaction + .insert_audit(commitment.audit_event, &commitment.audit_record) + .await?; + transaction + .insert_attempt( + Uuid::new_v4(), + &commitment, + &scope, + AttemptState::Completed, + 200, + json!({"kind": "booking", "claim": cancelled.clone()}), + ) + .await?; + transaction.commit().await?; + Ok(CommitOutcome::Cancelled(cancelled)) + } + + /// Expire holds whose TTL has passed. Each expiry is a state change with + /// its own history event, so the ledger never depends on the sweeper + /// having run for capacity to be free — the snapshot query already + /// discounts expired holds. + pub async fn expire_due_holds( + &self, + now: DateTime, + limit: i64, + ) -> Result { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let rows = transaction + .query( + "UPDATE scheduling_claims SET state='expired', closed_at=now(), changed_at=now() \ + WHERE claim_id IN (\ + SELECT claim_id FROM scheduling_claims \ + WHERE kind='hold' AND state='active' AND hold_expires_at <= $1 \ + ORDER BY hold_expires_at LIMIT $2 FOR UPDATE SKIP LOCKED)\ + RETURNING claim_id, revision", + &[&now, &limit], + ) + .await?; + for row in &rows { + let claim_id: Uuid = row.get(0); + transaction + .execute( + "INSERT INTO scheduling_history(event_id, claim_id, revision, kind, \ + occurred_at, actor, detail) \ + VALUES($1,$2,$3,'expired',$4,'system','{}')", + &[&Uuid::new_v4(), &claim_id, &row.get::<_, i64>(1), &now], + ) + .await?; + } + transaction.commit().await?; + Ok(rows.len() as u64) + } + + /// Claim due outbox intents for dispatch, marking their attempt. + pub async fn claim_due_intents( + &self, + now: DateTime, + limit: i64, + ) -> Result, StoreError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let rows = transaction + .query( + "UPDATE scheduling_outbox SET attempts = attempts + 1, next_attempt_at = $3 \ + WHERE outbox_id IN (\ + SELECT outbox_id FROM scheduling_outbox \ + WHERE delivery_state='pending' AND due_at <= $1 \ + ORDER BY due_at LIMIT $2 FOR UPDATE SKIP LOCKED) \ + RETURNING outbox_id, purpose, claim_id, appointment_revision, due_at, attempts, payload", + &[&now, &limit, &now], + ) + .await?; + transaction.commit().await?; + Ok(rows + .iter() + .map(|row| OutboxRow { + outbox_id: row.get(0), + purpose: row.get(1), + claim_id: row.get(2), + appointment_revision: row.get(3), + due_at: row.get(4), + attempts: row.get(5), + payload: row.get(6), + }) + .collect()) + } + + /// Mark one intent delivered. + pub async fn mark_intent_delivered(&self, outbox_id: Uuid) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "UPDATE scheduling_outbox SET delivery_state='delivered', delivered_at=now() \ + WHERE outbox_id=$1", + &[&outbox_id], + ) + .await?; + Ok(()) + } + + /// Schedule one intent's retry, or fail it once its attempts reach the + /// ceiling. + pub async fn retry_intent( + &self, + outbox_id: Uuid, + next_attempt_at: DateTime, + attempts_ceiling: i32, + ) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "UPDATE scheduling_outbox \ + SET delivery_state = CASE WHEN attempts >= $3 THEN 'failed' ELSE 'pending' END, \ + next_attempt_at = CASE WHEN attempts >= $3 THEN now() ELSE $2 END \ + WHERE outbox_id=$1", + &[&outbox_id, &next_attempt_at, &attempts_ceiling], + ) + .await?; + Ok(()) + } + + /// Hold one intent locally: the deployment has no destination + /// configured, so the intent stays recorded instead of pretending a + /// delivery happened. + pub async fn hold_intent_local(&self, outbox_id: Uuid) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "UPDATE scheduling_outbox SET delivery_state='local', delivered_at=now() \ + WHERE outbox_id=$1", + &[&outbox_id], + ) + .await?; + Ok(()) + } + + /// Erase cursors whose fifteen minutes have passed. + pub async fn erase_expired_cursors(&self, now: DateTime) -> Result { + let client = self.client().await?; + Ok(client + .execute( + "DELETE FROM scheduling_cursors WHERE expires_at <= $1", + &[&now], + ) + .await?) + } + + /// Record a refused attempt outside the capacity transaction: the store + /// refused the admission and rolled back, so the service writes the + /// receipt a replay of the same key must answer with. A concurrent writer + /// of the same key already stored one; the conflict is not an error, + /// because either receipt answers the same replay. + pub async fn record_refused_attempt( + &self, + commitment: &Commitment<'_>, + scope: &str, + status_code: u16, + receipt: Value, + ) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ + idempotency_key, request_hash, state, status_code, receipt, expires_at) \ + VALUES($1,$2,$3,$4,$5,$6,'refused',$7,$8,$9) ON CONFLICT DO NOTHING", + &[ + &Uuid::new_v4(), + &commitment.actor_issuer, + &commitment.actor_subject, + &scope, + &commitment.idempotency_key, + &commitment.request_hash, + &i32::from(status_code), + &receipt, + &commitment.attempt_expires_at, + ], + ) + .await?; + Ok(()) + } + + /// Record an authorization refusal in the audit journal. The capacity + /// transactions write their audit rows in-transaction; a grant that lapsed + /// or an actor that did not own the claim changes nothing, so its audit + /// row is written beside the refusal instead. + pub async fn record_refusal_audit( + &self, + audit_event: Uuid, + audit_record: Value, + ) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "INSERT INTO scheduling_audit_outbox(event_id, audit_record) VALUES($1,$2)", + &[&audit_event, &audit_record], + ) + .await?; + Ok(()) + } + + /// Erase idempotency receipts past their retention period. + pub async fn erase_expired_attempts(&self, now: DateTime) -> Result { + let client = self.client().await?; + Ok(client + .execute( + "DELETE FROM scheduling_attempts WHERE expires_at <= $1", + &[&now], + ) + .await?) + } + + /// The pending audit journal, oldest first. + pub async fn pending_audit(&self, limit: i64) -> Result, StoreError> { + let client = self.client().await?; + Ok(client + .query( + "SELECT event_id, audit_record FROM scheduling_audit_outbox \ + WHERE published_at IS NULL ORDER BY event_id LIMIT $1", + &[&limit], + ) + .await? + .into_iter() + .map(|row| (row.get(0), row.get(1))) + .collect()) + } + + pub async fn mark_audit_published(&self, event_id: Uuid) -> Result<(), StoreError> { + let client = self.client().await?; + client + .execute( + "UPDATE scheduling_audit_outbox SET published_at=now() \ + WHERE event_id=$1 AND published_at IS NULL", + &[&event_id], + ) + .await?; + Ok(()) + } +} + +const SELECT_CLAIM: &str = "SELECT claim_id, kind, state, offering, supply_id, channel, \ + displayed_start, displayed_end, occupied_start, occupied_end, units, duplicate_key, \ + hold_expires_at, revision, policy_revision, actor, reason, created_at, closed_at \ + FROM scheduling_claims WHERE claim_id=$1"; + +/// The consuming-claim filter, with hold expiry evaluated in the query. This +/// is the sentence the whole capacity contract turns on: an expired hold +/// stops consuming capacity at its expiry, whether or not any worker has +/// touched it. +const CONSUMING_CLAUSES: &str = "SELECT claim_id, supply_id, kind, channel, occupied_start, \ + occupied_end, units, duplicate_key, hold_expires_at \ + FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > $4)) \ + AND supply_id = ANY($1) \ + AND occupied_start < $3::timestamptz AND $2::timestamptz < occupied_end \ + ORDER BY occupied_start"; + +fn map_claim_row(row: Row) -> Result { + Ok(ClaimRow { + claim_id: row.get(0), + kind: match row.get::<_, String>(1).as_str() { + "booking" => LedgerKind::Booking, + _ => LedgerKind::Hold, + }, + state: match row.get::<_, String>(2).as_str() { + "active" => ClaimState::Active, + "released" => ClaimState::Released, + "cancelled" => ClaimState::Cancelled, + "consumed" => ClaimState::Consumed, + _ => ClaimState::Expired, + }, + offering: row.get(3), + supply_id: row.get(4), + channel: row.get(5), + displayed_start: row.get(6), + displayed_end: row.get(7), + occupied_start: row.get(8), + occupied_end: row.get(9), + units: row.get(10), + duplicate_key: row.get(11), + hold_expires_at: row.get(12), + revision: row.get(13), + policy_revision: row.get(14), + actor: row.get(15), + reason: row.get(16), + created_at: row.get(17), + closed_at: row.get(18), + }) +} + +fn snapshot_from_rows(rows: Vec) -> LedgerSnapshot { + LedgerSnapshot { + claims: rows + .iter() + .map(|row| LedgerClaim { + id: row.get::<_, Uuid>(0).to_string(), + supply_id: row.get(1), + kind: match row.get::<_, String>(2).as_str() { + "booking" => LedgerKind::Booking, + _ => LedgerKind::Hold, + }, + channel: row.get(3), + start: row.get(4), + end: row.get(5), + units: u32::try_from(row.get::<_, i32>(6)).unwrap_or(u32::MAX), + duplicate_key: row.get(7), + expires_at: row.get(8), + }) + .collect(), + } +} + +/// The window id a window admission's claim occupies. +fn window_supply_id<'a>(supply: &'a SupplyContext<'a>) -> &'a str { + match supply { + SupplyContext::Window { window, .. } => &window.id, + SupplyContext::ExactTime { .. } => "", + } +} + +/// Steps 1 and 2: lock the anchor row, then read the ledger snapshot with +/// expiry evaluated in the query. +async fn lock_and_snapshot( + transaction: &deadpool_postgres::Transaction<'_>, + supply: &SupplyContext<'_>, + now: DateTime, +) -> Result { + match supply { + SupplyContext::ExactTime { members, open, .. } => { + let supply_ids: Vec = members + .iter() + .map(|member| member.resource_id.clone()) + .collect(); + let earliest = open + .iter() + .map(|interval| interval.start) + .min() + .unwrap_or(now); + let latest = open + .iter() + .map(|interval| interval.end) + .max() + .unwrap_or(now); + transaction.lock_supply(&supply_ids).await?; + let rows = transaction + .query(CONSUMING_CLAUSES, &[&supply_ids, &earliest, &latest, &now]) + .await?; + Ok(snapshot_from_rows(rows)) + } + SupplyContext::Window { window, .. } => { + transaction + .lock_supply(std::slice::from_ref(&window.id)) + .await?; + let rows = transaction + .query( + "SELECT claim_id, supply_id, kind, channel, occupied_start, occupied_end, \ + units, duplicate_key, hold_expires_at \ + FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > $2)) \ + AND supply_id = $1 \ + ORDER BY occupied_start", + &[&window.id, &now], + ) + .await?; + Ok(snapshot_from_rows(rows)) + } + } +} + +/// The task-grant re-check, inside the transaction: the grant's own expiry +/// is the one fact that can change between the service's authorization +/// decision and the commit, so it is checked against the commitment's now. +fn check_grant_current(commitment: &Commitment<'_>) -> Result<(), CommitError> { + match commitment.grant_exp_unix { + Some(exp) if commitment.now.timestamp() < i64::try_from(exp).unwrap_or(i64::MAX) => Ok(()), + Some(_) => Err(CommitError::Unauthorized), + // A mutating commitment without a grant never reaches the store: the + // service refuses it before the transaction opens. + None => Ok(()), + } +} + +/// The in-memory evaluator call, step 4. +fn evaluate( + offering: ®istry_scheduling_core::OfferingPolicy, + supply: &SupplyContext<'_>, + request: ®istry_scheduling_core::AdmissionRequest, + snapshot: &LedgerSnapshot, + commitment: &Commitment<'_>, +) -> Result { + match supply { + SupplyContext::ExactTime { + exact, + members, + open, + closures, + } => evaluate_exact_time_admission( + &ExactTimeContext { + offering, + exact, + members, + open, + closures, + snapshot, + policy_revision: u64::try_from(commitment.policy_revision).unwrap_or(u64::MAX), + now: commitment.now, + }, + request, + ), + SupplyContext::Window { + window, + lead_time_minutes, + horizon_days, + } => evaluate_window_admission( + ®istry_scheduling_core::WindowContext { + offering, + window, + lead_time_minutes: *lead_time_minutes, + horizon_days: *horizon_days, + snapshot, + policy_revision: u64::try_from(commitment.policy_revision).unwrap_or(u64::MAX), + now: commitment.now, + }, + request, + ), + } +} + +fn hold_ledger_claim(hold: &ClaimRow) -> LedgerClaim { + LedgerClaim { + id: hold.claim_id.to_string(), + supply_id: hold.supply_id.clone(), + kind: hold.kind, + channel: hold.channel.clone(), + start: hold.occupied_start, + end: hold.occupied_end, + units: u32::try_from(hold.units).unwrap_or(u32::MAX), + duplicate_key: hold.duplicate_key.clone(), + expires_at: hold.hold_expires_at, + } +} + +/// Replay a stored attempt: the same key with a different payload is +/// refused as reused, an erased receipt as expired, and a retained one is +/// answered exactly as it was. +async fn replay_stored_attempt( + transaction: &deadpool_postgres::Transaction<'_>, + commitment: &Commitment<'_>, + scope: &str, +) -> Result, StoreError> { + let row = transaction + .query_opt( + "SELECT request_hash, state, status_code, receipt, expires_at \ + FROM scheduling_attempts \ + WHERE actor_issuer=$1 AND actor_subject=$2 AND scope=$3 AND idempotency_key=$4", + &[ + &commitment.actor_issuer, + &commitment.actor_subject, + &scope, + &commitment.idempotency_key, + ], + ) + .await?; + let Some(row) = row else { + return Ok(None); + }; + let stored_hash: String = row.get(0); + let expires_at: DateTime = row.get(4); + if stored_hash != commitment.request_hash { + return Ok(Some(ReplayOutcome::Refused(CommitError::KeyReused))); + } + if expires_at <= commitment.now { + return Ok(Some(ReplayOutcome::Refused(CommitError::KeyExpired))); + } + Ok(Some(ReplayOutcome::Replay { + status_code: u16::try_from(row.get::<_, i32>(2)).unwrap_or(500), + receipt: row.get(3), + })) +} + +/// Mint reminder intents for a freshly committed appointment: one outbox +/// row per authored offset whose due time is still ahead. An offset already +/// past mints nothing — a reminder about the past is noise, not a notice. +async fn mint_reminders( + transaction: &deadpool_postgres::Transaction<'_>, + claim: &ClaimRow, + offering: ®istry_scheduling_core::OfferingPolicy, + now: DateTime, +) -> Result<(), StoreError> { + for reminder in &offering.reminders { + let minutes = reminder.minutes_before; + let Some(due_at) = claim + .displayed_start + .checked_sub_signed(TimeDelta::minutes(i64::from(minutes))) + else { + continue; + }; + if due_at <= now { + continue; + } + transaction + .execute( + "INSERT INTO scheduling_outbox(outbox_id, purpose, claim_id, \ + appointment_revision, due_at, delivery_state, next_attempt_at, payload) \ + VALUES($1,'reminder',$2,$3,$4,'pending',$4,$5)", + &[ + &Uuid::new_v4(), + &claim.claim_id, + &claim.revision, + &due_at, + &json!({ + "appointmentId": claim.claim_id, + "revision": claim.revision, + "offering": offering.id, + "start": claim.displayed_start, + "minutesBefore": minutes, + }), + ], + ) + .await?; + } + Ok(()) +} + +fn confirmation_payload(claim: &ClaimRow, policy_revision: i64) -> Value { + json!({ + "appointmentId": claim.claim_id, + "revision": claim.revision, + "offering": claim.offering, + "start": claim.displayed_start, + "end": claim.displayed_end, + "policyRevision": policy_revision, + }) +} + +pub(crate) async fn replace_facts_in_transaction( + transaction: &deadpool_postgres::Transaction<'_>, + facts: &SchedulingFacts, + audit_event: Uuid, + audit_record: Value, +) -> Result<(), StoreError> { + transaction + .execute("DELETE FROM scheduling_pool_members", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_pools", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_locations", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_exceptions", &[]) + .await?; + for location in &facts.locations { + transaction + .execute( + "INSERT INTO scheduling_locations(location_id, timezone) VALUES($1,$2)", + &[&location.id, &location.timezone], + ) + .await?; + } + for pool in &facts.pools { + transaction + .execute( + "INSERT INTO scheduling_pools(pool_id) VALUES($1)", + &[&pool.id], + ) + .await?; + for member in &pool.members { + transaction + .execute( + "INSERT INTO scheduling_pool_members(resource_id, pool_id, capabilities, \ + available) VALUES($1,$2,$3,$4)", + &[ + &member.resource_id, + &pool.id, + &member.capabilities, + &member.available, + ], + ) + .await?; + } + } + for exception in &facts.exceptions { + let kind = match exception.kind { + registry_scheduling_core::ExceptionRecordKind::Closure => "closure", + registry_scheduling_core::ExceptionRecordKind::Opening => "opening", + }; + transaction + .execute( + "INSERT INTO scheduling_exceptions(exception_id, location, kind, date, \ + start_time, end_time, reopens, authority) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", + &[ + &exception.id, + &exception.location, + &kind, + &exception.date, + &exception.start_time, + &exception.end_time, + &exception.reopens, + &exception.authority, + ], + ) + .await?; + } + transaction + .execute( + "INSERT INTO scheduling_audit_outbox(event_id, audit_record) VALUES($1,$2)", + &[&audit_event, &audit_record], + ) + .await?; + Ok(()) +} + +/// A claim about to be written. +struct NewClaim<'c> { + claim_id: Uuid, + kind: LedgerKind, + state: ClaimState, + offering: &'c str, + supply_id: &'c str, + channel: Option<&'c str>, + displayed_start: DateTime, + displayed_end: DateTime, + occupied_start: DateTime, + occupied_end: DateTime, + units: i32, + duplicate_key: Option<&'c str>, + hold_expires_at: Option>, + revision: i64, + policy_revision: i64, + actor: &'c str, + reason: Option<&'c str>, +} + +/// A claim about to move to a new time or resource. +struct ClaimMove<'c> { + claim_id: Uuid, + supply_id: &'c str, + displayed_start: DateTime, + displayed_end: DateTime, + occupied_start: DateTime, + occupied_end: DateTime, + policy_revision: i64, + next_revision: i64, +} + +/// The private statement-set the capacity transactions run. Holding these on +/// a trait keeps every multi-step method on `Transaction` without exposing +/// the transaction handle itself. +#[async_trait::async_trait] +trait CapacityStatements { + async fn lock_supply(&self, supply_ids: &[String]) -> Result<(), StoreError>; + async fn insert_claim(&self, claim: &NewClaim<'_>) -> Result; + async fn close_claim( + &self, + claim_id: Uuid, + state: ClaimState, + reason: Option<&str>, + next_revision: i64, + ) -> Result<(), StoreError>; + async fn move_claim(&self, movement: &ClaimMove<'_>) -> Result<(), StoreError>; + async fn insert_history( + &self, + claim_id: Uuid, + revision: i64, + kind: &str, + occurred_at: DateTime, + actor: &str, + detail: Value, + ) -> Result<(), StoreError>; + async fn insert_outbox( + &self, + purpose: &str, + claim_id: Uuid, + appointment_revision: i64, + due_at: DateTime, + payload: Value, + ) -> Result<(), StoreError>; + async fn suppress_pending_reminders(&self, claim_id: Uuid) -> Result<(), StoreError>; + async fn insert_attempt( + &self, + attempt_id: Uuid, + commitment: &Commitment<'_>, + scope: &str, + state: AttemptState, + status_code: u16, + receipt: Value, + ) -> Result<(), StoreError>; + async fn insert_audit(&self, event_id: Uuid, record: &Value) -> Result<(), StoreError>; + async fn claim_in_transaction(&self, claim_id: Uuid) -> Result, StoreError>; + async fn active_holds_by_caller( + &self, + actor: &str, + now: DateTime, + ) -> Result; +} + +#[async_trait::async_trait] +impl CapacityStatements for deadpool_postgres::Transaction<'_> { + async fn lock_supply(&self, supply_ids: &[String]) -> Result<(), StoreError> { + let rows = self + .query( + "SELECT supply_id FROM scheduling_supply WHERE supply_id = ANY($1) FOR UPDATE", + &[&supply_ids], + ) + .await?; + if rows.len() != supply_ids.len() { + return Err(StoreError::Corrupt); + } + Ok(()) + } + + async fn insert_claim(&self, claim: &NewClaim<'_>) -> Result { + let kind = match claim.kind { + LedgerKind::Booking => "booking", + LedgerKind::Hold => "hold", + }; + let row = self + .query_one( + "INSERT INTO scheduling_claims(claim_id, kind, state, offering, supply_id, \ + channel, displayed_start, displayed_end, occupied_start, occupied_end, units, \ + duplicate_key, hold_expires_at, revision, policy_revision, actor, reason) \ + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) \ + RETURNING created_at", + &[ + &claim.claim_id, + &kind, + &claim.state.as_str(), + &claim.offering, + &claim.supply_id, + &claim.channel, + &claim.displayed_start, + &claim.displayed_end, + &claim.occupied_start, + &claim.occupied_end, + &claim.units, + &claim.duplicate_key, + &claim.hold_expires_at, + &claim.revision, + &claim.policy_revision, + &claim.actor, + &claim.reason, + ], + ) + .await?; + Ok(ClaimRow { + claim_id: claim.claim_id, + kind: claim.kind, + state: claim.state, + offering: claim.offering.to_owned(), + supply_id: claim.supply_id.to_owned(), + channel: claim.channel.map(str::to_owned), + displayed_start: claim.displayed_start, + displayed_end: claim.displayed_end, + occupied_start: claim.occupied_start, + occupied_end: claim.occupied_end, + units: claim.units, + duplicate_key: claim.duplicate_key.map(str::to_owned), + hold_expires_at: claim.hold_expires_at, + revision: claim.revision, + policy_revision: claim.policy_revision, + actor: claim.actor.to_owned(), + reason: claim.reason.map(str::to_owned), + created_at: row.get(0), + closed_at: None, + }) + } + + async fn close_claim( + &self, + claim_id: Uuid, + state: ClaimState, + reason: Option<&str>, + next_revision: i64, + ) -> Result<(), StoreError> { + self.execute( + "UPDATE scheduling_claims SET state=$2, reason=$3, revision=$4, \ + closed_at=now(), changed_at=now() WHERE claim_id=$1", + &[&claim_id, &state.as_str(), &reason, &next_revision], + ) + .await?; + Ok(()) + } + + async fn move_claim(&self, movement: &ClaimMove<'_>) -> Result<(), StoreError> { + self.execute( + "UPDATE scheduling_claims SET supply_id=$2, displayed_start=$3, displayed_end=$4, \ + occupied_start=$5, occupied_end=$6, policy_revision=$7, revision=$8, \ + changed_at=now() WHERE claim_id=$1", + &[ + &movement.claim_id, + &movement.supply_id, + &movement.displayed_start, + &movement.displayed_end, + &movement.occupied_start, + &movement.occupied_end, + &movement.policy_revision, + &movement.next_revision, + ], + ) + .await?; + Ok(()) + } + + async fn insert_history( + &self, + claim_id: Uuid, + revision: i64, + kind: &str, + occurred_at: DateTime, + actor: &str, + detail: Value, + ) -> Result<(), StoreError> { + self.execute( + "INSERT INTO scheduling_history(event_id, claim_id, revision, kind, occurred_at, \ + actor, detail) VALUES($1,$2,$3,$4,$5,$6,$7)", + &[ + &Uuid::new_v4(), + &claim_id, + &revision, + &kind, + &occurred_at, + &actor, + &detail, + ], + ) + .await?; + Ok(()) + } + + async fn insert_outbox( + &self, + purpose: &str, + claim_id: Uuid, + appointment_revision: i64, + due_at: DateTime, + payload: Value, + ) -> Result<(), StoreError> { + self.execute( + "INSERT INTO scheduling_outbox(outbox_id, purpose, claim_id, appointment_revision, \ + due_at, delivery_state, next_attempt_at, payload) \ + VALUES($1,$2,$3,$4,$5,'pending',$5,$6)", + &[ + &Uuid::new_v4(), + &purpose, + &claim_id, + &appointment_revision, + &due_at, + &payload, + ], + ) + .await?; + Ok(()) + } + + async fn suppress_pending_reminders(&self, claim_id: Uuid) -> Result<(), StoreError> { + self.execute( + "DELETE FROM scheduling_outbox \ + WHERE claim_id=$1 AND purpose='reminder' AND delivery_state='pending'", + &[&claim_id], + ) + .await?; + Ok(()) + } + + async fn insert_attempt( + &self, + attempt_id: Uuid, + commitment: &Commitment<'_>, + scope: &str, + state: AttemptState, + status_code: u16, + receipt: Value, + ) -> Result<(), StoreError> { + self.execute( + "INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ + idempotency_key, request_hash, state, status_code, receipt, expires_at) \ + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", + &[ + &attempt_id, + &commitment.actor_issuer, + &commitment.actor_subject, + &scope, + &commitment.idempotency_key, + &commitment.request_hash, + &state.as_str(), + &i32::from(status_code), + &receipt, + &commitment.attempt_expires_at, + ], + ) + .await?; + Ok(()) + } + + async fn insert_audit(&self, event_id: Uuid, record: &Value) -> Result<(), StoreError> { + self.execute( + "INSERT INTO scheduling_audit_outbox(event_id, audit_record) VALUES($1,$2)", + &[&event_id, &record], + ) + .await?; + Ok(()) + } + + async fn claim_in_transaction(&self, claim_id: Uuid) -> Result, StoreError> { + let row = self.query_opt(SELECT_CLAIM, &[&claim_id]).await?; + row.map(map_claim_row).transpose() + } + + async fn active_holds_by_caller( + &self, + actor: &str, + now: DateTime, + ) -> Result { + let row = self + .query_one( + "SELECT count(*) FROM scheduling_claims \ + WHERE actor=$1 AND kind='hold' AND state='active' AND hold_expires_at > $2", + &[&actor, &now], + ) + .await?; + Ok(row.get(0)) + } +} + +fn tls_connector( + config: &DatabaseConfig, + secrets: &SecretResolver, +) -> Result { + let provider = rustls::crypto::ring::default_provider(); + if provider.install_default().is_err() + && rustls::crypto::CryptoProvider::get_default().is_none() + { + return Err(StoreError::Configuration); + } + if let Some(reference) = &config.trusted_root_certificate_ref { + let certificate = secrets.resolve(reference).map_err(|error| { + StoreError::SecretConfiguration(describe_secret_failure( + "database.trustedRootCertificateRef", + reference, + &error, + )) + })?; + let mut roots = rustls::RootCertStore::empty(); + use rustls::pki_types::pem::PemObject as _; + let mut count = 0usize; + for certificate in + rustls::pki_types::CertificateDer::pem_slice_iter(certificate.expose_secret()) + { + roots + .add(certificate.map_err(|_| StoreError::Configuration)?) + .map_err(|_| StoreError::Configuration)?; + count += 1; + } + if count == 0 { + return Err(StoreError::Configuration); + } + let client = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(tokio_postgres_rustls::MakeRustlsConnect::new(client)) + } else { + tokio_postgres_rustls::MakeRustlsConnect::with_native_certs() + .map(|value| value.0) + .map_err(|_| StoreError::Configuration) + } +} From 46ba81cea1da2bef2b50bda9c8bfd8c4ab019c9f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 00:59:33 +0700 Subject: [PATCH 013/115] feat(scheduling): add schedulingctl records apply and package records apply performs the one attributable operator write of a deployment's live environment records: the document is parsed and validated offline in its own terms (duplicates, timezone, undeclared exception locations, strict dates and times) before any connection opens, then lands through the store's single replace transaction with its operator audit row. A PostgreSQL integration test behind the postgres-test feature proves the wholesale swap and both audit rows on a disposable schema. package writes the deployment identity the runtime verifies: the manifest lands beside the authored policy, an existing manifest is refused rather than replaced, and the written manifest is proven to verify through the runtime's own verifier before success is reported. Runtime-configuration and store failures now map to their own diagnostics instead of the generic authoring refusal, and the store binds exception dates as text cast to date, the pattern the rest of the workspace uses for typed columns. Signed-off-by: Jeremi Joslin --- Cargo.lock | 9 + Cargo.toml | 1 + crates/registry-scheduling/src/store.rs | 3 +- crates/registry-schedulingctl/Cargo.toml | 19 ++ crates/registry-schedulingctl/src/lib.rs | 252 ++++++++++++++- crates/registry-schedulingctl/src/project.rs | 60 +++- crates/registry-schedulingctl/src/records.rs | 294 ++++++++++++++++++ .../tests/records_apply_postgres.rs | 243 +++++++++++++++ 8 files changed, 873 insertions(+), 8 deletions(-) create mode 100644 crates/registry-schedulingctl/src/records.rs create mode 100644 crates/registry-schedulingctl/tests/records_apply_postgres.rs diff --git a/Cargo.lock b/Cargo.lock index af9b6ad80f..25c162d459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6170,11 +6170,20 @@ name = "registry-schedulingctl" version = "0.32.0" dependencies = [ "anyhow", + "chrono", + "chrono-tz", "clap", "registry-platform-buildinfo", + "registry-platform-config", + "registry-scheduling", "registry-scheduling-core", "serde_json", + "serde_norway", + "serde_path_to_error", "tempfile", + "tokio", + "tokio-postgres", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 852f3d50de..ad807c2bf6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,6 +121,7 @@ registry-relay-client-node = { path = "crates/registry-relay-client-node", versi registry-relay-client-py = { path = "crates/registry-relay-client-py", version = "0.32.0" } registry-relay-v2 = { path = "crates/registry-relay-v2", version = "0.32.0" } registry-relayctl = { path = "crates/registry-relayctl", version = "0.32.0" } +registry-scheduling = { path = "crates/registry-scheduling", version = "0.32.0" } registry-scheduling-core = { path = "crates/registry-scheduling-core", version = "0.32.0" } registry-schedulingctl = { path = "crates/registry-schedulingctl", version = "0.32.0" } registry-breg = { path = "crates/registry-breg", version = "0.32.0", default-features = false } diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index ab4d435727..640469352a 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -1884,7 +1884,8 @@ pub(crate) async fn replace_facts_in_transaction( transaction .execute( "INSERT INTO scheduling_exceptions(exception_id, location, kind, date, \ - start_time, end_time, reopens, authority) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", + start_time, end_time, reopens, authority) \ + VALUES($1,$2,$3,$4::text::date,$5,$6,$7,$8)", &[ &exception.id, &exception.location, diff --git a/crates/registry-schedulingctl/Cargo.toml b/crates/registry-schedulingctl/Cargo.toml index fcf189ea86..c58e2643bb 100644 --- a/crates/registry-schedulingctl/Cargo.toml +++ b/crates/registry-schedulingctl/Cargo.toml @@ -12,13 +12,32 @@ publish = false name = "schedulingctl" path = "src/main.rs" +[features] +postgres-test = ["registry-scheduling/postgres-test"] + [lints] workspace = true [dependencies] anyhow.workspace = true +chrono.workspace = true +chrono-tz.workspace = true clap.workspace = true registry-platform-buildinfo.workspace = true +registry-platform-config.workspace = true +registry-scheduling.workspace = true registry-scheduling-core.workspace = true +serde_norway.workspace = true +serde_path_to_error.workspace = true serde_json.workspace = true tempfile.workspace = true +tokio = { workspace = true, features = ["rt"] } +uuid.workspace = true + +[dev-dependencies] +registry-scheduling = { workspace = true, features = ["postgres-test"] } +tokio-postgres.workspace = true + +[[test]] +name = "records_apply_postgres" +required-features = ["postgres-test"] diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index 65fc7a09f2..74c6cc7db6 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -2,14 +2,19 @@ //! `schedulingctl`, the Registry Scheduling authoring and local operator //! tooling: `init` writes a complete starter project, `check` validates the -//! authored policy offline, `test` replays every fixture offline, and -//! `explain` publishes what the runtime would serve. +//! authored policy offline, `test` replays every fixture offline, `explain` +//! publishes what the runtime would serve, `package` writes the deployment +//! identity the runtime verifies, and `records apply` performs the one +//! attributable operator write of a deployment's live environment records. mod project; +pub mod records; mod templates; use anyhow::Result; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use registry_scheduling::config::RuntimeConfigError; +use registry_scheduling::store::StoreError; use serde_json::{json, Value}; use std::ffi::{OsStr, OsString}; use std::io; @@ -41,6 +46,10 @@ enum Command { Test(ProjectArgs), /// Explain the checked policy offline: what the runtime would publish. Explain(ProjectArgs), + /// Write the verified policy package manifest beside the authored policy. + Package(ProjectArgs), + /// Apply the live environment records of a deployment. + Records(RecordsArgs), } #[derive(Debug, Args)] @@ -70,6 +79,28 @@ struct ProjectArgs { project: PathBuf, } +#[derive(Debug, Args)] +struct RecordsArgs { + #[command(subcommand)] + command: RecordsCommand, +} + +#[derive(Debug, Subcommand)] +enum RecordsCommand { + /// Replace the deployment's environment records in one attributable write. + Apply(RecordsApplyArgs), +} + +#[derive(Debug, Args)] +struct RecordsApplyArgs { + /// Runtime configuration document of the deployment to write. + #[arg(value_name = "RUNTIME_CONFIG")] + config: PathBuf, + /// Environment records document: locations, pools, and exceptions. + #[arg(value_name = "RECORDS")] + records: PathBuf, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] enum OutputFormat { #[default] @@ -160,6 +191,10 @@ fn run(cli: Cli) -> Result { Command::Check(args) => project::check(&args.project), Command::Test(args) => project::test(&args.project), Command::Explain(args) => project::explain(&args.project), + Command::Package(args) => project::package(&args.project), + Command::Records(args) => match args.command { + RecordsCommand::Apply(apply) => records::apply(&apply.config, &apply.records), + }, } } @@ -193,6 +228,42 @@ fn usage_failure(message: String) -> Value { fn classify_failure(error: &anyhow::Error) -> (u8, Value) { let io_failure = error.chain().any(|cause| cause.is::()); + // A runtime configuration that will not load is a defect in an authored + // document; a store that cannot be reached is a defect in the deployment + // environment. Both name their own artifact instead of hiding behind the + // generic authoring refusal. + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + return ( + DOMAIN_REFUSAL_EXIT, + json!({ + "severity": "error", + "code": "schedulingctl.runtime-configuration.invalid", + "artifact": "runtime_configuration", + "path": "runtime.yaml", + "message": format!("{error:#}"), + "suggestedAction": "Correct the runtime configuration the message names, then retry.", + }), + ); + } + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + return ( + OPERATIONAL_FAILURE_EXIT, + json!({ + "severity": "error", + "code": "schedulingctl.store-unavailable", + "artifact": "database", + "path": "database", + "message": format!("{error:#}"), + "suggestedAction": "Restore the Scheduling database or its credentials, then retry.", + }), + ); + } if io_failure { ( OPERATIONAL_FAILURE_EXIT, @@ -289,6 +360,10 @@ fn human_lead(report: &Value) -> String { "Offline synthetic fixtures passed with incomplete authored inputs.".to_owned() } ("test", _, _) => "Offline synthetic fixtures passed.".to_owned(), + ("package", _, _) => { + "Policy package manifest written beside the authored policy.".to_owned() + } + ("records-apply", _, _) => "Environment records applied.".to_owned(), _ => format!("{command} succeeded."), } } @@ -390,14 +465,37 @@ mod tests { }; assert_eq!(args.template, "standalone-exact-time"); - for command in ["test", "explain"] { + for command in ["test", "explain", "package"] { let cli = Cli::try_parse_from(["schedulingctl", command, "/tmp/project"]).unwrap(); match command { "test" => assert!(matches!(cli.command, Command::Test(_))), - _ => assert!(matches!(cli.command, Command::Explain(_))), + "explain" => assert!(matches!(cli.command, Command::Explain(_))), + _ => assert!(matches!(cli.command, Command::Package(_))), } } + let cli = Cli::try_parse_from([ + "schedulingctl", + "records", + "apply", + "/runtime.yaml", + "/records.yaml", + ]) + .unwrap(); + let Command::Records(RecordsArgs { + command: RecordsCommand::Apply(args), + }) = cli.command + else { + panic!("expected records apply") + }; + assert_eq!(args.config, PathBuf::from("/runtime.yaml")); + assert_eq!(args.records, PathBuf::from("/records.yaml")); + + assert!(Cli::try_parse_from(["schedulingctl", "records"]).is_err()); + assert!( + Cli::try_parse_from(["schedulingctl", "records", "apply", "/runtime.yaml"]).is_err() + ); + assert!(Cli::try_parse_from([ "schedulingctl", "init", @@ -656,4 +754,150 @@ mod tests { assert!(text.starts_with("Authoring check completed with incomplete inputs.")); assert!(text.contains("finding scheduling.version: invalid-bound")); } + + #[test] + fn package_writes_a_verifiable_manifest_and_refuses_replacement() { + let (_root, project) = initialized("standalone-exact-time"); + let (exit, report, stderr) = run_json(&["package", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::SUCCESS); + assert!(stderr.is_empty()); + assert_eq!(report["command"], "package"); + assert!(report["packageDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + assert!(report["policyDigest"] + .as_str() + .unwrap() + .starts_with("sha256:")); + // The package identity and the policy's own digest are different + // documents and must never be conflated. + assert_ne!(report["packageDigest"], report["policyDigest"]); + assert_eq!(report["files"][0]["path"], "scheduling.yaml"); + assert_eq!(report["runtimeConfigurationIncluded"], false); + assert_eq!(report["secretsIncluded"], false); + + let manifest_path = project.join("scheduling.package.json"); + assert!(manifest_path.is_file()); + // The written manifest is exactly the one the runtime's own verifier + // accepts against the same policy text. + let policy_path = project.join("scheduling.yaml"); + let policy_text = std::fs::read_to_string(&policy_path).unwrap(); + let verified = + registry_scheduling::config::verify_policy_package(&policy_path, &policy_text) + .unwrap() + .expect("the written manifest verifies"); + assert_eq!(verified, report["packageDigest"].as_str().unwrap()); + // Pretty-printed, newline-terminated: a text document an operator + // diffs. + let bytes = std::fs::read(&manifest_path).unwrap(); + assert_eq!(bytes.last(), Some(&b'\n')); + + // Repackaging is a deliberate act: the existing manifest is refused, + // never silently replaced. + let (exit, report, stderr) = run_json(&["package", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + let message = report["diagnostics"][0]["message"].as_str().unwrap(); + assert!(message.contains("already exists"), "{message}"); + } + + #[test] + fn package_refuses_incomplete_authoring_and_writes_nothing() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "version: 1\n", + "version: 0\n", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + let (exit, report, stderr) = run_json(&["package", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert!(report["diagnostics"][0]["message"] + .as_str() + .unwrap() + .contains("finding")); + assert!(!project.join("scheduling.package.json").exists()); + } + + #[test] + fn runtime_configuration_and_store_failures_map_to_their_own_diagnostics() { + let config_error = anyhow::Error::new(RuntimeConfigError::RelativeRuntimePath); + let (exit, diagnostic) = classify_failure(&config_error); + assert_eq!(exit, DOMAIN_REFUSAL_EXIT); + assert_eq!( + diagnostic["code"], + "schedulingctl.runtime-configuration.invalid" + ); + assert_eq!(diagnostic["artifact"], "runtime_configuration"); + + let store_error = anyhow::Error::new(StoreError::Configuration).context("connecting"); + let (exit, diagnostic) = classify_failure(&store_error); + assert_eq!(exit, OPERATIONAL_FAILURE_EXIT); + assert_eq!(diagnostic["code"], "schedulingctl.store-unavailable"); + assert_eq!(diagnostic["artifact"], "database"); + for field in [ + "severity", + "code", + "artifact", + "path", + "message", + "suggestedAction", + ] { + assert!(diagnostic.get(field).is_some(), "missing {field}"); + } + } + + /// A records document that does not hold together is refused in its own + /// terms before any connection is opened: the write path never reaches + /// the database with a document the store would reject mid-swap. + #[test] + fn records_apply_refuses_an_invalid_document_before_touching_a_database() { + let (root, project) = initialized("standalone-exact-time"); + let config_path = root.path().join("runtime.yaml"); + std::fs::write( + &config_path, + format!( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1\n\ + kind: SchedulingRuntimeConfig\n\ + package:\n root: {}\n\ + listener:\n bind: 127.0.0.1:8105\n tlsTermination: development-loopback\n\ + secretProviders:\n environment: {{}}\n\ + authentication:\n oidc:\n issuer: https://issuer.example.test\n\ + \x20 audience: scheduling-api\n\ + database:\n runtimeUrlRef: secret:env/SCHEDULINGCTL_TEST_DATABASE\n\ + \x20 migrationUrlRef: secret:env/SCHEDULINGCTL_TEST_DATABASE\n\ + audit:\n path: {}/audit.jsonl\n\ + \x20 hashKeyRef: secret:env/SCHEDULINGCTL_TEST_AUDIT\n\ + retention:\n attemptReceiptDays: 2\n", + project.display(), + root.path().display() + ), + ) + .unwrap(); + let records_path = root.path().join("records.yaml"); + std::fs::write( + &records_path, + "locations:\n - id: bangkok-counter\n timezone: Asia/Nowhere\n", + ) + .unwrap(); + let (exit, report, stderr) = run_json(&[ + "records", + "apply", + config_path.to_str().unwrap(), + records_path.to_str().unwrap(), + ]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + let message = report["diagnostics"][0]["message"] + .as_str() + .unwrap() + .to_owned(); + assert!( + message.contains("unknown timezone Asia/Nowhere"), + "{message}" + ); + } } diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index 72102614b8..f6c98261c0 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -10,10 +10,11 @@ use std::fs; use std::path::{Path, PathBuf}; -use anyhow::{bail, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; +use registry_scheduling::config::{verify_policy_package, PolicyPackageManifest}; use registry_scheduling_core::{ parse_fixture_yaml, parse_policy_yaml, CaseStatus, SchedulingDiagnostic, SchedulingFixture, - SchedulingPolicy, AUTHORED_POLICY_FILE, + SchedulingPolicy, AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, }; use serde_json::{json, Value}; @@ -194,6 +195,59 @@ pub(super) fn explain(project: &Path) -> Result { })) } +/// Write the verified package manifest beside the authored policy. The +/// manifest is the deployment identity the runtime verifies at startup, so +/// repackaging after an edit is a deliberate act: an existing manifest is +/// never silently replaced, and the written manifest is proven to verify +/// against the exact policy text before the command reports success. +pub(super) fn package(project: &Path) -> Result { + let project = + fs::canonicalize(project).context("resolving the Scheduling authoring project")?; + let policy_text = read_authoring_input(&project.join(AUTHORED_POLICY_FILE))?; + let policy = parse_policy_yaml(&policy_text) + .map_err(|error| anyhow!("parsing {AUTHORED_POLICY_FILE} failed at {}", error.path()))?; + let findings = policy.check(); + if !findings.is_empty() { + bail!( + "the authored policy reports {} finding(s); run schedulingctl check and fix them before packaging", + findings.len() + ); + } + let manifest = PolicyPackageManifest::build(&policy_text) + .context("building the Scheduling policy package identity")?; + let manifest_path = project.join(SCHEDULING_PACKAGE_MANIFEST_FILE); + if manifest_path.exists() { + bail!( + "{} already exists; remove it deliberately before repackaging", + manifest_path.display() + ); + } + let mut bytes = + serde_json::to_vec_pretty(&manifest).context("encoding the package manifest")?; + bytes.push(b'\n'); + fs::write(&manifest_path, bytes) + .with_context(|| format!("writing {}", manifest_path.display()))?; + let verified = verify_policy_package(&project.join(AUTHORED_POLICY_FILE), &policy_text) + .with_context(|| format!("verifying the written {}", manifest_path.display()))?; + match verified { + Some(digest) if digest == manifest.policy_digest => {} + _ => bail!("the written package manifest does not verify against the authored policy"), + } + Ok(json!({ + "ok": true, + "command": "package", + "project": project, + "manifest": manifest_path, + "policyDigest": policy.policy_digest(), + "packageDigest": manifest.policy_digest, + "files": manifest.files, + "runtimeConfigurationIncluded": false, + "secretsIncluded": false, + "networkAccess": false, + "databaseAccess": false, + })) +} + fn run_fixture(project: &Path, policy: &SchedulingPolicy, path: &Path) -> Result { let relative: PathBuf = path.strip_prefix(project).unwrap_or(path).to_owned(); let fixture = load_fixture(path)?; @@ -236,7 +290,7 @@ fn load_fixture(path: &Path) -> Result { parse_fixture_yaml(&bytes).with_context(|| format!("parsing fixture {}", path.display())) } -fn read_authoring_input(path: &Path) -> Result { +pub(super) fn read_authoring_input(path: &Path) -> Result { let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?; if bytes.len() > MAXIMUM_INPUT_BYTES { bail!("{} exceeds the one MiB authoring limit", path.display()); diff --git a/crates/registry-schedulingctl/src/records.rs b/crates/registry-schedulingctl/src/records.rs new file mode 100644 index 0000000000..572fa5418a --- /dev/null +++ b/crates/registry-schedulingctl/src/records.rs @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The live environment-records command. +//! +//! `records apply` is the one attributable operator write that swaps a +//! deployment's locations, pools, members, and exceptions wholesale. The +//! records document is parsed and validated offline first — nothing reaches +//! the database until the whole document holds together — and the swap itself +//! lands through the store's single replace transaction, which writes its own +//! audit row beside the new facts. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_scheduling::config::RuntimeConfig; +use registry_scheduling::store::PostgresStore; +use registry_scheduling_core::SchedulingFacts; +use serde_json::{json, Value}; +use uuid::Uuid; + +pub fn apply(config_path: &Path, records_path: &Path) -> Result { + let config_path = + fs::canonicalize(config_path).context("resolving the Scheduling runtime configuration")?; + let records_path = + fs::canonicalize(records_path).context("resolving the Scheduling environment records")?; + let config = RuntimeConfig::load(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + let text = crate::project::read_authoring_input(&records_path)?; + let facts = parse_records(&text)?; + validate(&facts)?; + let counts = counts(&facts); + let resolver = secret_resolver(&config)?; + let store = PostgresStore::connect_migration(&config.database, &resolver) + .context("the Scheduling migration database configuration is invalid")?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("starting the Scheduling operator runtime")?; + runtime + .block_on(store.migrate()) + .context("applying the Scheduling schema")?; + let audit_event = Uuid::new_v4(); + let audit_record = json!({ + "actorKind": "operator", + "operation": "records.apply", + "outcome": "allowed", + "reason": "authorization.allowed", + "counts": counts, + }); + runtime + .block_on(store.replace_facts(&facts, audit_event, audit_record)) + .context("replacing the environment records")?; + Ok(json!({ + "ok": true, + "command": "records-apply", + "config": config_path, + "records": records_path, + "applied": counts, + })) +} + +/// Read the environment records, refusing a document the model does not +/// carry and naming the path of the first offending field. +fn parse_records(text: &str) -> Result { + let deserializer = serde_norway::Deserializer::from_str(text); + serde_path_to_error::deserialize(deserializer).map_err(|error| { + let path = error.path().to_string(); + anyhow!( + "parsing the environment records failed at {}: {error}", + if path.is_empty() { + "/".to_owned() + } else { + path + } + ) + }) +} + +/// The checks the store cannot make: the tables it writes have primary keys +/// and typed columns, so an inconsistent document would fail mid-swap with a +/// database error naming none of the author's vocabulary. Every rule here +/// refuses before the transaction opens, in the records document's own terms. +fn validate(facts: &SchedulingFacts) -> Result<()> { + let mut locations = BTreeSet::new(); + for location in &facts.locations { + if location.id.is_empty() { + bail!("a location record carries an empty id"); + } + if !locations.insert(&location.id) { + bail!("location {} is declared more than once", location.id); + } + if location.timezone.parse::().is_err() { + bail!( + "location {} names an unknown timezone {}", + location.id, + location.timezone + ); + } + } + let mut pools = BTreeSet::new(); + let mut resources = BTreeSet::new(); + for pool in &facts.pools { + if pool.id.is_empty() { + bail!("a resource pool carries an empty id"); + } + if !pools.insert(&pool.id) { + bail!("resource pool {} is declared more than once", pool.id); + } + for member in &pool.members { + if member.resource_id.is_empty() { + bail!( + "pool {} carries a member with an empty resource id", + pool.id + ); + } + if !resources.insert(&member.resource_id) { + bail!( + "resource {} is declared in more than one pool", + member.resource_id + ); + } + } + } + for exception in &facts.exceptions { + if exception.id.is_empty() { + bail!("an exception record carries an empty id"); + } + if !locations.contains(&exception.location) { + bail!( + "exception {} names location {} the records do not declare", + exception.id, + exception.location + ); + } + if !is_iso_date(&exception.date) { + bail!( + "exception {} carries a date that is not YYYY-MM-DD: {}", + exception.id, + exception.date + ); + } + for time in [&exception.start_time, &exception.end_time] { + if !is_hh_mm(time) { + bail!( + "exception {} carries a time that is not HH:MM: {}", + exception.id, + time + ); + } + } + } + Ok(()) +} + +/// The strict shapes the database columns accept: exactly `YYYY-MM-DD` and +/// exactly `HH:MM`, so a loose value is refused here rather than rejected by +/// the column mid-swap. +fn is_iso_date(value: &str) -> bool { + value.len() == 10 + && value.as_bytes()[4] == b'-' + && value.as_bytes()[7] == b'-' + && value + .bytes() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) + && chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok() +} + +fn is_hh_mm(value: &str) -> bool { + value.len() == 5 + && value.as_bytes()[2] == b':' + && value + .bytes() + .enumerate() + .all(|(index, byte)| index == 2 || byte.is_ascii_digit()) + && chrono::NaiveTime::parse_from_str(value, "%H:%M").is_ok() +} + +fn counts(facts: &SchedulingFacts) -> Value { + json!({ + "locations": facts.locations.len(), + "pools": facts.pools.len(), + "members": facts.pools.iter().map(|pool| pool.members.len()).sum::(), + "exceptions": facts.exceptions.len(), + }) +} + +fn secret_resolver(config: &RuntimeConfig) -> Result { + let mut providers = Vec::new(); + if config.secret_providers.file.is_some() { + providers.push(SecretProvider::File); + } + if config.secret_providers.environment.is_some() { + providers.push(SecretProvider::Environment); + } + SecretResolver::new( + providers, + config + .secret_providers + .file + .as_ref() + .map_or_else(|| Path::new(""), |file| file.root.as_path()), + ) + .context("configuring the Scheduling secret providers") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facts_from(text: &str) -> SchedulingFacts { + serde_norway::from_str(text).unwrap() + } + + #[test] + fn a_consistent_document_validates() { + let facts = facts_from( + "locations:\n - id: north-counter\n timezone: Asia/Bangkok\n\ + pools:\n - id: stations\n members:\n - resourceId: station-1\n\ + \x20 capabilities: []\n available: true\n\ + exceptions:\n - id: training\n location: north-counter\n kind: closure\n\ + \x20 date: '2026-10-07'\n startTime: '09:00'\n endTime: '12:30'\n", + ); + assert!(validate(&facts).is_ok()); + assert_eq!( + counts(&facts), + json!({"locations": 1, "pools": 1, "members": 1, "exceptions": 1}) + ); + } + + #[test] + fn inconsistent_documents_are_refused_in_their_own_terms() { + let cases = [ + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\n - id: a\n timezone: Asia/Bangkok\n", + "location a is declared more than once", + ), + ( + "locations:\n - id: a\n timezone: Mars/Olympus\n", + "names an unknown timezone Mars/Olympus", + ), + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\npools:\n \ + - id: p\n members:\n - resourceId: r1\n capabilities: []\n\ + \x20 available: true\n - resourceId: r1\n capabilities: []\n\ + \x20 available: true\n", + "resource r1 is declared in more than one pool", + ), + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\nexceptions:\n \ + - id: x\n location: elsewhere\n kind: closure\n date: '2026-10-07'\n\ + \x20 startTime: '09:00'\n endTime: '12:30'\n", + "names location elsewhere the records do not declare", + ), + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\nexceptions:\n \ + - id: x\n location: a\n kind: closure\n date: '2026-10-7'\n\ + \x20 startTime: '09:00'\n endTime: '12:30'\n", + "not YYYY-MM-DD", + ), + ( + "locations:\n - id: a\n timezone: Asia/Bangkok\nexceptions:\n \ + - id: x\n location: a\n kind: closure\n date: '2026-10-07'\n\ + \x20 startTime: '9:00'\n endTime: '12:30'\n", + "not HH:MM", + ), + ]; + for (document, expected) in cases { + let facts = facts_from(document); + let refusal = validate(&facts).expect_err("the document is refused"); + assert!( + refusal.to_string().contains(expected), + "{refusal} does not name {expected}" + ); + } + } + + #[test] + fn a_document_the_model_does_not_carry_is_refused_with_its_path() { + let refusal = parse_records( + "locations:\n - id: a\n timezone: Asia/Bangkok\n\ + schedule: daily\n", + ) + .expect_err("the stray field is refused"); + let message = refusal.to_string(); + assert!( + message.contains("locations") || message.contains("schedule"), + "{message} names neither the container nor the field" + ); + } +} diff --git a/crates/registry-schedulingctl/tests/records_apply_postgres.rs b/crates/registry-schedulingctl/tests/records_apply_postgres.rs new file mode 100644 index 0000000000..ca533bfcfd --- /dev/null +++ b/crates/registry-schedulingctl/tests/records_apply_postgres.rs @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `records apply` against a real PostgreSQL, on a disposable schema. +//! +//! The test proves the one attributable operator write end to end: the schema +//! migration runs, the first document lands whole (locations, pools, members, +//! and the typed exception columns), a second document replaces the first +//! rather than appending to it, and every apply leaves its audit row in the +//! outbox carrying the operator's allowed reason. + +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_scheduling::config::RuntimeConfig; +use registry_scheduling::store::PostgresStore; +use registry_schedulingctl::records; +use serde_json::json; +use std::path::Path; +use uuid::Uuid; + +/// A minimal exact-time policy that passes its checks, in the vocabulary of +/// the standalone starter project. +const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: bangkok-counter + because: A 30-minute registry update at an interchangeable counter station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry offices. + dates: [] +openings: + - id: bangkok-counter-hours + location: bangkok-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. +windows: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#; + +const FIRST_RECORDS: &str = r#"locations: + - id: bangkok-counter + timezone: Asia/Bangkok +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true +exceptions: + - id: staff-training + location: bangkok-counter + kind: closure + date: "2026-10-07" + startTime: "09:00" + endTime: "12:30" +"#; + +const SECOND_RECORDS: &str = r#"locations: + - id: bangkok-counter + timezone: Asia/Bangkok + - id: chiang-mai-counter + timezone: Asia/Bangkok +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true +"#; + +fn scoped_url(base: &str, schema: &str) -> String { + let separator = if base.contains('?') { '&' } else { '?' }; + format!("{base}{separator}options=-csearch_path%3D{schema}") +} + +/// `records apply` builds its own single-threaded runtime, so the call runs +/// on a plain thread: it cannot nest inside this test's runtime. +fn apply(config: &Path, records_path: &Path) -> serde_json::Value { + let config = config.to_path_buf(); + let records_path = records_path.to_path_buf(); + std::thread::spawn(move || records::apply(&config, &records_path)) + .join() + .expect("records apply does not panic") + .expect("records apply succeeds") +} + +#[tokio::test] +async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { + let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") + .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); + let schema = format!("records_apply_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .expect("the test server accepts an administrative connection"); + tokio::spawn(async move { + connection + .await + .expect("the administrative connection stays up") + }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .expect("a disposable schema is created"); + + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("scheduling.yaml"), POLICY).unwrap(); + std::fs::write(root.path().join("first.yaml"), FIRST_RECORDS).unwrap(); + std::fs::write(root.path().join("second.yaml"), SECOND_RECORDS).unwrap(); + std::fs::write( + root.path().join("runtime.yaml"), + format!( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1\n\ + kind: SchedulingRuntimeConfig\n\ + package:\n root: {project}\n\ + listener:\n bind: 127.0.0.1:8105\n tlsTermination: development-loopback\n\ + secretProviders:\n environment: {{}}\n\ + authentication:\n oidc:\n issuer: https://identity.example.test\n\ + \x20 audience: urn:example:scheduling\n\ + database:\n runtimeUrlRef: secret:env/SCHEDULING_RECORDS_TEST_DATABASE\n\ + \x20 migrationUrlRef: secret:env/SCHEDULING_RECORDS_TEST_DATABASE\n\ + \x20 testOnlyPlaintext: true\n\ + audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_RECORDS_TEST_AUDIT\n\ + retention:\n attemptReceiptDays: 2\n", + project = project.display(), + audit = root.path().join("audit.ndjson").display(), + ), + ) + .unwrap(); + let config_path = root.path().join("runtime.yaml"); + std::env::set_var( + "SCHEDULING_RECORDS_TEST_DATABASE", + scoped_url(&base, &schema), + ); + std::env::set_var( + "SCHEDULING_RECORDS_TEST_AUDIT", + "0123456789abcdef0123456789abcdef", + ); + + let report = apply(&config_path, &root.path().join("first.yaml")); + assert_eq!(report["command"], "records-apply"); + assert_eq!( + report["applied"], + json!({"locations": 1, "pools": 1, "members": 2, "exceptions": 1}) + ); + + let config = RuntimeConfig::load(&config_path).expect("the runtime configuration loads"); + let resolver = SecretResolver::new([SecretProvider::Environment], "") + .expect("the environment secret provider configures"); + let store = PostgresStore::connect_runtime(&config.database, &resolver).unwrap(); + + let facts = store.facts().await.unwrap(); + assert_eq!(facts.locations.len(), 1); + assert_eq!(facts.locations[0].id, "bangkok-counter"); + assert_eq!(facts.locations[0].timezone, "Asia/Bangkok"); + assert_eq!(facts.pools.len(), 1); + assert_eq!(facts.pools[0].id, "update-stations"); + let member_ids: Vec<&str> = facts.pools[0] + .members + .iter() + .map(|member| member.resource_id.as_str()) + .collect(); + assert_eq!(member_ids, vec!["station-1", "station-2"]); + assert_eq!(facts.exceptions.len(), 1); + assert_eq!(facts.exceptions[0].id, "staff-training"); + assert_eq!(facts.exceptions[0].date, "2026-10-07"); + + let audit = store.pending_audit(10).await.unwrap(); + let applies: Vec<_> = audit + .iter() + .filter(|(_, record)| record["operation"] == "records.apply") + .collect(); + assert_eq!(applies.len(), 1, "{audit:?}"); + assert_eq!(applies[0].1["outcome"], "allowed"); + assert_eq!(applies[0].1["reason"], "authorization.allowed"); + assert_eq!(applies[0].1["counts"]["exceptions"], 1); + + // The second document replaces the first: the second location lands, the + // retired station is gone, and the closure does not survive. + let report = apply(&config_path, &root.path().join("second.yaml")); + assert_eq!( + report["applied"], + json!({"locations": 2, "pools": 1, "members": 1, "exceptions": 0}) + ); + let facts = store.facts().await.unwrap(); + assert_eq!(facts.locations.len(), 2); + let retired_station = facts + .pools + .iter() + .flat_map(|pool| pool.members.iter()) + .any(|member| member.resource_id == "station-2"); + assert!(!retired_station, "a replace is not an append"); + assert!(facts.exceptions.is_empty(), "the closure did not survive"); + + let audit = store.pending_audit(10).await.unwrap(); + let applies: Vec<_> = audit + .iter() + .filter(|(_, record)| record["operation"] == "records.apply") + .collect(); + assert_eq!(applies.len(), 2, "{audit:?}"); + assert!(applies + .iter() + .all(|(_, record)| record["reason"] == "authorization.allowed")); + + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .expect("the disposable schema is dropped"); +} From 13484eccfc0669d8b13d20db0123f508c642dbf8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 09:09:16 +0700 Subject: [PATCH 014/115] feat(scheduling): type the request edge as product problem codes The request-edge rejections (400/404/405/413/415/422) join the closed problem vocabulary under the product's own prefix, exactly as Casework carries them: no shared platform problem prefix exists in the Registry Stack identifier catalog. The client now types the product's own request-edge documents and treats only foreign dialects as edge talk. Signed-off-by: Jeremi Joslin --- .../registry-scheduling-client/src/error.rs | 26 +++++- .../tests/http_boundary.rs | 11 +-- .../registry-scheduling-core/src/problem.rs | 82 +++++++++++++++++-- 3 files changed, 107 insertions(+), 12 deletions(-) diff --git a/crates/registry-scheduling-client/src/error.rs b/crates/registry-scheduling-client/src/error.rs index ce78f1901f..8c81d49973 100644 --- a/crates/registry-scheduling-client/src/error.rs +++ b/crates/registry-scheduling-client/src/error.rs @@ -110,6 +110,30 @@ mod tests { } } + /// The request-edge family is part of the closed vocabulary, so the + /// product's own route-not-found answer is a typed problem, never edge + /// talk; only a foreign dialect's is. + #[test] + fn the_products_own_request_edge_problem_is_typed() { + let code = ProblemCode::RequestNotFound; + let document = ProblemDocument { + type_uri: type_uri(code.code()), + title: code.title().to_owned(), + status: code.http_status(), + detail: code.detail().to_owned(), + code: code.code().to_owned(), + trace_id: TraceId::parse(TRACE_ID).expect("fixture trace identifier"), + }; + match domain_problem(StatusCode::NOT_FOUND, Some(TRACE_ID), &document) { + SchedulingClientError::Problem { + status: 404, + code: ProblemCode::RequestNotFound, + .. + } => {} + other => panic!("expected a typed request-edge problem, got {other:?}"), + } + } + #[test] fn every_problem_mismatch_degrades_to_a_protocol_failure() { let document = exhaustive_document(); @@ -130,7 +154,7 @@ mod tests { }, ProblemDocument { type_uri: format!( - "https://id.registrystack.org/problems/registry-platform/{}", + "https://example.test/problems/other/{}", document.code.replace('.', "/") ), ..document.clone() diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs index 2d62e3dd6e..23b3a97fd9 100644 --- a/crates/registry-scheduling-client/tests/http_boundary.rs +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -654,8 +654,8 @@ async fn an_exact_problem_document_maps_to_the_typed_runtime_code() { #[tokio::test] async fn edge_answers_stay_edge_talk() { let observations: Observations = Arc::new(Mutex::new(Vec::new())); - let platform_problem = serde_json::json!({ - "type": "https://id.registrystack.org/problems/registry-platform/request/not-found", + let foreign_problem = serde_json::json!({ + "type": "https://example.test/problems/other/request/not-found", "title": "Route not found", "status": 404, "detail": "The requested route does not exist.", @@ -665,7 +665,7 @@ async fn edge_answers_stay_edge_talk() { .to_string(); let mut untraced = Fixture::json(&observations, StatusCode::OK, SCHEDULING_DOCUMENT); untraced.traced = false; - let not_found = Fixture::json(&observations, StatusCode::NOT_FOUND, &platform_problem) + let not_found = Fixture::json(&observations, StatusCode::NOT_FOUND, &foreign_problem) .with_content_type("application/problem+json"); let plain_json = Fixture::json( &observations, @@ -696,8 +696,9 @@ async fn edge_answers_stay_edge_talk() { let token = BearerToken::new("fixture-secret").expect("fixture token"); let client = client(&address); - // A platform-owned route-not-found problem carries a different type base - // and a code outside the closed vocabulary: the edge talking. + // A route-not-found problem from a foreign dialect carries a type base + // outside this product's vocabulary: the edge talking. The product's own + // request-edge codes are typed by the error taxonomy tests. assert!(matches!( client.list_services(auth(&token), None).await, Err(SchedulingClientError::Protocol { diff --git a/crates/registry-scheduling-core/src/problem.rs b/crates/registry-scheduling-core/src/problem.rs index b44c867918..d56290e71e 100644 --- a/crates/registry-scheduling-core/src/problem.rs +++ b/crates/registry-scheduling-core/src/problem.rs @@ -10,6 +10,11 @@ //! reasons and problem codes must not be conflated. The authorization refusals //! callers see here are `authentication.refused`, `operation.not-authorized`, //! and `profile.not-authorized`. +//! +//! The `request.*` family carries the request-edge rejections (a route that +//! does not exist, a method the route refuses, a body too large or not JSON) +//! under this product's own prefix, exactly as Casework does: no shared +//! platform problem prefix exists in the Registry Stack catalog. use crate::naming::SCHEDULING_PROBLEM_TYPE_BASE; @@ -35,6 +40,12 @@ pub const PRECONDITION_FAILED_PROBLEM: &str = "precondition.failed"; pub const PRECONDITION_REQUIRED_PROBLEM: &str = "precondition.required"; pub const PREREQUISITE_MISSING_PROBLEM: &str = "prerequisite.missing"; pub const PROFILE_NOT_AUTHORIZED_PROBLEM: &str = "profile.not-authorized"; +pub const REQUEST_BODY_TOO_LARGE_PROBLEM: &str = "request.body-too-large"; +pub const REQUEST_INVALID_PROBLEM: &str = "request.invalid"; +pub const REQUEST_METHOD_NOT_ALLOWED_PROBLEM: &str = "request.method-not-allowed"; +pub const REQUEST_NOT_FOUND_PROBLEM: &str = "request.not-found"; +pub const REQUEST_UNPROCESSABLE_PROBLEM: &str = "request.unprocessable"; +pub const REQUEST_UNSUPPORTED_MEDIA_TYPE_PROBLEM: &str = "request.unsupported-media-type"; pub const RESOURCE_UNAVAILABLE_PROBLEM: &str = "resource.unavailable"; pub const REVISION_MISMATCH_PROBLEM: &str = "revision.mismatch"; pub const SCHEDULE_UNPUBLISHED_PROBLEM: &str = "schedule.unpublished"; @@ -65,6 +76,12 @@ pub enum ProblemCode { PreconditionRequired, PrerequisiteMissing, ProfileNotAuthorized, + RequestBodyTooLarge, + RequestInvalid, + RequestMethodNotAllowed, + RequestNotFound, + RequestUnprocessable, + RequestUnsupportedMediaType, ResourceUnavailable, RevisionMismatch, ScheduleUnpublished, @@ -96,6 +113,12 @@ impl ProblemCode { Self::PreconditionRequired, Self::PrerequisiteMissing, Self::ProfileNotAuthorized, + Self::RequestBodyTooLarge, + Self::RequestInvalid, + Self::RequestMethodNotAllowed, + Self::RequestNotFound, + Self::RequestUnprocessable, + Self::RequestUnsupportedMediaType, Self::ResourceUnavailable, Self::RevisionMismatch, Self::ScheduleUnpublished, @@ -127,6 +150,12 @@ impl ProblemCode { Self::PreconditionRequired => PRECONDITION_REQUIRED_PROBLEM, Self::PrerequisiteMissing => PREREQUISITE_MISSING_PROBLEM, Self::ProfileNotAuthorized => PROFILE_NOT_AUTHORIZED_PROBLEM, + Self::RequestBodyTooLarge => REQUEST_BODY_TOO_LARGE_PROBLEM, + Self::RequestInvalid => REQUEST_INVALID_PROBLEM, + Self::RequestMethodNotAllowed => REQUEST_METHOD_NOT_ALLOWED_PROBLEM, + Self::RequestNotFound => REQUEST_NOT_FOUND_PROBLEM, + Self::RequestUnprocessable => REQUEST_UNPROCESSABLE_PROBLEM, + Self::RequestUnsupportedMediaType => REQUEST_UNSUPPORTED_MEDIA_TYPE_PROBLEM, Self::ResourceUnavailable => RESOURCE_UNAVAILABLE_PROBLEM, Self::RevisionMismatch => REVISION_MISMATCH_PROBLEM, Self::ScheduleUnpublished => SCHEDULE_UNPUBLISHED_PROBLEM, @@ -160,9 +189,13 @@ impl ProblemCode { #[must_use] pub const fn http_status(self) -> u16 { match self { - Self::CursorInvalid => 400, + Self::CursorInvalid | Self::RequestInvalid => 400, Self::AuthenticationRefused => 401, Self::OperationNotAuthorized | Self::ProfileNotAuthorized => 403, + Self::RequestNotFound => 404, + Self::RequestMethodNotAllowed => 405, + Self::RequestBodyTooLarge => 413, + Self::RequestUnsupportedMediaType => 415, Self::BookingDuplicateActive | Self::CancellationCutoffPassed | Self::CapacityExhausted @@ -176,6 +209,7 @@ impl ProblemCode { | Self::HorizonOutside | Self::PartyCapacityInadequate | Self::PrerequisiteMissing + | Self::RequestUnprocessable | Self::ScheduleUnpublished => 422, Self::PreconditionRequired => 428, Self::EligibilityUnavailable | Self::HookUnavailable | Self::ServiceUnavailable => 503, @@ -211,6 +245,12 @@ impl ProblemCode { Self::PreconditionRequired => "Precondition required", Self::PrerequisiteMissing => "Prerequisite missing", Self::ProfileNotAuthorized => "Profile not authorized", + Self::RequestBodyTooLarge => "Payload too large", + Self::RequestInvalid => "Request invalid", + Self::RequestMethodNotAllowed => "Method not allowed", + Self::RequestNotFound => "Route not found", + Self::RequestUnprocessable => "Request unprocessable", + Self::RequestUnsupportedMediaType => "Unsupported media type", Self::ResourceUnavailable => "Resource unavailable", Self::RevisionMismatch => "Revision mismatch", Self::ScheduleUnpublished => "Schedule unpublished", @@ -286,6 +326,12 @@ impl ProblemCode { Self::ProfileNotAuthorized => { "The selected Scheduling profile does not authorize this request." } + Self::RequestBodyTooLarge => "The request body exceeds the accepted size.", + Self::RequestInvalid => "The request could not be read as a Scheduling request.", + Self::RequestMethodNotAllowed => "The route exists but not for this method.", + Self::RequestNotFound => "The requested route does not exist.", + Self::RequestUnprocessable => "The request body could not be processed.", + Self::RequestUnsupportedMediaType => "The request body is not JSON.", Self::ResourceUnavailable => { "Every capable member is unavailable for this interval." } @@ -315,7 +361,7 @@ mod tests { #[test] fn the_vocabulary_is_complete_and_closed() { - assert_eq!(ProblemCode::ALL.len(), 26); + assert_eq!(ProblemCode::ALL.len(), 32); for code in ProblemCode::ALL { assert_eq!(ProblemCode::from_code(code.code()), Some(*code)); } @@ -359,12 +405,13 @@ mod tests { /// Every code carries one pinned status from the statuses this product /// answers with, a non-empty title, and a non-empty value-free detail. - /// Transport statuses (404, 405, 413, 415) are absent on purpose: those - /// are edge rejections under the platform's own problem namespace, never - /// domain codes. + /// The request-edge family carries the transport statuses (404, 405, + /// 413, 415) under this product's own prefix, exactly as Casework does. #[test] fn every_code_pins_a_status_title_and_detail() { - let statuses = [400, 401, 403, 409, 410, 412, 422, 428, 503]; + let statuses = [ + 400, 401, 403, 404, 405, 409, 410, 412, 413, 415, 422, 428, 503, + ]; for code in ProblemCode::ALL { assert!( statuses.contains(&code.http_status()), @@ -377,6 +424,29 @@ mod tests { } } + /// The request-edge family is Casework's, pinned one status per code, so + /// the runtime's edge and every client agree on what a rejected request + /// looks like. + #[test] + fn the_request_edge_family_pins_casework_s_statuses() { + assert_eq!(ProblemCode::RequestInvalid.http_status(), 400); + assert_eq!(ProblemCode::RequestNotFound.http_status(), 404); + assert_eq!(ProblemCode::RequestMethodNotAllowed.http_status(), 405); + assert_eq!(ProblemCode::RequestBodyTooLarge.http_status(), 413); + assert_eq!(ProblemCode::RequestUnsupportedMediaType.http_status(), 415); + assert_eq!(ProblemCode::RequestUnprocessable.http_status(), 422); + for code in [ + ProblemCode::RequestInvalid, + ProblemCode::RequestNotFound, + ProblemCode::RequestMethodNotAllowed, + ProblemCode::RequestBodyTooLarge, + ProblemCode::RequestUnsupportedMediaType, + ProblemCode::RequestUnprocessable, + ] { + assert!(code.code().starts_with("request."), "{}", code.code()); + } + } + /// The statuses the acceptance scenarios reason about: a recoverable /// capacity conflict, a hold that can no longer be confirmed, the two /// idempotency answers, and the two precondition answers. From e661dcb7630ec2223553aa8fbd3cf47fa3168f77 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 09:19:43 +0700 Subject: [PATCH 015/115] feat(scheduling): add the product checkpoint and starter example The database-free checkpoint runs the dependency-direction gate, both starter templates through the whole offline authoring journey, the package manifest write, and a drift check holding the committed example to exactly what init writes. Signed-off-by: Jeremi Joslin --- products/scheduling/README.md | 16 +++- .../fixtures/counter-stations.yaml | 77 +++++++++++++++++ .../fixtures/fold-day-rebooking.yaml | 83 +++++++++++++++++++ .../standalone-exact-time/scheduling.yaml | 80 ++++++++++++++++++ .../scheduling/scripts/check-checkpoint.sh | 45 ++++++++++ 5 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 products/scheduling/examples/standalone-exact-time/fixtures/counter-stations.yaml create mode 100644 products/scheduling/examples/standalone-exact-time/fixtures/fold-day-rebooking.yaml create mode 100644 products/scheduling/examples/standalone-exact-time/scheduling.yaml create mode 100755 products/scheduling/scripts/check-checkpoint.sh diff --git a/products/scheduling/README.md b/products/scheduling/README.md index ba888bfd1c..e0e7e82256 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -8,14 +8,22 @@ model and evaluators live in `crates/registry-scheduling-core`; adopter tooling is `crates/registry-schedulingctl` (`schedulingctl`). This folder holds the product's own contracts, examples, fixtures, and gates. -It currently carries the dependency-direction gate that holds the product -boundary from the first commit: +The database-free checkpoint runs the dependency-direction gate and the whole +offline authoring journey: ```bash -python3 products/scheduling/scripts/check_dependency_direction.py -python3 -m unittest products/scheduling/scripts/test_dependency_direction.py +cargo build --locked -p registry-schedulingctl +products/scheduling/scripts/check-checkpoint.sh ``` +For PostgreSQL execution, destructive test suites require a separate disposable +database and the `postgres-test` feature; a test binary that skips because its +database URL is absent is not database verification. + +`examples/standalone-exact-time/` is exactly what +`schedulingctl init --template standalone-exact-time` writes, and the +checkpoint fails if the two drift apart. + No scheduling crate, directly or through any shared dependency, may reach a Base Registry Engine, Casework, or Evidence crate, and none of those products may reach a scheduling crate. The source-neutral core sits at the bottom of diff --git a/products/scheduling/examples/standalone-exact-time/fixtures/counter-stations.yaml b/products/scheduling/examples/standalone-exact-time/fixtures/counter-stations.yaml new file mode 100644 index 0000000000..8b58c7a9cf --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/fixtures/counter-stations.yaml @@ -0,0 +1,77 @@ +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: counter-stations +now: 2026-10-05T00:00:00Z +facts: + locations: + - id: bangkok-counter + timezone: Asia/Bangkok + pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true +initial: [] +cases: + - name: first-caller-takes-a-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-1 + - name: same-key-retry-is-refused + request: + offering: registry-update-30 + start: 2026-10-05T02:30:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:one + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: booking.duplicate-active + - name: second-caller-gets-the-other-station + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:two + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: station-2 + - name: both-stations-busy-refuses-another-caller + request: + offering: registry-update-30 + start: 2026-10-05T02:00:00Z + party: + recipients: 1 + attendees: 1 + duplicateKey: subject:three + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted diff --git a/products/scheduling/examples/standalone-exact-time/fixtures/fold-day-rebooking.yaml b/products/scheduling/examples/standalone-exact-time/fixtures/fold-day-rebooking.yaml new file mode 100644 index 0000000000..e87dcb03a1 --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/fixtures/fold-day-rebooking.yaml @@ -0,0 +1,83 @@ +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: fold-day-rebooking +now: 2026-11-01T04:00:00Z +# America/New_York falls back on 2026-11-01: local 01:30 occurs twice, and the +# 00:30-02:30 opening spans three real hours, [04:30Z, 07:30Z). The closure +# 00:15-00:45 has unambiguous endpoints in one offset and moves the opening +# start to 04:45Z, so the 30-minute grid anchors there: 04:45, 05:15, 05:45... +facts: + locations: + - id: new-york-hall + timezone: America/New_York + pools: + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true + exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" +initial: [] +cases: + - name: morning-booking-after-the-closure + request: + offering: fold-day-update-30 + start: 2026-11-01T05:15:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: reschedule-into-the-later-morning + request: + offering: fold-day-update-30 + start: 2026-11-01T06:45:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + rescheduleOf: case:morning-booking-after-the-closure + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: the-freed-slot-serves-another-caller + request: + offering: fold-day-update-30 + start: 2026-11-01T05:15:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + resource: hall-station-1 + - name: a-wall-clock-match-off-the-moved-grid-is-not-served + request: + offering: fold-day-update-30 + start: 2026-11-01T06:30:00Z + party: + recipients: 1 + attendees: 1 + policyRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: schedule.unpublished diff --git a/products/scheduling/examples/standalone-exact-time/scheduling.yaml b/products/scheduling/examples/standalone-exact-time/scheduling.yaml new file mode 100644 index 0000000000..61a73db8b3 --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/scheduling.yaml @@ -0,0 +1,80 @@ +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: bangkok-counter + because: A 30-minute registry update at an interchangeable counter station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + reminders: + - minutesBefore: 1440 + because: One reminder the day before a counter update. + - minutesBefore: 120 + because: A second reminder two hours before, when stations are scarce. + duplicateActiveKey: subject + requiresCapabilities: [] + prerequisites: [] + - id: fold-day-update-30 + service: registry-update + label: 30-minute fold-day update + mode: exact-time + location: new-york-hall + because: A 30-minute update at the hall, published across the autumn time change. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 1 + horizonDays: 3650 + pool: hall-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry offices. + dates: [] +openings: + - id: bangkok-counter-hours + location: bangkok-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. + - id: new-york-hall-hours + location: new-york-hall + holidaySet: office-holidays + weekdays: [sun] + startTime: "00:30" + endTime: "02:30" + effectiveFrom: "2026-11-01" + effectiveUntil: "2026-11-01" + because: Fold-day hours whose endpoints sit outside the repeated local hour. +windows: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. diff --git a/products/scheduling/scripts/check-checkpoint.sh b/products/scheduling/scripts/check-checkpoint.sh new file mode 100755 index 0000000000..4519e24fc9 --- /dev/null +++ b/products/scheduling/scripts/check-checkpoint.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu + +# shellcheck disable=SC1007 # the space after CDPATH= is the POSIX empty-assignment idiom the sibling products use +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +schedulingctl_bin=${SCHEDULINGCTL_BIN:-"$repo_root/target/debug/schedulingctl"} + +cd "$repo_root" +python3 products/scheduling/scripts/check_dependency_direction.py +python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py' + +if [ ! -x "$schedulingctl_bin" ]; then + echo "build schedulingctl first or set SCHEDULINGCTL_BIN" >&2 + exit 2 +fi + +work=$(mktemp -d "${TMPDIR:-/tmp}/scheduling-checkpoint.XXXXXX") +cleanup() { + case "$work" in + "${TMPDIR:-/tmp}"/scheduling-checkpoint.*) rm -rf -- "$work" ;; + *) exit 1 ;; + esac +} +trap cleanup EXIT HUP INT TERM + +for template in standalone-exact-time standalone-arrival-window; do + "$schedulingctl_bin" init "$work/$template" --template "$template" >/dev/null + "$schedulingctl_bin" check "$work/$template" >/dev/null + "$schedulingctl_bin" test "$work/$template" >/dev/null + "$schedulingctl_bin" explain "$work/$template" >/dev/null +done + +# The package journey on a fresh project: the manifest the runtime verifies. +"$schedulingctl_bin" init "$work/package" --template standalone-exact-time >/dev/null +"$schedulingctl_bin" package "$work/package" >/dev/null + +# The committed example is the starter template's output, so it can never +# drift from what an adopter initializes. +example="$repo_root/products/scheduling/examples/standalone-exact-time" +"$schedulingctl_bin" init "$work/example" --template standalone-exact-time >/dev/null +diff -r "$work/example" "$example" >/dev/null +"$schedulingctl_bin" check "$example" >/dev/null +"$schedulingctl_bin" test "$example" >/dev/null + +echo "Scheduling product contracts and offline authoring journey passed." From 18cda5d023ba63ea5bdd2a1b4bfa04a62139be45 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 10:53:21 +0700 Subject: [PATCH 016/115] fix(scheduling): correct review findings in the committed runtime half Six defects found reviewing the committed M2 half against the milestone contracts, each with its own verification: - Exact-time capacity locked member ids as supply anchors, but the publication anchors pool ids only: every commitment met a missing anchor row and answered corrupt instead of booking. The transaction now locks the pool and snapshots the members (store.rs). - The eagerly constructed internal errors on four lookup paths logged a phantom error line on every successful call. They now construct only on failure (service.rs). - Holding an intent local stamped a delivery instant its own comment said never happened. No instant is stamped (store.rs). - A reminder destination URL carrying a query or fragment passed the configuration check and was then refused by the frozen transport at startup. The check refuses it first (config.rs). - The postgres-test feature compiles the plaintext-database refusal out of the binary and compiled the pinning test's only use out with it. The use is now gated to match the refusal (config.rs). - An opening spanning more days than the weekly calendar expansion serves passed authoring and failed only at evaluation. The policy check now refuses the span at exactly the runtime boundary (registry-scheduling-core). The store also gains adopt(), the provisioning verb that claims the empty deployment identity the migration seeds, so a fresh database can reach a servable state; the runtime command wiring follows in the next commit. Review notes for the security-adjacent changes: the pool-anchor fix changes what the capacity transaction locks, not what it authorizes; the adopt verb writes the deployment identity under FOR UPDATE and refuses a mismatched claim, never overwriting one. Signed-off-by: Jeremi Joslin --- .../src/diagnostics.rs | 4 ++ crates/registry-scheduling-core/src/policy.rs | 41 ++++++++++++++ .../migrations/0001_scheduling.sql | 3 + crates/registry-scheduling/src/config.rs | 9 +++ crates/registry-scheduling/src/service.rs | 36 ++++-------- crates/registry-scheduling/src/store.rs | 55 ++++++++++++++++--- 6 files changed, 117 insertions(+), 31 deletions(-) diff --git a/crates/registry-scheduling-core/src/diagnostics.rs b/crates/registry-scheduling-core/src/diagnostics.rs index 58d5b089ed..1b1008ed06 100644 --- a/crates/registry-scheduling-core/src/diagnostics.rs +++ b/crates/registry-scheduling-core/src/diagnostics.rs @@ -37,6 +37,9 @@ pub enum PolicyCheckReason { WrongModeField, /// A numeric bound is zero, negative, or inverted. InvalidBound, + /// An opening's effective range spans more days than the weekly calendar + /// expansion can serve. + PatternSpanTooLarge, /// The banded table is empty, non-increasing, or carries non-positive /// units. InvalidBands, @@ -68,6 +71,7 @@ impl PolicyCheckReason { Self::MissingModeField => "missing-mode-field", Self::WrongModeField => "wrong-mode-field", Self::InvalidBound => "invalid-bound", + Self::PatternSpanTooLarge => "pattern-span-too-large", Self::InvalidBands => "invalid-bands", Self::SubquotaOverdrawn => "subquota-overdrawn", Self::SharedSupplyUnpartitioned => "shared-supply-unpartitioned", diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 37af2a35a4..3dffa5699c 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -448,6 +448,21 @@ impl SchedulingPolicy { format!("{path}.effectiveUntil"), PolicyCheckReason::InvalidBound, )); + } else if let (Some(from), Some(until)) = ( + chrono::NaiveDate::parse_from_str(&opening.effective_from, "%Y-%m-%d").ok(), + chrono::NaiveDate::parse_from_str(&opening.effective_until, "%Y-%m-%d").ok(), + ) { + // The weekly expansion refuses a span this wide at evaluation + // time; the authoring check refuses it first, so a checked + // policy never deploys into a runtime that cannot expand it. + if until.signed_duration_since(from).num_days() + > i64::from(registry_platform_calendar::MAXIMUM_PATTERN_SPAN_DAYS) + { + findings.push(SchedulingDiagnostic::new( + format!("{path}.effectiveUntil"), + PolicyCheckReason::PatternSpanTooLarge, + )); + } } check_because(&opening.because, &format!("{path}.because"), &mut findings); self.check_unique_id(&opening.id, &path, "openings", &mut findings); @@ -1183,6 +1198,32 @@ holdPolicy: assert!(rendered.contains(&"openings[0].holidaySet: unknown-holiday-set".to_owned())); } + /// The weekly expansion serves at most MAXIMUM_PATTERN_SPAN_DAYS days, and + /// the authoring check refuses a wider span at exactly that boundary, so a + /// checked policy never deploys into a runtime that cannot expand it. + #[test] + fn an_opening_span_is_refused_only_past_the_calendar_boundary() { + let base = chrono::NaiveDate::from_ymd_opt(2026, 1, 1).expect("a well-formed base date"); + let until = |days: u64| { + base.checked_add_days(chrono::Days::new(days)) + .expect("a bounded span") + .format("%Y-%m-%d") + .to_string() + }; + let mut policy = minimal_exact_time_policy(); + policy.openings[0].effective_from = base.format("%Y-%m-%d").to_string(); + policy.openings[0].effective_until = until(u64::from( + registry_platform_calendar::MAXIMUM_PATTERN_SPAN_DAYS, + )); + assert!(policy.check().is_empty(), "{:?}", policy.check()); + + policy.openings[0].effective_until = + until(u64::from(registry_platform_calendar::MAXIMUM_PATTERN_SPAN_DAYS) + 1); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"openings[0].effectiveUntil: pattern-span-too-large".to_owned())); + } + #[test] fn duplicate_ids_across_one_collection_are_rejected() { let mut policy = minimal_exact_time_policy(); diff --git a/crates/registry-scheduling/migrations/0001_scheduling.sql b/crates/registry-scheduling/migrations/0001_scheduling.sql index f2833f5741..d75027cc70 100644 --- a/crates/registry-scheduling/migrations/0001_scheduling.sql +++ b/crates/registry-scheduling/migrations/0001_scheduling.sql @@ -5,6 +5,9 @@ CREATE TABLE IF NOT EXISTS scheduling_meta ( policy_digest text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now() ); +-- The empty scheduling_id marks a database no deployment has adopted yet; +-- `scheduling migrate` claims it for the policy's scheduling id, and every +-- serve refuses both an empty and a mismatched one. INSERT INTO scheduling_meta (singleton, scheduling_id, policy_revision, policy_digest) VALUES (true, '', 1, '') ON CONFLICT (singleton) DO NOTHING; diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index e144bc718e..3d79c5b350 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -630,6 +630,12 @@ fn valid_destination_url(value: &str) -> bool { && parsed.password().is_none() && parsed.host().is_some() && parsed.port_or_known_default().is_some_and(|port| port != 0) + // The destination is one reviewed endpoint, not a template a + // configuration value may widen; the runtime's frozen transport + // refuses a query or fragment, so the authoring check refuses it + // first. + && parsed.query().is_none() + && parsed.fragment().is_none() { match parsed.scheme() { "https" => true, @@ -994,6 +1000,9 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} let canary = "DO_NOT_DISCLOSE_RUNTIME_VALUE"; let mut document = operator_value(&package, "development-loopback"); document["database"]["testOnlyPlaintext"] = serde_json::json!(canary); + // The refusal this pins exists only without the test feature: with + // it, plaintext is the configuration the suite itself runs under. + #[cfg(not(feature = "postgres-test"))] let operator = write_operator(root.path(), document); #[cfg(not(feature = "postgres-test"))] { diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index 0fecaa3b99..d3007d4a28 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -553,12 +553,9 @@ impl SchedulingService { if hold.kind != LedgerKind::Hold { return Err(ServiceError::Problem(ProblemCode::HoldReleased)); } - let offering = self - .policy - .offering(&hold.offering) - .ok_or(ServiceError::internal( - "a committed hold names no offering in the policy", - ))?; + let offering = self.policy.offering(&hold.offering).ok_or_else(|| { + ServiceError::internal("a committed hold names no offering in the policy") + })?; let grant = self.require_permission(caller, offering, HOLD_RELEASE_ACTION)?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let request_hash = canonical_hash(&json!({"hold": hold_id}))?; @@ -636,12 +633,9 @@ impl SchedulingService { if hold.kind != LedgerKind::Hold { return Err(ServiceError::Problem(ProblemCode::HoldReleased)); } - let offering = self - .policy - .offering(&hold.offering) - .ok_or(ServiceError::internal( - "a committed hold names no offering in the policy", - ))?; + let offering = self.policy.offering(&hold.offering).ok_or_else(|| { + ServiceError::internal("a committed hold names no offering in the policy") + })?; let grant = self.require_permission(caller, offering, APPOINTMENT_CREATE_ACTION)?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let supply = self.supply(offering).await?; @@ -743,12 +737,9 @@ impl SchedulingService { .booking(appointment_id) .await? .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; - let offering = - self.policy - .offering(&appointment.offering) - .ok_or(ServiceError::internal( - "a committed appointment names no offering in the policy", - ))?; + let offering = self.policy.offering(&appointment.offering).ok_or_else(|| { + ServiceError::internal("a committed appointment names no offering in the policy") + })?; let grant = self.require_permission(caller, offering, APPOINTMENT_RESCHEDULE_ACTION)?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let supply = self.supply(offering).await?; @@ -806,12 +797,9 @@ impl SchedulingService { .booking(appointment_id) .await? .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; - let offering = - self.policy - .offering(&appointment.offering) - .ok_or(ServiceError::internal( - "a committed appointment names no offering in the policy", - ))?; + let offering = self.policy.offering(&appointment.offering).ok_or_else(|| { + ServiceError::internal("a committed appointment names no offering in the policy") + })?; let grant = self.require_permission(caller, offering, APPOINTMENT_CANCEL_ACTION)?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let request_hash = canonical_hash(&json!({ diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 640469352a..e0b88eab78 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -396,6 +396,35 @@ impl PostgresStore { } } + /// Adopt this database for one deployment identity. A fresh database + /// carries the empty id the migration seeds; the first adopt claims it + /// for the policy's scheduling id, an adopt under the same id again is a + /// no-op, and an adopt under a different id is refused: two deployments + /// never share one database silently. + pub async fn adopt(&self, scheduling_id: &str) -> Result<(), StoreError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let row = transaction + .query_one( + "SELECT scheduling_id FROM scheduling_meta WHERE singleton FOR UPDATE", + &[], + ) + .await?; + let stored: String = row.get(0); + if stored.is_empty() { + transaction + .execute( + "UPDATE scheduling_meta SET scheduling_id=$1, updated_at=now() WHERE singleton", + &[&scheduling_id], + ) + .await?; + } else if stored != scheduling_id { + return Err(StoreError::Corrupt); + } + transaction.commit().await?; + Ok(()) + } + /// The deployment identity and current policy revision. pub async fn scheduling_meta(&self) -> Result<(String, i64, String), StoreError> { let client = self.client().await?; @@ -1415,13 +1444,13 @@ impl PostgresStore { /// Hold one intent locally: the deployment has no destination /// configured, so the intent stays recorded instead of pretending a - /// delivery happened. + /// delivery happened. Nothing is delivered, so no delivery instant is + /// stamped either. pub async fn hold_intent_local(&self, outbox_id: Uuid) -> Result<(), StoreError> { let client = self.client().await?; client .execute( - "UPDATE scheduling_outbox SET delivery_state='local', delivered_at=now() \ - WHERE outbox_id=$1", + "UPDATE scheduling_outbox SET delivery_state='local' WHERE outbox_id=$1", &[&outbox_id], ) .await?; @@ -1620,8 +1649,21 @@ async fn lock_and_snapshot( now: DateTime, ) -> Result { match supply { - SupplyContext::ExactTime { members, open, .. } => { - let supply_ids: Vec = members + SupplyContext::ExactTime { + exact, + members, + open, + .. + } => { + // The anchor is the pool, not the members: two offerings on one + // pool sell the same members and must serialize against each + // other, and `apply_policy` anchors pool ids only. + transaction + .lock_supply(std::slice::from_ref(&exact.pool)) + .await?; + // Claims occupy a member, so the snapshot reads member ids even + // though the lock is the pool's. + let member_ids: Vec = members .iter() .map(|member| member.resource_id.clone()) .collect(); @@ -1635,9 +1677,8 @@ async fn lock_and_snapshot( .map(|interval| interval.end) .max() .unwrap_or(now); - transaction.lock_supply(&supply_ids).await?; let rows = transaction - .query(CONSUMING_CLAUSES, &[&supply_ids, &earliest, &latest, &now]) + .query(CONSUMING_CLAUSES, &[&member_ids, &earliest, &latest, &now]) .await?; Ok(snapshot_from_rows(rows)) } From a0026755391850808b3df6a3f675d933fd33df93 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 10:54:42 +0700 Subject: [PATCH 017/115] feat(scheduling): serve the runtime edge with auth, workers, and schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduling binary becomes a service: migrate and serve. serve assembles the deployment the contracts describe and refuses to start half-provisioned. It verifies the policy package identity, refuses an empty or mismatched deployment identity before writing anything, keys the audit journal from one master secret through independently HKDF-derived chain and identifier sub-keys (AUDIT-03), and publishes the policy only when the digest moved. Four supervised workers run beside the listener — hold expiry, reminder dispatch, retention sweeps, and audit publication — and a worker that stops stops the process, so the runtime never keeps selling capacity its clocks no longer guard. The HTTP edge (http.rs) serves the fifteen pinned routes over the naming constants, types every refusal as a problem document with a trace id, and replays stored attempt receipts verbatim under their original status. Authentication (auth.rs) has no unauthenticated mode: a read requires the reads scope, explain its own scope, and every mutation a task grant whose scheduling bounds cover the offering's service, location, and action, re-checked inside the capacity transaction. Reminder dispatch is the one outbound hop: each due intent renders as one canonical CloudEvents 1.0 event and is POSTed to the frozen operator-configured destination, with no destination meaning intents stay recorded and local. The audit outbox publishes to the keyed chain with a crash-gap reconciliation that never duplicates an envelope. The operator configuration schema is generated from the strict types (schema.rs) and committed with a drift test, the same contract the other products hold. The PostgreSQL suite (tests/) drives the six commitments, availability, cursors, replay, the edge refusals, the unanchored-supply refusal, and deployment-identity adoption through the served router against a real database. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/Cargo.toml | 5 + .../examples/runtime-schema.rs | 42 + crates/registry-scheduling/src/auth.rs | 430 +++++++ crates/registry-scheduling/src/http.rs | 940 +++++++++++++++ crates/registry-scheduling/src/lib.rs | 5 + crates/registry-scheduling/src/main.rs | 10 +- crates/registry-scheduling/src/runtime.rs | 947 +++++++++++++++ crates/registry-scheduling/src/schema.rs | 290 +++++ .../tests/postgres_commitments.rs | 1036 +++++++++++++++++ .../generated/runtime/runtime.schema.json | 459 ++++++++ 10 files changed, 4162 insertions(+), 2 deletions(-) create mode 100644 crates/registry-scheduling/examples/runtime-schema.rs create mode 100644 crates/registry-scheduling/src/auth.rs create mode 100644 crates/registry-scheduling/src/http.rs create mode 100644 crates/registry-scheduling/src/runtime.rs create mode 100644 crates/registry-scheduling/src/schema.rs create mode 100644 crates/registry-scheduling/tests/postgres_commitments.rs create mode 100644 products/scheduling/generated/runtime/runtime.schema.json diff --git a/crates/registry-scheduling/Cargo.toml b/crates/registry-scheduling/Cargo.toml index a00a619be1..2d80e3d984 100644 --- a/crates/registry-scheduling/Cargo.toml +++ b/crates/registry-scheduling/Cargo.toml @@ -12,6 +12,11 @@ publish = false name = "scheduling" path = "src/main.rs" +[[test]] +name = "postgres_commitments" +path = "tests/postgres_commitments.rs" +required-features = ["postgres-test"] + [features] default = [] postgres-test = [] diff --git a/crates/registry-scheduling/examples/runtime-schema.rs b/crates/registry-scheduling/examples/runtime-schema.rs new file mode 100644 index 0000000000..5e17e41885 --- /dev/null +++ b/crates/registry-scheduling/examples/runtime-schema.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(feature = "schema")] +fn main() -> std::process::ExitCode { + let mut arguments = std::env::args_os().skip(1); + if arguments.next().as_deref() != Some(std::ffi::OsStr::new("--output")) { + eprintln!("usage: runtime-schema --output "); + return std::process::ExitCode::from(2); + } + let Some(output) = arguments.next().map(std::path::PathBuf::from) else { + eprintln!("usage: runtime-schema --output "); + return std::process::ExitCode::from(2); + }; + if arguments.next().is_some() { + eprintln!("usage: runtime-schema --output "); + return std::process::ExitCode::from(2); + } + let documents = match registry_scheduling::schema::runtime_documents() { + Ok(documents) => documents, + Err(error) => { + eprintln!("runtime schema generation failed: {error}"); + return std::process::ExitCode::FAILURE; + } + }; + if let Err(error) = std::fs::create_dir_all(&output) { + eprintln!("runtime schema generation failed: {error}"); + return std::process::ExitCode::FAILURE; + } + for (name, contents) in documents { + if let Err(error) = std::fs::write(output.join(name), contents) { + eprintln!("runtime schema generation failed: {error}"); + return std::process::ExitCode::FAILURE; + } + } + std::process::ExitCode::SUCCESS +} + +#[cfg(not(feature = "schema"))] +fn main() -> std::process::ExitCode { + eprintln!("runtime-schema requires the schema feature"); + std::process::ExitCode::from(2) +} diff --git a/crates/registry-scheduling/src/auth.rs b/crates/registry-scheduling/src/auth.rs new file mode 100644 index 0000000000..7d46ac0f04 --- /dev/null +++ b/crates/registry-scheduling/src/auth.rs @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Bearer access-token authentication for the Scheduling HTTP surface. +//! +//! There is no unauthenticated mode: every `/v1` route resolves its caller +//! here first. Authentication answers three questions in order. Is the +//! credential a verifiable access token from the configured issuer, for this +//! deployment's audience, signed by a key the issuer published? Which actor +//! kind does it claim, and does it carry a complete task grant? And does the +//! caller hold the scope the route's authentication profile demands? +//! +//! The three profiles mirror the route families. Readable routes take callers +//! holding the reads scope; the separately authorized explain path takes +//! callers holding its own scope; mutating routes demand no extra scope at +//! all, because their authority is the task grant, whose scheduling +//! permissions the service checks per offering. +//! +//! The task grant is extracted with the platform's contextual-authorization +//! claim profile. A token with no grant claims is an ordinary standing +//! caller: it may read, and the service refuses its mutations. A token with +//! partial grant claims is a malformed credential, refused here rather than +//! half-trusted later. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use registry_platform_authcommon::validate_compact_access_token; +use registry_platform_oidc::{ + actor_kind, grant_claims, ClaimNames, JwksFetcher, TokenVerifier, TokenVerifierConfig, +}; +use thiserror::Error; + +use crate::config::OidcConfig; +use crate::service::Caller; + +pub struct SchedulingAuthenticator { + verifier: TokenVerifier, + claim_names: ClaimNames, + audience: String, + reads_scope: String, + explain_scope: String, +} + +impl SchedulingAuthenticator { + #[must_use] + pub fn new(oidc: &OidcConfig, verifier: TokenVerifierConfig, keys: Arc) -> Self { + // The default contextual-claim profile is the closed one this product + // speaks; validating it here keeps a future name change honest. + let claim_names = ClaimNames::default(); + claim_names + .validate() + .expect("the default contextual claim names are valid"); + Self { + verifier: TokenVerifier::new(verifier, keys), + claim_names, + audience: oidc.audience.clone(), + reads_scope: oidc.reads_scope.clone(), + explain_scope: oidc.explain_scope.clone(), + } + } + + /// The profile every catalogue, availability, and owned-record read + /// demands: the caller holds the configured reads scope. + pub async fn authenticate_read(&self, token: &str) -> Result { + let (caller, scopes) = self.verified_caller(token).await?; + scopes + .iter() + .any(|scope| scope == &self.reads_scope) + .then_some(caller) + .ok_or(AuthenticationError::Profile) + } + + /// The profile the separately authorized explain path demands: the caller + /// holds the explain scope, which is never the reads scope. + pub async fn authenticate_explain(&self, token: &str) -> Result { + let (caller, scopes) = self.verified_caller(token).await?; + scopes + .iter() + .any(|scope| scope == &self.explain_scope) + .then_some(caller) + .ok_or(AuthenticationError::Profile) + } + + /// The profile the commitments demand: no product scope, because the + /// authority for a mutation is the task grant the service checks against + /// the offering's service, location, and action. + pub async fn authenticate_mutate(&self, token: &str) -> Result { + self.verified_caller(token).await.map(|(caller, _)| caller) + } + + /// Verify one credential and resolve its caller. A present grant is bound + /// to the verified client and this deployment's audience before the + /// caller ever reaches the service. + async fn verified_caller( + &self, + token: &str, + ) -> Result<(Caller, Vec), AuthenticationError> { + validate_compact_access_token(token).map_err(|_| AuthenticationError::Refused)?; + let verified = self.verifier.verify(token).await.map_err(|error| { + tracing::debug!(error = %error, "the Scheduling bearer credential did not verify"); + AuthenticationError::Refused + })?; + let kind = actor_kind(&verified.claims, &self.claim_names) + .map_err(|_| AuthenticationError::Claims)?; + let grant = grant_claims(&verified.claims, &self.claim_names, unix_now()) + .map_err(|_| AuthenticationError::Claims)?; + if let Some(grant) = &grant { + grant + .verify_context(&verified, &self.audience) + .map_err(|_| AuthenticationError::Profile)?; + } + let issuer = verified + .claims + .iss + .clone() + .filter(|issuer| !issuer.is_empty()) + .ok_or(AuthenticationError::Claims)?; + let subject = verified + .claims + .sub + .clone() + .filter(|subject| !subject.is_empty()) + .ok_or(AuthenticationError::Claims)?; + Ok(( + Caller { + actor_kind: kind.as_str().to_owned(), + issuer, + subject, + grant, + }, + verified.scopes, + )) + } +} + +impl std::fmt::Debug for SchedulingAuthenticator { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SchedulingAuthenticator") + .field("verifier", &"") + .field("audience", &"") + .field("reads_scope", &self.reads_scope) + .field("explain_scope", &self.explain_scope) + .finish() + } +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum AuthenticationError { + #[error("the bearer credential was refused")] + Refused, + #[error("the verified identity claims are invalid")] + Claims, + #[error("the caller's authentication profile is not authorized for this request")] + Profile, +} + +/// The observation of now the grant's expiry is judged against, in the same +/// units the token's own `exp` carries. +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use chrono::Utc; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use registry_platform_oidc::{ActorKind, JwksFetcherConfig}; + use serde_json::json; + + const ISSUER: &str = "https://task-token.test"; + const AUDIENCE: &str = "urn:registry-scheduling:test"; + const CLIENT: &str = "task-agent"; + const SECRET: &[u8] = b"01234567890123456789012345678901"; + + fn oidc() -> OidcConfig { + OidcConfig { + allowed_clients: vec![CLIENT.to_owned()], + issuer: ISSUER.to_owned(), + audience: AUDIENCE.to_owned(), + jwks_uri: None, + jwks_source: crate::config::OidcJwksSource::Discovery, + scope_claim: "registry_scopes".to_owned(), + reads_scope: "scheduling-read".to_owned(), + explain_scope: "scheduling-explain".to_owned(), + } + } + + fn keys() -> Arc { + Arc::new(JwksFetcher::new_static( + serde_json::from_value(json!({"keys": [{ + "kty": "oct", + "kid": "test", + "alg": "HS256", + "use": "sig", + "k": URL_SAFE_NO_PAD.encode(SECRET), + }]})) + .unwrap(), + JwksFetcherConfig::defaults(), + )) + } + + fn authenticator() -> SchedulingAuthenticator { + let verifier = TokenVerifierConfig::access_token_profile( + ISSUER, + vec![AUDIENCE.to_owned()], + vec![Algorithm::HS256], + vec!["at+jwt".to_owned()], + ) + .with_scope_claim("registry_scopes") + .with_allowed_clients(vec![CLIENT.to_owned()]); + SchedulingAuthenticator::new(&oidc(), verifier, keys()) + } + + /// Sign an access token, filling in the claims a real token always + /// carries so a test only has to state what it varies. + fn token(mut claims: serde_json::Value) -> String { + let now = Utc::now().timestamp(); + let object = claims.as_object_mut().unwrap(); + object.entry("iss").or_insert(json!(ISSUER)); + object.entry("aud").or_insert(json!(AUDIENCE)); + object.entry("iat").or_insert(json!(now)); + object.entry("exp").or_insert(json!(now + 300)); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test".to_owned()); + header.typ = Some("at+jwt".to_owned()); + jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(SECRET)).unwrap() + } + + /// The complete task-grant claim set a mutating caller carries, with the + /// bounds the test's permission assertions read. + fn grant_claims_value(resource: &str) -> serde_json::Value { + let now = Utc::now().timestamp(); + json!({ + "registry_actor_kind": "agent", + "registry_purpose": "registry-update", + "registry_grant_id": "grant-1", + "registry_grant_source_issuer": "https://authority.test", + "registry_grant_client": CLIENT, + "registry_grant_resource": resource, + "registry_grant_exp": now + 600, + "registry_grant_bounds": { + "type": "scheduling", + "permissions": [{ + "service": "registry-update", + "location": "north-counter", + "actions": ["hold.create", "appointment.create"], + }], + }, + "registry_approver": "approver-1", + }) + } + + fn merged(scopes: &str, grant: serde_json::Value) -> serde_json::Value { + let mut claims = json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": scopes, + "registry_actor_kind": "service", + }); + let object = claims.as_object_mut().unwrap(); + object.extend( + grant + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + claims + } + + #[tokio::test] + async fn a_read_caller_with_the_reads_scope_authenticates_without_a_grant() { + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + })); + let caller = authenticator() + .authenticate_read(&credential) + .await + .expect("a standing caller holding the reads scope reads"); + assert_eq!(caller.actor_kind, "service"); + assert_eq!(caller.issuer, ISSUER); + assert_eq!(caller.subject, "principal-1"); + assert!(caller.grant.is_none()); + } + + #[tokio::test] + async fn a_read_without_the_reads_scope_is_a_profile_refusal_but_still_mutates() { + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "unrelated-scope", + "registry_actor_kind": "service", + })); + let authenticator = authenticator(); + assert!(matches!( + authenticator.authenticate_read(&credential).await, + Err(AuthenticationError::Profile) + )); + // Mutating routes demand no product scope: their authority is the + // task grant, checked per offering in the service. + authenticator + .authenticate_mutate(&credential) + .await + .expect("a caller without the reads scope still reaches a mutation"); + } + + #[tokio::test] + async fn the_explain_path_demands_its_own_scope() { + let authenticator = authenticator(); + let reader = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + })); + assert!(matches!( + authenticator.authenticate_explain(&reader).await, + Err(AuthenticationError::Profile) + )); + let explainer = token(json!({ + "sub": "principal-2", + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + })); + authenticator + .authenticate_explain(&explainer) + .await + .expect("the explain scope opens the explain path"); + } + + #[tokio::test] + async fn a_complete_task_grant_is_extracted_and_bound_to_the_deployment() { + let credential = token(merged("scheduling-read", grant_claims_value(AUDIENCE))); + let caller = authenticator() + .authenticate_mutate(&credential) + .await + .expect("a complete grant authenticates"); + let grant = caller.grant.expect("the grant is extracted"); + assert_eq!(caller.actor_kind, "agent"); + let permissions = grant + .bounds() + .scheduling_permissions() + .expect("the scheduling bounds travel with the grant"); + assert_eq!(permissions.len(), 1); + assert_eq!(permissions[0].service(), "registry-update"); + assert_eq!(permissions[0].location(), "north-counter"); + assert!(permissions[0] + .actions() + .iter() + .any(|action| action == "hold.create")); + } + + #[tokio::test] + async fn a_grant_naming_another_resource_is_a_profile_refusal() { + let credential = token(merged( + "scheduling-read", + grant_claims_value("urn:registry:other-product"), + )); + assert!(matches!( + authenticator().authenticate_mutate(&credential).await, + Err(AuthenticationError::Profile) + )); + } + + #[tokio::test] + async fn a_partial_grant_is_refused_as_invalid_claims() { + let mut grant = grant_claims_value(AUDIENCE); + let object = grant.as_object_mut().unwrap(); + object.remove("registry_grant_exp"); + object.remove("registry_grant_client"); + let credential = token(merged("scheduling-read", grant)); + assert!(matches!( + authenticator().authenticate_mutate(&credential).await, + Err(AuthenticationError::Claims) + )); + } + + #[tokio::test] + async fn an_actor_kind_outside_the_vocabulary_is_refused() { + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "robot", + })); + assert!(matches!( + authenticator().authenticate_read(&credential).await, + Err(AuthenticationError::Claims) + )); + } + + #[tokio::test] + async fn a_credential_signed_by_an_unknown_key_is_refused() { + let mut claims = json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": ActorKind::Service.as_str(), + }); + let now = Utc::now().timestamp(); + let object = claims.as_object_mut().unwrap(); + object.insert("iss".to_owned(), json!(ISSUER)); + object.insert("aud".to_owned(), json!(AUDIENCE)); + object.insert("iat".to_owned(), json!(now)); + object.insert("exp".to_owned(), json!(now + 300)); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test".to_owned()); + header.typ = Some("at+jwt".to_owned()); + let forged = jsonwebtoken::encode( + &header, + &claims, + &EncodingKey::from_secret(b"another-secret-another-secret-another!"), + ) + .unwrap(); + assert!(matches!( + authenticator().authenticate_read(&forged).await, + Err(AuthenticationError::Refused) + )); + } +} diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs new file mode 100644 index 0000000000..d22bc5a635 --- /dev/null +++ b/crates/registry-scheduling/src/http.rs @@ -0,0 +1,940 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The HTTP surface: the pinned routes, the request boundary, and the one +//! problem rendering every refusal flows through. +//! +//! Every refusal leaves this edge as one problem shape: a code from the closed +//! vocabulary — the product refusals (authentication, authorization, +//! admission, idempotency, cursors, availability) and the `request.*` family +//! carrying the request-edge rejections (a route that does not exist, a +//! method the route refuses, a body too large or not JSON) — under the +//! Scheduling problem base, with the answering request's trace id. No shared +//! platform problem prefix exists in the Registry Stack catalog, so the edge +//! codes live in this product's own vocabulary exactly as Casework's do. +//! +//! Mutations answer through the service's commitment verdict. A minted +//! commitment answers with the route's success status and its document. A +//! replayed idempotency key answers as the stored attempt first did: a +//! success receipt projects through the same document, a refusal receipt +//! re-renders its pinned problem under the answering request's trace, and a +//! release's empty receipt answers as an empty 204 again. + +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::header::{ + AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE, +}; +use axum::http::{HeaderMap, StatusCode}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use registry_platform_authcommon::parse_bearer_token; +use registry_platform_httpsec::{ + request_body_limit_default, security_headers, CspBuilder, ProblemBody, TraceContext, +}; +use registry_scheduling_core::{ + type_uri, AdmissionRequest, CancelAppointmentRequest, CreateAppointmentRequest, ProblemCode, + RescheduleAppointmentRequest, APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, + HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, + OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, +}; +use serde::Deserialize; +use serde_json::Value; +use uuid::Uuid; + +use crate::auth::{AuthenticationError, SchedulingAuthenticator}; +use crate::service::{Caller, CommitmentAnswer, SchedulingService, ServiceError}; +use crate::store::PostgresStore; + +tokio::task_local! { + static REQUEST_TRACE: TraceContext; +} + +#[derive(Clone)] +pub struct HttpState { + pub service: Arc, + pub authenticator: Arc, + pub store: PostgresStore, +} + +const HOLD_ROUTE: &str = "/v1/holds/{hold_id}"; +const APPOINTMENT_ROUTE: &str = "/v1/appointments/{appointment_id}"; +const APPOINTMENT_RESCHEDULE_ROUTE: &str = "/v1/appointments/{appointment_id}/reschedule"; +const APPOINTMENT_CANCEL_ROUTE: &str = "/v1/appointments/{appointment_id}/cancel"; +const APPOINTMENT_HISTORY_ROUTE: &str = "/v1/appointments/{appointment_id}/history"; + +pub fn router(state: HttpState) -> Router { + http_edge( + Router::new() + .route("/healthz", get(healthz)) + .route("/readyz", get(readyz)) + .route(SCHEDULING_PATH, get(scheduling)) + .route(SERVICES_PATH, get(list_services)) + .route(OFFERINGS_PATH, get(list_offerings)) + .route(RESOURCES_PATH, get(list_resources)) + .route(LOCATIONS_PATH, get(list_locations)) + .route(AVAILABILITY_PATH, get(availability)) + .route(AVAILABILITY_EXPLAIN_PATH, get(explain)) + .route(HOLDS_PATH, post(create_hold)) + .route(HOLD_ROUTE, delete(release_hold)) + .route(APPOINTMENTS_PATH, post(create_appointment)) + .route(APPOINTMENT_ROUTE, get(get_appointment)) + .route(APPOINTMENT_RESCHEDULE_ROUTE, post(reschedule_appointment)) + .route(APPOINTMENT_CANCEL_ROUTE, post(cancel_appointment)) + .route(APPOINTMENT_HISTORY_ROUTE, get(appointment_history)), + ) + .with_state(state) +} + +fn http_edge(router: Router) -> Router +where + S: Clone + Send + Sync + 'static, +{ + router + .fallback(route_not_found) + .method_not_allowed_fallback(method_not_allowed) + .layer(request_body_limit_default()) + .layer(middleware::from_fn(http_boundary)) + .layer(security_headers(CspBuilder::restrictive()).without_hsts()) +} + +async fn http_boundary(request: axum::http::Request, next: Next) -> Response { + let trace = TraceContext::from_headers(request.headers()); + let mut response = REQUEST_TRACE + .scope(trace.clone(), async move { + normalize_framework_rejection(next.run(request).await) + }) + .await; + trace.apply(response.headers_mut()); + response.headers_mut().insert( + CACHE_CONTROL, + "no-store" + .parse() + .expect("the no-store policy is a valid header value"), + ); + response +} + +/// Framework rejections carry axum's own bodies, which no Scheduling caller +/// was promised. The statuses are kept; the body becomes the vocabulary's +/// `request.*` problem for that status, so every answer still reads as one +/// problem shape. +fn normalize_framework_rejection(response: Response) -> Response { + let is_problem = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + == Some("application/problem+json"); + if is_problem { + return response; + } + let problem = match response.status() { + StatusCode::BAD_REQUEST => Some(ProblemCode::RequestInvalid), + StatusCode::PAYLOAD_TOO_LARGE => Some(ProblemCode::RequestBodyTooLarge), + StatusCode::UNSUPPORTED_MEDIA_TYPE => Some(ProblemCode::RequestUnsupportedMediaType), + StatusCode::UNPROCESSABLE_ENTITY => Some(ProblemCode::RequestUnprocessable), + _ => None, + }; + problem.map_or(response, problem_response) +} + +async fn route_not_found() -> Response { + problem_response(ProblemCode::RequestNotFound) +} + +async fn method_not_allowed() -> Response { + problem_response(ProblemCode::RequestMethodNotAllowed) +} + +async fn healthz() -> StatusCode { + StatusCode::OK +} + +async fn readyz(State(state): State) -> Result { + if state.store.ready().await.is_ok() { + Ok(StatusCode::OK) + } else { + Err(HttpError(ProblemCode::ServiceUnavailable)) + } +} + +async fn scheduling( + State(state): State, + headers: HeaderMap, +) -> Result, HttpError> { + authenticate_read(&state, &headers).await?; + Ok(Json(state.service.scheduling())) +} + +async fn list_services( + State(state): State, + headers: HeaderMap, + Query(page): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .list_services(page.cursor.as_deref(), page.limit, Utc::now()) + .await?, + )) +} + +async fn list_offerings( + State(state): State, + headers: HeaderMap, + Query(page): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .list_offerings(page.cursor.as_deref(), page.limit, Utc::now()) + .await?, + )) +} + +async fn list_resources( + State(state): State, + headers: HeaderMap, + Query(page): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .list_resources(page.cursor.as_deref(), page.limit, Utc::now()) + .await?, + )) +} + +async fn list_locations( + State(state): State, + headers: HeaderMap, + Query(page): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .list_locations(page.cursor.as_deref(), page.limit, Utc::now()) + .await?, + )) +} + +async fn availability( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result< + Json>, + HttpError, +> { + authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .availability( + &query.offering, + query.start, + query.end, + query.cursor.as_deref(), + query.limit, + Utc::now(), + ) + .await?, + )) +} + +async fn explain( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result, HttpError> { + authenticate_explain(&state, &headers).await?; + Ok(Json( + state + .service + .explain(&query.offering, query.start, Utc::now()) + .await?, + )) +} + +async fn create_hold( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let key = idempotency_key(&headers)?; + let answer = state + .service + .create_hold(&caller, key, &request, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(hold) => (StatusCode::CREATED, Json(hold)).into_response(), + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn release_hold( + State(state): State, + headers: HeaderMap, + Path(hold_id): Path, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let answer = state + .service + .release_hold(&caller, hold_id, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(()) => StatusCode::NO_CONTENT.into_response(), + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn create_appointment( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let key = idempotency_key(&headers)?; + let answer = state + .service + .create_appointment(&caller, key, &request, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(appointment) => { + (StatusCode::CREATED, Json(appointment)).into_response() + } + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn get_appointment( + State(state): State, + headers: HeaderMap, + Path(appointment_id): Path, +) -> Result, HttpError> { + let caller = authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .get_appointment(&caller, appointment_id) + .await?, + )) +} + +async fn reschedule_appointment( + State(state): State, + headers: HeaderMap, + Path(appointment_id): Path, + Json(request): Json, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let key = idempotency_key(&headers)?; + let answer = state + .service + .reschedule_appointment(&caller, appointment_id, key, &request, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(appointment) => { + (StatusCode::OK, Json(appointment)).into_response() + } + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn cancel_appointment( + State(state): State, + headers: HeaderMap, + Path(appointment_id): Path, + Json(request): Json, +) -> Result { + let caller = authenticate_mutate(&state, &headers).await?; + let key = idempotency_key(&headers)?; + let answer = state + .service + .cancel_appointment(&caller, appointment_id, key, &request, Utc::now()) + .await?; + Ok(match answer { + CommitmentAnswer::Minted(appointment) => { + (StatusCode::OK, Json(appointment)).into_response() + } + CommitmentAnswer::Replay { + status_code, + receipt, + } => replay_response(status_code, receipt), + }) +} + +async fn appointment_history( + State(state): State, + headers: HeaderMap, + Path(appointment_id): Path, + Query(page): Query, +) -> Result< + Json< + registry_scheduling_core::PageDocument< + registry_scheduling_core::AppointmentHistoryEntryDocument, + >, + >, + HttpError, +> { + let caller = authenticate_read(&state, &headers).await?; + Ok(Json( + state + .service + .appointment_history( + &caller, + appointment_id, + page.cursor.as_deref(), + page.limit, + Utc::now(), + ) + .await?, + )) +} + +#[derive(Debug, Default, Deserialize)] +struct ListingQuery { + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct AvailabilityQuery { + offering: String, + #[serde(default)] + start: Option>, + #[serde(default)] + end: Option>, + #[serde(default)] + cursor: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Deserialize)] +struct ExplainQuery { + offering: String, + start: DateTime, +} + +async fn authenticate_read(state: &HttpState, headers: &HeaderMap) -> Result { + let token = bearer_token(headers)?; + state + .authenticator + .authenticate_read(token) + .await + .map_err(authentication_problem) +} + +async fn authenticate_explain(state: &HttpState, headers: &HeaderMap) -> Result { + let token = bearer_token(headers)?; + state + .authenticator + .authenticate_explain(token) + .await + .map_err(authentication_problem) +} + +async fn authenticate_mutate(state: &HttpState, headers: &HeaderMap) -> Result { + let token = bearer_token(headers)?; + state + .authenticator + .authenticate_mutate(token) + .await + .map_err(authentication_problem) +} + +fn bearer_token(headers: &HeaderMap) -> Result<&str, HttpError> { + let authorization = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .ok_or(HttpError(ProblemCode::AuthenticationRefused))?; + parse_bearer_token(authorization).map_err(|_| HttpError(ProblemCode::AuthenticationRefused)) +} + +fn authentication_problem(error: AuthenticationError) -> HttpError { + match error { + AuthenticationError::Refused | AuthenticationError::Claims => { + HttpError(ProblemCode::AuthenticationRefused) + } + AuthenticationError::Profile => HttpError(ProblemCode::ProfileNotAuthorized), + } +} + +/// The idempotency key every mutating command carries: present, bounded, and +/// graphical, so it can never smuggle a header-breaking byte. +fn idempotency_key(headers: &HeaderMap) -> Result<&str, HttpError> { + let value = headers + .get(IDEMPOTENCY_KEY_HEADER) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) + .ok_or(HttpError(ProblemCode::RequestInvalid))?; + if value.len() > MAXIMUM_IDEMPOTENCY_KEY_BYTES + || !value.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(HttpError(ProblemCode::RequestInvalid)); + } + Ok(value) +} + +/// One refusal from the closed vocabulary, as an HTTP answer: every product +/// decision and every request-edge rejection renders through the same pinned +/// document. +#[derive(Debug)] +pub struct HttpError(pub ProblemCode); + +impl From for HttpError { + fn from(error: ServiceError) -> Self { + match error { + ServiceError::Problem(problem) => Self(problem), + } + } +} + +impl IntoResponse for HttpError { + fn into_response(self) -> Response { + problem_response(self.0) + } +} + +fn current_trace() -> TraceContext { + REQUEST_TRACE + .try_with(Clone::clone) + .unwrap_or_else(|_| TraceContext::server_created()) +} + +/// Render one problem: the pinned code, title, status, and detail +/// from the closed vocabulary, under the answering request's trace. +fn problem_response(problem: ProblemCode) -> Response { + let trace = current_trace(); + let status = problem.http_status(); + let body = ProblemBody { + type_uri: type_uri(problem.code()), + title: problem.title(), + status, + detail: problem.detail(), + code: problem.code(), + trace_id: trace.trace_id.clone(), + }; + finish_problem(status, body, &trace) +} + +fn finish_problem(status: u16, body: ProblemBody, trace: &TraceContext) -> Response { + let status = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut response = ( + status, + [(CONTENT_TYPE, "application/problem+json")], + Json(body), + ) + .into_response(); + trace.apply(response.headers_mut()); + if status == StatusCode::UNAUTHORIZED { + response.headers_mut().insert( + WWW_AUTHENTICATE, + "Bearer" + .parse() + .expect("the bearer challenge is a valid header value"), + ); + } + if status == StatusCode::SERVICE_UNAVAILABLE { + response.headers_mut().insert( + RETRY_AFTER, + "5".parse() + .expect("the retry interval is a valid header value"), + ); + } + response +} + +/// Answer a replayed idempotency key exactly as the stored attempt first +/// answered. A release's empty receipt answers as an empty 204 again. A +/// refusal receipt carries its problem without a trace id, so it re-renders +/// from its pinned code under the answering request's trace — the first +/// answer's words, this answer's correlation. A receipt in no known shape is +/// corrupt stored state: the caller learns nothing from it. +fn replay_response(status_code: u16, receipt: Value) -> Response { + if receipt.is_null() { + return StatusCode::NO_CONTENT.into_response(); + } + let Some(code) = receipt + .get("problem") + .and_then(|problem| problem.get("code")) + .and_then(Value::as_str) + else { + tracing::error!("a stored attempt receipt carries no recognizable problem"); + return problem_response(ProblemCode::ServiceUnavailable); + }; + match ProblemCode::from_code(code) { + Some(problem) => { + if problem.http_status() != status_code { + tracing::warn!( + stored = status_code, + pinned = problem.http_status(), + "a stored attempt receipt disagrees with its pinned status" + ); + } + problem_response(problem) + } + None => { + tracing::error!(code, "a stored attempt receipt carries an unknown code"); + problem_response(ProblemCode::ServiceUnavailable) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::{to_bytes, Body}; + use axum::http::header::IntoHeaderName; + use axum::http::Request; + use tower::ServiceExt; + + const TRACEPARENT: &str = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"; + const TRACE_ID: &str = "0123456789abcdef0123456789abcdef"; + + fn traced_request(method: &str, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header("traceparent", TRACEPARENT) + .body(Body::empty()) + .expect("a traced test request") + } + + /// Drive one in-process request to its answer; an infallible router + /// always answers. + async fn send(router: Router, request: Request) -> Response { + router + .oneshot(request) + .await + .expect("an in-process request always answers") + } + + async fn body_json(response: Response) -> serde_json::Value { + let bytes = to_bytes(response.into_body(), 1 << 20) + .await + .expect("a bounded test body"); + serde_json::from_slice(&bytes).expect("a JSON test body") + } + + /// The edge under test, without a service behind it: the boundary layers + /// and the fallbacks are the surface these tests pin. + fn edge_router() -> Router { + async fn echo(Json(_): Json) -> StatusCode { + StatusCode::OK + } + http_edge(Router::new().route("/v1/echo", post(echo))) + } + + #[tokio::test] + async fn an_unknown_route_answers_the_pinned_problem_under_the_callers_trace() { + let response = send(edge_router(), traced_request("GET", "/v1/nothing")).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/problem+json" + ); + assert_eq!(response.headers().get("traceparent").unwrap(), TRACEPARENT); + let body = body_json(response).await; + assert_eq!( + body["type"], + "https://id.registrystack.org/problems/registry-scheduling/request/not-found" + ); + assert_eq!(body["title"], "Route not found"); + assert_eq!(body["status"], 404); + assert_eq!(body["detail"], "The requested route does not exist."); + assert_eq!(body["code"], "request.not-found"); + assert_eq!(body["traceId"], TRACE_ID); + } + + #[tokio::test] + async fn a_known_route_with_a_foreign_method_is_method_not_allowed() { + let response = send(edge_router(), traced_request("POST", "/v1/nothing")).await; + // The method fallback wins over the route fallback for a path no + // route claims at all. + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let response = send(edge_router(), traced_request("GET", "/v1/echo")).await; + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + let body = body_json(response).await; + assert_eq!(body["code"], "request.method-not-allowed"); + assert_eq!(body["status"], 405); + } + + #[tokio::test] + async fn framework_rejections_normalize_to_request_problems() { + // Malformed JSON is a 400, a type mismatch a 422, and a non-JSON + // content type a 415: three framework answers, one problem vocabulary. + let malformed = Request::builder() + .method("POST") + .uri("/v1/echo") + .header("content-type", "application/json") + .body(Body::from("this is not json")) + .unwrap(); + let response = send(edge_router(), malformed).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_json(response).await; + assert_eq!(body["code"], "request.invalid"); + + let mismatched = Request::builder() + .method("POST") + .uri("/v1/echo") + .header("content-type", "application/json") + .body(Body::from(r#"{"observedRevision": "not a number"}"#)) + .unwrap(); + let response = send(edge_router(), mismatched).await; + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = body_json(response).await; + assert_eq!(body["code"], "request.unprocessable"); + + let wrong_media = Request::builder() + .method("POST") + .uri("/v1/echo") + .header("content-type", "text/plain") + .body(Body::from(r#"{"x":1}"#)) + .unwrap(); + let response = send(edge_router(), wrong_media).await; + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + let body = body_json(response).await; + assert_eq!(body["code"], "request.unsupported-media-type"); + } + + #[tokio::test] + async fn answers_carry_no_store_and_the_callers_trace() { + let response = send( + edge_router(), + Request::builder() + .method("POST") + .uri("/v1/echo") + .header("traceparent", TRACEPARENT) + .header("content-type", "application/json") + .body(Body::from(r#"{"observedRevision": 1}"#)) + .unwrap(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-store"); + // The answering traceparent keeps the caller's trace id under a fresh + // server span id: pin the trace id segment, not the whole header. + let answered = response + .headers() + .get("traceparent") + .unwrap() + .to_str() + .unwrap(); + assert_eq!(&answered[3..35], TRACE_ID); + } + + #[test] + fn every_problem_of_the_closed_vocabulary_renders_its_pinned_document() { + for problem in ProblemCode::ALL { + let response = problem_response(*problem); + let status = StatusCode::from_u16(problem.http_status()).unwrap(); + assert_eq!(response.status(), status, "the code {}", problem.code()); + } + } + + #[tokio::test] + async fn product_problems_carry_the_vocabulary_dialect() { + let trace = TraceContext::from_headers(&{ + let mut headers = HeaderMap::new(); + headers.insert( + "traceparent", + TRACEPARENT.parse().expect("a valid traceparent"), + ); + headers + }); + let response = REQUEST_TRACE + .scope(trace.clone(), async { + problem_response(ProblemCode::CapacityExhausted) + }) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = body_json(response).await; + assert_eq!(body["type"], type_uri("capacity.exhausted")); + assert_eq!(body["title"], "Capacity exhausted"); + assert_eq!(body["status"], 409); + assert_eq!(body["detail"], ProblemCode::CapacityExhausted.detail()); + assert_eq!(body["code"], "capacity.exhausted"); + assert_eq!( + body["traceId"], + serde_json::to_value(&trace.trace_id).unwrap() + ); + } + + #[tokio::test] + async fn an_unauthenticated_problem_challenges_for_a_bearer() { + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + problem_response(ProblemCode::AuthenticationRefused) + }) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers().get(WWW_AUTHENTICATE).unwrap(), "Bearer"); + } + + #[tokio::test] + async fn an_unavailable_problem_names_a_retry_interval() { + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + problem_response(ProblemCode::ServiceUnavailable) + }) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers().get(RETRY_AFTER).unwrap(), "5"); + } + + fn stored_refusal(problem: ProblemCode) -> Value { + // The receipt shape the service records under a caller's refused + // idempotency key: the pinned problem, deliberately without a trace. + serde_json::json!({ + "problem": { + "type": type_uri(problem.code()), + "title": problem.title(), + "status": problem.http_status(), + "detail": problem.detail(), + "code": problem.code(), + } + }) + } + + #[tokio::test] + async fn a_replayed_refusal_re_renders_its_pinned_code_under_the_current_trace() { + let trace = TraceContext::from_headers(&{ + let mut headers = HeaderMap::new(); + headers.insert( + "traceparent", + TRACEPARENT.parse().expect("a valid traceparent"), + ); + headers + }); + let response = REQUEST_TRACE + .scope(trace.clone(), async { + replay_response(409, stored_refusal(ProblemCode::CapacityExhausted)) + }) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = body_json(response).await; + assert_eq!(body["code"], "capacity.exhausted"); + assert_eq!(body["status"], 409); + assert_eq!( + body["traceId"], + serde_json::to_value(&trace.trace_id).unwrap() + ); + } + + #[tokio::test] + async fn a_replayed_release_answers_an_empty_204_again() { + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + replay_response(204, Value::Null) + }) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let bytes = to_bytes(response.into_body(), 1 << 10).await.unwrap(); + assert!(bytes.is_empty()); + } + + #[tokio::test] + async fn a_receipt_in_no_known_shape_is_never_shown_to_the_caller() { + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + replay_response(409, serde_json::json!({"surprise": true})) + }) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = body_json(response).await; + assert_eq!(body["code"], "service.unavailable"); + } + + fn headers_with(key: K, value: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(key, value.parse().expect("a valid header value")); + headers + } + + #[test] + fn the_idempotency_key_is_present_bounded_and_graphical() { + assert!(matches!( + idempotency_key(&HeaderMap::new()), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + assert!(matches!( + idempotency_key(&headers_with(IDEMPOTENCY_KEY_HEADER, "")), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + assert!(matches!( + idempotency_key(&headers_with(IDEMPOTENCY_KEY_HEADER, "a key with spaces")), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + let oversized = "k".repeat(MAXIMUM_IDEMPOTENCY_KEY_BYTES + 1); + assert!(matches!( + idempotency_key(&headers_with(IDEMPOTENCY_KEY_HEADER, &oversized)), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + assert_eq!( + idempotency_key(&headers_with(IDEMPOTENCY_KEY_HEADER, "hold-7")).unwrap(), + "hold-7" + ); + } + + #[test] + fn the_item_routes_sit_on_the_pinned_collection_paths() { + assert!(HOLD_ROUTE.starts_with(HOLDS_PATH)); + assert!(APPOINTMENT_ROUTE.starts_with(APPOINTMENTS_PATH)); + assert!(APPOINTMENT_RESCHEDULE_ROUTE.starts_with(APPOINTMENTS_PATH)); + assert!(APPOINTMENT_CANCEL_ROUTE.starts_with(APPOINTMENTS_PATH)); + assert!(APPOINTMENT_HISTORY_ROUTE.starts_with(APPOINTMENTS_PATH)); + } + + #[test] + fn a_bearer_header_is_parsed_and_anything_else_is_a_refusal() { + assert_eq!( + bearer_token(&headers_with(AUTHORIZATION, "Bearer fixture-secret")).unwrap(), + "fixture-secret" + ); + assert!(matches!( + bearer_token(&headers_with(AUTHORIZATION, "Basic dXNlcjpwYXNz")), + Err(HttpError(ProblemCode::AuthenticationRefused)) + )); + assert!(matches!( + bearer_token(&HeaderMap::new()), + Err(HttpError(ProblemCode::AuthenticationRefused)) + )); + } +} diff --git a/crates/registry-scheduling/src/lib.rs b/crates/registry-scheduling/src/lib.rs index 0a13f44e3b..34e7b575e3 100644 --- a/crates/registry-scheduling/src/lib.rs +++ b/crates/registry-scheduling/src/lib.rs @@ -16,7 +16,12 @@ //! and shared `registry-platform-*` primitives, and nothing here may grow a //! second product's protocol types. +pub mod auth; pub mod config; pub mod cursors; +pub mod http; +pub mod runtime; +#[cfg(feature = "schema")] +pub mod schema; pub mod service; pub mod store; diff --git a/crates/registry-scheduling/src/main.rs b/crates/registry-scheduling/src/main.rs index 08322a2988..37475cb4d0 100644 --- a/crates/registry-scheduling/src/main.rs +++ b/crates/registry-scheduling/src/main.rs @@ -2,6 +2,12 @@ //! The `scheduling` binary entry point. -fn main() { - println!("{}", registry_platform_buildinfo::DISPLAY_VERSION); +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + let matches = registry_scheduling::runtime::command().get_matches(); + if let Err(error) = registry_scheduling::runtime::run(&matches).await { + eprintln!("scheduling: {error}"); + std::process::exit(1); + } } diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs new file mode 100644 index 0000000000..ceea31205d --- /dev/null +++ b/crates/registry-scheduling/src/runtime.rs @@ -0,0 +1,947 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Service assembly and the supervised background loops: `scheduling serve` +//! builds the store connection, the authenticator, the audit chain, and the +//! scheduling service, then runs the HTTP listener beside four workers — hold +//! expiry, reminder-intent dispatch, retention sweeps, and audit publication. +//! A worker that dies stops the process rather than letting the runtime keep +//! selling capacity its clocks no longer guard. +//! +//! Reminder dispatch is the one place the runtime speaks to another system: +//! each due outbox intent is rendered as one CloudEvents 1.0 JSON event and +//! POSTed to the operator-configured destination, exactly once per claim, with +//! the transport and the notification bus wholly external (INT-02). When no +//! destination is configured the outbox is the boundary: intents are recorded +//! and marked local, never pretended delivered. + +use std::future::Future; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, TimeDelta, Utc}; +use clap::{Arg, Command}; +use registry_platform_audit::{AuditEnvelope, AuditProfile, ChainState, JsonlFileSink}; +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_config::{ProtectedSecret, SecretProvider, SecretResolver}; +use registry_platform_httputil::destination::{ + DataDestinationPolicy, DataDestinationRequestTemplate, DestinationAuthorizationTemplate, + DestinationAuthorizationValue, DestinationBodyTemplate, DestinationMethod, DestinationProfile, + FixedDestinationPolicy, +}; +use serde_json::Value; +use thiserror::Error; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tokio::time::{Interval, MissedTickBehavior}; +use url::Url; +use uuid::Uuid; + +use crate::auth::SchedulingAuthenticator; +use crate::config::{ReminderDestinationConfig, RuntimeConfig, RuntimeConfigError}; +use crate::http::{router, HttpState}; +use crate::service::SchedulingService; +use crate::store::{OutboxRow, PostgresStore, StoreError}; + +/// How often each background loop wakes. The intervals are fixed constants, +/// not configuration: they are internal mechanics of one deployment, not an +/// operator-tunable contract. +const WORKER_INTERVAL: Duration = Duration::from_secs(2); +/// How often the audit publication loop wakes; publication is the loop whose +/// lag directly bounds the accountability record's durability. +const AUDIT_PUBLICATION_INTERVAL: Duration = Duration::from_secs(1); +/// Retention sweeps run every thirtieth maintenance tick. +const RETENTION_TICKS: u8 = 30; +/// How many holds one expiry pass may retire, and how many intents one +/// dispatch pass may claim. +const HOLD_EXPIRY_BATCH: i64 = 100; +const REMINDER_BATCH: i64 = 100; +/// The delivery attempts an intent may consume before it is held as failed for +/// the operator to see. +const REMINDER_ATTEMPTS_CEILING: i32 = 8; +/// Exponential backoff between reminder attempts, from half a minute to an +/// hour. +const REMINDER_RETRY_BASE_SECONDS: i64 = 30; +const REMINDER_RETRY_MAX_SECONDS: i64 = 3600; +/// One rendered reminder event is bounded, as is the whole request. +const REMINDER_MAXIMUM_BODY_BYTES: usize = 16 * 1024; +const REMINDER_MAXIMUM_REQUEST_BYTES: usize = 32 * 1024; +const REMINDER_BEARER_MAXIMUM_BYTES: usize = 8192; +/// The whole dispatch of one intent — resolve, connect, send, and the status +/// line — shares one deadline. +const REMINDER_SEND_TIMEOUT: Duration = Duration::from_secs(5); + +#[must_use] +pub fn command() -> Command { + Command::new("scheduling") + .version(registry_platform_buildinfo::DISPLAY_VERSION) + .about("Run and maintain Registry Scheduling") + .arg( + Arg::new("runtime-config") + .long("runtime-config") + .value_name("FILE") + .help("Absolute path to the runtime configuration file.") + .required(true), + ) + .subcommand_required(true) + .subcommand(Command::new("migrate").about("Apply Scheduling database migrations")) + .subcommand(Command::new("serve").about("Run the Scheduling HTTP service")) +} + +pub async fn run(matches: &clap::ArgMatches) -> Result<(), RuntimeError> { + let path = matches + .get_one::("runtime-config") + .ok_or(RuntimeError::Arguments)?; + match matches.subcommand_name() { + Some("migrate") => migrate_from_path(path).await, + Some("serve") => serve_from_path(path).await, + _ => Err(RuntimeError::Arguments), + } +} + +pub async fn migrate_from_path(path: impl AsRef) -> Result<(), RuntimeError> { + let config = RuntimeConfig::load(path)?; + let policy = config.load_policy()?; + let secrets = secret_resolver(&config)?; + let store = PostgresStore::connect_migration(&config.database, &secrets)?; + store.migrate().await?; + // Migrations leave the deployment identity unset; adopting it here is the + // provisioning act that binds this database to the policy's scheduling + // id, which every serve verifies before it writes anything. + store.adopt(&policy.scheduling.id).await?; + Ok(()) +} + +pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> { + let config = RuntimeConfig::load(path)?; + match config.policy_package_digest()? { + Some(digest) => tracing::info!( + policy_package_digest = %digest, + "verified Scheduling policy package" + ), + None => tracing::info!("loading authored Scheduling policy for loopback development"), + } + let policy = config.load_policy()?; + let scheduling_id = policy.scheduling.id.clone(); + let policy_digest = policy.policy_digest(); + let secrets = secret_resolver(&config)?; + let store = PostgresStore::connect_runtime(&config.database, &secrets)?; + store.ready().await?; + + // The deployment identity is read before anything is written: the store + // refuses to apply a policy under a scheduling id the deployment does not + // carry, and an empty one is a deployment that has not been adopted yet. + let (stored_id, _, _) = store.scheduling_meta().await?; + if stored_id.is_empty() { + return Err(RuntimeError::Unbootstrapped); + } + if stored_id != scheduling_id { + return Err(RuntimeError::DeploymentIdentity); + } + + let (verifier, keys) = config.oidc_verifier(&secrets).await?; + let authenticator = Arc::new(SchedulingAuthenticator::new( + &config.authentication.oidc, + verifier, + keys, + )); + + // The journal is keyed and verified before the policy revision can move, + // so a mis-provisioned deployment never advances state it cannot hold to + // account. + let audit_secret = resolve_audit_secret(&secrets, &config.audit.hash_key_ref)?; + // One master secret, two independently HKDF-derived sub-keys: the chain + // integrity key and the identifier-hash key never share key material + // (AUDIT-03), so one leaked sub-key reveals neither its sibling nor the + // master. + let audit_profile = + AuditProfile::production_from_secret_bytes(audit_secret.expose_secret().to_vec().into()) + .map_err(|_| RuntimeError::Audit)?; + let audit_sink = Arc::new( + JsonlFileSink::new_single_writer(&config.audit.path).map_err(|_| RuntimeError::Audit)?, + ); + let audit_chain = Arc::new( + audit_profile + .bootstrap_or_start_empty(audit_sink.as_ref()) + .await + .map_err(|_| RuntimeError::Audit)?, + ); + // The keyed bootstrap above authenticates the retained chain before its + // tail identity is used to reconcile a possible append/mark crash gap. + let mut audit_publication_state = AuditPublicationState::from_verified_tail( + audit_sink + .last_envelope() + .await + .map_err(|_| RuntimeError::Audit)? + .as_ref(), + ); + + let reminders = match &config.destinations.reminders { + Some(destination) => { + Some(reminder_transport(destination, &secrets).map_err(RuntimeError::Destination)?) + } + None => None, + }; + + // Publishing a policy may bump the revision every process start only when + // the digest changed; the anchors the policy names must exist for any + // offering to resolve supply. + let pool_ids = offering_pool_ids(&policy); + let window_ids = offering_window_ids(&policy); + let policy_revision = store + .apply_policy(&scheduling_id, &policy_digest, &pool_ids, &window_ids) + .await?; + + let service = Arc::new(SchedulingService::new( + store.clone(), + policy, + scheduling_id.clone(), + policy_revision, + policy_digest, + audit_profile.key_hasher(), + config.retention.attempt_receipt_days, + )); + + let (worker_stopped, worker_stops) = mpsc::channel(WORKER_STOP_CAPACITY); + let mut workers = Vec::new(); + + let expiry_store = store.clone(); + workers.push(supervise( + "hold expiry", + worker_stopped.clone(), + async move { + let mut interval = worker_timer(); + loop { + interval.tick().await; + let now = Utc::now(); + match expiry_store.expire_due_holds(now, HOLD_EXPIRY_BATCH).await { + Ok(expired) if expired > 0 => { + tracing::info!(expired, "Scheduling hold expiry pass retired due holds"); + } + Ok(_) => {} + Err(error) => tracing::warn!( + error = %error, + "Scheduling hold expiry pass did not complete" + ), + } + } + }, + )); + + let dispatch_store = store.clone(); + let dispatch_id = scheduling_id.clone(); + let dispatch_destination = reminders; + workers.push(supervise( + "reminder dispatch", + worker_stopped.clone(), + async move { + let mut interval = worker_timer(); + loop { + interval.tick().await; + if let Err(error) = dispatch_due_intents( + &dispatch_store, + &dispatch_id, + dispatch_destination.as_ref(), + ) + .await + { + tracing::warn!( + error = %error, + "Scheduling reminder dispatch pass did not complete" + ); + } + } + }, + )); + + let retention_store = store.clone(); + workers.push(supervise("retention", worker_stopped.clone(), async move { + let mut interval = worker_timer(); + let mut ticks = 0_u8; + loop { + interval.tick().await; + ticks = (ticks + 1) % RETENTION_TICKS; + if ticks != 0 { + continue; + } + let now = Utc::now(); + if let Err(error) = retention_store.erase_expired_cursors(now).await { + tracing::warn!(error = %error, "Scheduling cursor retention pass did not complete"); + } + if let Err(error) = retention_store.erase_expired_attempts(now).await { + tracing::warn!( + error = %error, + "Scheduling attempt receipt retention pass did not complete" + ); + } + } + })); + + let audit_publisher = AuditPublisher { + store: store.clone(), + chain: audit_chain, + sink: audit_sink, + }; + workers.push(supervise("audit publication", worker_stopped, async move { + let mut interval = tokio::time::interval(AUDIT_PUBLICATION_INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + interval.tick().await; + if let Err(stage) = + publish_audit_pass(&audit_publisher, &mut audit_publication_state).await + { + tracing::warn!(stage, "Scheduling audit publication pass did not complete"); + } + } + })); + + let app = router(HttpState { + service, + authenticator, + store, + }); + let listener = tokio::net::TcpListener::bind(config.listener.bind).await?; + let served = serve_until_worker_stops(listener, app, worker_stops).await; + for worker in workers { + worker.abort(); + } + served +} + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error("the scheduling command arguments are invalid")] + Arguments, + #[error(transparent)] + Config(#[from] RuntimeConfigError), + #[error("the Scheduling secret configuration is invalid")] + SecretConfiguration, + #[error("the Scheduling audit journal could not be opened or extended")] + Audit, + #[error("{0}")] + AuditSecret(String), + #[error(transparent)] + Store(#[from] StoreError), + #[error("the Scheduling listener could not bind or serve")] + Listen(#[from] std::io::Error), + #[error("the Scheduling reminder destination is invalid: {0}")] + Destination(String), + #[error("a Scheduling background worker stopped")] + WorkerStopped, + #[error( + "the deployment identity is unset: adopt this database before serving, by running \ + `scheduling migrate` with this runtime configuration" + )] + Unbootstrapped, + #[error("the deployment's scheduling id does not match the authored policy")] + DeploymentIdentity, + #[error("a due reminder intent could not be rendered as an event")] + ReminderEvent, +} + +/// Serve until a supervised background loop stops. The listener never stops on +/// its own, so a clean return means a worker stopped, and the process reports +/// that as a failure for whatever supervises it to restart. +async fn serve_until_worker_stops( + listener: tokio::net::TcpListener, + app: axum::Router, + stops: mpsc::Receiver<&'static str>, +) -> Result<(), RuntimeError> { + axum::serve(listener, app) + .with_graceful_shutdown(worker_stop(stops)) + .await?; + Err(RuntimeError::WorkerStopped) +} + +/// One slot per supervised loop, so a stopping worker never blocks on the +/// listener reading its report. +const WORKER_STOP_CAPACITY: usize = 16; + +/// Run a background loop under a task that outlives it. The loops never return +/// on their own, so a supervised task that finishes carries a panic, and its +/// name travels to the listener. +fn supervise( + name: &'static str, + stopped: mpsc::Sender<&'static str>, + worker: impl Future + Send + 'static, +) -> JoinHandle<()> { + tokio::spawn(async move { + match tokio::spawn(worker).await { + Ok(()) => tracing::error!(worker = name, "a Scheduling background worker returned"), + Err(error) => { + tracing::error!(worker = name, error = %error, "a Scheduling background worker panicked"); + } + } + if stopped.send(name).await.is_err() { + tracing::debug!(worker = name, "the Scheduling listener had already stopped"); + } + }) +} + +/// Resolve once a supervised background loop has stopped. A process whose +/// clocks no longer fire keeps neither its listener nor its readiness. +async fn worker_stop(mut stopped: mpsc::Receiver<&'static str>) { + match stopped.recv().await { + Some(worker) => tracing::error!( + worker, + "stopping the Scheduling listener after a background worker stopped" + ), + None => { + tracing::error!( + "stopping the Scheduling listener after every background worker stopped" + ) + } + } +} + +fn worker_timer() -> Interval { + let mut interval = tokio::time::interval(WORKER_INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + interval +} + +/// Resolve the audit journal's keying secret, naming the reference on refusal. +/// The journal is keyed before the listener binds, so this refusal is the +/// first line an operator sees on a mis-provisioned deployment. It carries a +/// valid configured reference and the rule that broke, never the key bytes. +fn resolve_audit_secret( + secrets: &SecretResolver, + reference: &str, +) -> Result { + secrets.resolve(reference).map_err(|error| { + RuntimeError::AuditSecret(crate::config::describe_secret_failure( + "audit.hashKeyRef", + reference, + &error, + )) + }) +} + +pub fn secret_resolver(config: &RuntimeConfig) -> Result { + let mut providers = Vec::new(); + if config.secret_providers.file.is_some() { + providers.push(SecretProvider::File); + } + if config.secret_providers.environment.is_some() { + providers.push(SecretProvider::Environment); + } + SecretResolver::new( + providers, + config + .secret_providers + .file + .as_ref() + .map_or_else(|| Path::new(""), |file| file.root.as_path()), + ) + .map_err(|_| RuntimeError::SecretConfiguration) +} + +/// The pool anchors the policy's exact-time offerings name. +fn offering_pool_ids(policy: ®istry_scheduling_core::SchedulingPolicy) -> Vec { + let mut ids: Vec = policy + .offerings + .iter() + .filter_map(|offering| offering.exact_time.as_ref().map(|exact| exact.pool.clone())) + .collect(); + ids.sort(); + ids.dedup(); + ids +} + +/// The published windows the policy's arrival-window offerings serve in. +fn offering_window_ids(policy: ®istry_scheduling_core::SchedulingPolicy) -> Vec { + let mut ids: Vec = policy + .offerings + .iter() + .filter_map(|offering| { + offering + .arrival + .as_ref() + .map(|arrival| arrival.window.clone()) + }) + .collect(); + ids.sort(); + ids.dedup(); + ids +} + +// --------------------------------------------------------------------------- +// Reminder dispatch +// --------------------------------------------------------------------------- + +/// The frozen outbound transport for due reminder intents: one fixed +/// destination, one reviewed request shape, and an operator-configured bearer +/// credential that never reaches a log line. +struct ReminderTransport { + policy: DataDestinationPolicy, + template: DataDestinationRequestTemplate, + bearer: Option, +} + +/// Split an operator-configured destination URL into the origin a frozen +/// destination policy holds and the fixed path its request template compiles. +/// A URL carrying a query or fragment is refused: the destination is one +/// reviewed endpoint, not a template a configuration value may widen. +fn destination_origin_and_path(configured: &str) -> Result<(String, String), &'static str> { + let parsed = Url::parse(configured).map_err(|_| "the destination URL does not parse")?; + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err("the destination URL may not carry a query or fragment"); + } + let path = parsed.path().to_owned(); + let mut origin = parsed; + origin.set_path(""); + origin.set_query(None); + origin.set_fragment(None); + Ok((origin.to_string(), path)) +} + +fn reminder_transport( + destination: &ReminderDestinationConfig, + secrets: &SecretResolver, +) -> Result { + let (origin, path) = + destination_origin_and_path(&destination.url).map_err(|detail| detail.to_owned())?; + let scheme = origin + .split_once("://") + .map(|(scheme, _)| scheme.to_owned()) + .unwrap_or_default(); + // The configuration check already confines plain http to loopback hosts; + // the transport pins the same rule into the frozen policy. + let profile = match scheme.as_str() { + "https" => DestinationProfile::ProductionHttps, + "http" => DestinationProfile::LoopbackDevelopmentHttp, + _ => return Err("the destination URL scheme is not http(s)".to_owned()), + }; + let path = if path.is_empty() { + "/".to_owned() + } else { + path + }; + let policy = FixedDestinationPolicy::new("scheduling-reminders", &origin, profile, &[]) + .map_err(|_| "the destination could not be frozen as a fixed origin".to_owned())?; + let authorization = match &destination.bearer_token_ref { + Some(reference) => { + let secret = secrets.resolve(reference).map_err(|error| { + crate::config::describe_secret_failure( + "destinations.reminders.bearerTokenRef", + reference, + &error, + ) + })?; + return Ok(ReminderTransport { + policy, + template: reminder_template( + &path, + DestinationAuthorizationTemplate::Bearer { + max_value_bytes: REMINDER_BEARER_MAXIMUM_BYTES, + }, + )?, + bearer: Some(secret), + }); + } + None => DestinationAuthorizationTemplate::Forbidden, + }; + Ok(ReminderTransport { + policy, + template: reminder_template(&path, authorization)?, + bearer: None, + }) +} + +fn reminder_template( + path: &str, + authorization: DestinationAuthorizationTemplate, +) -> Result { + DataDestinationRequestTemplate::new_with_exact_headers( + DestinationMethod::ReviewedReadOnlyPost, + path, + &[], + &[ + ("accept", b"application/json".as_slice()), + ("content-type", b"application/cloudevents+json".as_slice()), + ], + authorization, + DestinationBodyTemplate::Required { + max_bytes: REMINDER_MAXIMUM_BODY_BYTES, + }, + REMINDER_MAXIMUM_REQUEST_BYTES, + ) + .map_err(|_| "the reminder request shape could not be compiled".to_owned()) +} + +/// Render one due intent as a CloudEvents 1.0 event in canonical JSON: the +/// intent's own id, the deployment it came from, the purpose it was minted +/// under, the instant it fell due, and the payload the runtime committed. +fn reminder_event(scheduling_id: &str, row: &OutboxRow) -> Result, RuntimeError> { + let event = serde_json::json!({ + "specversion": "1.0", + "id": row.outbox_id.to_string(), + "source": scheduling_id, + "type": format!("org.registrystack.scheduling.{}", row.purpose), + "time": row.due_at.to_rfc3339(), + "data": row.payload, + }); + canonicalize_json(&event).map_err(|_| RuntimeError::ReminderEvent) +} + +/// How one dispatch attempt's outcome is held, before it is written back. +enum Delivery { + /// The destination took the event. + Delivered, + /// The attempt proved nothing either way: try again after a backoff. + Transient, + /// The destination refused the event outright: hold it for the operator + /// instead of retrying a refusal forever. + Refused, +} + +fn delivery_of(status: u16) -> Delivery { + match status { + 200..=299 => Delivery::Delivered, + 408 | 429 | 500..=599 => Delivery::Transient, + _ => Delivery::Refused, + } +} + +/// The backoff before the next attempt of one intent, exponential from the +/// base and capped at the hour. +fn next_attempt(attempts: i32) -> DateTime { + let shift = (attempts.max(1) - 1).min(7) as u32; + let seconds = (REMINDER_RETRY_BASE_SECONDS << shift).min(REMINDER_RETRY_MAX_SECONDS); + Utc::now() + TimeDelta::seconds(seconds) +} + +/// Claim every due intent and give each one dispatch attempt. A transport +/// failure proves nothing and schedules a retry; a refusal outside the retry +/// classes is held as failed for the operator; with no destination configured +/// the intent is marked local — recorded, never pretended delivered. +async fn dispatch_due_intents( + store: &PostgresStore, + scheduling_id: &str, + transport: Option<&ReminderTransport>, +) -> Result<(), StoreError> { + let now = Utc::now(); + for row in store.claim_due_intents(now, REMINDER_BATCH).await? { + let Some(transport) = transport else { + store.hold_intent_local(row.outbox_id).await?; + continue; + }; + let body = match reminder_event(scheduling_id, &row) { + Ok(body) => body, + Err(error) => { + tracing::error!( + error = %error, + outbox_id = %row.outbox_id, + "a due reminder intent could not be rendered" + ); + // An unrenderable intent is held, not retried: no amount of + // retrying fixes a payload that cannot become an event. + store + .retry_intent(row.outbox_id, next_attempt(row.attempts), row.attempts) + .await?; + continue; + } + }; + let authorization = transport + .bearer + .as_ref() + .map(|secret| DestinationAuthorizationValue::bearer(secret.expose_secret().to_vec())) + .transpose(); + let request = match authorization { + Ok(authorization) => transport.template.render(&[], &[], authorization, Some(body)), + Err(_) => Err(registry_platform_httputil::destination::DestinationRequestError::InvalidAuthorization), + }; + let outcome = match request { + Ok(request) => match transport.policy.send(request, REMINDER_SEND_TIMEOUT).await { + Ok(response) => Some(delivery_of(response.status().as_u16())), + Err(error) => { + tracing::warn!( + error = %error, + outbox_id = %row.outbox_id, + "a reminder delivery attempt did not reach a verdict" + ); + Some(Delivery::Transient) + } + }, + Err(error) => { + tracing::error!( + error = %error, + outbox_id = %row.outbox_id, + "a reminder delivery request could not be rendered" + ); + None + } + }; + match outcome.unwrap_or(Delivery::Transient) { + Delivery::Delivered => store.mark_intent_delivered(row.outbox_id).await?, + Delivery::Transient => { + store + .retry_intent( + row.outbox_id, + next_attempt(row.attempts), + REMINDER_ATTEMPTS_CEILING, + ) + .await?; + } + // A refusal the retry classes do not cover (an authorization + // refusal, a routing refusal) is terminal: the ceiling set to the + // attempt count holds the intent as failed on this very write. + Delivery::Refused => { + tracing::warn!( + outbox_id = %row.outbox_id, + status = row.attempts, + "a reminder destination refused an intent outright" + ); + store + .retry_intent(row.outbox_id, next_attempt(row.attempts), row.attempts) + .await?; + } + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Audit publication +// --------------------------------------------------------------------------- + +struct AuditPublisher { + store: PostgresStore, + chain: Arc, + sink: Arc, +} + +#[derive(Default)] +struct AuditPublicationState { + unconfirmed: Option, +} + +impl AuditPublicationState { + fn from_verified_tail(tail: Option<&AuditEnvelope>) -> Self { + let unconfirmed = tail + .and_then(|envelope| envelope.record.as_object()) + .and_then(|record| record.get("eventId")) + .and_then(Value::as_str) + .and_then(|event_id| Uuid::parse_str(event_id).ok()); + Self { unconfirmed } + } +} + +/// Publish every pending audit record: append it to the keyed chain, then mark +/// it published. The one record whose append may have landed before its mark +/// is remembered and re-marked first on the next pass, so a crash between the +/// two writes never duplicates an envelope. +async fn publish_audit_pass( + publisher: &AuditPublisher, + state: &mut AuditPublicationState, +) -> Result<(), &'static str> { + if let Some(event_id) = state.unconfirmed { + publisher + .store + .mark_audit_published(event_id) + .await + .map_err(|_| "published-mark")?; + state.unconfirmed = None; + } + let records = publisher + .store + .pending_audit(100) + .await + .map_err(|_| "pending-read")?; + for (event_id, record) in records { + let record = audit_record_with_event_id(event_id, record).ok_or("record-identity")?; + publisher + .chain + .append(publisher.sink.as_ref(), record) + .await + .map_err(|_| "sink-append")?; + state.unconfirmed = Some(event_id); + publisher + .store + .mark_audit_published(event_id) + .await + .map_err(|_| "published-mark")?; + state.unconfirmed = None; + } + Ok(()) +} + +/// Stamp the pending record with its audit identity. The records the service +/// writes are already pseudonymized; publication adds only the event id, and +/// refuses a record that already carries a different one. +fn audit_record_with_event_id(event_id: Uuid, mut record: Value) -> Option { + let fields = record.as_object_mut()?; + let event_id = event_id.to_string(); + match fields.get("eventId") { + Some(Value::String(existing)) if existing == &event_id => {} + Some(_) => return None, + None => { + fields.insert("eventId".to_owned(), Value::String(event_id)); + } + } + Some(record) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone as _; + + fn intent(purpose: &str) -> OutboxRow { + let due = Utc.with_ymd_and_hms(2026, 10, 5, 8, 0, 0).unwrap(); + OutboxRow { + outbox_id: Uuid::new_v4(), + purpose: purpose.to_owned(), + claim_id: Uuid::new_v4(), + appointment_revision: 3, + due_at: due, + attempts: 1, + payload: serde_json::json!({"appointment": "a-1", "minutesBefore": 60}), + } + } + + #[test] + fn a_due_intent_renders_as_one_canonical_cloudevents_event() { + let row = intent("reminder-60"); + let body = reminder_event("registry-north", &row).unwrap(); + let event: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(event["specversion"], "1.0"); + assert_eq!(event["id"], row.outbox_id.to_string()); + assert_eq!(event["source"], "registry-north"); + assert_eq!(event["type"], "org.registrystack.scheduling.reminder-60"); + assert_eq!(event["time"], "2026-10-05T08:00:00+00:00"); + assert_eq!(event["data"]["minutesBefore"], 60); + // Canonical JSON carries no insignificant whitespace. + assert!(!String::from_utf8(body.clone()).unwrap().contains(' ')); + } + + #[test] + fn the_delivery_verdict_partitions_the_status_line() { + assert!(matches!(delivery_of(200), Delivery::Delivered)); + assert!(matches!(delivery_of(204), Delivery::Delivered)); + assert!(matches!(delivery_of(408), Delivery::Transient)); + assert!(matches!(delivery_of(429), Delivery::Transient)); + assert!(matches!(delivery_of(503), Delivery::Transient)); + assert!(matches!(delivery_of(301), Delivery::Refused)); + assert!(matches!(delivery_of(401), Delivery::Refused)); + assert!(matches!(delivery_of(422), Delivery::Refused)); + } + + #[test] + fn retry_backoff_grows_exponentially_and_caps_at_the_hour() { + let now = Utc::now(); + let first = next_attempt(1).signed_duration_since(now).num_seconds(); + let second = next_attempt(2).signed_duration_since(now).num_seconds(); + let ceiling = next_attempt(50).signed_duration_since(now).num_seconds(); + assert_eq!(first, REMINDER_RETRY_BASE_SECONDS); + assert_eq!(second, 2 * REMINDER_RETRY_BASE_SECONDS); + assert_eq!(ceiling, REMINDER_RETRY_MAX_SECONDS); + } + + #[test] + fn a_destination_url_splits_into_origin_and_fixed_path() { + let (origin, path) = destination_origin_and_path("https://bus.example/reminders").unwrap(); + assert_eq!(origin, "https://bus.example/"); + assert_eq!(path, "/reminders"); + let (origin, path) = destination_origin_and_path("http://127.0.0.1:9000/outbox").unwrap(); + assert_eq!(origin, "http://127.0.0.1:9000/"); + assert_eq!(path, "/outbox"); + assert!(destination_origin_and_path("https://bus.example/r?wide=1").is_err()); + assert!(destination_origin_and_path("https://bus.example/r#f").is_err()); + assert!(destination_origin_and_path("not a url").is_err()); + } + + #[test] + fn the_offering_anchors_are_collected_without_duplicates() { + use registry_scheduling_core::{ + ArrivalOffering, ExactTimeOffering, HoldPolicy, OfferingPolicy, PolicyIdentity, + SchedulingMode, SchedulingPolicy, ServicePolicy, + }; + let exact_time = || { + Some(ExactTimeOffering { + duration_minutes: 30, + buffer_before_minutes: 5, + buffer_after_minutes: 5, + lead_time_minutes: 60, + horizon_days: 30, + pool: "north".to_owned(), + start_increment_minutes: 30, + max_recipients: 1, + }) + }; + let offering = + |id: &str, exact: Option, arrival: Option| { + OfferingPolicy { + id: id.to_owned(), + service: "s1".to_owned(), + label: id.to_owned(), + mode: if exact.is_some() { + SchedulingMode::ExactTime + } else { + SchedulingMode::ArrivalWindow + }, + location: "l1".to_owned(), + because: "test".to_owned(), + exact_time: exact, + arrival, + cancellation_cutoff_minutes: 240, + reminders: Vec::new(), + duplicate_active_key: None, + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + } + }; + let policy = SchedulingPolicy { + api_version: "test".to_owned(), + kind: "test".to_owned(), + scheduling: PolicyIdentity { + id: "standalone".to_owned(), + version: 1, + }, + services: vec![ServicePolicy { + id: "s1".to_owned(), + label: "Service".to_owned(), + }], + offerings: vec![ + offering("o1", exact_time(), None), + // The same pool again: one anchor, not two. + offering("o2", exact_time(), None), + offering( + "o3", + None, + Some(ArrivalOffering { + window: "w-morning".to_owned(), + lead_time_minutes: 60, + horizon_days: 30, + }), + ), + ], + holiday_sets: Vec::new(), + openings: Vec::new(), + windows: Vec::new(), + hold_policy: HoldPolicy { + ttl_minutes: 10, + max_per_caller: 2, + because: "test".to_owned(), + }, + hooks: Vec::new(), + }; + assert_eq!(offering_pool_ids(&policy), vec!["north".to_owned()]); + assert_eq!(offering_window_ids(&policy), vec!["w-morning".to_owned()]); + } + + #[test] + fn a_pending_audit_record_is_stamped_with_its_identity_exactly_once() { + let event_id = Uuid::new_v4(); + let record = + audit_record_with_event_id(event_id, serde_json::json!({"event": "x"})).unwrap(); + assert_eq!(record["eventId"], event_id.to_string()); + // An agreeing stamp is accepted; a disagreeing one is refused. + assert!(audit_record_with_event_id(event_id, record.clone()).is_some()); + assert!(audit_record_with_event_id( + Uuid::new_v4(), + serde_json::json!({"eventId": "another"}) + ) + .is_none()); + } +} diff --git a/crates/registry-scheduling/src/schema.rs b/crates/registry-scheduling/src/schema.rs new file mode 100644 index 0000000000..4821a75693 --- /dev/null +++ b/crates/registry-scheduling/src/schema.rs @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Generated JSON Schema for the Scheduling runtime configuration. +//! +//! The schema is derived from the strict `RuntimeConfig` types, never written +//! by hand. Regenerate the committed document with: +//! +//! ```bash +//! cargo run -p registry-scheduling --features schema --example runtime-schema -- \ +//! --output products/scheduling/generated/runtime +//! ``` +//! +//! The derived schema states the bounds `RuntimeConfig::check` enforces, so +//! a document an operator's editor accepts is one the runtime starts on +//! rather than one it refuses after the operator has already written it. + +use std::collections::BTreeMap; + +use serde_json::Value; + +use registry_scheduling_core::{ + RUNTIME_SCHEMA_FILE, SCHEDULING_RUNTIME_API_VERSION, SCHEDULING_RUNTIME_KIND, + SCHEDULING_RUNTIME_SCHEMA_ID, +}; + +use crate::config::RuntimeConfig; + +const SECRET_REFERENCE_SCHEMA_PATTERN: &str = + "^(?:secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$"; + +pub fn runtime_documents() -> Result, serde_json::Error> { + let mut derived = serde_json::to_value(schemars::schema_for!(RuntimeConfig))?; + set_const(&mut derived, "apiVersion", SCHEDULING_RUNTIME_API_VERSION); + set_const(&mut derived, "kind", SCHEDULING_RUNTIME_KIND); + install_runtime_constraints(&mut derived); + let mut object = match derived { + Value::Object(object) => object, + _ => unreachable!("schemars derives a schema object for RuntimeConfig"), + }; + object.insert( + "$schema".to_owned(), + Value::String("https://json-schema.org/draft/2020-12/schema".to_owned()), + ); + object.insert( + "$id".to_owned(), + Value::String(SCHEDULING_RUNTIME_SCHEMA_ID.to_owned()), + ); + object.insert( + "title".to_owned(), + Value::String("Registry Scheduling runtime configuration".to_owned()), + ); + let mut rendered = serde_json::to_string_pretty(&Value::Object(object))?; + rendered.push('\n'); + Ok([(RUNTIME_SCHEMA_FILE, rendered)].into()) +} + +/// State in the schema the bounds `RuntimeConfig::check` and +/// `validate_secret_references` enforce at load: operated paths are absolute, +/// every secret field is a secret reference, a static JWKS document names its +/// provider, and at least one secret provider is configured. +fn install_runtime_constraints(schema: &mut Value) { + for (definition, property) in [ + ("RuntimePackageConfig", "root"), + ("FileSecretProviderConfig", "root"), + ("AuditConfig", "path"), + ] { + set_definition_property( + schema, + definition, + property, + "pattern", + Value::String("^/".to_owned()), + ); + } + for (definition, property) in [ + ("DatabaseConfig", "runtimeUrlRef"), + ("DatabaseConfig", "migrationUrlRef"), + ("DatabaseConfig", "trustedRootCertificateRef"), + ("AuditConfig", "hashKeyRef"), + ("ReminderDestinationConfig", "bearerTokenRef"), + ] { + set_definition_property( + schema, + definition, + property, + "pattern", + Value::String("^secret:(?:env|file)/".to_owned()), + ); + } + set_jwks_document_reference_constraints(schema); + if let Some(providers) = schema + .get_mut("$defs") + .and_then(|definitions| definitions.get_mut("SecretProvidersConfig")) + .and_then(Value::as_object_mut) + { + providers.insert( + "anyOf".to_owned(), + serde_json::json!([ + {"required": ["file"], "properties": {"file": {"$ref": "#/$defs/FileSecretProviderConfig"}}}, + {"required": ["environment"], "properties": {"environment": {"$ref": "#/$defs/EnvironmentSecretProviderConfig"}}} + ]), + ); + } + if let Some(root) = schema.as_object_mut() { + root.insert( + "allOf".to_owned(), + serde_json::json!([ + secret_provider_requirement("^secret:env/", "environment"), + secret_provider_requirement("^secret:file/", "file") + ]), + ); + } +} + +/// A static JWKS document reference is the one secret field whose full +/// reference grammar the schema states: the other fields need only name a +/// provider, while this one an operator authors directly. +fn set_jwks_document_reference_constraints(schema: &mut Value) { + if let Some(variants) = schema + .pointer_mut("/$defs/OidcJwksSource/oneOf") + .and_then(Value::as_array_mut) + { + for variant in variants { + if let Some(document_reference) = variant + .pointer_mut("/properties/documentRef") + .and_then(Value::as_object_mut) + { + document_reference.insert( + "pattern".to_owned(), + Value::String(SECRET_REFERENCE_SCHEMA_PATTERN.to_owned()), + ); + } + } + } +} + +/// A static JWKS document reference names its provider by its prefix, so a +/// configuration carrying one must enable that provider. +fn secret_provider_requirement(reference_pattern: &str, provider: &str) -> Value { + serde_json::json!({ + "if": { + "properties": { + "authentication": { + "properties": { + "oidc": { + "properties": { + "jwksSource": { + "properties": { + "documentRef": {"pattern": reference_pattern} + }, + "required": ["documentRef"] + } + }, + "required": ["jwksSource"] + } + }, + "required": ["oidc"] + } + }, + "required": ["authentication"] + }, + "then": { + "properties": { + "secretProviders": { + "properties": { + provider: { + "$ref": format!("#/$defs/{}SecretProviderConfig", match provider { + "environment" => "Environment", + "file" => "File", + _ => unreachable!("closed secret provider schema"), + }) + } + }, + "required": [provider] + } + } + } + }) +} + +fn set_definition_property( + schema: &mut Value, + definition: &str, + property: &str, + keyword: &str, + value: Value, +) { + if let Some(member) = schema + .get_mut("$defs") + .and_then(|definitions| definitions.get_mut(definition)) + .and_then(|definition| definition.get_mut("properties")) + .and_then(|properties| properties.get_mut(property)) + .and_then(Value::as_object_mut) + { + member.insert(keyword.to_owned(), value); + } +} + +fn set_const(schema: &mut Value, property: &str, expected: &str) { + if let Some(member) = schema + .get_mut("properties") + .and_then(|properties| properties.get_mut(property)) + .and_then(Value::as_object_mut) + { + member.insert("const".to_owned(), Value::String(expected.to_owned())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_schema_is_deterministic_and_versioned() { + let first = runtime_documents().unwrap(); + let second = runtime_documents().unwrap(); + assert_eq!(first, second); + let document: Value = serde_json::from_str(&first[RUNTIME_SCHEMA_FILE]).unwrap(); + assert_eq!(document["$id"], SCHEDULING_RUNTIME_SCHEMA_ID); + assert_eq!( + document["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!( + document["title"], + "Registry Scheduling runtime configuration" + ); + assert_eq!( + document["properties"]["apiVersion"]["const"], + SCHEDULING_RUNTIME_API_VERSION + ); + assert_eq!( + document["properties"]["kind"]["const"], + SCHEDULING_RUNTIME_KIND + ); + } + + #[test] + fn the_schema_states_the_bounds_the_runtime_enforces() { + let documents = runtime_documents().unwrap(); + let document: Value = serde_json::from_str(&documents[RUNTIME_SCHEMA_FILE]).unwrap(); + for (definition, property) in [ + ("RuntimePackageConfig", "root"), + ("FileSecretProviderConfig", "root"), + ("AuditConfig", "path"), + ] { + assert_eq!( + document["$defs"][definition]["properties"][property]["pattern"], "^/", + "{definition}.{property} must be absolute" + ); + } + for (definition, property) in [ + ("DatabaseConfig", "runtimeUrlRef"), + ("DatabaseConfig", "migrationUrlRef"), + ("DatabaseConfig", "trustedRootCertificateRef"), + ("AuditConfig", "hashKeyRef"), + ("ReminderDestinationConfig", "bearerTokenRef"), + ] { + assert_eq!( + document["$defs"][definition]["properties"][property]["pattern"], + "^secret:(?:env|file)/", + "{definition}.{property} must be a secret reference" + ); + } + assert_eq!( + document["$defs"]["SecretProvidersConfig"]["anyOf"], + serde_json::json!([ + {"required": ["file"], "properties": {"file": {"$ref": "#/$defs/FileSecretProviderConfig"}}}, + {"required": ["environment"], "properties": {"environment": {"$ref": "#/$defs/EnvironmentSecretProviderConfig"}}} + ]), + "at least one secret provider must be configured" + ); + assert_eq!( + document["$defs"]["OidcJwksSource"]["oneOf"][1]["properties"]["documentRef"]["pattern"], + SECRET_REFERENCE_SCHEMA_PATTERN + ); + } + + #[test] + fn committed_runtime_schema_matches_generated_bytes() { + let generated = runtime_documents().unwrap(); + let committed = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/scheduling/generated/runtime") + .join(RUNTIME_SCHEMA_FILE); + assert_eq!( + std::fs::read_to_string(committed).unwrap(), + generated[RUNTIME_SCHEMA_FILE] + ); + } +} diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs new file mode 100644 index 0000000000..aeaa4de364 --- /dev/null +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -0,0 +1,1036 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Database-backed commitment tests: the six commitments, availability, +//! cursors, the hold-expiry cycle, and the unanchored-supply refusal, +//! exercised through the pinned HTTP surface against a real PostgreSQL +//! deployment. +//! +//! Every test runs in its own schema inside the database named by +//! `SCHEDULING_TEST_DATABASE_URL`, adopted under one scheduling id, seeded +//! with one location, two pools of one member each, and driven through the +//! same router the runtime serves. Without the variable each test skips with +//! a notice: a skipped test binary is not database verification, so the gate +//! that matters runs with the variable set. + +use axum::body::{to_bytes, Body}; +use axum::http::{Request, StatusCode}; +use axum::Router; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use registry_platform_audit::{AuditHashSecret, AuditKeyHasher}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use registry_scheduling::auth::SchedulingAuthenticator; +use registry_scheduling::config::{DatabaseConfig, OidcConfig, OidcJwksSource}; +use registry_scheduling::http::{router, HttpState}; +use registry_scheduling::service::SchedulingService; +use registry_scheduling::store::PostgresStore; +use registry_scheduling_core::parse_policy_yaml; +use serde_json::{json, Value}; +use std::sync::Arc; +use tower::ServiceExt; +use uuid::Uuid; + +const ISSUER: &str = "https://task-token.test"; +const AUDIENCE: &str = "urn:registry-scheduling:test"; +const CLIENT: &str = "task-agent"; +const SECRET: &[u8] = b"01234567890123456789012345678901"; +const SCHEDULING_ID: &str = "commitments-test"; +const OFFERING: &str = "registry-update-30"; +const SECOND_OFFERING: &str = "registry-review-45"; + +/// One plain booking grid: slots every 30 minutes around the clock, every +/// day, one hour of lead time, a four-hour cancellation cutoff, and a +/// ten-minute hold. The wide opening keeps every query window these tests +/// use, all at least 110 minutes wide, certain to contain a bookable start +/// regardless of when the test runs, and the single member per pool makes +/// every commitment's capacity effect total: a booked or held slot is not +/// availability. The second offering sells a second pool so one fixture can +/// also pin a commitment against a pool the publication never anchored. +const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: {id: commitments-test, version: 1} +services: + - {id: registry-update, label: Registry record update} + - {id: registry-review, label: Registry record review} +holidaySets: [{id: none, revision: 1, because: test, dates: []}] +openings: + - id: all-day + location: north-counter + holidaySet: none + weekdays: [mon, tue, wed, thu, fri, sat, sun] + startTime: "00:00" + endTime: "23:30" + effectiveFrom: "2026-01-01" + effectiveUntil: "2035-06-30" + because: test +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: test + cancellationCutoffMinutes: 240 + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 60 + pool: north-counter + startIncrementMinutes: 30 + maxRecipients: 1 + requiresCapabilities: [] + prerequisites: [] + - id: registry-review-45 + service: registry-review + label: 45-minute record review + mode: exact-time + location: north-counter + because: test + cancellationCutoffMinutes: 240 + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 60 + pool: two-counter + startIncrementMinutes: 30 + maxRecipients: 1 + requiresCapabilities: [] + prerequisites: [] +windows: [] +holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} +"#; + +struct Fixture { + http: Router, + store: PostgresStore, + revision: u64, + /// A standing reader: the reads scope, no task grant. + reader: String, + /// The booking agent: reads and explain scopes plus the full task grant. + agent: String, +} + +impl Fixture { + async fn request( + &self, + method: &str, + uri: &str, + token: &str, + idempotency: Option<&str>, + body: Option, + ) -> (StatusCode, Value) { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json"); + if let Some(key) = idempotency { + builder = builder.header("idempotency-key", key); + } + let body = match body { + Some(value) => Body::from(value.to_string()), + None => Body::empty(), + }; + let response = self + .http + .clone() + .oneshot(builder.body(body).expect("a well-formed test request")) + .await + .expect("an in-process request always answers"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1 << 20) + .await + .expect("a bounded test body"); + let value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).expect("a JSON test body") + }; + (status, value) + } + + async fn get(&self, uri: &str, token: &str) -> (StatusCode, Value) { + self.request("GET", uri, token, None, None).await + } + + async fn post(&self, uri: &str, token: &str, key: &str, body: Value) -> (StatusCode, Value) { + self.request("POST", uri, token, Some(key), Some(body)) + .await + } + + async fn delete(&self, uri: &str, token: &str) -> (StatusCode, Value) { + self.request("DELETE", uri, token, None, None).await + } +} + +/// A fresh deployment in its own schema: migrated, adopted, seeded, published +/// with every pool the policy names, and serving through the same router the +/// runtime serves. +async fn fixture() -> Option { + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let mut pool_ids: Vec = policy + .offerings + .iter() + .filter_map(|offering| offering.exact_time.as_ref().map(|exact| exact.pool.clone())) + .collect(); + pool_ids.sort(); + pool_ids.dedup(); + fixture_anchoring(&pool_ids).await +} + +/// A fresh deployment whose publication anchored exactly `pool_ids`, so a +/// test can pin what happens when a policy names a pool no anchor covers. +async fn fixture_anchoring(pool_ids: &[String]) -> Option { + let Ok(base) = std::env::var("SCHEDULING_TEST_DATABASE_URL") else { + return None; + }; + let schema = format!("scheduling_{}", Uuid::new_v4().simple()); + let separator = if base.contains('?') { '&' } else { '?' }; + let scoped = format!("{base}{separator}options=-csearch_path%3D{schema}"); + + // The admin connection creates the isolated schema before anything + // resolves into it: table creation follows search_path, and the scoped + // URL every store connection carries points at this schema, so it must + // exist before the migrations run. + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .expect("connect the scheduling test database"); + tokio::spawn(async move { connection.await.expect("the scheduling admin connection") }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .expect("an isolated scheduling schema"); + admin + .batch_execute(&format!("SET search_path TO {schema}")) + .await + .expect("the scheduling test schema"); + + let secret_name = format!("SCHEDULING_SCHEMA_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + std::env::set_var(&secret_name, &scoped); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp") + .expect("the scheduling test secret resolver"); + let database = DatabaseConfig { + runtime_url_ref: format!("secret:env/{secret_name}"), + migration_url_ref: format!("secret:env/{secret_name}"), + trusted_root_certificate_ref: None, + test_only_plaintext: true, + }; + let migration_store = + PostgresStore::connect_migration(&database, &secrets).expect("the migration store"); + migration_store + .migrate() + .await + .expect("the scheduling migrations"); + let store = PostgresStore::connect_runtime(&database, &secrets).expect("the runtime store"); + + // The admin connection seeds the facts the policy resolves supply + // against — the path operator tooling owns — and the store adopts the + // deployment identity, the same provisioning verb `scheduling migrate` + // runs on a fresh database. + for statement in [ + "INSERT INTO scheduling_locations(location_id, timezone) VALUES('north-counter','UTC')", + "INSERT INTO scheduling_pools(pool_id) VALUES('north-counter')", + "INSERT INTO scheduling_pools(pool_id) VALUES('two-counter')", + "INSERT INTO scheduling_pool_members(resource_id, pool_id, available) \ + VALUES('station-1','north-counter',true)", + "INSERT INTO scheduling_pool_members(resource_id, pool_id, available) \ + VALUES('station-2','two-counter',true)", + ] { + admin + .batch_execute(statement) + .await + .expect("seed the scheduling facts"); + } + store + .adopt(SCHEDULING_ID) + .await + .expect("adopt the scheduling deployment"); + + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let digest = policy.policy_digest(); + let revision = store + .apply_policy(SCHEDULING_ID, &digest, pool_ids, &[]) + .await + .expect("publish the scheduling policy"); + let keying = AuditHashSecret::new(vec![0x42; 32]).expect("the test audit hash secret"); + let service = Arc::new(SchedulingService::new( + store.clone(), + policy, + SCHEDULING_ID.to_owned(), + revision, + digest, + AuditKeyHasher::Keyed(keying), + 7, + )); + let http = router(HttpState { + service, + authenticator: Arc::new(authenticator()), + store: store.clone(), + }); + Some(Fixture { + http, + store, + revision: u64::try_from(revision).expect("a bounded policy revision"), + reader: reader_token(), + agent: agent_token(), + }) +} + +fn authenticator() -> SchedulingAuthenticator { + let oidc = OidcConfig { + allowed_clients: vec![CLIENT.to_owned()], + issuer: ISSUER.to_owned(), + audience: AUDIENCE.to_owned(), + jwks_uri: None, + jwks_source: OidcJwksSource::Discovery, + scope_claim: "registry_scopes".to_owned(), + reads_scope: "scheduling-read".to_owned(), + explain_scope: "scheduling-explain".to_owned(), + }; + let verifier = TokenVerifierConfig::access_token_profile( + ISSUER, + vec![AUDIENCE.to_owned()], + vec![Algorithm::HS256], + vec!["at+jwt".to_owned()], + ) + .with_scope_claim("registry_scopes") + .with_allowed_clients(vec![CLIENT.to_owned()]); + let keys = Arc::new(JwksFetcher::new_static( + serde_json::from_value(json!({"keys": [{ + "kty": "oct", + "kid": "test", + "alg": "HS256", + "use": "sig", + "k": URL_SAFE_NO_PAD.encode(SECRET), + }]})) + .expect("the test signing keys"), + JwksFetcherConfig::defaults(), + )); + SchedulingAuthenticator::new(&oidc, verifier, keys) +} + +/// Sign an access token, filling in the claims a real token always carries. +fn token(mut claims: Value) -> String { + let now = Utc::now().timestamp(); + let object = claims.as_object_mut().unwrap(); + object.entry("iss").or_insert(json!(ISSUER)); + object.entry("aud").or_insert(json!(AUDIENCE)); + object.entry("iat").or_insert(json!(now)); + object.entry("exp").or_insert(json!(now + 300)); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test".to_owned()); + header.typ = Some("at+jwt".to_owned()); + jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(SECRET)).unwrap() +} + +fn reader_token() -> String { + token(json!({ + "sub": "principal-read", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + })) +} + +/// The task grant the booking agent carries: every action over the test +/// policy's services and location. +fn grant_claims() -> Value { + json!({ + "registry_actor_kind": "agent", + "registry_purpose": "registry-update", + "registry_grant_id": "grant-1", + "registry_grant_source_issuer": "https://authority.test", + "registry_grant_client": CLIENT, + "registry_grant_resource": AUDIENCE, + "registry_grant_exp": Utc::now().timestamp() + 600, + "registry_grant_bounds": { + "type": "scheduling", + "permissions": [{ + "service": "registry-update", + "location": "north-counter", + "actions": [ + "hold.create", + "hold.release", + "appointment.create", + "appointment.reschedule", + "appointment.cancel", + ], + }, { + "service": "registry-review", + "location": "north-counter", + "actions": ["appointment.create"], + }], + }, + "registry_approver": "approver-1", + }) +} + +fn agent_token() -> String { + let mut claims = json!({ + "sub": "principal-agent", + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + }); + let object = claims.as_object_mut().unwrap(); + object.extend( + grant_claims() + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +/// A complete admission request for `offering` at `slot`. +fn admission(fx: &Fixture, offering: &str, slot: DateTime) -> Value { + json!({ + "offering": offering, + "start": stamp(slot), + "party": {"recipients": 1, "attendees": 1}, + "channel": null, + "duplicateKey": null, + "policyRevision": fx.revision, + "windowRevision": null, + "capabilities": [], + "prerequisites": [], + "rescheduleOf": null, + }) +} + +fn stamp(moment: DateTime) -> String { + moment.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn moment(value: &Value, field: &str) -> DateTime { + DateTime::parse_from_rfc3339(value[field].as_str().expect("an RFC 3339 field")) + .expect("a parseable RFC 3339 field") + .with_timezone(&Utc) +} + +fn availability_uri(offering: &str, from_minutes: i64, to_minutes: i64) -> String { + let now = Utc::now(); + format!( + "/v1/availability?offering={offering}&start={}&end={}", + stamp(now + TimeDelta::minutes(from_minutes)), + stamp(now + TimeDelta::minutes(to_minutes)), + ) +} + +/// The earliest bookable slot the offering lists in `[now + from, now + to]`. +/// The test policy's grid steps every 30 minutes around the clock, so every +/// window these tests use, all at least 110 minutes wide, always carries one. +async fn first_slot( + fx: &Fixture, + offering: &str, + from_minutes: i64, + to_minutes: i64, +) -> DateTime { + let (status, page) = fx + .get( + &availability_uri(offering, from_minutes, to_minutes), + &fx.reader, + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "availability answers over the window" + ); + let start = page["items"] + .as_array() + .into_iter() + .flatten() + .find(|entry| entry["kind"] == "slot") + .and_then(|entry| entry["start"].as_str()) + .unwrap_or_else(|| panic!("a bookable slot inside [{from_minutes}, {to_minutes}] minutes")); + DateTime::parse_from_rfc3339(start) + .expect("a parseable slot start") + .with_timezone(&Utc) +} + +async fn slot_offered( + fx: &Fixture, + offering: &str, + from_minutes: i64, + to_minutes: i64, + slot: DateTime, +) -> bool { + let (_, page) = fx + .get( + &availability_uri(offering, from_minutes, to_minutes), + &fx.reader, + ) + .await; + page["items"].as_array().is_some_and(|items| { + items.iter().any(|entry| { + entry["kind"] == "slot" + && DateTime::parse_from_rfc3339(entry["start"].as_str().unwrap_or_default()) + .is_ok_and(|start| start.with_timezone(&Utc) == slot) + }) + }) +} + +/// Book directly on a fresh slot of the default offering and return the +/// appointment. +async fn booked(fx: &Fixture, from_minutes: i64, to_minutes: i64, key: &str) -> (String, u64) { + let slot = first_slot(fx, OFFERING, from_minutes, to_minutes).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + key, + json!({"hold": null, "admission": admission(fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "the direct create commits"); + ( + appointment["appointmentId"].as_str().unwrap().to_owned(), + appointment["revision"].as_u64().unwrap(), + ) +} + +#[tokio::test] +async fn a_hold_confirms_into_an_appointment_with_attributable_history() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "hold-1", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let hold_id = hold["holdId"].as_str().unwrap().to_owned(); + assert_eq!(hold["offering"], OFFERING); + assert_eq!(hold["resource"], "station-1"); + assert_eq!(hold["policyRevision"], fx.revision); + assert_eq!(moment(&hold, "start"), slot); + let expires_in = moment(&hold, "expiresAt") - Utc::now(); + assert!( + expires_in >= TimeDelta::minutes(9) && expires_in <= TimeDelta::minutes(11), + "the hold expires at the authored ten minutes: {expires_in}" + ); + + // A retried create with the same key answers as it first did. + let (status, replayed) = fx + .post( + "/v1/holds", + &fx.agent, + "hold-1", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(replayed["holdId"], hold_id); + + // The held slot is not availability while the hold stands. + assert!(!slot_offered(&fx, OFFERING, 90, 200, slot).await); + + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "confirm-1", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(appointment["state"], "confirmed"); + assert_eq!(appointment["resource"], "station-1"); + assert_eq!(moment(&appointment, "start"), slot); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + + let (status, history) = fx + .get( + &format!("/v1/appointments/{appointment_id}/history"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + let items = history["items"].as_array().unwrap(); + // Confirming mints the appointment as its own claim: one attributable + // event, the confirmation itself. The hold's own "held" and "consumed" + // events stay on the hold's history. + assert_eq!(items.len(), 1, "a confirmed hold books one appointment"); + assert_eq!(items[0]["kind"], "confirmed"); + assert_eq!(items[0]["revision"], 1); + assert!(items[0]["eventId"].is_string()); + assert!(items[0]["occurredAt"].is_string()); + assert!(items[0]["actor"].is_string()); + + let (status, fetched) = fx + .get(&format!("/v1/appointments/{appointment_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched["appointmentId"], appointment_id); +} + +#[tokio::test] +async fn a_direct_create_books_without_a_hold_and_exhausts_capacity() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "direct-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(appointment["state"], "confirmed"); + assert_eq!(moment(&appointment, "start"), slot); + + // One member serves one booking: the same start is no longer offered and + // a second direct create is a capacity refusal. + assert!(!slot_offered(&fx, OFFERING, 300, 440, slot).await); + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "direct-2", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "capacity.exhausted"); +} + +#[tokio::test] +async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + let from = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "resched-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, from)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + let observed = appointment["revision"].as_u64().unwrap(); + + let other = first_slot(&fx, OFFERING, 480, 620).await; + let (status, moved) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "resched-1", + json!({"observedRevision": observed, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(moved["revision"], json!(observed + 1)); + assert_eq!(moment(&moved, "start"), other); + assert!( + !slot_offered(&fx, OFFERING, 480, 620, other).await, + "the new start is committed" + ); + assert!( + slot_offered(&fx, OFFERING, 300, 440, from).await, + "the replaced start is bookable again" + ); + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "resched-2", + json!({"observedRevision": observed, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + assert_eq!(problem["code"], "revision.mismatch"); +} + +#[tokio::test] +async fn cancellation_respects_the_cutoff_and_replays_its_verdict() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + // Near: inside the four-hour cutoff. Far: clear of it. + let (near_id, near_revision) = booked(&fx, 90, 200, "cancel-near").await; + let (status, refused) = fx + .post( + &format!("/v1/appointments/{near_id}/cancel"), + &fx.agent, + "cancel-near-key", + json!({"observedRevision": near_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(refused["code"], "cancellation.cutoff-passed"); + // The refused verdict replays under the same key. + let (status, replayed) = fx + .post( + &format!("/v1/appointments/{near_id}/cancel"), + &fx.agent, + "cancel-near-key", + json!({"observedRevision": near_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(replayed["code"], "cancellation.cutoff-passed"); + let (status, untouched) = fx + .get(&format!("/v1/appointments/{near_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + untouched["state"], "confirmed", + "a refused cancel changes nothing" + ); + + let (far_id, far_revision) = booked(&fx, 300, 440, "cancel-far").await; + let far_slot = moment( + &fx.get(&format!("/v1/appointments/{far_id}"), &fx.agent) + .await + .1, + "start", + ); + let (status, cancelled) = fx + .post( + &format!("/v1/appointments/{far_id}/cancel"), + &fx.agent, + "cancel-far-key", + json!({"observedRevision": far_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(cancelled["state"], "cancelled"); + assert!(cancelled["cancelledAt"].is_string()); + assert!( + slot_offered(&fx, OFFERING, 300, 440, far_slot).await, + "a cancelled appointment's capacity is bookable again" + ); + // The cancelled appointment's history walks newest first: the + // cancellation over the confirmation it cancelled. + let (status, history) = fx + .get(&format!("/v1/appointments/{far_id}/history"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + let items = history["items"].as_array().unwrap(); + let kinds: Vec<&str> = items + .iter() + .filter_map(|entry| entry["kind"].as_str()) + .collect(); + assert_eq!(kinds, ["cancelled", "confirmed"]); + let revisions: Vec = items + .iter() + .map(|entry| entry["revision"].as_u64().expect("a bounded revision")) + .collect(); + assert_eq!(revisions, [2, 1], "history walks newest first"); + let (status, again) = fx + .post( + &format!("/v1/appointments/{far_id}/cancel"), + &fx.agent, + "cancel-far-key", + json!({"observedRevision": far_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(again["state"], "cancelled"); +} + +#[tokio::test] +async fn a_release_returns_capacity_answers_replay_and_blocks_confirmation() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "hold-r", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let hold_id = hold["holdId"].as_str().unwrap().to_owned(); + assert!(!slot_offered(&fx, OFFERING, 300, 440, slot).await); + + let (status, _) = fx.delete(&format!("/v1/holds/{hold_id}"), &fx.agent).await; + assert_eq!(status, StatusCode::NO_CONTENT); + assert!( + slot_offered(&fx, OFFERING, 300, 440, slot).await, + "the release returns capacity" + ); + + // The release carries the hold's own id as its idempotency key: a retried + // release answers with the same empty 204. + let (status, _) = fx.delete(&format!("/v1/holds/{hold_id}"), &fx.agent).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // A released hold can no longer be confirmed. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "confirm-r", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "hold.released"); +} + +#[tokio::test] +async fn an_expired_hold_returns_capacity_and_refuses_confirmation() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "hold-x", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let hold_id = hold["holdId"].as_str().unwrap().to_owned(); + assert!(!slot_offered(&fx, OFFERING, 90, 200, slot).await); + + // The hold-expiry worker's pass, run past the hold's due moment. + let expired = fx + .store + .expire_due_holds(Utc::now() + TimeDelta::minutes(11), 100) + .await + .expect("the hold expiry pass"); + assert_eq!(expired, 1); + assert!( + slot_offered(&fx, OFFERING, 90, 200, slot).await, + "capacity reappears" + ); + + // Once the sweeper has closed the hold, confirmation meets a claim in no + // longer active state and answers hold.released; the distinct hold.expired + // refusal belongs to the window between lapse and sweep, which this suite + // cannot reach without sleeping past the TTL. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "confirm-x", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "hold.released"); +} + +#[tokio::test] +async fn cursors_page_their_own_listing_and_refuse_foreign_contexts() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + let (status, first) = fx.get("/v1/services?limit=1", &fx.reader).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(first["items"].as_array().unwrap().len(), 1); + let cursor = first["nextCursor"].as_str().unwrap().to_owned(); + let first_id = first["items"][0]["id"].as_str().unwrap().to_owned(); + + let (status, second) = fx + .get(&format!("/v1/services?limit=1&cursor={cursor}"), &fx.reader) + .await; + assert_eq!(status, StatusCode::OK); + let items = second["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_ne!(items[0]["id"], first_id, "the page after the cursor is new"); + assert!( + second["nextCursor"].is_null(), + "two services fit in two pages" + ); + + // A cursor answers only the listing that minted it. + let (status, problem) = fx + .get( + &format!("/v1/offerings?limit=1&cursor={cursor}"), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(problem["code"], "cursor.invalid"); + + // Availability cursors bind to the exact window they paged. + let (status, window) = fx + .get( + &format!("{}&limit=1", availability_uri(OFFERING, 90, 200)), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::OK); + let availability_cursor = window["nextCursor"].as_str().unwrap().to_owned(); + let (status, problem) = fx + .get( + &format!( + "{}&limit=1&cursor={availability_cursor}", + availability_uri(OFFERING, 300, 440), + ), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(problem["code"], "cursor.invalid"); +} + +#[tokio::test] +async fn the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + + let (status, problem) = fx.request("GET", "/v1/services", "", None, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(problem["code"], "authentication.refused"); + + // The reader holds the reads scope but not the explain scope. + let (status, problem) = fx + .get( + &format!( + "/v1/availability/explain?offering={OFFERING}&start={}", + stamp(slot) + ), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(problem["code"], "profile.not-authorized"); + + // Readable availability is not authority to book: the reader carries no + // task grant. + let (status, problem) = fx + .post( + "/v1/holds", + &fx.reader, + "reader-hold", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(problem["code"], "operation.not-authorized"); + + // With the explain scope, a free start explains as admitting, and a + // committed one explains the public code with the detailed one behind it. + let (status, free) = fx + .get( + &format!( + "/v1/availability/explain?offering={OFFERING}&start={}", + stamp(slot) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + free["publicCode"].is_null(), + "a free start explains as admitting" + ); + + let other = first_slot(&fx, OFFERING, 480, 620).await; + let _ = booked(&fx, 480, 620, "explain-booked").await; + let (status, explained) = fx + .get( + &format!( + "/v1/availability/explain?offering={OFFERING}&start={}", + stamp(other) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + // A contended start carries the same public and detailed code; the + // detailed vocabulary exists for refusals the public projection hides + // behind the same few words. + assert_eq!(explained["publicCode"], "capacity.exhausted"); + assert_eq!(explained["detailedCode"], "capacity.exhausted"); +} + +#[tokio::test] +async fn a_commitment_against_an_unanchored_pool_refuses_loudly() { + // Publish the policy with only one of its two pools anchored: reads + // still answer, while a commitment that must lock the unanchored pool + // refuses rather than transacting without the anchor it serializes + // against, and the refusal leaves no stored attempt behind. + let Some(fx) = fixture_anchoring(&["north-counter".to_owned()]).await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + let slot = first_slot(&fx, SECOND_OFFERING, 300, 440).await; + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "unanchored-1", + json!({"hold": null, "admission": admission(&fx, SECOND_OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(problem["code"], "service.unavailable"); + + // Nothing was decided: no attempt was recorded, so a fresh key meets the + // same refusal. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "unanchored-2", + json!({"hold": null, "admission": admission(&fx, SECOND_OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(problem["code"], "service.unavailable"); +} + +/// Adoption binds one deployment identity: claiming from empty is the +/// fixture's own provisioning line, the same id again is a no-op, and a +/// different id is refused, so two deployments never share one database +/// silently. +#[tokio::test] +async fn adoption_binds_one_identity_and_refuses_a_second() { + let Some(fx) = fixture().await else { + eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); + return; + }; + fx.store + .adopt(SCHEDULING_ID) + .await + .expect("re-adopting the same id is a no-op"); + assert!(matches!( + fx.store.adopt("another-deployment").await, + Err(registry_scheduling::store::StoreError::Corrupt) + )); + // The refused adopt changed nothing: the deployment still answers under + // its adopted identity. + let (status, document) = fx.get("/v1/scheduling", &fx.reader).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(document["schedulingId"], SCHEDULING_ID); +} diff --git a/products/scheduling/generated/runtime/runtime.schema.json b/products/scheduling/generated/runtime/runtime.schema.json new file mode 100644 index 0000000000..1c194f7700 --- /dev/null +++ b/products/scheduling/generated/runtime/runtime.schema.json @@ -0,0 +1,459 @@ +{ + "$defs": { + "AuditConfig": { + "additionalProperties": false, + "properties": { + "hashKeyRef": { + "pattern": "^secret:(?:env|file)/", + "type": "string" + }, + "path": { + "pattern": "^/", + "type": "string" + } + }, + "required": [ + "path", + "hashKeyRef" + ], + "type": "object" + }, + "AuthenticationConfig": { + "additionalProperties": false, + "properties": { + "oidc": { + "$ref": "#/$defs/OidcConfig" + } + }, + "required": [ + "oidc" + ], + "type": "object" + }, + "DatabaseConfig": { + "additionalProperties": false, + "properties": { + "migrationUrlRef": { + "pattern": "^secret:(?:env|file)/", + "type": "string" + }, + "runtimeUrlRef": { + "pattern": "^secret:(?:env|file)/", + "type": "string" + }, + "testOnlyPlaintext": { + "default": false, + "type": "boolean" + }, + "trustedRootCertificateRef": { + "default": null, + "pattern": "^secret:(?:env|file)/", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "runtimeUrlRef", + "migrationUrlRef" + ], + "type": "object" + }, + "DestinationsConfig": { + "additionalProperties": false, + "description": "Where due reminder intents are dispatched. An absent reminders destination\nis a supported deployment: intents are written and stay local, so an\noperator can adopt the product before wiring a notification bus.", + "properties": { + "reminders": { + "anyOf": [ + { + "$ref": "#/$defs/ReminderDestinationConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "EnvironmentSecretProviderConfig": { + "additionalProperties": false, + "type": "object" + }, + "FileSecretProviderConfig": { + "additionalProperties": false, + "properties": { + "root": { + "pattern": "^/", + "type": "string" + } + }, + "required": [ + "root" + ], + "type": "object" + }, + "ListenerConfig": { + "additionalProperties": false, + "properties": { + "bind": { + "default": "127.0.0.1:8105", + "type": "string" + }, + "networkExposure": { + "$ref": "#/$defs/ListenerNetworkExposure" + }, + "tlsTermination": { + "$ref": "#/$defs/TlsTermination" + } + }, + "required": [ + "tlsTermination" + ], + "type": "object" + }, + "ListenerNetworkExposure": { + "description": "The operator-declared private network placement of the HTTP listener.", + "enum": [ + "private-address", + "container-private" + ], + "type": "string" + }, + "OidcConfig": { + "additionalProperties": false, + "properties": { + "allowedClients": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "audience": { + "type": "string" + }, + "explainScope": { + "default": "scheduling-explain", + "description": "The scope the separately authorized explain path requires.", + "type": "string" + }, + "issuer": { + "type": "string" + }, + "jwksSource": { + "$ref": "#/$defs/OidcJwksSource" + }, + "jwksUri": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "readsScope": { + "default": "scheduling-read", + "description": "The scope every listing and availability read requires.", + "type": "string" + }, + "scopeClaim": { + "default": "registry_scopes", + "type": "string" + } + }, + "required": [ + "issuer", + "audience" + ], + "type": "object" + }, + "OidcJwksSource": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "discovery", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "documentRef": { + "pattern": "^(?:secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$", + "type": "string" + }, + "kind": { + "const": "static", + "type": "string" + } + }, + "required": [ + "kind", + "documentRef" + ], + "type": "object" + } + ] + }, + "ReminderDestinationConfig": { + "additionalProperties": false, + "properties": { + "bearerTokenRef": { + "default": null, + "pattern": "^secret:(?:env|file)/", + "type": [ + "string", + "null" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "RetentionConfig": { + "additionalProperties": false, + "description": "Retention periods the retention sweep enforces. Every value is deployment\nconfiguration, never policy: a jurisdiction's retention schedule approves\nthe deployed numbers, and changing one is an operational event.", + "properties": { + "attemptReceiptDays": { + "default": 7, + "description": "How long a stored idempotency receipt stays replayable.", + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "RuntimePackageConfig": { + "additionalProperties": false, + "description": "The root of an authored scheduling project, holding `scheduling.yaml` and\nits optional package manifest.", + "properties": { + "root": { + "pattern": "^/", + "type": "string" + } + }, + "required": [ + "root" + ], + "type": "object" + }, + "SecretProvidersConfig": { + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "file": { + "$ref": "#/$defs/FileSecretProviderConfig" + } + }, + "required": [ + "file" + ] + }, + { + "properties": { + "environment": { + "$ref": "#/$defs/EnvironmentSecretProviderConfig" + } + }, + "required": [ + "environment" + ] + } + ], + "properties": { + "environment": { + "anyOf": [ + { + "$ref": "#/$defs/EnvironmentSecretProviderConfig" + }, + { + "type": "null" + } + ] + }, + "file": { + "anyOf": [ + { + "$ref": "#/$defs/FileSecretProviderConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "TlsTermination": { + "description": "Declares the trusted transport boundary for the runtime's plaintext HTTP\nlistener. Production listeners require operator-controlled upstream TLS\ntermination; direct plaintext is limited to the explicit loopback-only\ndevelopment mode.", + "enum": [ + "operator-controlled-upstream", + "development-loopback" + ], + "type": "string" + } + }, + "$id": "https://id.registrystack.org/schemas/scheduling/runtime/runtime.v1alpha1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "authentication": { + "properties": { + "oidc": { + "properties": { + "jwksSource": { + "properties": { + "documentRef": { + "pattern": "^secret:env/" + } + }, + "required": [ + "documentRef" + ] + } + }, + "required": [ + "jwksSource" + ] + } + }, + "required": [ + "oidc" + ] + } + }, + "required": [ + "authentication" + ] + }, + "then": { + "properties": { + "secretProviders": { + "properties": { + "environment": { + "$ref": "#/$defs/EnvironmentSecretProviderConfig" + } + }, + "required": [ + "environment" + ] + } + } + } + }, + { + "if": { + "properties": { + "authentication": { + "properties": { + "oidc": { + "properties": { + "jwksSource": { + "properties": { + "documentRef": { + "pattern": "^secret:file/" + } + }, + "required": [ + "documentRef" + ] + } + }, + "required": [ + "jwksSource" + ] + } + }, + "required": [ + "oidc" + ] + } + }, + "required": [ + "authentication" + ] + }, + "then": { + "properties": { + "secretProviders": { + "properties": { + "file": { + "$ref": "#/$defs/FileSecretProviderConfig" + } + }, + "required": [ + "file" + ] + } + } + } + } + ], + "description": "The operator runtime configuration document.", + "properties": { + "apiVersion": { + "const": "registry.registrystack.org/scheduling-runtime/v1alpha1", + "type": "string" + }, + "audit": { + "$ref": "#/$defs/AuditConfig" + }, + "authentication": { + "$ref": "#/$defs/AuthenticationConfig" + }, + "database": { + "$ref": "#/$defs/DatabaseConfig" + }, + "destinations": { + "$ref": "#/$defs/DestinationsConfig" + }, + "kind": { + "const": "SchedulingRuntimeConfig", + "type": "string" + }, + "listener": { + "$ref": "#/$defs/ListenerConfig" + }, + "package": { + "$ref": "#/$defs/RuntimePackageConfig" + }, + "retention": { + "$ref": "#/$defs/RetentionConfig" + }, + "secretProviders": { + "$ref": "#/$defs/SecretProvidersConfig" + } + }, + "required": [ + "apiVersion", + "kind", + "package", + "listener", + "secretProviders", + "database", + "authentication", + "audit", + "retention" + ], + "title": "Registry Scheduling runtime configuration", + "type": "object" +} From bffe2b54b986260dc6877d65e9223f3128c734a0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 10:56:53 +0700 Subject: [PATCH 018/115] feat(scheduling): register problem and schema identifiers Scheduling joins the Registry Stack identifier catalog: the 32 problem codes publish under https://id.registrystack.org/problems/registry-scheduling/, exported by a core example beside the casework and BReg exporters, and the runtime configuration schema registers as an active v1alpha1 schema source generated from crates/registry-scheduling/src/schema.rs. The generated catalog is reproduced by products/identifiers/scripts/generate.py --write and verified by its check. Signed-off-by: Jeremi Joslin --- .../examples/problem-catalog.rs | 66 ++ .../identifiers/contracts/catalog-source.json | 18 + .../identifiers/generated/catalog.v1.json | 626 ++++++++++++++++++ 3 files changed, 710 insertions(+) create mode 100644 crates/registry-scheduling-core/examples/problem-catalog.rs diff --git a/crates/registry-scheduling-core/examples/problem-catalog.rs b/crates/registry-scheduling-core/examples/problem-catalog.rs new file mode 100644 index 0000000000..0f93497e9c --- /dev/null +++ b/crates/registry-scheduling-core/examples/problem-catalog.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 + +use registry_scheduling_core::{type_uri, ProblemCode}; +use serde::Serialize; +use std::{env, fs, path::PathBuf, process::ExitCode}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProblemCatalog<'a> { + entries: Vec>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProblemEntry<'a> { + uri: String, + code: &'a str, + title: &'a str, + description: &'a str, + http_statuses: [u16; 1], +} + +fn main() -> ExitCode { + let mut arguments = env::args_os().skip(1); + let Some(flag) = arguments.next() else { + return usage(); + }; + let Some(output) = arguments.next() else { + return usage(); + }; + if flag != "--output" || arguments.next().is_some() { + return usage(); + } + + let catalog = ProblemCatalog { + entries: ProblemCode::ALL + .iter() + .copied() + .map(|problem| ProblemEntry { + uri: type_uri(problem.code()), + code: problem.code(), + title: problem.title(), + description: problem.detail(), + http_statuses: [problem.http_status()], + }) + .collect(), + }; + let mut bytes = match serde_json::to_vec_pretty(&catalog) { + Ok(bytes) => bytes, + Err(_) => { + eprintln!("problem catalog could not be serialized"); + return ExitCode::FAILURE; + } + }; + bytes.push(b'\n'); + if fs::write(PathBuf::from(output), bytes).is_err() { + eprintln!("problem catalog could not be written"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +fn usage() -> ExitCode { + eprintln!("usage: problem-catalog --output "); + ExitCode::from(2) +} diff --git a/products/identifiers/contracts/catalog-source.json b/products/identifiers/contracts/catalog-source.json index f651e0378e..4905755d4a 100644 --- a/products/identifiers/contracts/catalog-source.json +++ b/products/identifiers/contracts/catalog-source.json @@ -51,6 +51,16 @@ "exporterPath": "crates/registry-casework/examples/problem-catalog.rs", "cargoPackage": "registry-casework", "cargoExample": "problem-catalog" + }, + { + "owner": "scheduling", + "status": "active", + "compatibilityLine": "v1alpha1", + "uriPrefix": "https://id.registrystack.org/problems/registry-scheduling/", + "sourcePath": "crates/registry-scheduling-core/src/problem.rs", + "exporterPath": "crates/registry-scheduling-core/examples/problem-catalog.rs", + "cargoPackage": "registry-scheduling-core", + "cargoExample": "problem-catalog" } ], "referenceExclusions": [ @@ -158,6 +168,14 @@ "status": "active", "compatibilityLine": "v1alpha1", "description": "Registry Casework runtime configuration JSON Schema." + }, + { + "glob": "products/scheduling/generated/runtime/*.schema.json", + "sourcePath": "crates/registry-scheduling/src/schema.rs", + "owner": "scheduling", + "status": "active", + "compatibilityLine": "v1alpha1", + "description": "Registry Scheduling runtime configuration JSON Schema." } ], "records": [ diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 930769ffbf..ba7f91646d 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -2085,6 +2085,614 @@ ] } }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/authentication/refused", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Authentication refused", + "description": "The bearer credential is missing, invalid, or expired. Sign in again.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "authentication.refused", + "httpStatuses": [ + 401 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/booking/duplicate-active", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Duplicate active booking", + "description": "An active booking already holds this party's duplicate key. Cancel or complete it before booking again.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "booking.duplicate-active", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/cancellation/cutoff-passed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Cancellation cutoff passed", + "description": "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "cancellation.cutoff-passed", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/capability/unmatched", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Capability unmatched", + "description": "No backing member carries every capability this offering requires.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "capability.unmatched", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/capacity/exhausted", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Capacity exhausted", + "description": "The supply is fully committed for the requested interval. Choose another time.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "capacity.exhausted", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/cursor/expired", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Cursor expired", + "description": "This cursor has expired. Start again without a cursor and deduplicate entries by id.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "cursor.expired", + "httpStatuses": [ + 410 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/cursor/invalid", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Cursor invalid", + "description": "The cursor is invalid for this request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "cursor.invalid", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/eligibility/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Eligibility unavailable", + "description": "A required eligibility check could not run. Retry; do not treat this as permission.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "eligibility.unavailable", + "httpStatuses": [ + 503 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/hold/expired", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Hold expired", + "description": "The hold expired before confirmation. Its capacity is bookable again; start a new request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "hold.expired", + "httpStatuses": [ + 410 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/hold/released", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Hold released", + "description": "The claim is not an active hold. It may already be confirmed or released.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "hold.released", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/hook/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Hook unavailable", + "description": "A required lifecycle hook could not run, so the request was refused rather than half-applied.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "hook.unavailable", + "httpStatuses": [ + 503 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/horizon/outside", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Start outside the booking horizon", + "description": "The requested start is earlier than the lead time allows or further ahead than the horizon allows.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "horizon.outside", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/idempotency/expired", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Idempotency window expired", + "description": "The stored response for this idempotency key has expired. Reconcile the original operation before choosing a new key.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "idempotency.expired", + "httpStatuses": [ + 410 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/idempotency/key-reused", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Idempotency key reused", + "description": "This idempotency key was used for a different request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "idempotency.key-reused", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/location/closed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Location closed", + "description": "A closure covers the requested start. Choose a start outside the closure.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "location.closed", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/operation/not-authorized", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Operation not authorized", + "description": "Your current Scheduling authority does not allow this operation.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "operation.not-authorized", + "httpStatuses": [ + 403 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/party/capacity-inadequate", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Party capacity inadequate", + "description": "The party is larger than this offering can ever serve, or its size falls outside the published units.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "party.capacity-inadequate", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/policy/changed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Policy changed", + "description": "The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "policy.changed", + "httpStatuses": [ + 412 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/precondition/failed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Precondition failed", + "description": "The appointment changed since you loaded it. Reload and try again.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "precondition.failed", + "httpStatuses": [ + 412 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/precondition/required", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Precondition required", + "description": "This mutation requires the revision you loaded, or the duplicate key this offering keys on.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "precondition.required", + "httpStatuses": [ + 428 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/prerequisite/missing", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Prerequisite missing", + "description": "The party is missing a prerequisite this offering requires.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "prerequisite.missing", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/profile/not-authorized", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Profile not authorized", + "description": "The selected Scheduling profile does not authorize this request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "profile.not-authorized", + "httpStatuses": [ + 403 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/body-too-large", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Payload too large", + "description": "The request body exceeds the accepted size.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "request.body-too-large", + "httpStatuses": [ + 413 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/invalid", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Request invalid", + "description": "The request could not be read as a Scheduling request.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "request.invalid", + "httpStatuses": [ + 400 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/method-not-allowed", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Method not allowed", + "description": "The route exists but not for this method.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "request.method-not-allowed", + "httpStatuses": [ + 405 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/not-found", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Route not found", + "description": "The requested route does not exist.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "request.not-found", + "httpStatuses": [ + 404 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/unprocessable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Request unprocessable", + "description": "The request body could not be processed.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "request.unprocessable", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/request/unsupported-media-type", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Unsupported media type", + "description": "The request body is not JSON.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "request.unsupported-media-type", + "httpStatuses": [ + 415 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/resource/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Resource unavailable", + "description": "Every capable member is unavailable for this interval.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "resource.unavailable", + "httpStatuses": [ + 409 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/revision/mismatch", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Revision mismatch", + "description": "The window revision changed before the request committed. Reload the catalogue and try again.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "revision.mismatch", + "httpStatuses": [ + 412 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/schedule/unpublished", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Schedule unpublished", + "description": "No published schedule serves that start. Choose a start on the published grid.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "schedule.unpublished", + "httpStatuses": [ + 422 + ] + } + }, + { + "uri": "https://id.registrystack.org/problems/registry-scheduling/service/unavailable", + "kind": "problem", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Scheduling service unavailable", + "description": "Scheduling storage is unavailable. Try again after the service recovers.", + "source": { + "path": "crates/registry-scheduling-core/src/problem.rs", + "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + }, + "problem": { + "code": "service.unavailable", + "httpStatuses": [ + 503 + ] + } + }, { "uri": "https://id.registrystack.org/profiles/registry-record/v1", "kind": "profile", @@ -2265,6 +2873,24 @@ "mediaType": "application/schema+json" } }, + { + "uri": "https://id.registrystack.org/schemas/scheduling/runtime/runtime.v1alpha1.schema.json", + "kind": "schema", + "status": "active", + "compatibilityLine": "v1alpha1", + "owner": "scheduling", + "title": "Registry Scheduling runtime configuration", + "description": "Registry Scheduling runtime configuration JSON Schema.", + "source": { + "path": "crates/registry-scheduling/src/schema.rs", + "sha256": "f27ebebd2182e7aa05a844a1320ea7d8c24787ba259224cc1f9b8b880d18f709" + }, + "artifact": { + "path": "products/scheduling/generated/runtime/runtime.schema.json", + "sha256": "775713d41483477dcb49e7d13237c7881a84a17aa70f8e307bfeff70d1633faa", + "mediaType": "application/schema+json" + } + }, { "uri": "https://id.registrystack.org/vocab/codelist", "kind": "vocabulary-term", From bf14ab5811c1325481da462c77b2a797f3432bab Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 10:59:51 +0700 Subject: [PATCH 019/115] docs(scheduling): describe the product in agent guidance Scheduling becomes one of the five named runtime products, with its four crates on the repository map, its boundary paragraph beside the other products', and its gates on the verify-your-change list: the checkpoint script, the PostgreSQL suite under postgres-test, the identifier reference closure, and the schema generator command. Signed-off-by: Jeremi Joslin --- AGENTS.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 5d267018a8..9d0552a97a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This is the Registry Stack monorepo: registry-facing services over the data institutions already hold and the registries they do not hold yet. Pre-1.0; APIs and deployment contracts may change. -Four independent runtime products are relevant: +Five independent runtime products are relevant: - **Base Registry Engine** compiles a declared registry project into a PostgreSQL-backed writable registry: schema, REST API, per-profile @@ -16,6 +16,9 @@ Four independent runtime products are relevant: - **Registry Casework** gives authorized human teams a coordinated inbox over source-owned work while leaving eligibility and registry mutations with the source system. +- **Registry Scheduling** publishes bookable services over anchored supply + and gives authorized callers short holds and confirmed appointments, with + every capacity decision made inside one transaction under a task grant. The products compose without merging their boundaries. Evidence may use a Base Registry Engine route or a Relay-protected API as a fixed HTTP source and @@ -78,6 +81,10 @@ The dependency runs one way only in production: no Evidence crate depends on | `crates/registry-casework-client` | Rust Casework client and its bounded problem and recovery contract | | `crates/registry-casework-client-node` | Internal napi-rs binding used to assemble the unified Node.js client | | `crates/registry-casework-client-py` | Internal PyO3 binding used to assemble the unified Python client | +| `crates/registry-scheduling-core` | Source-neutral Scheduling policy model, wire DTOs, problem codes, and authoring checks | +| `crates/registry-scheduling` | PostgreSQL-backed Scheduling runtime and the `scheduling` binary | +| `crates/registry-schedulingctl` | Scheduling authoring and local operator tooling and the `schedulingctl` binary | +| `crates/registry-scheduling-client` | Bounded Rust Scheduling client over the runtime's HTTP contract | | `crates/registry-record` | Product-neutral Registry Record v1 response DTOs shared by the Base Registry Engine and Relay clients | | `crates/registry-stack-client` | Rust facade over the maintained Registry Stack product clients | | `crates/registry-stack-client-node` | Public `@registrystack/client` facade and platform package definitions | @@ -126,6 +133,22 @@ caller-visible inboxes. Source adapters retain source visibility and action authority. The source-neutral core and generic clients must not depend on BReg protocol types, and BReg must not depend on Casework. +Registry Scheduling is implemented by `registry-scheduling`, +`registry-scheduling-core`, `registry-schedulingctl`, and its Rust client +crate. Its product contracts, generated schemas, examples, and focused gates +live under `products/scheduling`. +Scheduling sells capacity only over supply the operator anchored: resource +pools and published windows are runtime records the policy references by +identifier, a hold or appointment is a claim against that supply inside one +capacity transaction, and every mutation carries a task grant re-checked +inside that transaction against the offering's service, location, and action. +Reminder delivery and retention are outbox work, never inline side effects, +and the runtime has no scripting engine: the reserved hook ABI +`registry.scheduling-hook/v1` exists on the authored form only. The +source-neutral core and the client must not depend on the runtime or another +product's protocol types, and no scheduling crate may reach a BReg, Casework, +or Evidence crate in either direction. + Registry Discovery is a curated index over public provider descriptions, not a trust broker, authorization service, protocol adapter, or data proxy. `registry-discovery-profile` is the narrow shared publication contract that @@ -378,6 +401,26 @@ follow `products/casework/README.md`: its destructive test suites require separate disposable databases and the `postgres-test` feature. A test binary that skips because its database URL is absent is not database verification. +Registry Scheduling product and gates: + +```bash +cargo build --locked -p registry-schedulingctl +products/scheduling/scripts/check-checkpoint.sh +SCHEDULING_TEST_DATABASE_URL= cargo test --locked \ + -p registry-scheduling --features postgres-test --test postgres_commitments +python3 products/identifiers/scripts/generate.py --check-references +``` + +The checkpoint script runs the database-free checks; the PostgreSQL suite +needs a disposable database named by `SCHEDULING_TEST_DATABASE_URL` under the +same `postgres-test` caveat. The runtime configuration schema is regenerated, +never hand-edited: + +```bash +cargo run -p registry-scheduling --features schema --example runtime-schema -- \ + --output products/scheduling/generated/runtime +``` + The unified Node.js and Python packages are generated from the maintained product bindings. After changing a binding or facade, run: From de120c510a0883a16294163288795b4dcd2a6b93 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 11:11:54 +0700 Subject: [PATCH 020/115] feat(scheduling): publish the scheduling command lines in the CLI reference The scheduling and schedulingctl command trees join the generated CLI reference: the catalog crate registers both, the generator knows their group, and the sidebar builder seats their pages. The review record moves to the catalog that includes them. The calendar extraction had also left two Casework evidence anchors citing the removed casework-core calendar module; both now cite the platform calendar file the evaluation moved into. Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 ++ crates/registry-cli-docs/Cargo.toml | 2 ++ crates/registry-cli-docs/src/lib.rs | 4 ++++ docs/site/scripts/generate-cli-reference.mjs | 3 +++ docs/site/src/content/docs/configure/casework.mdx | 2 +- docs/site/src/content/docs/explanation/how-casework-works.mdx | 2 +- docs/site/src/lib/cli-reference-sidebar.mjs | 2 ++ 7 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 25c162d459..f8ab7eaa94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5188,6 +5188,8 @@ dependencies = [ "registry-relay-v2", "registry-relayctl", "registry-render", + "registry-scheduling", + "registry-schedulingctl", "serde", "serde_json", ] diff --git a/crates/registry-cli-docs/Cargo.toml b/crates/registry-cli-docs/Cargo.toml index a110f63f6a..35d96ea139 100644 --- a/crates/registry-cli-docs/Cargo.toml +++ b/crates/registry-cli-docs/Cargo.toml @@ -24,5 +24,7 @@ registry-breg = { workspace = true, features = ["runtime"] } registry-bregctl.workspace = true registry-caseworkctl.workspace = true registry-casework.workspace = true +registry-scheduling.workspace = true +registry-schedulingctl.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-cli-docs/src/lib.rs b/crates/registry-cli-docs/src/lib.rs index 246631481b..cb93115ea6 100644 --- a/crates/registry-cli-docs/src/lib.rs +++ b/crates/registry-cli-docs/src/lib.rs @@ -14,6 +14,8 @@ pub fn catalog() -> Catalog { command_reference(registry_evidencectl::command(), None, None), command_reference(registry_caseworkctl::command(), None, None), command_reference(registry_casework::command(), None, None), + command_reference(registry_scheduling::runtime::command(), None, None), + command_reference(registry_schedulingctl::command(), None, None), command_reference(registry_breg::command(), None, None), command_reference( registry_bregctl::command(), @@ -91,6 +93,8 @@ mod tests { "registry-render", "relay", "relayctl", + "scheduling", + "schedulingctl", ] ); } diff --git a/docs/site/scripts/generate-cli-reference.mjs b/docs/site/scripts/generate-cli-reference.mjs index bfbcba8fc3..e8e797328c 100644 --- a/docs/site/scripts/generate-cli-reference.mjs +++ b/docs/site/scripts/generate-cli-reference.mjs @@ -35,6 +35,8 @@ export const expectedBinaries = [ 'registry-render', 'relay', 'relayctl', + 'scheduling', + 'schedulingctl', ]; const generatedTree = 'src/content/docs/reference/cli'; @@ -51,6 +53,7 @@ const hiddenCommands = new Set([ const groups = [ { title: 'Base Registry Engine', binaries: ['breg', 'bregctl'] }, { title: 'Registry Casework', binaries: ['casework', 'caseworkctl'] }, + { title: 'Registry Scheduling', binaries: ['scheduling', 'schedulingctl'] }, { title: 'Registry Relay', binaries: ['relay', 'relayctl'] }, { title: 'Evidence Gateway', binaries: ['evidence', 'evidencectl'] }, { title: 'Evidence credential delivery', binaries: ['evidence-oid4vci'] }, diff --git a/docs/site/src/content/docs/configure/casework.mdx b/docs/site/src/content/docs/configure/casework.mdx index 3337000afe..5aedaa16c0 100644 --- a/docs/site/src/content/docs/configure/casework.mdx +++ b/docs/site/src/content/docs/configure/casework.mdx @@ -386,7 +386,7 @@ Each revision is immutable, and a running clock occurrence stays pinned to the r [Retain, erase, and settle](../../operate/casework-retention/) covers the operator side of that change. {/* Evidence: crates/registry-casework-core/src/policy.rs, HolidaySetDocument and CalendarPolicy; - crates/registry-casework-core/src/calendar.rs, evaluate_working_day_deadline(). */} + crates/registry-platform-calendar/src/working_day.rs, evaluate_working_day_deadline(). */} ### Drafts, recovery, and inbox bounds diff --git a/docs/site/src/content/docs/explanation/how-casework-works.mdx b/docs/site/src/content/docs/explanation/how-casework-works.mdx index 5950c527a9..b4f3920e59 100644 --- a/docs/site/src/content/docs/explanation/how-casework-works.mdx +++ b/docs/site/src/content/docs/explanation/how-casework-works.mdx @@ -210,7 +210,7 @@ A calendar names a timezone and its working weekdays, and its holiday dates arri Republishing holidays never silently rewrites a running deadline: an Administrator previews the recompute in batches, reviews it, and applies the reviewed preview. {/* Evidence: crates/registry-casework-core/src/policy.rs, MAXIMUM_CLOCKS, MAXIMUM_CALENDARS, and MAXIMUM_CLOCK_REMINDERS; - crates/registry-casework-core/src/calendar.rs, evaluate_working_day_deadline; + crates/registry-platform-calendar/src/working_day.rs, evaluate_working_day_deadline; crates/registry-casework/src/clocks.rs, apply_clock_recompute. */} What a clock does when it fires is narrow and local. diff --git a/docs/site/src/lib/cli-reference-sidebar.mjs b/docs/site/src/lib/cli-reference-sidebar.mjs index 22e01c3b41..e403852328 100644 --- a/docs/site/src/lib/cli-reference-sidebar.mjs +++ b/docs/site/src/lib/cli-reference-sidebar.mjs @@ -12,6 +12,8 @@ const binaries = [ 'bregctl', 'casework', 'caseworkctl', + 'scheduling', + 'schedulingctl', 'relay', 'relayctl', 'evidence', From ff0ab60c749c3c2b8075bb32faa467eaf6c66bd6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 12:09:39 +0700 Subject: [PATCH 021/115] feat(scheduling): add the local acceptance demo over the public contract run.sh builds the real scheduling and schedulingctl binaries, stands up a disposable TLS-enabled PostgreSQL (its own throwaway PKI, since the released runtime refuses plaintext database links), publishes the standalone-exact-time policy with demo environment records, and drives four acceptance scenarios against the served contract under real authentication: the two-caller race for the last station (AT-01), hold expiry (AT-05), lost-confirmation replay (AT-06), and the daylight-saving fold grid (AT-19). Tokens are real RFC 9068 access tokens signed by a throwaway RSA key the runtime verifies through its static JWKS source; mutations carry complete task grants the runtime re-checks inside the capacity transaction. The support module's pure pieces (JWK derivation, token minting, grant claims, records, runtime config, pinned scenario facts) carry unit tests. Signed-off-by: Jeremi Joslin --- products/scheduling/demo/README.md | 59 ++ products/scheduling/demo/run.sh | 195 +++++ products/scheduling/demo/support/demo.py | 802 ++++++++++++++++++ products/scheduling/demo/support/test_demo.py | 207 +++++ 4 files changed, 1263 insertions(+) create mode 100644 products/scheduling/demo/README.md create mode 100755 products/scheduling/demo/run.sh create mode 100644 products/scheduling/demo/support/demo.py create mode 100644 products/scheduling/demo/support/test_demo.py diff --git a/products/scheduling/demo/README.md b/products/scheduling/demo/README.md new file mode 100644 index 0000000000..a8551d142a --- /dev/null +++ b/products/scheduling/demo/README.md @@ -0,0 +1,59 @@ +# Registry Scheduling acceptance demo + +`run.sh` proves four acceptance scenarios from the Scheduling functional +specification against the real binaries and a real PostgreSQL database, over +the public HTTP contract and under real authentication: + +| Scenario | What the demo asserts | +| --- | --- | +| AT-01 | Two callers confirm against the last station concurrently. Exactly one appointment is created; the loser receives `capacity.exhausted` (409), and the slot afterwards holds no free unit. | +| AT-05 | A hold expires. The slot becomes bookable again, and confirming the expired hold fails with `hold.expired` (410). | +| AT-06 | A confirmation commits but its response is lost. A retry under the same `idempotency-key` returns the original appointment; a changed payload under that key is refused with `idempotency.key-reused` (409). | +| AT-19 | The New York hall opening across the 2026-11-01 daylight-saving fold serves the grid the morning closure moved (anchored 04:45Z), never the wall-clock match 06:30Z, which is refused with `schedule.unpublished` (422). | + +## Running it + +```sh +products/scheduling/demo/run.sh +``` + +Docker, cargo, openssl, python3, curl, and jq must be available. The script +builds `scheduling` and `schedulingctl`, starts a disposable PostgreSQL 17 +container it removes afterwards, publishes the `standalone-exact-time` +example policy with demo environment records (one station per pool, plus the +fold-day closure), serves the runtime on loopback, and runs the scenarios. +The demo shortens the hold TTL to one minute so the expiry scenario runs +quickly; the other scenarios run while that clock does. + +`--database-url URL` runs against a disposable PostgreSQL you own instead of +a Docker container (migrations are applied and the data is replaced). +`--installed` uses the two binaries from `PATH` rather than building them. + +## How authentication works here + +There is no auth-free mode. The script generates a throwaway RSA key pair, +publishes the public half as the static JWKS document the runtime +configuration points at, and signs RFC 9068 access tokens (`typ: at+jwt`) +for three demo callers. Reads carry the `scheduling-read` scope; bookings +carry no product scope but a complete task grant whose `scheduling` +permissions name the offering's service, location, and action, which the +runtime re-checks inside the capacity transaction. Nothing in the run +directory is a credential beyond the throwaway key pair, and the key pair +never leaves the run directory. + +## What this demo does not cover + +The AT-05 row says "while the cleanup worker is stopped". The runtime's +cleanup loop is a fixed internal interval with no operator switch, and the +store counts a hold only while it is unexpired at the observed now, so a +stopped or delayed worker cannot keep capacity locked either way. The demo +asserts the observable contract the scenario pins (capacity reopens, late +confirmation fails) immediately at expiry, and the worker-independent +enforcement is covered directly by the store's own PostgreSQL tests in +`crates/registry-scheduling/tests/postgres_commitments.rs`. + +The support module carries unit tests for its pure pieces: + +```sh +python3 -m unittest products/scheduling/demo/support/test_demo.py -v +``` diff --git a/products/scheduling/demo/run.sh b/products/scheduling/demo/run.sh new file mode 100755 index 0000000000..5ac353fa85 --- /dev/null +++ b/products/scheduling/demo/run.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +set -euo pipefail + +# The Registry Scheduling local acceptance demo. This script builds the real +# `scheduling` and `schedulingctl` binaries, stands up a disposable +# PostgreSQL database (its own container unless --database-url names one), +# publishes the standalone-exact-time policy with demo environment records, +# serves the runtime on loopback, and drives four acceptance scenarios over +# the public HTTP contract: AT-01 (two-caller race for the last unit), +# AT-05 (hold expiry), AT-06 (lost-confirmation idempotent replay), and +# AT-19 (the daylight-saving fold grid). See README.md beside this script. + +demo_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +root=$(cd -- "$demo_dir/../../.." && pwd) +run_dir="$demo_dir/.run" +support="$demo_dir/support/demo.py" +example="$root/products/scheduling/examples/standalone-exact-time" +database_url='' +database_root_ca='' +installed=false + +usage() { + cat >&2 <<'HELP' +usage: products/scheduling/demo/run.sh [--database-url URL [--database-root-ca PATH]] [--installed] + +Run the Scheduling acceptance demo. Without --database-url the script starts +and removes its own TLS-enabled PostgreSQL 17 container, which requires +Docker; the URL form runs against a disposable TLS-enabled database you own +(migrations are applied and its data is replaced), and --database-root-ca +names the PEM file holding the authority that signed that server's chain. +--installed uses scheduling and schedulingctl from PATH instead of building +from this checkout. Docker, cargo (unless --installed), openssl, python3, +and curl are required. The run directory is kept when a scenario fails, for +inspection. +HELP + exit 2 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --database-url) + [[ $# -ge 2 ]] || usage + database_url="$2" + shift 2 + ;; + --database-root-ca) + [[ $# -ge 2 ]] || usage + database_root_ca="$2" + shift 2 + ;; + --installed) installed=true; shift ;; + --help|-h) usage ;; + *) usage ;; + esac +done +if [[ -n "$database_root_ca" && -z "$database_url" ]]; then + printf '%s\n' '--database-root-ca applies to --database-url.' >&2 + usage +fi + +for command in python3 openssl curl; do + if ! command -v "$command" >/dev/null 2>&1; then + printf '%s is required.\n' "$command" >&2 + exit 2 + fi +done + +if [[ "$installed" == true ]]; then + scheduling=$(command -v scheduling) || { printf '%s\n' 'scheduling is required in --installed mode.' >&2; exit 2; } + schedulingctl=$(command -v schedulingctl) || { printf '%s\n' 'schedulingctl is required in --installed mode.' >&2; exit 2; } +else + command -v cargo >/dev/null 2>&1 || { printf '%s\n' 'cargo is required.' >&2; exit 2; } + export CARGO_INCREMENTAL=0 + cargo build --manifest-path "$root/Cargo.toml" --locked \ + -p registry-scheduling -p registry-schedulingctl --bins >/dev/null + scheduling="$root/target/debug/scheduling" + schedulingctl="$root/target/debug/schedulingctl" +fi + +if [[ -e "$run_dir" ]]; then + printf 'demo state path already exists: %s. Remove it after stopping any session that owns it.\n' "$run_dir" >&2 + exit 2 +fi +umask 077 +mkdir -m 700 "$run_dir" + +container='' + +cleanup() { + exit_code=$? + [[ -n "${serve_pid:-}" ]] && kill "$serve_pid" >/dev/null 2>&1 || true + if [[ -n "$container" ]] && command -v docker >/dev/null 2>&1; then + docker rm -f "$container" >/dev/null 2>&1 || true + fi + if [[ "$exit_code" -eq 0 ]]; then + rm -rf -- "$run_dir" + else + printf 'demo failed; run state kept at %s\n' "$run_dir" >&2 + fi + exit "$exit_code" +} +trap cleanup EXIT INT TERM + +read -r db_port http_port < <(python3 - "$support" <<'PY' +import socket +ports = [] +with socket.socket() as first, socket.socket() as second: + first.bind(("127.0.0.1", 0)) + second.bind(("127.0.0.1", 0)) + ports = [first.getsockname()[1], second.getsockname()[1]] +print(*ports) +PY +) + +if [[ -z "$database_root_ca" ]]; then + root_ca_args=() +else + root_ca_args=(--database-root-ca "$database_root_ca") +fi +python3 "$support" prepare \ + --root "$run_dir" \ + --example "$example" \ + --database-url "${database_url:-postgres://postgres:demo@127.0.0.1:${db_port}/postgres}" \ + --port "$http_port" \ + "${root_ca_args[@]}" + +if [[ -z "$database_url" ]]; then + command -v docker >/dev/null 2>&1 || { printf '%s\n' 'docker is required without --database-url.' >&2; exit 2; } + container="scheduling-demo-pg-$$" + printf 'starting disposable TLS-enabled PostgreSQL on 127.0.0.1:%s\n' "$db_port" + # The runtime requires TLS to PostgreSQL even locally, so the container + # serves the demo's own throwaway certificate: the wrapper copies it where + # the postgres user can read it, then hands off to the stock entrypoint. + docker run -d --rm --name "$container" \ + -e POSTGRES_PASSWORD=demo \ + -p "127.0.0.1:${db_port}:5432" \ + -v "$run_dir/db-tls:/tls:ro" \ + postgres:17-alpine \ + sh -c 'cp /tls/ca.crt /tls/server.crt /tls/server.key /var/lib/postgresql/ && \ + chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key && \ + chmod 600 /var/lib/postgresql/server.key && \ + exec docker-entrypoint.sh postgres \ + -c ssl=on \ + -c ssl_cert_file=/var/lib/postgresql/server.crt \ + -c ssl_key_file=/var/lib/postgresql/server.key' >/dev/null + # Wait on TCP inside the container, not the unix socket: the stock + # entrypoint's initdb phase runs a temporary socket-only server, so a + # socket probe can report ready before the real TLS listener exists. + ready=false + for _ in $(seq 1 60); do + if docker exec "$container" pg_isready -h 127.0.0.1 -p 5432 -U postgres -d postgres >/dev/null 2>&1; then + ready=true + break + fi + sleep 1 + done + if [[ "$ready" != true ]]; then + printf 'the demo PostgreSQL did not become ready; its logs follow.\n' >&2 + docker logs "$container" >&2 || true + exit 1 + fi +fi + +project="$run_dir/project" +"$schedulingctl" check --deny-findings "$project" >/dev/null +"$schedulingctl" test "$project" >/dev/null +"$schedulingctl" package "$project" >/dev/null +"$scheduling" --runtime-config "$run_dir/runtime.yaml" migrate +"$schedulingctl" records apply "$run_dir/runtime.yaml" "$run_dir/records.yaml" >/dev/null + +printf 'serving the Scheduling runtime on 127.0.0.1:%s\n' "$http_port" +"$scheduling" --runtime-config "$run_dir/runtime.yaml" serve >"$run_dir/serve.log" 2>&1 & +serve_pid=$! + +ready=false +for _ in $(seq 1 60); do + if curl --silent --fail --max-time 2 "http://127.0.0.1:${http_port}/readyz" >/dev/null 2>&1; then + ready=true + break + fi + if ! kill -0 "$serve_pid" >/dev/null 2>&1; then + break + fi + sleep 1 +done +if [[ "$ready" != true ]]; then + printf 'the runtime did not become ready; the serve log follows.\n' >&2 + cat "$run_dir/serve.log" >&2 || true + exit 1 +fi + +python3 "$support" verify \ + --base-url "http://127.0.0.1:${http_port}" \ + --signing-key "$run_dir/demo-signing-key.pem" diff --git a/products/scheduling/demo/support/demo.py b/products/scheduling/demo/support/demo.py new file mode 100644 index 0000000000..d94626ee3b --- /dev/null +++ b/products/scheduling/demo/support/demo.py @@ -0,0 +1,802 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 + +"""Support module for the Registry Scheduling local acceptance demo. + +The demo drives the real `scheduling` and `schedulingctl` binaries against a +disposable PostgreSQL database and proves four acceptance scenarios over the +public HTTP contract (see products/scheduling/demo/README.md): + +- AT-01 two callers confirm against the last unit concurrently; +- AT-05 a hold expires and capacity reopens while late confirmation fails; +- AT-06 a lost confirmation response replays under the same idempotency key; +- AT-19 a weekly opening across a daylight-saving fold serves the moved grid. + +Authentication is real: the script generates an RSA key pair, publishes the +public key as a static JWKS document the runtime verifies, and signs RFC 9068 +access tokens for each demo caller, including the task grants the mutating +routes demand. Nothing here is a credential beyond this throwaway run +directory, and no secret is printed. + +Python 3 standard library only, like every Registry Stack support script. +""" + +import argparse +import base64 +import json +import secrets +import shutil +import subprocess +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +# The fixed demo identities. The issuer and audience never leave the run +# directory; the clients are the three callers the runtime config admits. +DEMO_ISSUER = "https://issuer.scheduling-demo.invalid" +DEMO_AUDIENCE = "scheduling-demo" +DEMO_KEY_ID = "scheduling-demo-1" +BOOKER_A = "demo-booker-a" +BOOKER_B = "demo-booker-b" +READER = "demo-reader" + +# The task-grant permissions every booking caller carries: one permission per +# location, naming the offering's service and the actions the demo exercises. +GRANT_PERMISSIONS = [ + { + "service": "registry-update", + "location": "bangkok-counter", + "actions": [ + "hold.create", + "hold.release", + "appointment.create", + "appointment.reschedule", + "appointment.cancel", + ], + }, + { + "service": "registry-update", + "location": "new-york-hall", + "actions": [ + "appointment.create", + "appointment.reschedule", + "appointment.cancel", + ], + }, +] + +# The offering and slot facts the scenarios pin. The Bangkok counter opens +# 2026-10-01 (a Thursday) at 09:00 local (02:00Z in Asia/Bangkok); the grid +# steps in 30-minute increments. The fold-day offering serves 2026-11-01, +# where America/New_York falls back and the closure moves the grid anchor. +BANGKOK_OFFERING = "registry-update-30" +BANGKOK_RACE_START = "2026-10-01T02:00:00Z" +# The counter offering carries five-minute buffers before and after each +# booking, so back-to-back grid slots conflict on the one station. The three +# committed Bangkok bookings (race, hold, replay) therefore sit an hour apart. +BANGKOK_HOLD_START = "2026-10-01T04:00:00Z" +BANGKOK_REPLAY_START = "2026-10-01T03:00:00Z" +BANGKOK_REPLAY_CHANGED_START = "2026-10-01T03:30:00Z" +BANGKOK_DAY_START = "2026-10-01T00:00:00Z" +BANGKOK_DAY_END = "2026-10-02T00:00:00Z" + +FOLD_OFFERING = "fold-day-update-30" +FOLD_DAY_START = "2026-11-01T00:00:00Z" +FOLD_DAY_END = "2026-11-02T00:00:00Z" +# America/New_York folds on 2026-11-01: the 00:30-02:30 opening spans +# [04:30Z, 07:30Z), and the 00:15-00:45 closure moves the grid anchor to +# 04:45Z. 06:30Z is a wall-clock match off the moved grid and must not serve. +FOLD_FIRST_SLOT = "2026-11-01T04:45:00Z" +FOLD_SERVED_SLOTS = [ + "2026-11-01T04:45:00Z", + "2026-11-01T05:15:00Z", + "2026-11-01T05:45:00Z", +] +FOLD_UNSERVED_START = "2026-11-01T06:30:00Z" +FOLD_BOOKED_START = "2026-11-01T05:15:00Z" + + +def b64url(raw: bytes) -> str: + """Base64url without padding, the JWT and JWK encoding.""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +# -------------------------------------------------------------------------- +# Minimal DER reading for one purpose: turn an RSA SubjectPublicKeyInfo into +# the JWK the runtime's JWKS verifier accepts. No third-party library is +# available to support scripts, so the two-byte header walk lives here. +# -------------------------------------------------------------------------- + +def _der_tlv(data: bytes, offset: int) -> tuple[int, bytes, bytes]: + """Read one DER tag-length-value at `offset`, returning the tag, the + value bytes, and the buffer that follows it.""" + tag = data[offset] + length = data[offset + 1] + if length & 0x80: + size = length & 0x7F + length = int.from_bytes(data[offset + 2 : offset + 2 + size], "big") + offset += 2 + size + else: + offset += 2 + return tag, data[offset : offset + length], data[offset + length :] + + +def rsa_public_jwk(public_der: bytes) -> dict: + """Build the RS256 JWK for an RSA SubjectPublicKeyInfo document.""" + outer_tag, spki, remainder = _der_tlv(public_der, 0) + if outer_tag != 0x30 or remainder: + raise ValueError("the public key is not one DER sequence") + _, _, after_algorithm = _der_tlv(spki, 0) + bit_tag, bit_string, _ = _der_tlv(after_algorithm, 0) + if bit_tag != 0x03: + raise ValueError("the SubjectPublicKeyInfo carries no bit string") + _, rsa_pair, _ = _der_tlv(bit_string[1:], 0) + _, modulus_raw, after_modulus = _der_tlv(rsa_pair, 0) + _, exponent_raw, _ = _der_tlv(after_modulus, 0) + modulus = modulus_raw.lstrip(b"\x00") + exponent = exponent_raw.lstrip(b"\x00") + if not modulus or not exponent: + raise ValueError("the RSA public key integers are empty") + return { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": DEMO_KEY_ID, + "n": b64url(modulus), + "e": b64url(exponent), + } + + +# -------------------------------------------------------------------------- +# Token minting. The header is an RFC 9068 access token (`typ: at+jwt`); the +# signing itself is delegated to openssl so no signing code lives here. +# -------------------------------------------------------------------------- + +def openssl_sign(signing_input: bytes, key_path: Path) -> bytes: + result = subprocess.run( + ["openssl", "dgst", "-sha256", "-sign", str(key_path)], + input=signing_input, + capture_output=True, + check=True, + ) + return result.stdout + + +def mint_token(signer, claims: dict) -> str: + header = {"alg": "RS256", "typ": "at+jwt", "kid": DEMO_KEY_ID} + encoded_header = b64url(json.dumps(header, separators=(",", ":")).encode()) + encoded_payload = b64url(json.dumps(claims, separators=(",", ":")).encode()) + signature = b64url(signer(f"{encoded_header}.{encoded_payload}".encode())) + return f"{encoded_header}.{encoded_payload}.{signature}" + + +def caller_claims( + client: str, + subject: str, + scopes: list[str] | None = None, + grant: bool = False, + now: int | None = None, +) -> dict: + """One caller's claims: identity, actor kind, scopes, and an optional + task grant whose scheduling permissions cover the demo's offerings.""" + issued_at = int(time.time()) if now is None else now + claims = { + "iss": DEMO_ISSUER, + "aud": DEMO_AUDIENCE, + "sub": subject, + "iat": issued_at, + "exp": issued_at + 3600, + "client_id": client, + "registry_actor_kind": "service", + } + if scopes is not None: + claims["registry_scopes"] = scopes + if grant: + claims.update( + { + "registry_grant_id": f"grant-{secrets.token_hex(8)}", + "registry_grant_source_issuer": DEMO_ISSUER, + "registry_grant_client": client, + "registry_grant_resource": DEMO_AUDIENCE, + "registry_grant_exp": issued_at + 3600, + "registry_grant_bounds": { + "type": "scheduling", + "permissions": GRANT_PERMISSIONS, + }, + # A complete grant names its purpose and its approver beside + # the six registry_grant_* members; an incomplete set is + # refused as a credentials problem, not honored partially. + "registry_purpose": "registry-update", + "registry_approver": "demo-approver", + } + ) + return claims + + +def admission_body(offering: str, start: str, policy_revision: int, subject: str) -> dict: + """One admission request body. The example policy keys duplicate-active + bookings by subject, so each caller names itself as the duplicate key the + same way the offering's policy prescribes.""" + return { + "offering": offering, + "start": start, + "party": {"recipients": 1, "attendees": 1}, + "duplicateKey": subject, + "policyRevision": policy_revision, + "capabilities": [], + "prerequisites": [], + } + + +# -------------------------------------------------------------------------- +# HTTP over the public contract. +# -------------------------------------------------------------------------- + +def request( + method: str, + base_url: str, + path: str, + token: str | None = None, + idempotency_key: str | None = None, + body: dict | None = None, + query: dict | None = None, +) -> tuple[int, dict | None]: + url = f"{base_url}{path}" + if query: + url = f"{url}?{urllib.parse.urlencode(query)}" + payload = None + headers = {"accept": "application/json"} + if body is not None: + payload = json.dumps(body).encode() + headers["content-type"] = "application/json" + if token is not None: + headers["authorization"] = f"Bearer {token}" + if idempotency_key is not None: + headers["idempotency-key"] = idempotency_key + call = urllib.request.Request(url, data=payload, headers=headers, method=method) + try: + with urllib.request.urlopen(call, timeout=15) as response: + raw = response.read() + document = json.loads(raw) if raw else None + return response.status, document + except urllib.error.HTTPError as error: + raw = error.read() + return error.code, json.loads(raw) if raw else None + + +def parse_instant(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def wait_until(deadline: datetime) -> None: + remaining = (deadline - datetime.now(timezone.utc)).total_seconds() + if remaining > 0: + time.sleep(remaining + 0.5) + + +# -------------------------------------------------------------------------- +# The authoring pieces `run.sh` needs written before the runtime starts. +# -------------------------------------------------------------------------- + +def shorten_hold_ttl(policy_text: str) -> str: + """Set the hold TTL to one minute so the expiry scenario runs quickly.""" + marker = "ttlMinutes: 5" + if policy_text.count(marker) != 1: + raise ValueError("the template policy does not carry exactly one five-minute hold TTL") + return policy_text.replace(marker, "ttlMinutes: 1") + + +DEMO_RECORDS = """\ +# Live environment records for the scheduling demo: one interchangeable +# station at the Bangkok counter (so the race scenario competes for the last +# unit), one at the New York hall, and the fold-day prep closure that moves +# the 2026-11-01 grid anchor past 04:45Z. +locations: + - id: bangkok-counter + timezone: Asia/Bangkok + - id: new-york-hall + timezone: America/New_York +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true +exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" +""" + + +# The OpenSSL config fragments the throwaway database TLS materials need. +# The runtime's released binary requires TLS to PostgreSQL, so the demo +# generates a two-day certificate authority, a server certificate for +# loopback, and trusts exactly that authority in its runtime configuration. +CA_REQUEST_CONFIG = """\ +[req] +distinguished_name = name +x509_extensions = ca +prompt = no +[name] +CN = Scheduling Demo Database CA +[ca] +basicConstraints = critical,CA:TRUE +keyUsage = critical,keyCertSign,cRLSign +""" + +SERVER_SIGN_CONFIG = """\ +[server] +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature,keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = IP:127.0.0.1,DNS:localhost +""" + + +def generate_database_tls(root: Path) -> None: + """Generate the demo's throwaway database PKI: a certificate authority + under the secrets root (the trust the runtime configuration names) and a + server key pair the demo's PostgreSQL container serves.""" + tls = root / "db-tls" + tls.mkdir() + (tls / "ca.cnf").write_text(CA_REQUEST_CONFIG) + (tls / "server.ext").write_text(SERVER_SIGN_CONFIG) + run = lambda arguments: subprocess.run( # noqa: E731 + ["openssl", *arguments], check=True, capture_output=True, cwd=tls + ) + run([ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", + "-keyout", "ca.key", "-out", "ca.crt", "-config", "ca.cnf", + ]) + run([ + "req", "-newkey", "rsa:2048", "-nodes", + "-keyout", "server.key", "-out", "server.csr", + "-subj", "/CN=localhost", + ]) + run([ + "x509", "-req", "-in", "server.csr", + "-CA", "ca.crt", "-CAkey", "ca.key", "-CAcreateserial", + "-out", "server.crt", "-days", "2", + "-extfile", "server.ext", "-extensions", "server", + ]) + # Refuse a certificate the extensions never reached: an extension-free + # server certificate is rejected only at connection time, far from its + # cause, and the silent form of this failure is exactly that. + names = subprocess.run( + ["openssl", "x509", "-in", "server.crt", "-noout", "-ext", "subjectAltName"], + check=True, capture_output=True, text=True, cwd=tls, + ).stdout + if "IP Address:127.0.0.1" not in names and "DNS:localhost" not in names: + raise SystemExit("the demo server certificate carries no subjectAltName") + + +def runtime_config( + project: Path, + secrets_root: Path, + audit_path: Path, + port: int, + trusted_root_ca: Path | None, +) -> str: + trust = ( + f" trustedRootCertificateRef: secret:file/db-root-ca\n" + if trusted_root_ca is not None + else "" + ) + return f"""\ +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +package: + root: {project} +listener: + bind: 127.0.0.1:{port} + tlsTermination: development-loopback +secretProviders: + file: + root: {secrets_root} +database: + runtimeUrlRef: secret:file/db-url + migrationUrlRef: secret:file/db-url +{trust}authentication: + oidc: + issuer: {DEMO_ISSUER} + audience: {DEMO_AUDIENCE} + allowedClients: [{BOOKER_A}, {BOOKER_B}, {READER}] + jwksSource: + kind: static + documentRef: secret:file/jwks +audit: + path: {audit_path} + hashKeyRef: secret:file/audit-key +retention: {{}} +destinations: + reminders: null +""" + + +def prepare( + root: Path, + example: Path, + database_url: str, + port: int, + database_root_ca: Path | None, +) -> None: + """Lay out the run directory: project copy, key material, records, and + the runtime configuration, all owner-only.""" + project = root / "project" + shutil.copytree(example, project) + policy_path = project / "scheduling.yaml" + policy_path.write_text(shorten_hold_ttl(policy_path.read_text())) + + signing_key = root / "demo-signing-key.pem" + subprocess.run( + [ + "openssl", + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + "-out", + str(signing_key), + ], + check=True, + capture_output=True, + ) + public_der = subprocess.run( + ["openssl", "rsa", "-in", str(signing_key), "-pubout", "-outform", "DER"], + check=True, + capture_output=True, + ).stdout + jwks = {"keys": [rsa_public_jwk(public_der)]} + + secrets_root = root / "secrets" + secrets_root.mkdir() + audit_dir = root / "audit" + audit_dir.mkdir() + # The audit path is the journal file itself; its advisory lock file is + # created beside it. + audit_path = audit_dir / "audit.jsonl" + # The demo always generates its own database PKI: the demo container + # serves it, and the runtime trusts exactly its authority. An external + # --database-url server presents some other chain, so the operator names + # the authority to trust instead and the demo materials stay unused. + generate_database_tls(root) + trust_root: Path | None = None + if database_root_ca is None: + (secrets_root / "db-root-ca").write_bytes((root / "db-tls" / "ca.crt").read_bytes()) + trust_root = secrets_root / "db-root-ca" + elif database_root_ca.is_file(): + (secrets_root / "db-root-ca").write_bytes(database_root_ca.read_bytes()) + trust_root = secrets_root / "db-root-ca" + else: + raise SystemExit(f"the database root CA does not exist: {database_root_ca}") + # Secret files carry no trailing newline: every byte is the secret. + (secrets_root / "db-url").write_text(database_url) + (secrets_root / "jwks").write_text(json.dumps(jwks, separators=(",", ":"))) + (secrets_root / "audit-key").write_text(secrets.token_hex(32)) + (root / "records.yaml").write_text(DEMO_RECORDS) + (root / "runtime.yaml").write_text( + runtime_config( + project.resolve(), secrets_root.resolve(), audit_path.resolve(), port, trust_root + ) + ) + + +# -------------------------------------------------------------------------- +# The four acceptance scenarios. +# -------------------------------------------------------------------------- + +class Verifier: + def __init__(self, base_url: str, signing_key: Path) -> None: + self.base_url = base_url + self.signer = lambda data: openssl_sign(data, signing_key) + self.failures: list[str] = [] + self.checks = 0 + status, document = request("GET", base_url, "/v1/scheduling", token=self.reader_token()) + self.check("the deployment answers its service document", status == 200, + f"status={status} body={document}") + if document is None or "policyRevision" not in document: + raise SystemExit("the running deployment did not answer /v1/scheduling") + self.policy_revision = document["policyRevision"] + + def check(self, claim: str, holds: bool, evidence: str = "") -> None: + self.checks += 1 + if holds: + print(f" pass: {claim}") + else: + print(f" FAIL: {claim}{': ' + evidence if evidence else ''}") + self.failures.append(claim) + + def reader_token(self) -> str: + return mint_token( + self.signer, + caller_claims(READER, "demo-catalogue-reader", scopes=["scheduling-read"]), + ) + + def booker_token(self, name: str, subject: str) -> str: + return mint_token(self.signer, caller_claims(name, subject, grant=True)) + + def admission(self, offering: str, start: str, subject: str) -> dict: + return admission_body(offering, start, self.policy_revision, subject) + + def availability(self, token: str, offering: str, start: str, end: str) -> list[dict]: + _, document = request( + "GET", + self.base_url, + "/v1/availability", + token=token, + query={"offering": offering, "start": start, "end": end}, + ) + return document["items"] if document else [] + + def race_for_the_last_unit(self) -> None: + """AT-01: two callers confirm against the last unit concurrently; at + most one claim succeeds and the loser receives a recoverable + capacity conflict.""" + print("AT-01: two callers race for the last station") + reader = self.reader_token() + slots = self.availability(reader, BANGKOK_OFFERING, BANGKOK_DAY_START, BANGKOK_DAY_END) + race_slot = next( + (item for item in slots if item["kind"] == "slot" + and parse_instant(item["start"]) == parse_instant(BANGKOK_RACE_START)), + None, + ) + self.check("the raced slot is published with one free unit", + race_slot is not None and race_slot["free"] == 1, + json.dumps(race_slot)) + + bodies: list[tuple[int, dict | None]] = [] + barrier = threading.Barrier(2) + + def fire(client: str, subject: str, key: str) -> None: + barrier.wait() + bodies.append(request( + "POST", + self.base_url, + "/v1/appointments", + token=self.booker_token(client, subject), + idempotency_key=key, + body={"admission": self.admission(BANGKOK_OFFERING, BANGKOK_RACE_START, subject)}, + )) + + threads = [ + threading.Thread(target=fire, args=(BOOKER_A, "demo-race-a", "demo-race-a")), + threading.Thread(target=fire, args=(BOOKER_B, "demo-race-b", "demo-race-b")), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + statuses = sorted(status for status, _ in bodies) + codes = [document.get("code") if document else None for _, document in bodies] + self.check("exactly one confirmation succeeds and one is refused", + statuses == [201, 409], f"statuses={statuses} codes={codes}") + self.check("the loser receives the recoverable capacity conflict", + "capacity.exhausted" in codes, f"codes={codes}") + + after = self.availability(reader, BANGKOK_OFFERING, BANGKOK_DAY_START, BANGKOK_DAY_END) + raced = next( + (item for item in after if item["kind"] == "slot" + and parse_instant(item["start"]) == parse_instant(BANGKOK_RACE_START)), + None, + ) + # A fully committed slot is not offered at all: the page lists only + # slots that hold a free unit. + self.check("the raced slot holds no free unit afterwards", + raced is None or raced["free"] == 0, + json.dumps(raced)) + + def hold_create(self) -> dict | None: + """AT-05, first half: mint a hold and confirm its slot shows no free + unit while the hold lives.""" + print("AT-05: a hold is minted, then expires") + reader = self.reader_token() + status, hold = request( + "POST", + self.base_url, + "/v1/holds", + token=self.booker_token(BOOKER_A, "demo-hold"), + idempotency_key="demo-hold-create", + body=self.admission(BANGKOK_OFFERING, BANGKOK_HOLD_START, "demo-hold"), + ) + self.check("the hold is minted", status == 201 and hold is not None, + f"status={status} body={hold}") + if status != 201 or hold is None: + return None + held_slot = next( + (item for item in self.availability(reader, BANGKOK_OFFERING, BANGKOK_DAY_START, BANGKOK_DAY_END) + if item["kind"] == "slot" and parse_instant(item["start"]) == parse_instant(BANGKOK_HOLD_START)), + None, + ) + # Like the raced slot, a held slot leaves the page entirely while no + # free unit remains. + self.check("the held slot shows no free unit while the hold lives", + held_slot is None or held_slot["free"] == 0, + json.dumps(held_slot)) + print(f" the hold expires at {hold['expiresAt']}; the other scenarios run while its clock does") + return hold + + def hold_expired(self, hold: dict) -> None: + """AT-05, second half: after the TTL passes, capacity is bookable + again and the late confirmation of the expired hold is refused. The + store counts a hold only while it is unexpired at the observed now, + so these outcomes do not depend on the cleanup worker having run.""" + reader = self.reader_token() + booker = self.booker_token(BOOKER_A, "demo-hold") + wait_until(parse_instant(hold["expiresAt"])) + expired_slot = next( + (item for item in self.availability(reader, BANGKOK_OFFERING, BANGKOK_DAY_START, BANGKOK_DAY_END) + if item["kind"] == "slot" and parse_instant(item["start"]) == parse_instant(BANGKOK_HOLD_START)), + None, + ) + self.check("the expired hold's capacity is bookable again", + expired_slot is not None and expired_slot["free"] == 1, + json.dumps(expired_slot)) + status, problem = request( + "POST", + self.base_url, + "/v1/appointments", + token=booker, + idempotency_key="demo-hold-confirm-late", + body={"hold": hold["holdId"]}, + ) + self.check("late confirmation of the expired hold fails with hold.expired", + status == 410 and problem is not None and problem.get("code") == "hold.expired", + f"status={status} body={problem}") + + def lost_confirmation_replay(self) -> None: + """AT-06: a confirmation commits but its response is lost; the + same-key retry returns the original appointment and a changed + payload under that key is refused.""" + print("AT-06: a lost confirmation replays under the same key") + booker = self.booker_token(BOOKER_A, "demo-replay") + key = "demo-replay-1" + status, first = request( + "POST", + self.base_url, + "/v1/appointments", + token=booker, + idempotency_key=key, + body={"admission": self.admission(BANGKOK_OFFERING, BANGKOK_REPLAY_START, "demo-replay")}, + ) + self.check("the original confirmation commits", status == 201 and first is not None, + f"status={status}") + if first is None: + return + # The response never reached the caller; the retry repeats the exact + # request under the exact key. + status, replay = request( + "POST", + self.base_url, + "/v1/appointments", + token=booker, + idempotency_key=key, + body={"admission": self.admission(BANGKOK_OFFERING, BANGKOK_REPLAY_START, "demo-replay")}, + ) + self.check("the same-key retry returns the original appointment", + status == 201 and replay is not None + and replay.get("appointmentId") == first["appointmentId"], + f"status={status}") + status, problem = request( + "POST", + self.base_url, + "/v1/appointments", + token=booker, + idempotency_key=key, + body={"admission": self.admission(BANGKOK_OFFERING, BANGKOK_REPLAY_CHANGED_START, "demo-replay")}, + ) + self.check("a changed payload under that key is refused", + status == 409 and problem is not None + and problem.get("code") == "idempotency.key-reused", + f"status={status} body={problem}") + + def fold_day_grid(self) -> None: + """AT-19: the Sunday opening across the 2026-11-01 New York fold + serves the grid the closure moved (04:45Z anchor), never a + wall-clock match off it, with no shifted commitment.""" + print("AT-19: the fold-day opening serves the moved grid") + reader = self.reader_token() + items = self.availability(reader, FOLD_OFFERING, FOLD_DAY_START, FOLD_DAY_END) + slots = [item for item in items if item["kind"] == "slot"] + starts = [parse_instant(item["start"]) for item in slots] + self.check("the fold-day grid anchors at the moved 04:45Z start", + bool(starts) and min(starts) == parse_instant(FOLD_FIRST_SLOT), + json.dumps([item["start"] for item in slots])) + for expected in FOLD_SERVED_SLOTS: + self.check(f"the moved grid serves {expected}", + parse_instant(expected) in starts) + self.check("the fold does not duplicate the pre-closure 04:30Z start", + parse_instant("2026-11-01T04:30:00Z") not in starts) + self.check("the wall-clock match 06:30Z is not served", + parse_instant(FOLD_UNSERVED_START) not in starts) + status, booked = request( + "POST", + self.base_url, + "/v1/appointments", + token=self.booker_token(BOOKER_A, "demo-fold-a"), + idempotency_key="demo-fold-early", + body={"admission": self.admission(FOLD_OFFERING, FOLD_BOOKED_START, "demo-fold-a")}, + ) + self.check("the moved-grid start books exactly as published", + status == 201 and booked is not None + and parse_instant(booked["start"]) == parse_instant(FOLD_BOOKED_START), + f"status={status}") + status, problem = request( + "POST", + self.base_url, + "/v1/appointments", + token=self.booker_token(BOOKER_B, "demo-fold-b"), + idempotency_key="demo-fold-wall-clock", + body={"admission": self.admission(FOLD_OFFERING, FOLD_UNSERVED_START, "demo-fold-b")}, + ) + self.check("a wall-clock match off the moved grid is refused", + status == 422 and problem is not None + and problem.get("code") == "schedule.unpublished", + f"status={status} body={problem}") + + def run_all(self) -> int: + self.race_for_the_last_unit() + hold = self.hold_create() + self.lost_confirmation_replay() + self.fold_day_grid() + if hold is not None: + self.hold_expired(hold) + print(f"{self.checks - len(self.failures)}/{self.checks} checks passed") + if self.failures: + for failure in self.failures: + print(f"failed: {failure}") + return 1 + return 0 + + +def verify(base_url: str, signing_key: Path) -> int: + # The four scenarios share no state except the deployment itself: each + # books under its own subject, its own slot, and its own idempotency key. + verifier = Verifier(base_url, signing_key) + return verifier.run_all() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + + prepare_parser = commands.add_parser("prepare") + prepare_parser.add_argument("--root", type=Path, required=True) + prepare_parser.add_argument("--example", type=Path, required=True) + prepare_parser.add_argument("--database-url", required=True) + prepare_parser.add_argument("--port", type=int, required=True) + prepare_parser.add_argument("--database-root-ca", type=Path) + + verify_parser = commands.add_parser("verify") + verify_parser.add_argument("--base-url", required=True) + verify_parser.add_argument("--signing-key", type=Path, required=True) + + arguments = parser.parse_args() + if arguments.command == "prepare": + prepare( + arguments.root, + arguments.example, + arguments.database_url, + arguments.port, + arguments.database_root_ca, + ) + return 0 + return verify(arguments.base_url, arguments.signing_key) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/scheduling/demo/support/test_demo.py b/products/scheduling/demo/support/test_demo.py new file mode 100644 index 0000000000..82683102c5 --- /dev/null +++ b/products/scheduling/demo/support/test_demo.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the pure pieces of the Scheduling demo support module. + +Run from the repository root: + + python3 -m unittest products/scheduling/demo/support/test_demo.py -v +""" + +import base64 +import json +import subprocess +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import demo # noqa: E402 + + +class B64UrlTest(unittest.TestCase): + def test_encoding_drops_padding_and_keeps_url_safety(self): + self.assertEqual(demo.b64url(b"\xff"), "_w") + self.assertEqual(demo.b64url(b"ab"), "YWI") + self.assertEqual(demo.b64url(b"abc"), "YWJj") + + +class RsaJwkTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.key = demo_key_path() + subprocess.run( + ["openssl", "genpkey", "-algorithm", "RSA", + "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(cls.key)], + check=True, capture_output=True, + ) + cls.public_der = subprocess.run( + ["openssl", "rsa", "-in", str(cls.key), "-pubout", "-outform", "DER"], + check=True, capture_output=True, + ).stdout + + @classmethod + def tearDownClass(cls): + cls.key.unlink(missing_ok=True) + + def test_the_public_key_becomes_an_rs256_jwk(self): + jwk = demo.rsa_public_jwk(self.public_der) + self.assertEqual(jwk["kty"], "RSA") + self.assertEqual(jwk["alg"], "RS256") + self.assertEqual(jwk["use"], "sig") + # A 2048-bit modulus is 256 bytes before encoding; e is the usual + # 65537, which base64url-encodes to the well-known AQAB. + modulus = base64.urlsafe_b64decode(jwk["n"] + "===") + self.assertEqual(len(modulus), 256) + self.assertEqual(jwk["e"], "AQAB") + + def test_a_non_sequence_document_is_refused(self): + with self.assertRaises(ValueError): + demo.rsa_public_jwk(b"\x02\x01\x00") + + +def demo_key_path() -> Path: + import tempfile + return Path(tempfile.gettempdir()) / "scheduling-demo-test-key.pem" + + +class MintTokenTest(unittest.TestCase): + def test_the_header_is_an_rfc9068_access_token(self): + def signer(data): + self.assertEqual(data.count(b"."), 1) + return b"signature" + + token = demo.mint_token(signer, {"iss": "https://issuer.test", "sub": "s"}) + header, payload, signature = token.split(".") + decoded_header = json.loads(base64.urlsafe_b64decode(header + "===")) + decoded_payload = json.loads(base64.urlsafe_b64decode(payload + "===")) + self.assertEqual(decoded_header, {"alg": "RS256", "typ": "at+jwt", "kid": demo.DEMO_KEY_ID}) + self.assertEqual(signature, demo.b64url(b"signature")) + self.assertEqual(decoded_payload["iss"], "https://issuer.test") + self.assertEqual(decoded_payload["sub"], "s") + + +class CallerClaimsTest(unittest.TestCase): + def test_a_reader_carries_scopes_and_no_grant(self): + claims = demo.caller_claims("demo-reader", "reader-subject", scopes=["scheduling-read"]) + self.assertEqual(claims["registry_scopes"], ["scheduling-read"]) + self.assertEqual(claims["registry_actor_kind"], "service") + self.assertNotIn("registry_grant_bounds", claims) + + def test_a_booker_carries_a_complete_scheduling_grant(self): + claims = demo.caller_claims("demo-booker-a", "booker-subject", grant=True, now=1_000_000) + self.assertEqual(claims["registry_grant_client"], "demo-booker-a") + self.assertEqual(claims["registry_grant_resource"], demo.DEMO_AUDIENCE) + self.assertEqual(claims["registry_grant_exp"], 1_000_000 + 3600) + self.assertEqual(claims["registry_purpose"], "registry-update") + self.assertEqual(claims["registry_approver"], "demo-approver") + bounds = claims["registry_grant_bounds"] + self.assertEqual(bounds["type"], "scheduling") + locations = {permission["location"] for permission in bounds["permissions"]} + self.assertEqual(locations, {"bangkok-counter", "new-york-hall"}) + + +class AdmissionBodyTest(unittest.TestCase): + def test_the_body_names_its_caller_as_the_duplicate_key(self): + body = demo.admission_body(demo.BANGKOK_OFFERING, demo.BANGKOK_HOLD_START, 7, "demo-hold") + self.assertEqual(body["offering"], demo.BANGKOK_OFFERING) + self.assertEqual(body["start"], demo.BANGKOK_HOLD_START) + self.assertEqual(body["duplicateKey"], "demo-hold") + self.assertEqual(body["policyRevision"], 7) + self.assertEqual(body["party"], {"recipients": 1, "attendees": 1}) + self.assertEqual(body["capabilities"], []) + self.assertEqual(body["prerequisites"], []) + + +class HoldTtlTest(unittest.TestCase): + def test_the_demo_policy_ttl_is_shortened_exactly_once(self): + policy = "holdPolicy:\n ttlMinutes: 5\n maxPerCaller: 3\n" + self.assertEqual(demo.shorten_hold_ttl(policy), + "holdPolicy:\n ttlMinutes: 1\n maxPerCaller: 3\n") + + def test_an_unexpected_template_is_refused_rather_than_silently_kept(self): + with self.assertRaises(ValueError): + demo.shorten_hold_ttl("holdPolicy:\n ttlMinutes: 9\n") + + +class DemoRecordsTest(unittest.TestCase): + def test_the_records_document_carries_one_station_per_pool(self): + import re + members = re.findall(r"resourceId: (\S+)", demo.DEMO_RECORDS) + self.assertEqual(members, ["station-1", "hall-station-1"]) + # The fold-day closure the AT-19 grid assertions depend on. + self.assertIn('date: "2026-11-01"', demo.DEMO_RECORDS) + self.assertIn("kind: closure", demo.DEMO_RECORDS) + + +class RuntimeConfigTest(unittest.TestCase): + def test_the_runtime_config_pins_every_required_block(self): + config = demo.runtime_config( + Path("/run/project"), Path("/run/secrets"), Path("/run/audit"), 8105, None + ) + for required in ( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1", + "kind: SchedulingRuntimeConfig", + "root: /run/project", + "bind: 127.0.0.1:8105", + "tlsTermination: development-loopback", + "root: /run/secrets", + "runtimeUrlRef: secret:file/db-url", + "migrationUrlRef: secret:file/db-url", + f"issuer: {demo.DEMO_ISSUER}", + f"audience: {demo.DEMO_AUDIENCE}", + "kind: static", + "documentRef: secret:file/jwks", + "path: /run/audit", + "hashKeyRef: secret:file/audit-key", + ): + self.assertIn(required, config) + self.assertNotIn("trustedRootCertificateRef", config) + + def test_the_database_trust_root_is_named_when_present(self): + config = demo.runtime_config( + Path("/run/project"), Path("/run/secrets"), Path("/run/audit"), 8105, + Path("/run/secrets/db-root-ca"), + ) + self.assertIn( + "trustedRootCertificateRef: secret:file/db-root-ca", + config, + ) + + def test_the_audit_path_names_the_journal_file(self): + config = demo.runtime_config( + Path("/run/project"), Path("/run/secrets"), Path("/run/audit/audit.jsonl"), 8105, None + ) + self.assertIn("path: /run/audit/audit.jsonl", config) + + +class BangkokSpacingTest(unittest.TestCase): + def test_the_committed_bangkok_bookings_clear_each_others_buffers(self): + starts = [ + demo.parse_instant(demo.BANGKOK_RACE_START), + demo.parse_instant(demo.BANGKOK_HOLD_START), + demo.parse_instant(demo.BANGKOK_REPLAY_START), + ] + for earlier, later in zip(sorted(starts), sorted(starts)[1:]): + # The offering buffers five minutes each side of a 30-minute + # booking, so two committed bookings need more than a slot's + # width between their starts to leave the station free. + self.assertGreaterEqual((later - earlier).total_seconds(), 3600) + for value in starts: + self.assertEqual(value.minute % 30, 0) + + +class FoldDayExpectationsTest(unittest.TestCase): + def test_the_pinned_grid_is_thirty_minutes_from_the_moved_anchor(self): + starts = [demo.parse_instant(value) for value in demo.FOLD_SERVED_SLOTS] + self.assertEqual(starts[0], demo.parse_instant(demo.FOLD_FIRST_SLOT)) + for earlier, later in zip(starts, starts[1:]): + self.assertEqual((later - earlier).total_seconds(), 1800) + # The wall-clock match the scenario refuses is not on the grid. + refused = demo.parse_instant(demo.FOLD_UNSERVED_START) + self.assertNotIn(refused, starts) + + +if __name__ == "__main__": + unittest.main() From e928f42acf746d3c9132b48631c5de1a5aecbe1c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 12:11:58 +0700 Subject: [PATCH 022/115] feat(scheduling): generate the OpenAPI contract document with drift checks generate_openapi.py writes the deterministic OpenAPI 3.1 document for the sixteen public routes and verifies it against the Rust truth beside it: the route inventory, handlers, authority profiles, bodies, parameters, and success statuses are parsed out of http.rs; the problem vocabulary, pinned titles, details, and statuses are read from the problem-catalog example's export, never copied; and every wire schema is compared field by field with the struct it projects. The per-operation problem mappings are the one hand-shaped table (the catalog exports no operations contract yet) and are verified for existence, pinned status, production reachability, and rule-derived minimums. check-checkpoint.sh runs the generator's --check first, so a drifted document fails the product gate. reserved-problems lists eligibility.unavailable and hook.unavailable, which the vocabulary defines and pins but no milestone path produces yet. Signed-off-by: Jeremi Joslin --- .../registry-scheduling.openapi.json | 5409 +++++++++++++++++ .../scheduling/scripts/check-checkpoint.sh | 1 + .../scheduling/scripts/generate_openapi.py | 1449 +++++ .../scripts/test_generate_openapi.py | 640 ++ 4 files changed, 7499 insertions(+) create mode 100644 products/scheduling/generated/registry-scheduling.openapi.json create mode 100644 products/scheduling/scripts/generate_openapi.py create mode 100644 products/scheduling/scripts/test_generate_openapi.py diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json new file mode 100644 index 0000000000..72910d7295 --- /dev/null +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -0,0 +1,5409 @@ +{ + "components": { + "headers": { + "CacheControlHeader": { + "description": "Every answer is marked no-store, so a booking view is never served from a cache.", + "schema": { + "const": "no-store" + } + }, + "TraceparentHeader": { + "description": "W3C trace context for the request and response.", + "schema": { + "type": "string" + } + } + }, + "schemas": { + "AdmissionRequest": { + "additionalProperties": false, + "properties": { + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "channel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "duplicateKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "offering": { + "type": "string" + }, + "party": { + "$ref": "#/components/schemas/PartyCounts" + }, + "policyRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "prerequisites": { + "items": { + "type": "string" + }, + "type": "array" + }, + "rescheduleOf": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "start": { + "format": "date-time", + "type": "string" + }, + "windowRevision": { + "anyOf": [ + { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "offering", + "start", + "party", + "policyRevision", + "capabilities", + "prerequisites" + ], + "type": "object" + }, + "AppointmentDocument": { + "additionalProperties": false, + "properties": { + "appointmentId": { + "type": "string" + }, + "cancelledAt": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "channel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, + "end": { + "format": "date-time", + "type": "string" + }, + "offering": { + "type": "string" + }, + "policyRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "revision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "start": { + "format": "date-time", + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/AppointmentState" + }, + "units": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "appointmentId", + "offering", + "start", + "end", + "units", + "revision", + "state", + "policyRevision", + "createdAt" + ], + "type": "object" + }, + "AppointmentHistoryEntryDocument": { + "additionalProperties": false, + "properties": { + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "detail": {}, + "eventId": { + "type": "string" + }, + "kind": { + "description": "The lifecycle step the store recorded: held, confirmed, consumed, released, rescheduled, or cancelled.", + "type": "string" + }, + "occurredAt": { + "format": "date-time", + "type": "string" + }, + "revision": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "eventId", + "kind", + "revision", + "occurredAt", + "detail" + ], + "type": "object" + }, + "AppointmentHistoryPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AppointmentHistoryEntryDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "AppointmentState": { + "enum": [ + "confirmed", + "cancelled" + ], + "type": "string" + }, + "AvailabilityEntry": { + "discriminator": { + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/AvailabilitySlot" + }, + { + "$ref": "#/components/schemas/AvailabilityWindow" + } + ] + }, + "AvailabilityPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AvailabilityEntry" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "AvailabilitySlot": { + "additionalProperties": false, + "properties": { + "end": { + "format": "date-time", + "type": "string" + }, + "free": { + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "slot" + }, + "start": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "kind", + "start", + "end", + "free" + ], + "type": "object" + }, + "AvailabilityWindow": { + "additionalProperties": false, + "properties": { + "channelRemaining": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "end": { + "format": "date-time", + "type": "string" + }, + "kind": { + "const": "window" + }, + "remaining": { + "minimum": 0, + "type": "integer" + }, + "start": { + "format": "date-time", + "type": "string" + }, + "window": { + "type": "string" + } + }, + "required": [ + "kind", + "window", + "start", + "end", + "remaining" + ], + "type": "object" + }, + "CancelAppointmentRequest": { + "additionalProperties": false, + "properties": { + "observedRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "observedRevision" + ], + "type": "object" + }, + "CreateAppointmentRequest": { + "additionalProperties": false, + "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is precondition.failed.", + "minProperties": 1, + "properties": { + "admission": { + "anyOf": [ + { + "$ref": "#/components/schemas/AdmissionRequest" + }, + { + "type": "null" + } + ] + }, + "hold": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ExplainDocument": { + "additionalProperties": false, + "properties": { + "detailedCode": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "offering": { + "type": "string" + }, + "publicCode": { + "type": "string" + }, + "start": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "offering", + "start" + ], + "type": "object" + }, + "HoldDocument": { + "additionalProperties": false, + "properties": { + "end": { + "format": "date-time", + "type": "string" + }, + "expiresAt": { + "format": "date-time", + "type": "string" + }, + "holdId": { + "type": "string" + }, + "offering": { + "type": "string" + }, + "policyRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "resource": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "start": { + "format": "date-time", + "type": "string" + }, + "units": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "holdId", + "offering", + "start", + "end", + "units", + "expiresAt", + "policyRevision" + ], + "type": "object" + }, + "LocationDocument": { + "additionalProperties": false, + "properties": { + "locationId": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "locationId", + "timezone" + ], + "type": "object" + }, + "LocationPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/LocationDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "OfferingDocument": { + "additionalProperties": false, + "properties": { + "bufferAfterMinutes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "bufferBeforeMinutes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "cancellationCutoffMinutes": { + "minimum": 0, + "type": "integer" + }, + "durationMinutes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "horizonDays": { + "minimum": 0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "leadTimeMinutes": { + "minimum": 0, + "type": "integer" + }, + "location": { + "type": "string" + }, + "maxRecipients": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "mode": { + "enum": [ + "exact-time", + "arrival-window" + ], + "type": "string" + }, + "prerequisites": { + "items": { + "type": "string" + }, + "type": "array" + }, + "reminders": { + "items": { + "$ref": "#/components/schemas/ReminderDocument" + }, + "type": "array" + }, + "requiresCapabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "service": { + "type": "string" + }, + "startIncrementMinutes": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "window": { + "anyOf": [ + { + "$ref": "#/components/schemas/WindowDocument" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "service", + "label", + "mode", + "location", + "leadTimeMinutes", + "horizonDays", + "cancellationCutoffMinutes", + "reminders", + "requiresCapabilities", + "prerequisites" + ], + "type": "object" + }, + "OfferingPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/OfferingDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "PartyCounts": { + "additionalProperties": false, + "properties": { + "attendees": { + "minimum": 0, + "type": "integer" + }, + "recipients": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "recipients", + "attendees" + ], + "type": "object" + }, + "Problem": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "authentication.refused", + "booking.duplicate-active", + "cancellation.cutoff-passed", + "capability.unmatched", + "capacity.exhausted", + "cursor.expired", + "cursor.invalid", + "eligibility.unavailable", + "hold.expired", + "hold.released", + "hook.unavailable", + "horizon.outside", + "idempotency.expired", + "idempotency.key-reused", + "location.closed", + "operation.not-authorized", + "party.capacity-inadequate", + "policy.changed", + "precondition.failed", + "precondition.required", + "prerequisite.missing", + "profile.not-authorized", + "request.body-too-large", + "request.invalid", + "request.method-not-allowed", + "request.not-found", + "request.unprocessable", + "request.unsupported-media-type", + "resource.unavailable", + "revision.mismatch", + "schedule.unpublished", + "service.unavailable" + ], + "type": "string" + }, + "detail": { + "type": "string" + }, + "status": { + "type": "integer" + }, + "title": { + "type": "string" + }, + "traceId": { + "type": "string" + }, + "type": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "detail", + "code", + "traceId" + ], + "type": "object" + }, + "ProblemAuthenticationRefused": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "authentication.refused" + }, + "detail": { + "const": "The bearer credential is missing, invalid, or expired. Sign in again." + }, + "status": { + "const": 401 + }, + "title": { + "const": "Authentication refused" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/authentication/refused" + } + }, + "type": "object" + } + ] + }, + "ProblemBookingDuplicateActive": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "booking.duplicate-active" + }, + "detail": { + "const": "An active booking already holds this party's duplicate key. Cancel or complete it before booking again." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Duplicate active booking" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/booking/duplicate-active" + } + }, + "type": "object" + } + ] + }, + "ProblemCancellationCutoffPassed": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "cancellation.cutoff-passed" + }, + "detail": { + "const": "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Cancellation cutoff passed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/cancellation/cutoff-passed" + } + }, + "type": "object" + } + ] + }, + "ProblemCapabilityUnmatched": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "capability.unmatched" + }, + "detail": { + "const": "No backing member carries every capability this offering requires." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Capability unmatched" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/capability/unmatched" + } + }, + "type": "object" + } + ] + }, + "ProblemCapacityExhausted": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "capacity.exhausted" + }, + "detail": { + "const": "The supply is fully committed for the requested interval. Choose another time." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Capacity exhausted" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/capacity/exhausted" + } + }, + "type": "object" + } + ] + }, + "ProblemCursorExpired": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "cursor.expired" + }, + "detail": { + "const": "This cursor has expired. Start again without a cursor and deduplicate entries by id." + }, + "status": { + "const": 410 + }, + "title": { + "const": "Cursor expired" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/cursor/expired" + } + }, + "type": "object" + } + ] + }, + "ProblemCursorInvalid": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "cursor.invalid" + }, + "detail": { + "const": "The cursor is invalid for this request." + }, + "status": { + "const": 400 + }, + "title": { + "const": "Cursor invalid" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/cursor/invalid" + } + }, + "type": "object" + } + ] + }, + "ProblemEligibilityUnavailable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "eligibility.unavailable" + }, + "detail": { + "const": "A required eligibility check could not run. Retry; do not treat this as permission." + }, + "status": { + "const": 503 + }, + "title": { + "const": "Eligibility unavailable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/eligibility/unavailable" + } + }, + "type": "object" + } + ] + }, + "ProblemHoldExpired": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "hold.expired" + }, + "detail": { + "const": "The hold expired before confirmation. Its capacity is bookable again; start a new request." + }, + "status": { + "const": 410 + }, + "title": { + "const": "Hold expired" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/hold/expired" + } + }, + "type": "object" + } + ] + }, + "ProblemHoldReleased": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "hold.released" + }, + "detail": { + "const": "The claim is not an active hold. It may already be confirmed or released." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Hold released" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/hold/released" + } + }, + "type": "object" + } + ] + }, + "ProblemHookUnavailable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "hook.unavailable" + }, + "detail": { + "const": "A required lifecycle hook could not run, so the request was refused rather than half-applied." + }, + "status": { + "const": 503 + }, + "title": { + "const": "Hook unavailable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/hook/unavailable" + } + }, + "type": "object" + } + ] + }, + "ProblemHorizonOutside": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "horizon.outside" + }, + "detail": { + "const": "The requested start is earlier than the lead time allows or further ahead than the horizon allows." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Start outside the booking horizon" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/horizon/outside" + } + }, + "type": "object" + } + ] + }, + "ProblemIdempotencyExpired": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "idempotency.expired" + }, + "detail": { + "const": "The stored response for this idempotency key has expired. Reconcile the original operation before choosing a new key." + }, + "status": { + "const": 410 + }, + "title": { + "const": "Idempotency window expired" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/idempotency/expired" + } + }, + "type": "object" + } + ] + }, + "ProblemIdempotencyKeyReused": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "idempotency.key-reused" + }, + "detail": { + "const": "This idempotency key was used for a different request." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Idempotency key reused" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/idempotency/key-reused" + } + }, + "type": "object" + } + ] + }, + "ProblemLocationClosed": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "location.closed" + }, + "detail": { + "const": "A closure covers the requested start. Choose a start outside the closure." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Location closed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/location/closed" + } + }, + "type": "object" + } + ] + }, + "ProblemOperationNotAuthorized": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "operation.not-authorized" + }, + "detail": { + "const": "Your current Scheduling authority does not allow this operation." + }, + "status": { + "const": 403 + }, + "title": { + "const": "Operation not authorized" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/operation/not-authorized" + } + }, + "type": "object" + } + ] + }, + "ProblemPartyCapacityInadequate": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "party.capacity-inadequate" + }, + "detail": { + "const": "The party is larger than this offering can ever serve, or its size falls outside the published units." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Party capacity inadequate" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/party/capacity-inadequate" + } + }, + "type": "object" + } + ] + }, + "ProblemPolicyChanged": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "policy.changed" + }, + "detail": { + "const": "The policy revision changed before the request committed. Reload the catalogue and try again with the current revision." + }, + "status": { + "const": 412 + }, + "title": { + "const": "Policy changed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/policy/changed" + } + }, + "type": "object" + } + ] + }, + "ProblemPreconditionFailed": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "precondition.failed" + }, + "detail": { + "const": "The appointment changed since you loaded it. Reload and try again." + }, + "status": { + "const": 412 + }, + "title": { + "const": "Precondition failed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/precondition/failed" + } + }, + "type": "object" + } + ] + }, + "ProblemPreconditionRequired": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "precondition.required" + }, + "detail": { + "const": "This mutation requires the revision you loaded, or the duplicate key this offering keys on." + }, + "status": { + "const": 428 + }, + "title": { + "const": "Precondition required" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/precondition/required" + } + }, + "type": "object" + } + ] + }, + "ProblemPrerequisiteMissing": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "prerequisite.missing" + }, + "detail": { + "const": "The party is missing a prerequisite this offering requires." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Prerequisite missing" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/prerequisite/missing" + } + }, + "type": "object" + } + ] + }, + "ProblemProfileNotAuthorized": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "profile.not-authorized" + }, + "detail": { + "const": "The selected Scheduling profile does not authorize this request." + }, + "status": { + "const": 403 + }, + "title": { + "const": "Profile not authorized" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/profile/not-authorized" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestBodyTooLarge": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.body-too-large" + }, + "detail": { + "const": "The request body exceeds the accepted size." + }, + "status": { + "const": 413 + }, + "title": { + "const": "Payload too large" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/body-too-large" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestInvalid": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.invalid" + }, + "detail": { + "const": "The request could not be read as a Scheduling request." + }, + "status": { + "const": 400 + }, + "title": { + "const": "Request invalid" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/invalid" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestMethodNotAllowed": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.method-not-allowed" + }, + "detail": { + "const": "The route exists but not for this method." + }, + "status": { + "const": 405 + }, + "title": { + "const": "Method not allowed" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/method-not-allowed" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestNotFound": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.not-found" + }, + "detail": { + "const": "The requested route does not exist." + }, + "status": { + "const": 404 + }, + "title": { + "const": "Route not found" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/not-found" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestUnprocessable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.unprocessable" + }, + "detail": { + "const": "The request body could not be processed." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Request unprocessable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/unprocessable" + } + }, + "type": "object" + } + ] + }, + "ProblemRequestUnsupportedMediaType": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "request.unsupported-media-type" + }, + "detail": { + "const": "The request body is not JSON." + }, + "status": { + "const": 415 + }, + "title": { + "const": "Unsupported media type" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/request/unsupported-media-type" + } + }, + "type": "object" + } + ] + }, + "ProblemResourceUnavailable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "resource.unavailable" + }, + "detail": { + "const": "Every capable member is unavailable for this interval." + }, + "status": { + "const": 409 + }, + "title": { + "const": "Resource unavailable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/resource/unavailable" + } + }, + "type": "object" + } + ] + }, + "ProblemRevisionMismatch": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "revision.mismatch" + }, + "detail": { + "const": "The window revision changed before the request committed. Reload the catalogue and try again." + }, + "status": { + "const": 412 + }, + "title": { + "const": "Revision mismatch" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/revision/mismatch" + } + }, + "type": "object" + } + ] + }, + "ProblemScheduleUnpublished": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "schedule.unpublished" + }, + "detail": { + "const": "No published schedule serves that start. Choose a start on the published grid." + }, + "status": { + "const": 422 + }, + "title": { + "const": "Schedule unpublished" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/schedule/unpublished" + } + }, + "type": "object" + } + ] + }, + "ProblemServiceUnavailable": { + "allOf": [ + { + "$ref": "#/components/schemas/Problem" + }, + { + "properties": { + "code": { + "const": "service.unavailable" + }, + "detail": { + "const": "Scheduling storage is unavailable. Try again after the service recovers." + }, + "status": { + "const": 503 + }, + "title": { + "const": "Scheduling service unavailable" + }, + "type": { + "const": "https://id.registrystack.org/problems/registry-scheduling/service/unavailable" + } + }, + "type": "object" + } + ] + }, + "ReminderDocument": { + "additionalProperties": false, + "properties": { + "minutesBefore": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "minutesBefore" + ], + "type": "object" + }, + "RescheduleAppointmentRequest": { + "additionalProperties": false, + "properties": { + "admission": { + "$ref": "#/components/schemas/AdmissionRequest" + }, + "observedRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "observedRevision", + "admission" + ], + "type": "object" + }, + "ResourceDocument": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "pool": { + "type": "string" + }, + "resourceId": { + "type": "string" + } + }, + "required": [ + "resourceId", + "pool", + "capabilities", + "available" + ], + "type": "object" + }, + "ResourcePage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ResourceDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "SchedulingServiceDocument": { + "additionalProperties": false, + "properties": { + "policyDigest": { + "type": "string" + }, + "policyRevision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "schedulingId": { + "type": "string" + } + }, + "required": [ + "schedulingId", + "policyRevision", + "policyDigest" + ], + "type": "object" + }, + "ServiceDocument": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "type": "object" + }, + "ServicePage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ServiceDocument" + }, + "type": "array" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "items", + "nextCursor" + ], + "type": "object" + }, + "WindowDocument": { + "additionalProperties": false, + "properties": { + "end": { + "format": "date-time", + "type": "string" + }, + "id": { + "type": "string" + }, + "revision": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "start": { + "format": "date-time", + "type": "string" + }, + "units": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "revision", + "start", + "end", + "units" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "description": "A fresh access token from the configured issuer, for this deployment's audience, signed by a key the issuer published. Catalogue, availability, and owned-record reads demand the configured reads scope (scheduling-read by default); the explain path demands the configured explain scope (scheduling-explain by default), which is never the reads scope. A mutating route demands no scope at all: its authority is the complete task grant the token carries, whose scheduling permissions the service checks against the offering's service and location and the action the route performs (hold.create, hold.release, appointment.create, appointment.reschedule, appointment.cancel). A token with partial grant claims is refused rather than half-trusted.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "Implemented Scheduling HTTP contract: published openings, exact-time offerings over interchangeable resource pools, published arrival windows with channel subquotas, holds, and accountable bookings. Mutating authority is a complete task grant; every refusal is one problem from the closed vocabulary.", + "license": { + "identifier": "Apache-2.0", + "name": "Apache-2.0" + }, + "title": "Registry Scheduling API", + "version": "v1alpha1" + }, + "openapi": "3.1.0", + "paths": { + "/healthz": { + "get": { + "description": "Process is live. No authentication, no storage, no policy.", + "operationId": "getLiveness", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [], + "summary": "Liveness", + "x-scheduling-authority": "unauthenticated" + } + }, + "/readyz": { + "get": { + "description": "Asks the Scheduling store to answer. A store that cannot answer is service.unavailable, with Retry-After.", + "operationId": "getReadiness", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [], + "summary": "Readiness", + "x-scheduling-authority": "unauthenticated" + } + }, + "/v1/appointments": { + "post": { + "description": "Carries exactly one of hold or admission. Confirming transfers the hold's own reservation, so capacity is re-checked only for identity: the hold must still be active and unexpired, its policy revision still current, and its holder still the caller. A direct create is evaluated against the live ledger like a hold, and commits instead of reserving. Both branches demand a complete task grant naming the offering's service, location, and appointment.create.", + "operationId": "createAppointment", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAppointmentRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemBookingDuplicateActive" + }, + { + "$ref": "#/components/schemas/ProblemCapacityExhausted" + }, + { + "$ref": "#/components/schemas/ProblemLocationClosed" + }, + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemHoldReleased" + } + ] + } + } + }, + "description": "Problem response: booking.duplicate-active, capacity.exhausted, location.closed, idempotency.key-reused, hold.released", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + }, + { + "$ref": "#/components/schemas/ProblemHoldExpired" + } + ] + } + } + }, + "description": "Problem response: idempotency.expired, hold.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemPolicyChanged" + }, + { + "$ref": "#/components/schemas/ProblemRevisionMismatch" + }, + { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + ] + } + } + }, + "description": "Problem response: policy.changed, revision.mismatch, precondition.failed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + }, + { + "$ref": "#/components/schemas/ProblemCapabilityUnmatched" + }, + { + "$ref": "#/components/schemas/ProblemHorizonOutside" + }, + { + "$ref": "#/components/schemas/ProblemPartyCapacityInadequate" + }, + { + "$ref": "#/components/schemas/ProblemPrerequisiteMissing" + }, + { + "$ref": "#/components/schemas/ProblemScheduleUnpublished" + } + ] + } + } + }, + "description": "Problem response: request.unprocessable, capability.unmatched, horizon.outside, party.capacity-inadequate, prerequisite.missing, schedule.unpublished", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Confirm a hold or book directly", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "task-grant" + } + }, + "/v1/appointments/{appointment_id}": { + "get": { + "description": "Answers only to the caller that owns the booking. Another caller's appointment, a hold, and an unknown identifier are all operation.not-authorized, so existence is never disclosed.", + "operationId": "getAppointment", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The appointment's identifier.", + "in": "path", + "name": "appointment_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read one owned appointment", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/appointments/{appointment_id}/cancel": { + "post": { + "description": "Releases the appointment's capacity and records the reason, under the task grant that names appointment.cancel. It is guarded by observedRevision and the offering's cancellation cutoff, never by the policy revision, so an appointment is always cancellable under the policy that currently governs its offering.", + "operationId": "cancelAppointment", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + }, + { + "description": "The appointment's identifier.", + "in": "path", + "name": "appointment_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelAppointmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemCancellationCutoffPassed" + }, + { + "$ref": "#/components/schemas/ProblemHoldReleased" + } + ] + } + } + }, + "description": "Problem response: idempotency.key-reused, cancellation.cutoff-passed, hold.released", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRevisionMismatch" + } + } + }, + "description": "Problem response: revision.mismatch", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + } + } + }, + "description": "Problem response: request.unprocessable", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Cancel an owned appointment", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "task-grant" + } + }, + "/v1/appointments/{appointment_id}/history": { + "get": { + "description": "Newest first, one bounded page at a time. Each step carries the revision it produced, the pseudonymized actor reference when it had one, and the detail the store recorded. Existence is never disclosed: another caller's appointment answers operation.not-authorized.", + "operationId": "listAppointmentHistory", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The appointment's identifier.", + "in": "path", + "name": "appointment_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentHistoryPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Read an owned appointment's history", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/appointments/{appointment_id}/reschedule": { + "post": { + "description": "Re-books the appointment under the task grant that names appointment.reschedule, with a fresh admission evaluation; the appointment it replaces is excluded from the conflict checks, so a reschedule never competes with itself. The policy guard wins over the revision guard: a caller whose observed revision is also stale learns the policy moved first.", + "operationId": "rescheduleAppointment", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + }, + { + "description": "The appointment's identifier.", + "in": "path", + "name": "appointment_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RescheduleAppointmentRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AppointmentDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemBookingDuplicateActive" + }, + { + "$ref": "#/components/schemas/ProblemCapacityExhausted" + }, + { + "$ref": "#/components/schemas/ProblemLocationClosed" + }, + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + }, + { + "$ref": "#/components/schemas/ProblemHoldReleased" + } + ] + } + } + }, + "description": "Problem response: booking.duplicate-active, capacity.exhausted, location.closed, idempotency.key-reused, hold.released", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemPolicyChanged" + }, + { + "$ref": "#/components/schemas/ProblemRevisionMismatch" + } + ] + } + } + }, + "description": "Problem response: policy.changed, revision.mismatch", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + }, + { + "$ref": "#/components/schemas/ProblemCapabilityUnmatched" + }, + { + "$ref": "#/components/schemas/ProblemHorizonOutside" + }, + { + "$ref": "#/components/schemas/ProblemPartyCapacityInadequate" + }, + { + "$ref": "#/components/schemas/ProblemPrerequisiteMissing" + }, + { + "$ref": "#/components/schemas/ProblemScheduleUnpublished" + } + ] + } + } + }, + "description": "Problem response: request.unprocessable, capability.unmatched, horizon.outside, party.capacity-inadequate, prerequisite.missing, schedule.unpublished", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Move an owned appointment", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "task-grant" + } + }, + "/v1/availability": { + "get": { + "description": "Bounded availability. Exact-time offerings answer in grid slots and arrival-window offerings answer in published windows; only entries with capacity left are listed, so a slot nobody can serve is not availability. A search spans at most 62 days from start, and a wider ask continues by cursor.", + "operationId": "searchAvailability", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Required offering identifier. An offering the deployed policy does not publish is precondition.failed.", + "in": "query", + "name": "offering", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Earliest answerable start, defaulting to now.", + "in": "query", + "name": "start", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Latest answerable start. An end earlier than one minute after start is raised to it, and one further ahead than the 62-day span allows is clipped to it.", + "in": "query", + "name": "end", + "required": false, + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this offering, its range, and the instant the last page ended at. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired. Deduplicate entries by their start.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AvailabilityPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Search published availability", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/availability/explain": { + "get": { + "description": "The separately authorized explanation of one start: the problem code every caller may see, the detailed code only this path discloses, and the refusal in words. A start that admits as things stand answers with no codes at all. The probe is a minimal party, so the answer explains the calendar and the capacity, never another caller's booking. resource.unavailable is disclosed here and never on the public path, whose answer is capacity.exhausted.", + "operationId": "explainAvailability", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Required offering identifier. An offering the deployed policy does not publish is precondition.failed.", + "in": "query", + "name": "offering", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "The start to explain.", + "in": "query", + "name": "start", + "required": true, + "schema": { + "format": "date-time", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExplainDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + } + }, + "description": "Problem response: precondition.failed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Explain one start's refusal", + "x-scheduling-authority": "explain-scope" + } + }, + "/v1/holds": { + "post": { + "description": "Reserves supply instead of committing it and answers the hold with its expiry. The caller must carry a complete task grant whose scheduling permission names the offering's service, its location, and hold.create; readable availability is not authority to book. The hold expires on its own, so its capacity returns without a release.", + "operationId": "createHold", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + "in": "header", + "name": "Idempotency-Key", + "required": true, + "schema": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[!-~]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdmissionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HoldDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemBookingDuplicateActive" + }, + { + "$ref": "#/components/schemas/ProblemCapacityExhausted" + }, + { + "$ref": "#/components/schemas/ProblemLocationClosed" + }, + { + "$ref": "#/components/schemas/ProblemIdempotencyKeyReused" + } + ] + } + } + }, + "description": "Problem response: booking.duplicate-active, capacity.exhausted, location.closed, idempotency.key-reused", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "412": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemPolicyChanged" + }, + { + "$ref": "#/components/schemas/ProblemRevisionMismatch" + }, + { + "$ref": "#/components/schemas/ProblemPreconditionFailed" + } + ] + } + } + }, + "description": "Problem response: policy.changed, revision.mismatch, precondition.failed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestUnsupportedMediaType" + } + } + }, + "description": "Problem response: request.unsupported-media-type", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "422": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestUnprocessable" + }, + { + "$ref": "#/components/schemas/ProblemCapabilityUnmatched" + }, + { + "$ref": "#/components/schemas/ProblemHorizonOutside" + }, + { + "$ref": "#/components/schemas/ProblemPartyCapacityInadequate" + }, + { + "$ref": "#/components/schemas/ProblemPrerequisiteMissing" + }, + { + "$ref": "#/components/schemas/ProblemScheduleUnpublished" + } + ] + } + } + }, + "description": "Problem response: request.unprocessable, capability.unmatched, horizon.outside, party.capacity-inadequate, prerequisite.missing, schedule.unpublished", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "428": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemPreconditionRequired" + } + } + }, + "description": "Problem response: precondition.required", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Reserve an admission", + "x-maximum-body-bytes": 1048576, + "x-scheduling-authority": "task-grant" + } + }, + "/v1/holds/{hold_id}": { + "delete": { + "description": "Gives a hold's capacity back before it expires, under the task grant that names hold.release. The hold's own identifier is the idempotency key, so a retried release answers as the first one did; a receipt retained past its window is idempotency.expired. A claim that is not an active hold is hold.released, whether unknown, already confirmed, or already released, and a grant naming another holder is operation.not-authorized.", + "operationId": "releaseHold", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "The hold's identifier.", + "in": "path", + "name": "hold_id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + }, + { + "$ref": "#/components/schemas/ProblemOperationNotAuthorized" + } + ] + } + } + }, + "description": "Problem response: profile.not-authorized, operation.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "409": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemHoldReleased" + } + } + }, + "description": "Problem response: hold.released", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemIdempotencyExpired" + } + } + }, + "description": "Problem response: idempotency.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Release a hold", + "x-scheduling-authority": "task-grant" + } + }, + "/v1/locations": { + "get": { + "description": "Each location carries the IANA timezone its published openings expand in.", + "operationId": "listLocations", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the locations", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/offerings": { + "get": { + "description": "The public view of the authored policy, sorted by identifier. Exact-time offerings carry durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, startIncrementMinutes, and maxRecipients; arrival-window offerings carry window. A field the offering's mode does not use is null, never zero.", + "operationId": "listOfferings", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OfferingPage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the published offering catalogue", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/resources": { + "get": { + "description": "A pool is its concrete members, never an independent counter. available carries no reason: why a member is unavailable is private staff information, and the public answer to a caller is capacity.exhausted.", + "operationId": "listResources", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourcePage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the backing resource pool members", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/scheduling": { + "get": { + "description": "Answers the deployment identifier, the current authored policy revision, and its digest, so a caller can detect that the policy it read has since moved. Reading it grants no catalogue, availability, or booking authority.", + "operationId": "describeScheduling", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchedulingServiceDocument" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Describe this Scheduling deployment", + "x-scheduling-authority": "reads-scope" + } + }, + "/v1/services": { + "get": { + "description": "Sorted by identifier. A page is a 15-minute view of a live catalogue; follow nextCursor until it is absent.", + "operationId": "listServices", + "parameters": [ + { + "description": "Optional W3C trace context continued in the response.", + "in": "header", + "name": "traceparent", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "maxLength": 256, + "type": "string" + } + }, + { + "description": "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 200, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServicePage" + } + } + }, + "description": "Success", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemRequestInvalid" + }, + { + "$ref": "#/components/schemas/ProblemCursorInvalid" + } + ] + } + } + }, + "description": "Problem response: request.invalid, cursor.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemAuthenticationRefused" + } + } + }, + "description": "Problem response: authentication.refused", + "headers": { + "WWW-Authenticate": { + "description": "Bearer authentication challenge.", + "schema": { + "const": "Bearer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemProfileNotAuthorized" + } + } + }, + "description": "Problem response: profile.not-authorized", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "405": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + } + } + }, + "description": "Problem response: request.method-not-allowed", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "410": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemCursorExpired" + } + } + }, + "description": "Problem response: cursor.expired", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "413": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestBodyTooLarge" + } + } + }, + "description": "Problem response: request.body-too-large", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "List the service catalogue", + "x-scheduling-authority": "reads-scope" + } + } + }, + "servers": [ + { + "description": "Operator-managed TLS endpoint in front of the private Scheduling runtime.", + "url": "https://scheduling.example.test" + } + ], + "x-registry-scheduling-edge-problems": [ + "request.body-too-large", + "request.invalid", + "request.method-not-allowed", + "request.not-found", + "request.unprocessable", + "request.unsupported-media-type" + ], + "x-registry-scheduling-reserved-problems": [ + "eligibility.unavailable", + "hook.unavailable", + "resource.unavailable" + ] +} diff --git a/products/scheduling/scripts/check-checkpoint.sh b/products/scheduling/scripts/check-checkpoint.sh index 4519e24fc9..b3dbfe7915 100755 --- a/products/scheduling/scripts/check-checkpoint.sh +++ b/products/scheduling/scripts/check-checkpoint.sh @@ -6,6 +6,7 @@ repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) schedulingctl_bin=${SCHEDULINGCTL_BIN:-"$repo_root/target/debug/schedulingctl"} cd "$repo_root" +python3 products/scheduling/scripts/generate_openapi.py --check python3 products/scheduling/scripts/check_dependency_direction.py python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py' diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py new file mode 100644 index 0000000000..1a86a87a85 --- /dev/null +++ b/products/scheduling/scripts/generate_openapi.py @@ -0,0 +1,1449 @@ +#!/usr/bin/env python3 +"""Generate the deterministic Registry Scheduling OpenAPI contract. + +The document is hand-shaped here and then verified against the Rust truth +beside it, so neither side can drift alone. The verification follows the +Casework generator's discipline: + +* the route inventory, and each operation's handler, authority profile, JSON + body, idempotency key, path and query parameters, and success status, are + read back out of `crates/registry-scheduling/src/http.rs`; +* the problem vocabulary, and each code's pinned type URI, title, detail, and + HTTP status, are read from the `problem-catalog` example's output, never + copied here; +* every wire document is verified field by field against the Rust struct it + projects, snake_case to camelCase. + +One contract is not Rust-owned yet: the per-operation problem mappings. The +`problem-catalog` example emits `entries` only, with no Casework-style +`operations` table, so the mappings are hand-shaped from the handler-by-handler +reading of the service and store flows and are verified in every way the +available truth allows (`verify_operation_problems`): every code must exist in +the exported catalog, its status in the document must be the code's pinned one, +each documented code must be produced somewhere in the runtime, and every code +an operation can answer with by rule must be present. Extending the exporter +with an operations contract is the follow-up that closes the remaining gap. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +HTTP_SOURCE = "crates/registry-scheduling/src/http.rs" +SERVICE_SOURCE = "crates/registry-scheduling/src/service.rs" +STORE_SOURCE = "crates/registry-scheduling/src/store.rs" +CURSORS_SOURCE = "crates/registry-scheduling/src/cursors.rs" +AUTH_SOURCE = "crates/registry-scheduling/src/auth.rs" +CONFIG_SOURCE = "crates/registry-scheduling/src/config.rs" +NAMING_SOURCE = "crates/registry-scheduling-core/src/naming.rs" +WIRE_SOURCE = "crates/registry-scheduling-core/src/wire.rs" +MODEL_SOURCE = "crates/registry-scheduling-core/src/model.rs" +ADMISSION_SOURCE = "crates/registry-scheduling-core/src/admission.rs" +PROBLEM_SOURCE = "crates/registry-scheduling-core/src/problem.rs" +HTTPSEC_SOURCE = "crates/registry-platform-httpsec/src/server.rs" + +# The committed document `--write` produces and `--check` verifies. +OUTPUT = "products/scheduling/generated/registry-scheduling.openapi.json" + +# The routes the router answers, as literal paths. `verify_source` re-derives +# this set from the `axum` router, resolving the constants the router names +# through `crates/registry-scheduling-core/src/naming.rs`. +ROUTES = { + "/healthz", + "/readyz", + "/v1/scheduling", + "/v1/services", + "/v1/offerings", + "/v1/resources", + "/v1/locations", + "/v1/availability", + "/v1/availability/explain", + "/v1/holds", + "/v1/holds/{hold_id}", + "/v1/appointments", + "/v1/appointments/{appointment_id}", + "/v1/appointments/{appointment_id}/reschedule", + "/v1/appointments/{appointment_id}/cancel", + "/v1/appointments/{appointment_id}/history", +} + +# The header name a mutating command carries, as `naming.rs` pins it. +HEADERS = { + "IDEMPOTENCY_KEY_HEADER": "idempotency-key", +} + +# The `request.*` family: the request-edge rejections the edge itself answers, +# on any route, under this product's own prefix. No documented operation +# answers `request.not-found`; it is the fallback for a route no operation +# claims. +EDGE_PROBLEMS = [ + "request.body-too-large", + "request.invalid", + "request.method-not-allowed", + "request.not-found", + "request.unprocessable", + "request.unsupported-media-type", +] + +# Vocabulary entries no documented operation answers. `eligibility.unavailable` +# and `hook.unavailable` are produced nowhere in this milestone (there is no +# hook engine yet); `resource.unavailable` is the one detailed-only code, and +# it travels only inside the explain document, never as a public problem. +RESERVED_PROBLEMS = [ + "eligibility.unavailable", + "hook.unavailable", + "resource.unavailable", +] + +# The wire documents, verified field by field against the Rust structs. +SCHEMA_STRUCTS = { + WIRE_SOURCE: { + "SchedulingServiceDocument": "SchedulingServiceDocument", + "ServiceDocument": "ServiceDocument", + "OfferingDocument": "OfferingDocument", + "ReminderDocument": "ReminderDocument", + "WindowDocument": "WindowDocument", + "ResourceDocument": "ResourceDocument", + "LocationDocument": "LocationDocument", + "HoldDocument": "HoldDocument", + "AppointmentDocument": "AppointmentDocument", + "CreateAppointmentRequest": "CreateAppointmentRequest", + "RescheduleAppointmentRequest": "RescheduleAppointmentRequest", + "CancelAppointmentRequest": "CancelAppointmentRequest", + "AppointmentHistoryEntryDocument": "AppointmentHistoryEntryDocument", + "ExplainDocument": "ExplainDocument", + }, + MODEL_SOURCE: { + "AdmissionRequest": "AdmissionRequest", + "PartyCounts": "PartyCounts", + }, +} + +# The axum handler behind each operation. `verify_handler_wiring` reads the +# handler back out of http.rs and checks the document against what it does. +ROUTE_HANDLERS = { + ("GET", "/healthz"): "healthz", + ("GET", "/readyz"): "readyz", + ("GET", "/v1/scheduling"): "scheduling", + ("GET", "/v1/services"): "list_services", + ("GET", "/v1/offerings"): "list_offerings", + ("GET", "/v1/resources"): "list_resources", + ("GET", "/v1/locations"): "list_locations", + ("GET", "/v1/availability"): "availability", + ("GET", "/v1/availability/explain"): "explain", + ("POST", "/v1/holds"): "create_hold", + ("DELETE", "/v1/holds/{hold_id}"): "release_hold", + ("POST", "/v1/appointments"): "create_appointment", + ("GET", "/v1/appointments/{appointment_id}"): "get_appointment", + ("POST", "/v1/appointments/{appointment_id}/reschedule"): "reschedule_appointment", + ("POST", "/v1/appointments/{appointment_id}/cancel"): "cancel_appointment", + ("GET", "/v1/appointments/{appointment_id}/history"): "appointment_history", +} + +OPERATION_IDS = { + ("GET", "/healthz"): "getLiveness", + ("GET", "/readyz"): "getReadiness", + ("GET", "/v1/scheduling"): "describeScheduling", + ("GET", "/v1/services"): "listServices", + ("GET", "/v1/offerings"): "listOfferings", + ("GET", "/v1/resources"): "listResources", + ("GET", "/v1/locations"): "listLocations", + ("GET", "/v1/availability"): "searchAvailability", + ("GET", "/v1/availability/explain"): "explainAvailability", + ("POST", "/v1/holds"): "createHold", + ("DELETE", "/v1/holds/{hold_id}"): "releaseHold", + ("POST", "/v1/appointments"): "createAppointment", + ("GET", "/v1/appointments/{appointment_id}"): "getAppointment", + ("POST", "/v1/appointments/{appointment_id}/reschedule"): "rescheduleAppointment", + ("POST", "/v1/appointments/{appointment_id}/cancel"): "cancelAppointment", + ("GET", "/v1/appointments/{appointment_id}/history"): "listAppointmentHistory", +} + +# The per-operation problem mappings, hand-shaped from the handler-by-handler +# reading of the flows the handlers delegate to. Status is a property of the +# code, so each list below is grouped onto its pinned statuses by the catalog, +# never by hand. +# +# Every operation: the edge answers 405 for a foreign method and 413 for a body +# over the router's limit. Every authenticated operation: a missing, invalid, +# or expired bearer is authentication.refused, and a credential that does not +# carry the route's authority is profile.not-authorized. Every operation whose +# service method opens a store round trip can answer service.unavailable; the +# one that cannot (`SchedulingService::scheduling`) documents no problems. +EDGE = ["request.body-too-large", "request.method-not-allowed"] +AUTHENTICATION = ["authentication.refused", "profile.not-authorized"] +JSON_BODY = [ + "request.invalid", + "request.unprocessable", + "request.unsupported-media-type", +] +QUERY = ["request.invalid"] +CURSOR = ["cursor.expired", "cursor.invalid"] +STORAGE = ["service.unavailable"] +AUTHORITY = ["operation.not-authorized"] +IDEMPOTENCY = ["idempotency.expired", "idempotency.key-reused"] +# The refusal vocabulary the admission evaluator answers a booking ask with. +ADMISSION = [ + "booking.duplicate-active", + "capacity.exhausted", + "capability.unmatched", + "horizon.outside", + "location.closed", + "party.capacity-inadequate", + "policy.changed", + "precondition.required", + "prerequisite.missing", + "revision.mismatch", + "schedule.unpublished", +] + +OPERATION_PROBLEMS = { + ("GET", "/healthz"): EDGE, + ("GET", "/readyz"): EDGE + STORAGE, + ("GET", "/v1/scheduling"): EDGE + AUTHENTICATION, + ("GET", "/v1/services"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, + ("GET", "/v1/offerings"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, + ("GET", "/v1/resources"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, + ("GET", "/v1/locations"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, + ("GET", "/v1/availability"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE + ["precondition.failed"], + ("GET", "/v1/availability/explain"): EDGE + AUTHENTICATION + QUERY + STORAGE + ["precondition.failed"], + ("POST", "/v1/holds"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY + + ["precondition.failed", "service.unavailable"], + # A release carries no caller-chosen key, so `idempotency.key-reused` is + # not reachable: the key and the request hash are both the hold's own id. + ("DELETE", "/v1/holds/{hold_id}"): EDGE + AUTHENTICATION + AUTHORITY + + ["hold.released", "idempotency.expired", "service.unavailable"], + ("POST", "/v1/appointments"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY + + ["hold.expired", "hold.released", "precondition.failed", "service.unavailable"], + ("GET", "/v1/appointments/{appointment_id}"): EDGE + AUTHENTICATION + AUTHORITY + STORAGE, + # A reschedule resolves its offering from the appointment, so an unknown + # offering is operator state, not precondition.failed. + ("POST", "/v1/appointments/{appointment_id}/reschedule"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + + IDEMPOTENCY + AUTHORITY + ["hold.released", "service.unavailable"], + # Cancellation is guarded by the observed revision and the cutoff, never by + # the policy revision, so it answers no admission refusal at all. + ("POST", "/v1/appointments/{appointment_id}/cancel"): EDGE + AUTHENTICATION + JSON_BODY + IDEMPOTENCY + + AUTHORITY + ["cancellation.cutoff-passed", "hold.released", "revision.mismatch", "service.unavailable"], + ("GET", "/v1/appointments/{appointment_id}/history"): EDGE + AUTHENTICATION + AUTHORITY + QUERY + CURSOR + + STORAGE, +} + + +def ref(name: str) -> dict: + return {"$ref": f"#/components/schemas/{name}"} + + +def header_ref(name: str) -> dict: + return {"$ref": f"#/components/headers/{name}"} + + +def obj(properties: dict, required: list[str] | None = None) -> dict: + result = {"type": "object", "additionalProperties": False, "properties": properties} + if required: + result["required"] = required + return result + + +def array(items: dict) -> dict: + return {"type": "array", "items": items} + + +def nullable(schema: dict) -> dict: + return {"anyOf": [schema, {"type": "null"}]} + + +IDEMPOTENCY_SCHEMA = { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[!-~]+$", +} +CURSOR_SCHEMA = {"type": "string", "maxLength": 256} +LIMIT_SCHEMA = {"type": "integer", "minimum": 1, "maximum": 200, "default": 50} +INSTANT_SCHEMA = {"type": "string", "format": "date-time"} +UUID_SCHEMA = {"type": "string", "format": "uuid"} +# The router's request body limit, as `registry-platform-httpsec` pins it. +MAXIMUM_BODY_BYTES = 1_048_576 + + +def parameter(name: str, where: str, description: str, schema: dict | None = None, required: bool = True) -> dict: + return {"name": name, "in": where, "required": required, "description": description, "schema": schema or {"type": "string"}} + + +TRACEPARENT = parameter( + "traceparent", + "header", + "Optional W3C trace context continued in the response.", + required=False, +) +IDEMPOTENCY = parameter( + "Idempotency-Key", + "header", + "Caller-selected ASCII graphic key bound to this exact request. An exact retry replays the first answer, a changed request is idempotency.key-reused, and a receipt retained past its window is idempotency.expired.", + IDEMPOTENCY_SCHEMA, +) +HOLD_ID = parameter("hold_id", "path", "The hold's identifier.", UUID_SCHEMA) +APPOINTMENT_ID = parameter("appointment_id", "path", "The appointment's identifier.", UUID_SCHEMA) +CURSOR_QUERY = parameter( + "cursor", + "query", + "Opaque 15-minute cursor bound to this listing. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired, and the listing restarts from its first page. Deduplicate entries by id.", + CURSOR_SCHEMA, + required=False, +) +LIMIT_QUERY = parameter( + "limit", + "query", + "Page size from 1 through 200; the default is 50 and larger values are served as 200.", + LIMIT_SCHEMA, + required=False, +) +OFFERING_QUERY = parameter( + "offering", + "query", + "Required offering identifier. An offering the deployed policy does not publish is precondition.failed.", + {"type": "string", "minLength": 1}, +) + + +def response(schema_name: str | None = None, description: str = "Success") -> dict: + result = { + "description": description, + "headers": { + "cache-control": header_ref("CacheControlHeader"), + "traceparent": header_ref("TraceparentHeader"), + }, + } + if schema_name: + result["content"] = {"application/json": {"schema": ref(schema_name)}} + return result + + +def problem_component_name(code: str) -> str: + return "Problem" + "".join(part.title() for part in re.split(r"[.-]", code)) + + +def problem_variant_schema(entry: dict) -> dict: + status = entry["httpStatuses"][0] + return { + "allOf": [ + ref("Problem"), + { + "type": "object", + "properties": { + "type": {"const": entry["uri"]}, + "title": {"const": entry["title"]}, + "status": {"const": status}, + "detail": {"const": entry["description"]}, + "code": {"const": entry["code"]}, + }, + }, + ] + } + + +def problem_variant(entry: dict) -> dict: + return ref(problem_component_name(entry["code"])) + + +def problem_responses(codes: list[str], catalog: dict[str, dict]) -> dict: + by_status: dict[int, list[dict]] = {} + for code in codes: + entry = catalog[code] + by_status.setdefault(entry["httpStatuses"][0], []).append(entry) + result = {} + for status, entries in sorted(by_status.items()): + variants = [problem_variant(entry) for entry in entries] + schema = variants[0] if len(variants) == 1 else {"oneOf": variants} + value = { + "description": "Problem response: " + + ", ".join(entry["code"] for entry in entries), + "headers": { + "cache-control": header_ref("CacheControlHeader"), + "traceparent": header_ref("TraceparentHeader"), + }, + "content": {"application/problem+json": {"schema": schema}}, + } + if status == 401: + value["headers"]["WWW-Authenticate"] = { + "description": "Bearer authentication challenge.", + "schema": {"const": "Bearer"}, + } + if status == 503: + value["headers"]["Retry-After"] = { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": {"type": "integer", "minimum": 0}, + } + result[str(status)] = value + return result + + +def operation( + summary: str, + schema_name: str | None = None, + *, + authority: str = "reads-scope", + body: str | None = None, + parameters: list[dict] | None = None, + status: str = "200", + description: str | None = None, + idempotency: bool = False, +) -> dict: + """One authenticated operation, in the shape the edge actually answers.""" + params = [TRACEPARENT] + if idempotency: + params.append(IDEMPOTENCY) + params.extend(parameters or []) + result = { + "summary": summary, + "security": [{"bearerAuth": []}], + "x-scheduling-authority": authority, + "parameters": params, + "responses": {status: response(schema_name)}, + } + if description: + result["description"] = description + if body: + result["requestBody"] = { + "required": True, + "content": {"application/json": {"schema": ref(body)}}, + } + result["x-maximum-body-bytes"] = MAXIMUM_BODY_BYTES + return result + + +def unauthenticated(summary: str, description: str, schema_name: str | None = None) -> dict: + return { + "summary": summary, + "description": description, + "security": [], + "x-scheduling-authority": "unauthenticated", + "parameters": [TRACEPARENT], + "responses": {"200": response(schema_name)}, + } + + +def page(item_schema_name: str) -> dict: + """`PageDocument` with its item type named; the wire type is generic.""" + return obj( + {"items": array(ref(item_schema_name)), "nextCursor": nullable({"type": "string"})}, + ["items", "nextCursor"], + ) + + +def schemas(problem_entries: list[dict]) -> dict: + text = {"type": "string"} + # u32 quantities and u64 revision counters, as the wire types pin them. + count = {"type": "integer", "minimum": 0} + revision = {"type": "integer", "format": "int64", "minimum": 0} + instant = INSTANT_SCHEMA + mode = {"type": "string", "enum": ["exact-time", "arrival-window"]} + appointment_state = {"type": "string", "enum": ["confirmed", "cancelled"]} + party = obj({"recipients": count, "attendees": count}, ["recipients", "attendees"]) + admission = obj( + { + "offering": text, + "start": instant, + "party": ref("PartyCounts"), + "channel": nullable(text), + "duplicateKey": nullable(text), + "policyRevision": revision, + "windowRevision": nullable(revision), + "capabilities": array(text), + "prerequisites": array(text), + "rescheduleOf": nullable(text), + }, + ["offering", "start", "party", "policyRevision", "capabilities", "prerequisites"], + ) + result = { + "SchedulingServiceDocument": obj( + {"schedulingId": text, "policyRevision": revision, "policyDigest": text}, + ["schedulingId", "policyRevision", "policyDigest"], + ), + "ServiceDocument": obj({"id": text, "label": text}, ["id", "label"]), + "ServicePage": page("ServiceDocument"), + "ReminderDocument": obj({"minutesBefore": count}, ["minutesBefore"]), + "WindowDocument": obj( + {"id": text, "revision": revision, "start": instant, "end": instant, "units": count}, + ["id", "revision", "start", "end", "units"], + ), + "OfferingDocument": obj( + { + "id": text, + "service": text, + "label": text, + "mode": mode, + "location": text, + "leadTimeMinutes": count, + "horizonDays": count, + "cancellationCutoffMinutes": count, + "durationMinutes": nullable(count), + "bufferBeforeMinutes": nullable(count), + "bufferAfterMinutes": nullable(count), + "startIncrementMinutes": nullable(count), + "maxRecipients": nullable(count), + "window": nullable(ref("WindowDocument")), + "reminders": array(ref("ReminderDocument")), + "requiresCapabilities": array(text), + "prerequisites": array(text), + }, + [ + "id", + "service", + "label", + "mode", + "location", + "leadTimeMinutes", + "horizonDays", + "cancellationCutoffMinutes", + "reminders", + "requiresCapabilities", + "prerequisites", + ], + ), + "OfferingPage": page("OfferingDocument"), + "ResourceDocument": obj( + { + "resourceId": text, + "pool": text, + "capabilities": array(text), + "available": {"type": "boolean"}, + }, + ["resourceId", "pool", "capabilities", "available"], + ), + "ResourcePage": page("ResourceDocument"), + "LocationDocument": obj({"locationId": text, "timezone": text}, ["locationId", "timezone"]), + "LocationPage": page("LocationDocument"), + "AvailabilitySlot": obj( + {"kind": {"const": "slot"}, "start": instant, "end": instant, "free": count}, + ["kind", "start", "end", "free"], + ), + "AvailabilityWindow": obj( + { + "kind": {"const": "window"}, + "window": text, + "start": instant, + "end": instant, + "remaining": count, + "channelRemaining": nullable(count), + }, + ["kind", "window", "start", "end", "remaining"], + ), + "AvailabilityEntry": { + "oneOf": [ref("AvailabilitySlot"), ref("AvailabilityWindow")], + "discriminator": {"propertyName": "kind"}, + }, + "AvailabilityPage": page("AvailabilityEntry"), + "HoldDocument": obj( + { + "holdId": text, + "offering": text, + "start": instant, + "end": instant, + "resource": nullable(text), + "units": count, + "expiresAt": instant, + "policyRevision": revision, + }, + ["holdId", "offering", "start", "end", "units", "expiresAt", "policyRevision"], + ), + "PartyCounts": party, + "AdmissionRequest": admission, + "CreateAppointmentRequest": { + "type": "object", + "additionalProperties": False, + "minProperties": 1, + "properties": {"hold": nullable(text), "admission": nullable(ref("AdmissionRequest"))}, + "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is precondition.failed.", + }, + "RescheduleAppointmentRequest": obj( + {"observedRevision": revision, "admission": ref("AdmissionRequest")}, + ["observedRevision", "admission"], + ), + "CancelAppointmentRequest": obj( + {"observedRevision": revision, "reason": nullable(text)}, + ["observedRevision"], + ), + "AppointmentState": appointment_state, + "AppointmentDocument": obj( + { + "appointmentId": text, + "offering": text, + "start": instant, + "end": instant, + "resource": nullable(text), + "units": count, + "channel": nullable(text), + "revision": revision, + "state": ref("AppointmentState"), + "policyRevision": revision, + "createdAt": instant, + "cancelledAt": nullable(instant), + }, + [ + "appointmentId", + "offering", + "start", + "end", + "units", + "revision", + "state", + "policyRevision", + "createdAt", + ], + ), + "AppointmentHistoryEntryDocument": obj( + { + "eventId": text, + "kind": { + "type": "string", + "description": "The lifecycle step the store recorded: held, confirmed, consumed, released, rescheduled, or cancelled.", + }, + "revision": revision, + "occurredAt": instant, + "actor": nullable(text), + "detail": {}, + }, + ["eventId", "kind", "revision", "occurredAt", "detail"], + ), + "AppointmentHistoryPage": page("AppointmentHistoryEntryDocument"), + "ExplainDocument": obj( + { + "offering": text, + "start": instant, + "publicCode": text, + "detailedCode": text, + "explanation": text, + }, + ["offering", "start"], + ), + "Problem": obj( + { + "type": {"type": "string", "format": "uri"}, + "title": text, + "status": {"type": "integer"}, + "detail": text, + "code": {"type": "string", "enum": [entry["code"] for entry in problem_entries]}, + "traceId": text, + }, + ["type", "title", "status", "detail", "code", "traceId"], + ), + } + result.update( + { + problem_component_name(entry["code"]): problem_variant_schema(entry) + for entry in problem_entries + } + ) + return result + + +def document(contract: dict) -> dict: + entries = contract["entries"] + catalog = catalog_of(contract) + paths = { + "/healthz": {"get": unauthenticated( + "Liveness", + "Process is live. No authentication, no storage, no policy.", + )}, + "/readyz": {"get": unauthenticated( + "Readiness", + "Asks the Scheduling store to answer. A store that cannot answer is service.unavailable, with Retry-After.", + )}, + "/v1/scheduling": {"get": operation( + "Describe this Scheduling deployment", + "SchedulingServiceDocument", + description="Answers the deployment identifier, the current authored policy revision, and its digest, so a caller can detect that the policy it read has since moved. Reading it grants no catalogue, availability, or booking authority.", + )}, + "/v1/services": {"get": operation( + "List the service catalogue", + "ServicePage", + parameters=[CURSOR_QUERY, LIMIT_QUERY], + description="Sorted by identifier. A page is a 15-minute view of a live catalogue; follow nextCursor until it is absent.", + )}, + "/v1/offerings": {"get": operation( + "List the published offering catalogue", + "OfferingPage", + parameters=[CURSOR_QUERY, LIMIT_QUERY], + description="The public view of the authored policy, sorted by identifier. Exact-time offerings carry durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, startIncrementMinutes, and maxRecipients; arrival-window offerings carry window. A field the offering's mode does not use is null, never zero.", + )}, + "/v1/resources": {"get": operation( + "List the backing resource pool members", + "ResourcePage", + parameters=[CURSOR_QUERY, LIMIT_QUERY], + description="A pool is its concrete members, never an independent counter. available carries no reason: why a member is unavailable is private staff information, and the public answer to a caller is capacity.exhausted.", + )}, + "/v1/locations": {"get": operation( + "List the locations", + "LocationPage", + parameters=[CURSOR_QUERY, LIMIT_QUERY], + description="Each location carries the IANA timezone its published openings expand in.", + )}, + "/v1/availability": {"get": operation( + "Search published availability", + "AvailabilityPage", + parameters=[ + OFFERING_QUERY, + parameter( + "start", + "query", + "Earliest answerable start, defaulting to now.", + INSTANT_SCHEMA, + required=False, + ), + parameter( + "end", + "query", + "Latest answerable start. An end earlier than one minute after start is raised to it, and one further ahead than the 62-day span allows is clipped to it.", + INSTANT_SCHEMA, + required=False, + ), + parameter( + "cursor", + "query", + "Opaque 15-minute cursor bound to this offering, its range, and the instant the last page ended at. A malformed, unknown, or foreign cursor is cursor.invalid; an expired one is cursor.expired. Deduplicate entries by their start.", + CURSOR_SCHEMA, + required=False, + ), + LIMIT_QUERY, + ], + description="Bounded availability. Exact-time offerings answer in grid slots and arrival-window offerings answer in published windows; only entries with capacity left are listed, so a slot nobody can serve is not availability. A search spans at most 62 days from start, and a wider ask continues by cursor.", + )}, + "/v1/availability/explain": {"get": operation( + "Explain one start's refusal", + "ExplainDocument", + authority="explain-scope", + parameters=[ + OFFERING_QUERY, + parameter("start", "query", "The start to explain.", INSTANT_SCHEMA), + ], + description="The separately authorized explanation of one start: the problem code every caller may see, the detailed code only this path discloses, and the refusal in words. A start that admits as things stand answers with no codes at all. The probe is a minimal party, so the answer explains the calendar and the capacity, never another caller's booking. resource.unavailable is disclosed here and never on the public path, whose answer is capacity.exhausted.", + )}, + "/v1/holds": {"post": operation( + "Reserve an admission", + "HoldDocument", + authority="task-grant", + status="201", + body="AdmissionRequest", + idempotency=True, + description="Reserves supply instead of committing it and answers the hold with its expiry. The caller must carry a complete task grant whose scheduling permission names the offering's service, its location, and hold.create; readable availability is not authority to book. The hold expires on its own, so its capacity returns without a release.", + )}, + "/v1/holds/{hold_id}": {"delete": operation( + "Release a hold", + authority="task-grant", + status="204", + parameters=[HOLD_ID], + description="Gives a hold's capacity back before it expires, under the task grant that names hold.release. The hold's own identifier is the idempotency key, so a retried release answers as the first one did; a receipt retained past its window is idempotency.expired. A claim that is not an active hold is hold.released, whether unknown, already confirmed, or already released, and a grant naming another holder is operation.not-authorized.", + )}, + "/v1/appointments": {"post": operation( + "Confirm a hold or book directly", + "AppointmentDocument", + authority="task-grant", + status="201", + body="CreateAppointmentRequest", + idempotency=True, + description="Carries exactly one of hold or admission. Confirming transfers the hold's own reservation, so capacity is re-checked only for identity: the hold must still be active and unexpired, its policy revision still current, and its holder still the caller. A direct create is evaluated against the live ledger like a hold, and commits instead of reserving. Both branches demand a complete task grant naming the offering's service, location, and appointment.create.", + )}, + "/v1/appointments/{appointment_id}": {"get": operation( + "Read one owned appointment", + "AppointmentDocument", + parameters=[APPOINTMENT_ID], + description="Answers only to the caller that owns the booking. Another caller's appointment, a hold, and an unknown identifier are all operation.not-authorized, so existence is never disclosed.", + )}, + "/v1/appointments/{appointment_id}/reschedule": {"post": operation( + "Move an owned appointment", + "AppointmentDocument", + authority="task-grant", + body="RescheduleAppointmentRequest", + idempotency=True, + parameters=[APPOINTMENT_ID], + description="Re-books the appointment under the task grant that names appointment.reschedule, with a fresh admission evaluation; the appointment it replaces is excluded from the conflict checks, so a reschedule never competes with itself. The policy guard wins over the revision guard: a caller whose observed revision is also stale learns the policy moved first.", + )}, + "/v1/appointments/{appointment_id}/cancel": {"post": operation( + "Cancel an owned appointment", + "AppointmentDocument", + authority="task-grant", + body="CancelAppointmentRequest", + idempotency=True, + parameters=[APPOINTMENT_ID], + description="Releases the appointment's capacity and records the reason, under the task grant that names appointment.cancel. It is guarded by observedRevision and the offering's cancellation cutoff, never by the policy revision, so an appointment is always cancellable under the policy that currently governs its offering.", + )}, + "/v1/appointments/{appointment_id}/history": {"get": operation( + "Read an owned appointment's history", + "AppointmentHistoryPage", + parameters=[APPOINTMENT_ID, CURSOR_QUERY, LIMIT_QUERY], + description="Newest first, one bounded page at a time. Each step carries the revision it produced, the pseudonymized actor reference when it had one, and the detail the store recorded. Existence is never disclosed: another caller's appointment answers operation.not-authorized.", + )}, + } + for key, codes in OPERATION_PROBLEMS.items(): + method, path = key + paths[path][method.lower()]["responses"].update(problem_responses(codes, catalog)) + paths[path][method.lower()]["operationId"] = OPERATION_IDS[key] + result = { + "openapi": "3.1.0", + "info": { + "title": "Registry Scheduling API", + "version": "v1alpha1", + "description": "Implemented Scheduling HTTP contract: published openings, exact-time offerings over interchangeable resource pools, published arrival windows with channel subquotas, holds, and accountable bookings. Mutating authority is a complete task grant; every refusal is one problem from the closed vocabulary.", + "license": {"name": "Apache-2.0", "identifier": "Apache-2.0"}, + }, + "servers": [{ + "url": "https://scheduling.example.test", + "description": "Operator-managed TLS endpoint in front of the private Scheduling runtime.", + }], + "paths": paths, + "components": { + "headers": { + "CacheControlHeader": { + "description": "Every answer is marked no-store, so a booking view is never served from a cache.", + "schema": {"const": "no-store"}, + }, + "TraceparentHeader": { + "description": "W3C trace context for the request and response.", + "schema": {"type": "string"}, + }, + }, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "A fresh access token from the configured issuer, for this deployment's audience, signed by a key the issuer published. Catalogue, availability, and owned-record reads demand the configured reads scope (scheduling-read by default); the explain path demands the configured explain scope (scheduling-explain by default), which is never the reads scope. A mutating route demands no scope at all: its authority is the complete task grant the token carries, whose scheduling permissions the service checks against the offering's service and location and the action the route performs (hold.create, hold.release, appointment.create, appointment.reschedule, appointment.cancel). A token with partial grant claims is refused rather than half-trusted.", + }, + }, + "schemas": schemas(entries), + }, + "x-registry-scheduling-edge-problems": EDGE_PROBLEMS, + "x-registry-scheduling-reserved-problems": RESERVED_PROBLEMS, + } + return result + + +# --- Rust truth ------------------------------------------------------------- +# +# Every verification below reads the Rust sources, or the problem catalog the +# `problem-catalog` example emits, and compares it with the hand-shaped +# document. Nothing here trusts the document's own words. + + +def rust_function(source: str, name: str) -> tuple[str, str]: + """One Rust function or method, split into its signature and its body.""" + match = re.search( + rf"(?:pub )?(?:async )?fn {re.escape(name)}\((?P[^)]*)\)(?P.*?)(?=\n(?:pub )?(?:async )?fn |\n#\[|\Z)", + source, + re.S, + ) + if not match: + raise ValueError(f"Rust function is missing: {name}") + text = match.group(0) + signature, body = text.split("{", 1) + return signature, body + + +def route_inventory(http_source: str, naming_source: str) -> dict[tuple[str, str], str]: + """The (method, path) -> handler table, read back out of the axum router.""" + signature, body = rust_function(http_source, "router") + constants: dict[str, str] = {} + for source in (naming_source, http_source): + constants.update( + { + name: value + for name, value in re.findall( + r'(?:pub )?const ([A-Z_][A-Z0-9_]*): &str = "([^"]*)";', source + ) + } + ) + inventory: dict[tuple[str, str], str] = {} + for expression, method, handler in re.findall( + r'\.route\(\s*("[^"]+"|[A-Z_][A-Z0-9_]*),\s*(get|post|put|delete)\(([a-z_][a-z0-9_]*)\)\)', + body, + ): + path = expression[1:-1] if expression.startswith('"') else constants[expression] + inventory[(method.upper(), path)] = handler + return inventory + + +def query_struct(http_source: str, name: str) -> dict[str, tuple[bool, str]]: + """One axum `Query` struct's fields as (required, type family) triples.""" + match = re.search(rf"struct {re.escape(name)}\s*\{{(?P.*?)^\}}", http_source, re.S | re.M) + if not match: + raise ValueError(f"Rust query struct is missing: {name}") + fields: dict[str, tuple[bool, str]] = {} + for field_name, field_type in re.findall( + r"(?:#\[serde\([^\)]*\)\]\s*)?\n\s*([a-z_]+):\s*([^,\n]+),", match.group("body") + ): + optional = re.match(r"^Option<(.+)>$", field_type) + base = optional.group(1) if optional else field_type + family = "date-time" if base.startswith("DateTime<") else ( + "integer" if base in ("usize", "u32", "u64") else "string" + ) + fields[camel_case(field_name)] = (not bool(optional), family) + return fields + + +def handler_wiring( + http_source: str, + service_source: str, + inventory: dict[tuple[str, str], str], + variants: dict[str, str], +) -> dict[tuple[str, str], dict]: + """What each handler does, as the router and its own body state it.""" + wiring = {} + for key, handler in inventory.items(): + signature, body = rust_function(http_source, handler) + # The extractors sit between the signature's first paren and its last, + # so a multi-line parameter list is read whole. + parameters = signature[signature.index("(") + 1 : signature.rindex(")")] + json_body = re.search(r"Json\(\w+\):\s*Json<([A-Za-z0-9_]+)>", parameters) + query = re.search(r"Query\(\w+\):\s*Query<([A-Za-z0-9_]+)>", parameters) + path_parameters = re.findall(r"Path\((\w+)\):\s*Path<([A-Za-z0-9_:]+)>", parameters) + authority = "unauthenticated" + for function, value in ( + ("authenticate_read", "reads-scope"), + ("authenticate_explain", "explain-scope"), + ("authenticate_mutate", "task-grant"), + ): + if f"{function}(&state" in body: + authority = value + service = re.search(r"state\.service\.([a-z_]+)\(", body) + method_name = service.group(1) if service else None + # A handler with no service call behind it (the infrastructure routes) + # answers nothing the service could refuse. + infallible = ( + "-> Result<" not in rust_function(service_source, method_name)[0] + if method_name + else None + ) + wiring[key] = { + "handler": handler, + "authority": authority, + "body": json_body.group(1) if json_body else None, + "query": query.group(1) if query else None, + "path_parameters": path_parameters, + "idempotency_key": "idempotency_key(&headers)?" in body, + "service_method": method_name, + "service_infallible": infallible, + "success_status": ( + 204 + if "StatusCode::NO_CONTENT" in body + else 201 + if "StatusCode::CREATED" in body + else 200 + ), + "problem_codes": [problem_code(variants, name, key) for name in re.findall(r"ProblemCode::([A-Z][A-Za-z0-9]*)", body)], + } + return wiring + + +def problem_code(variants: dict[str, str], name: str, key: tuple[str, str]) -> str: + """One handler-named variant, or a visible failure if it is not a code.""" + if name not in variants: + raise ValueError(f"{key} names {name}, which is no problem in the vocabulary") + return variants[name] + + +def verify_handler_wiring( + repository_root: Path, paths: dict, variants: dict[str, str] +) -> None: + """Check every operation against what its handler actually does.""" + http_source = production_source(repository_root, HTTP_SOURCE) + service_source = production_source(repository_root, SERVICE_SOURCE) + naming_source = production_source(repository_root, NAMING_SOURCE) + inventory = route_inventory(http_source, naming_source) + if set(inventory) != set(ROUTE_HANDLERS): + raise ValueError( + "OpenAPI route inventory drifted from the router; " + f"undocumented={sorted(set(inventory) - set(ROUTE_HANDLERS))}, " + f"unserved={sorted(set(ROUTE_HANDLERS) - set(inventory))}" + ) + for (method, path), handler in inventory.items(): + if ROUTE_HANDLERS[(method, path)] != handler: + raise ValueError(f"OpenAPI handler drifted for {(method, path)}: {handler}") + if set(ROUTE_HANDLERS) != set(OPERATION_IDS): + raise ValueError("every operation must carry an operationId") + wiring = handler_wiring(http_source, service_source, inventory, variants) + for key, derived in wiring.items(): + method, path = key + operation = paths[path][method.lower()] + if operation.get("operationId") != OPERATION_IDS[key]: + raise ValueError(f"OpenAPI operationId drifted for {key}") + expected_security = [] if derived["authority"] == "unauthenticated" else [{"bearerAuth": []}] + if operation["security"] != expected_security: + raise ValueError(f"OpenAPI security drifted from the handler for {key}") + if operation["x-scheduling-authority"] != derived["authority"]: + raise ValueError(f"OpenAPI authority drifted from the handler for {key}") + has_body = "requestBody" in operation + if has_body != bool(derived["body"]): + raise ValueError(f"OpenAPI request body drifted from the handler for {key}") + if has_body: + if derived["body"] not in operation["requestBody"]["content"]["application/json"]["schema"]["$ref"]: + raise ValueError(f"OpenAPI request body schema drifted from the handler for {key}") + if operation["x-maximum-body-bytes"] != MAXIMUM_BODY_BYTES: + raise ValueError(f"OpenAPI request body bound drifted from the router for {key}") + elif "x-maximum-body-bytes" in operation: + raise ValueError(f"OpenAPI names a body bound on a bodyless operation for {key}") + names = [parameter["name"] for parameter in operation["parameters"]] + if ("Idempotency-Key" in names) != derived["idempotency_key"]: + raise ValueError(f"OpenAPI idempotency key drifted from the handler for {key}") + if derived["idempotency_key"]: + idempotency = next( + parameter for parameter in operation["parameters"] if parameter["name"] == "Idempotency-Key" + ) + if not idempotency["required"] or idempotency["schema"] != IDEMPOTENCY_SCHEMA: + raise ValueError(f"OpenAPI idempotency key bound drifted from the handler for {key}") + template_names = re.findall(r"\{([a-z_]+)\}", path) + rust_path_names = [name for name, _ in derived["path_parameters"]] + if template_names != rust_path_names: + raise ValueError(f"OpenAPI path parameters drifted from the handler for {key}") + for name in template_names: + parameter = next(item for item in operation["parameters"] if item["name"] == name) + if not parameter["required"] or parameter["schema"] != UUID_SCHEMA: + raise ValueError(f"OpenAPI path parameter schema drifted for {key}: {name}") + query_fields = query_struct(http_source, derived["query"]) if derived["query"] else {} + documented_query = { + parameter["name"]: parameter + for parameter in operation["parameters"] + if parameter["in"] == "query" + } + if set(documented_query) != set(query_fields): + raise ValueError(f"OpenAPI query parameters drifted from the handler for {key}") + for name, (required, family) in query_fields.items(): + schema = documented_query[name]["schema"] + actual = "date-time" if schema.get("format") == "date-time" else schema["type"] + if documented_query[name]["required"] != required or actual != family: + raise ValueError(f"OpenAPI query parameter {name} drifted from the handler for {key}") + successes = [status for status in operation["responses"] if int(status) < 400] + if successes != [str(derived["success_status"])]: + raise ValueError( + f"OpenAPI success status drifted from the handler for {key}; " + f"documented={successes}, handler={derived['success_status']}" + ) + documented_problems = [ + status for status in operation["responses"] if int(status) >= 400 + ] + unavailable = "503" in documented_problems + # An infallible service method can answer no store refusal, so its + # operation documents no service.unavailable; a fallible one must, + # because every one of them opens a store round trip. + if derived["service_infallible"] is True and unavailable: + raise ValueError(f"an infallible service method documents 503 for {key}") + if derived["service_infallible"] is False and not unavailable: + raise ValueError(f"a fallible service method documents no 503 for {key}") + if not documented_problems and derived["authority"] != "unauthenticated": + raise ValueError(f"an authenticated operation answers no refusal for {key}") + for code in derived["problem_codes"]: + if code not in OPERATION_PROBLEMS[key]: + raise ValueError(f"handler names {code} but the document does not answer it for {key}") + + +def schema_problem_codes(openapi: dict, schema: dict) -> set[str]: + variants = schema.get("oneOf", [schema]) + codes = set() + for variant in variants: + reference = variant.get("$ref") + if not reference or not reference.startswith("#/components/schemas/Problem"): + raise ValueError(f"problem response does not use a closed problem component: {schema}") + name = reference.rsplit("/", 1)[1] + component = openapi["components"]["schemas"][name] + codes.add(component["allOf"][1]["properties"]["code"]["const"]) + return codes + + +def verify_operation_problems( + openapi: dict, contract: dict, catalog: dict[str, dict], variants: dict[str, str] +) -> None: + """Check the hand-shaped per-operation mappings against the catalog. + + Status is a property of the code in Scheduling, so the document's status + grouping is compared with the status the catalog pins for each code, and + every documented code must be produced somewhere in the runtime. + """ + documented = { + (method.upper(), path) + for path, path_item in openapi["paths"].items() + for method in path_item + } + if documented != set(OPERATION_PROBLEMS): + raise ValueError( + "OpenAPI problem inventory drifted; " + f"documented_only={sorted(documented - set(OPERATION_PROBLEMS))}, " + f"unmapped={sorted(set(OPERATION_PROBLEMS) - documented)}" + ) + for code in EDGE_PROBLEMS + RESERVED_PROBLEMS: + if code not in catalog: + raise ValueError(f"declared problem is outside the Rust vocabulary: {code}") + produced = produced_problem_codes(_root(), variants) + reserved = set(RESERVED_PROBLEMS) + # The two reserved codes are produced nowhere; `resource.unavailable` is, + # but only as the detailed code the explain path discloses. + if set(RESERVED_PROBLEMS[:2]) & produced: + raise ValueError("a problem documented as reserved is produced by the runtime") + for key, codes in OPERATION_PROBLEMS.items(): + method, path = key + operation = openapi["paths"][path][method.lower()] + for code in codes: + if code not in catalog: + raise ValueError(f"problem code is outside the Rust vocabulary: {code}") + if code in reserved: + raise ValueError(f"reserved problem documented as an operation answer: {code}") + if code not in produced: + raise ValueError(f"no runtime path produces {code}, documented for {key}") + expected_by_status: dict[int, set[str]] = {} + for code in codes: + expected_by_status.setdefault(catalog[code]["httpStatuses"][0], set()).add(code) + actual_by_status = { + int(status): schema_problem_codes( + openapi, response["content"]["application/problem+json"]["schema"] + ) + for status, response in operation["responses"].items() + if int(status) >= 400 + } + if actual_by_status != expected_by_status: + raise ValueError( + f"per-operation problem mapping drifted for {key}; " + f"documented={actual_by_status}, expected={expected_by_status}" + ) + required = {"request.body-too-large", "request.method-not-allowed"} + if operation["x-scheduling-authority"] != "unauthenticated": + required |= {"authentication.refused", "profile.not-authorized"} + if operation.get("requestBody"): + required |= {"request.invalid", "request.unprocessable", "request.unsupported-media-type"} + if any(parameter["in"] == "query" for parameter in operation["parameters"]): + required |= {"request.invalid"} + if any(parameter["name"] == "Idempotency-Key" for parameter in operation["parameters"]): + required |= {"request.invalid"} + if not required <= set(codes): + raise ValueError( + f"per-operation problem mapping lacks the codes its wiring requires for {key}: " + f"{sorted(required - set(codes))}" + ) + answered = {code for codes in OPERATION_PROBLEMS.values() for code in codes} + if answered != set(catalog) - reserved - {"request.not-found"}: + raise ValueError( + "documented problem coverage drifted from the closed vocabulary; " + f"unanswered={sorted(set(catalog) - reserved - answered)}, " + f"unknown={sorted(answered - set(catalog))}" + ) + + +def production_source(repository_root: Path, relative: str) -> str: + """A source file up to its test module: production sites only.""" + source = (repository_root / relative).read_text(encoding="utf-8") + return source.split("#[cfg(test)]", 1)[0] + + +def produced_problem_codes(repository_root: Path, variants: dict[str, str]) -> set[str]: + """The codes the runtime and the core name outside the vocabulary itself. + + A code with no production site cannot reach a caller, so it may not be + documented as an operation's answer. + """ + produced = set() + for relative in (HTTP_SOURCE, SERVICE_SOURCE, STORE_SOURCE, CURSORS_SOURCE, ADMISSION_SOURCE): + source = production_source(repository_root, relative) + produced |= { + variants[name] for name in re.findall(r"ProblemCode::([A-Z][A-Za-z0-9]*)", source) + } + return produced + + +def load_rust_contract(repository_root: Path) -> dict: + with tempfile.TemporaryDirectory(prefix="registry-scheduling-openapi-") as directory: + output = Path(directory) / "problem-catalog.json" + subprocess.run( + [ + "cargo", + "run", + "--locked", + "--quiet", + "-p", + "registry-scheduling-core", + "--example", + "problem-catalog", + "--", + "--output", + str(output), + ], + cwd=repository_root, + check=True, + ) + contract = json.loads(output.read_text(encoding="utf-8")) + if set(contract) != {"entries"}: + raise ValueError(f"Rust problem catalog fields drifted: {sorted(contract)}") + codes = [entry["code"] for entry in contract["entries"]] + if codes != sorted(set(codes)): + raise ValueError("Rust problem catalog codes are not unique and sorted") + if any(len(entry["httpStatuses"]) != 1 for entry in contract["entries"]): + raise ValueError("each Scheduling problem must have exactly one HTTP status") + return contract + + +def camel_case(value: str) -> str: + head, *tail = value.split("_") + return head + "".join(part.title() for part in tail) + + +def code_variant(code: str) -> str: + """The Rust variant name a problem code spells: `hold.expired` -> `HoldExpired`.""" + return "".join(part.title() for part in re.split(r"[.-]", code)) + + +def rust_struct_fields(source: str, name: str) -> set[str]: + match = re.search(rf"pub struct {re.escape(name)}(?:<[^>]+>)?\s*\{{(?P.*?)^\}}", source, re.S | re.M) + if not match: + raise ValueError(f"Rust DTO is missing: {name}") + return set(re.findall(r"^\s*pub\s+([a-z_]+)\s*:", match.group("body"), re.M)) + + +def rust_kebab_case_unit_enum_values(source: str, name: str) -> list[str]: + match = re.search( + rf'#\[serde\(rename_all = "kebab-case"\)\]\s*pub enum {re.escape(name)}\s*\{{(?P.*?)^\}}', + source, + re.S | re.M, + ) + if not match: + raise ValueError(f"Rust kebab-case enum is missing: {name}") + variants = re.findall(r"^\s*([A-Z][A-Za-z0-9]*)\s*,?\s*$", match.group("body"), re.M) + return [re.sub(r"(? None: + openapi_schemas = openapi["components"]["schemas"] + for relative, structs in SCHEMA_STRUCTS.items(): + source = (repository_root / relative).read_text(encoding="utf-8") + for rust_name, schema_name in structs.items(): + rust_fields = {camel_case(field) for field in rust_struct_fields(source, rust_name)} + schema_fields = set(openapi_schemas[schema_name]["properties"]) + if rust_fields != schema_fields: + raise ValueError( + f"OpenAPI DTO shape drifted from {rust_name}; " + f"documented_only={sorted(schema_fields - rust_fields)}, " + f"rust_only={sorted(rust_fields - schema_fields)}" + ) + wire = production_source(repository_root, WIRE_SOURCE) + page_fields = {camel_case(field) for field in rust_struct_fields(wire, "PageDocument")} + for schema_name in ( + "ServicePage", + "OfferingPage", + "ResourcePage", + "LocationPage", + "AvailabilityPage", + "AppointmentHistoryPage", + ): + if set(openapi_schemas[schema_name]["properties"]) != page_fields: + raise ValueError(f"OpenAPI page shape drifted for {schema_name}") + if set(openapi_schemas[schema_name]["required"]) != page_fields: + raise ValueError(f"OpenAPI page required fields drifted for {schema_name}") + if set(openapi_schemas["OfferingDocument"]["properties"]["mode"]["enum"]) != set( + rust_kebab_case_unit_enum_values(wire, "SchedulingModeDocument") + ): + raise ValueError("OpenAPI offering mode values drifted from Rust") + if set(openapi_schemas["AppointmentState"]["enum"]) != set( + rust_kebab_case_unit_enum_values(wire, "AppointmentStateDocument") + ): + raise ValueError("OpenAPI appointment state values drifted from Rust") + entry_match = re.search( + r"#\[serde\((?P[^\)]*)\)\]\s*pub enum AvailabilityEntry\s*\{(?P.*?)^\}", + wire, + re.S | re.M, + ) + if not entry_match: + raise ValueError("Rust availability entry is missing") + attributes = entry_match.group("attributes") + body = entry_match.group("body") + for text in ('tag = "kind"', 'rename_all_fields = "camelCase"', "deny_unknown_fields"): + if text not in attributes: + raise ValueError(f"OpenAPI availability entries drifted from Rust: {text}") + variants = re.findall(r"^\s*([A-Z][A-Za-z0-9]*)\s*\{", body, re.M) + expected = {re.sub(r"(?", "window: String"): + if marker not in body: + raise ValueError(f"OpenAPI availability entry field drifted from Rust: {marker}") + if { + variant["$ref"].rsplit("/", 1)[1] + for variant in openapi_schemas["AvailabilityEntry"]["oneOf"] + } != {"AvailabilitySlot", "AvailabilityWindow"}: + raise ValueError("OpenAPI availability entry variants drifted from Rust") + for variant, kind in ( + (openapi_schemas["AvailabilitySlot"], "slot"), + (openapi_schemas["AvailabilityWindow"], "window"), + ): + if variant["properties"]["kind"]["const"] != kind: + raise ValueError("OpenAPI availability discriminator drifted from Rust") + explain = openapi_schemas["ExplainDocument"] + for field in ("publicCode", "detailedCode", "explanation"): + if field in explain["required"] or "anyOf" in explain["properties"][field]: + raise ValueError(f"OpenAPI explain projection drifted for {field}") + if wire.count('skip_serializing_if = "Option::is_none"') != 3: + raise ValueError("OpenAPI explain optionality drifted from Rust") + create = openapi_schemas["CreateAppointmentRequest"] + if set(create["properties"]) != {"hold", "admission"} or create["minProperties"] != 1: + raise ValueError("OpenAPI exclusive create shape drifted from Rust") + + +def marker(source: str, text: str, origin: str) -> None: + if text not in source: + raise ValueError(f"OpenAPI bound drifted from Rust in {origin}: {text}") + + +def verify_source(repository_root: Path) -> None: + """Check the bounds and edge behaviour the document states, in Rust.""" + http_source = production_source(repository_root, HTTP_SOURCE) + naming_source = production_source(repository_root, NAMING_SOURCE) + service_source = production_source(repository_root, SERVICE_SOURCE) + store_source = production_source(repository_root, STORE_SOURCE) + cursors_source = production_source(repository_root, CURSORS_SOURCE) + auth_source = production_source(repository_root, AUTH_SOURCE) + config_source = production_source(repository_root, CONFIG_SOURCE) + admission_source = production_source(repository_root, ADMISSION_SOURCE) + problem_source = production_source(repository_root, PROBLEM_SOURCE) + httpsec_source = production_source(repository_root, HTTPSEC_SOURCE) + + inventory = route_inventory(http_source, naming_source) + paths = {path for _, path in inventory} + if paths != ROUTES: + raise ValueError( + "maintained OpenAPI route inventory drifted; " + f"undocumented={sorted(paths - ROUTES)}, " + f"unserved={sorted(ROUTES - paths)}" + ) + for constant, value in HEADERS.items(): + marker(naming_source, f'pub const {constant}: &str = "{value}";', "naming") + marker(naming_source, "pub const MAXIMUM_IDEMPOTENCY_KEY_BYTES: usize = 128;", "naming") + marker(naming_source, 'pub const CURSOR_QUERY_PARAMETER: &str = "cursor";', "naming") + marker(naming_source, 'pub const LIMIT_QUERY_PARAMETER: &str = "limit";', "naming") + marker(cursors_source, "pub const CURSOR_LIFETIME_MINUTES: i64 = 15;", "cursors") + marker(cursors_source, "const MAXIMUM_CURSOR_BYTES: usize = 256;", "cursors") + marker(service_source, "pub const DEFAULT_PAGE_LIMIT: usize = 50;", "service") + marker(service_source, "pub const MAXIMUM_PAGE_LIMIT: usize = 200;", "service") + marker(service_source, "const MAXIMUM_AVAILABILITY_SPAN_DAYS: i64 = 62;", "service") + for action in ( + "HOLD_CREATE_ACTION", + "HOLD_RELEASE_ACTION", + "APPOINTMENT_CREATE_ACTION", + "APPOINTMENT_RESCHEDULE_ACTION", + "APPOINTMENT_CANCEL_ACTION", + ): + marker(service_source, f"pub const {action}: &str = ", "service") + marker(config_source, 'fn default_reads_scope() -> String {\n "scheduling-read".to_owned()', "config") + marker(config_source, 'fn default_explain_scope() -> String {\n "scheduling-explain".to_owned()', "config") + marker(httpsec_source, "pub const DEFAULT_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;", "httpsec") + problem_body = re.search( + r"#\[serde\(rename_all = \"camelCase\"\)\]\s*pub struct ProblemBody\s*\{(?P.*?)^\}", + httpsec_source, + re.S | re.M, + ) + if not problem_body: + raise ValueError("the problem envelope is missing from the platform") + fields = set(re.findall(r"^\s*pub\s+([a-z_]+)\s*:", problem_body.group("body"), re.M)) + if fields != {"type_uri", "title", "status", "detail", "code", "trace_id"}: + raise ValueError(f"problem envelope fields drifted; actual={sorted(fields)}") + for text in ( + "async fn route_not_found()", + "async fn method_not_allowed()", + "fn normalize_framework_rejection", + "StatusCode::BAD_REQUEST => Some(ProblemCode::RequestInvalid)", + "StatusCode::PAYLOAD_TOO_LARGE => Some(ProblemCode::RequestBodyTooLarge)", + "StatusCode::UNSUPPORTED_MEDIA_TYPE => Some(ProblemCode::RequestUnsupportedMediaType)", + "StatusCode::UNPROCESSABLE_ENTITY => Some(ProblemCode::RequestUnprocessable)", + "problem_response(ProblemCode::RequestNotFound)", + "problem_response(ProblemCode::RequestMethodNotAllowed)", + "byte.is_ascii_graphic()", + "value.len() > MAXIMUM_IDEMPOTENCY_KEY_BYTES", + "parse_bearer_token(authorization)", + 'status == StatusCode::UNAUTHORIZED', + '"Bearer"', + "status == StatusCode::SERVICE_UNAVAILABLE", + 'RETRY_AFTER,\n "5"', + 'CACHE_CONTROL,\n "no-store"', + ): + marker(http_source, text, "http") + for text in ( + "pub async fn authenticate_read", + "pub async fn authenticate_explain", + "pub async fn authenticate_mutate", + "grant_claims(&verified.claims", + ".ok_or(AuthenticationError::Profile)", + ): + marker(auth_source, text, "auth") + for text in ( + "pub enum CommitError", + "WHERE actor_issuer=$1 AND actor_subject=$2 AND scope=$3 AND idempotency_key=$4", + ): + marker(store_source, text, "store") + # The commitment verdict, not the store, names the caller's problem. + for text in ( + "CommitError::KeyReused => ProblemCode::IdempotencyKeyReused", + "CommitError::KeyExpired => ProblemCode::IdempotencyExpired", + "CommitError::HoldCeiling => ProblemCode::CapacityExhausted", + "CommitError::Unauthorized => ProblemCode::OperationNotAuthorized", + "CommitError::RevisionMismatch => ProblemCode::RevisionMismatch", + "CommitError::CutoffPassed => ProblemCode::CancellationCutoffPassed", + ): + marker(service_source, text, "service") + for text in ( + "pub enum AdmissionRefusal", + "pub const fn public_code(&self) -> ProblemCode", + "Self::ResourceUnavailable { .. } => ProblemCode::CapacityExhausted", + "Self::DuplicateKeyRequired { .. } => ProblemCode::PreconditionRequired", + "pub const fn detailed_code(&self) -> ProblemCode", + "Self::ResourceUnavailable { .. } => ProblemCode::ResourceUnavailable", + ): + marker(admission_source, text, "admission") + for text in ( + "pub const fn is_detailed_only(self) -> bool", + "matches!(self, Self::ResourceUnavailable)", + "pub fn type_uri(code: &str) -> String", + "SCHEDULING_PROBLEM_TYPE_BASE", + ): + marker(problem_source, text, "problem") + + +def _root() -> Path: + return Path(__file__).resolve().parents[3] + + +def catalog_of(contract: dict) -> dict[str, dict]: + return {entry["code"]: entry for entry in contract["entries"]} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--check", action="store_true", help="verify the committed document instead of writing it") + args = parser.parse_args() + repository_root = _root() + output = repository_root / OUTPUT + try: + contract = load_rust_contract(repository_root) + variants = {code_variant(entry["code"]): entry["code"] for entry in contract["entries"]} + verify_source(repository_root) + openapi = document(contract) + verify_handler_wiring(repository_root, openapi["paths"], variants) + verify_dto_schemas(repository_root, openapi) + verify_operation_problems(openapi, contract, catalog_of(contract), variants) + rendered = json.dumps(openapi, indent=2, sort_keys=True) + "\n" + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + print(f"OpenAPI source check failed: {error}", file=sys.stderr) + return 1 + if args.check: + if not output.exists() or output.read_text(encoding="utf-8") != rendered: + print(f"{output.relative_to(repository_root)} is stale; regenerate it", file=sys.stderr) + return 1 + print("Registry Scheduling OpenAPI is current.") + return 0 + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered, encoding="utf-8") + print(output.relative_to(repository_root)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/scheduling/scripts/test_generate_openapi.py b/products/scheduling/scripts/test_generate_openapi.py new file mode 100644 index 0000000000..4faf92f0db --- /dev/null +++ b/products/scheduling/scripts/test_generate_openapi.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import copy +import importlib.util +import json +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = Path(__file__).with_name("generate_openapi.py") +SPEC = importlib.util.spec_from_file_location("scheduling_generate_openapi", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +GENERATOR = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = GENERATOR +SPEC.loader.exec_module(GENERATOR) + + +def problem_codes(openapi: dict, response: dict) -> set[str]: + schema = response["content"]["application/problem+json"]["schema"] + variants = schema.get("oneOf", [schema]) + result = set() + for variant in variants: + name = variant["$ref"].rsplit("/", 1)[1] + component = openapi["components"]["schemas"][name] + result.add(component["allOf"][1]["properties"]["code"]["const"]) + return result + + +def parameter(openapi: dict, method: str, path: str, name: str) -> dict: + parameters = openapi["paths"][path][method]["parameters"] + return next(item for item in parameters if item["name"] == name) + + +def operation(openapi: dict, key: tuple[str, str]) -> dict: + method, path = key + return openapi["paths"][path][method.lower()] + + +def codes_of(openapi: dict, key: tuple[str, str]) -> dict[int, set[str]]: + return { + int(status): problem_codes(openapi, response) + for status, response in operation(openapi, key)["responses"].items() + if int(status) >= 400 + } + + +def documented_codes(openapi: dict, key: tuple[str, str]) -> set[str]: + by_status = codes_of(openapi, key) + return set().union(*by_status.values()) if by_status else set() + + +class GeneratedOpenApiTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.contract = GENERATOR.load_rust_contract(ROOT) + cls.catalog = GENERATOR.catalog_of(cls.contract) + cls.variants = { + GENERATOR.code_variant(entry["code"]): entry["code"] + for entry in cls.contract["entries"] + } + cls.openapi = GENERATOR.document(cls.contract) + cls.committed = (ROOT / GENERATOR.OUTPUT).read_text(encoding="utf-8") + + def test_every_documented_problem_matches_its_pinned_rust_catalog_entry(self) -> None: + for entry in self.contract["entries"]: + component = self.openapi["components"]["schemas"][ + GENERATOR.problem_component_name(entry["code"]) + ] + properties = component["allOf"][1]["properties"] + self.assertEqual(entry["uri"], properties["type"]["const"]) + self.assertEqual(entry["title"], properties["title"]["const"]) + self.assertEqual(entry["description"], properties["detail"]["const"]) + self.assertEqual(entry["httpStatuses"][0], properties["status"]["const"]) + self.assertEqual(32, len(self.contract["entries"])) + self.assertEqual(59, len(self.openapi["components"]["schemas"])) + + def test_every_operation_answers_exactly_the_problems_it_was_mapped(self) -> None: + for key, expected in GENERATOR.OPERATION_PROBLEMS.items(): + by_status = codes_of(self.openapi, key) + self.assertEqual(sorted(expected), sorted(set().union(*by_status.values())), key) + for status, codes in by_status.items(): + for code in codes: + self.assertEqual(status, self.catalog[code]["httpStatuses"][0], code) + + def test_problem_status_is_never_documented_by_hand(self) -> None: + """A mutated catalog status must fail the check, not pass silently.""" + bad_contract = copy.deepcopy(self.contract) + entry = next( + entry for entry in bad_contract["entries"] if entry["code"] == "capacity.exhausted" + ) + entry["httpStatuses"] = [500] + with self.assertRaisesRegex(ValueError, "per-operation problem mapping drifted"): + GENERATOR.verify_operation_problems( + copy.deepcopy(self.openapi), + bad_contract, + GENERATOR.catalog_of(bad_contract), + self.variants, + ) + + def test_a_reserved_code_can_never_be_documented_as_an_answer(self) -> None: + key = ("GET", "/v1/services") + saved = list(GENERATOR.OPERATION_PROBLEMS[key]) + GENERATOR.OPERATION_PROBLEMS[key] = saved + ["eligibility.unavailable"] + try: + with self.assertRaisesRegex( + ValueError, "reserved problem documented as an operation answer" + ): + GENERATOR.verify_operation_problems( + copy.deepcopy(self.openapi), self.contract, self.catalog, self.variants + ) + finally: + GENERATOR.OPERATION_PROBLEMS[key] = saved + + def test_a_reserved_code_stays_in_the_vocabulary_it_is_reserved_from(self) -> None: + documented = { + code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes + } + for code in GENERATOR.RESERVED_PROBLEMS: + self.assertNotIn(code, documented) + self.assertIn(code, self.catalog) + self.assertIn(code, self.openapi["components"]["schemas"]["Problem"]["properties"]["code"]["enum"]) + + def test_documented_coverage_is_the_closed_vocabulary_minus_the_unreachable(self) -> None: + documented = { + code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes + } + self.assertEqual( + set(self.catalog) - set(GENERATOR.RESERVED_PROBLEMS) - {"request.not-found"}, + documented, + ) + self.assertEqual( + { + "request.body-too-large", + "request.invalid", + "request.method-not-allowed", + "request.not-found", + "request.unprocessable", + "request.unsupported-media-type", + }, + set(self.openapi["x-registry-scheduling-edge-problems"]), + ) + self.assertEqual( + {"eligibility.unavailable", "hook.unavailable", "resource.unavailable"}, + set(self.openapi["x-registry-scheduling-reserved-problems"]), + ) + + def test_only_a_reachable_code_is_documented_as_an_answer(self) -> None: + produced = GENERATOR.produced_problem_codes(ROOT, self.variants) + documented = { + code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes + } + self.assertEqual(documented, documented & produced) + self.assertEqual(set(), set(GENERATOR.RESERVED_PROBLEMS[:2]) & produced) + self.assertIn("resource.unavailable", produced) + + def test_every_route_is_described_by_exactly_one_operation(self) -> None: + documented = { + (method.upper(), path) + for path, path_item in self.openapi["paths"].items() + for method in path_item + } + self.assertEqual(set(GENERATOR.ROUTE_HANDLERS), documented) + self.assertEqual(GENERATOR.ROUTES, {path for _, path in documented}) + self.assertEqual(set(GENERATOR.OPERATION_IDS), documented) + for key, operation_id in GENERATOR.OPERATION_IDS.items(): + self.assertEqual(operation_id, operation(self.openapi, key)["operationId"]) + + def test_a_drifted_route_table_fails_the_generator(self) -> None: + saved = set(GENERATOR.ROUTES) + try: + GENERATOR.ROUTES.discard("/v1/locations") + with self.assertRaisesRegex(ValueError, "route inventory drifted"): + GENERATOR.verify_source(ROOT) + finally: + GENERATOR.ROUTES.clear() + GENERATOR.ROUTES.update(saved) + + def test_an_operation_documents_the_authority_its_handler_uses(self) -> None: + expected = { + ("GET", "/healthz"): "unauthenticated", + ("GET", "/readyz"): "unauthenticated", + ("GET", "/v1/scheduling"): "reads-scope", + ("GET", "/v1/services"): "reads-scope", + ("GET", "/v1/offerings"): "reads-scope", + ("GET", "/v1/resources"): "reads-scope", + ("GET", "/v1/locations"): "reads-scope", + ("GET", "/v1/availability"): "reads-scope", + ("GET", "/v1/availability/explain"): "explain-scope", + ("POST", "/v1/holds"): "task-grant", + ("DELETE", "/v1/holds/{hold_id}"): "task-grant", + ("POST", "/v1/appointments"): "task-grant", + ("GET", "/v1/appointments/{appointment_id}"): "reads-scope", + ("POST", "/v1/appointments/{appointment_id}/reschedule"): "task-grant", + ("POST", "/v1/appointments/{appointment_id}/cancel"): "task-grant", + ("GET", "/v1/appointments/{appointment_id}/history"): "reads-scope", + } + for key, authority in expected.items(): + documented = operation(self.openapi, key) + self.assertEqual(authority, documented["x-scheduling-authority"], key) + if authority == "unauthenticated": + self.assertEqual([], documented["security"], key) + else: + self.assertEqual([{"bearerAuth": []}], documented["security"], key) + authorities = [ + item["x-scheduling-authority"] + for path in self.openapi["paths"].values() + for item in path.values() + ] + self.assertEqual(1, authorities.count("explain-scope")) + self.assertEqual(2, authorities.count("unauthenticated")) + self.assertEqual(5, authorities.count("task-grant")) + self.assertEqual(8, authorities.count("reads-scope")) + + def test_an_idempotency_key_is_demanded_exactly_where_the_handler_reads_it(self) -> None: + expected = { + ("POST", "/v1/holds"), + ("POST", "/v1/appointments"), + ("POST", "/v1/appointments/{appointment_id}/reschedule"), + ("POST", "/v1/appointments/{appointment_id}/cancel"), + } + schema = {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[!-~]+$"} + for key in GENERATOR.ROUTE_HANDLERS: + names = {item["name"] for item in operation(self.openapi, key)["parameters"]} + self.assertEqual(key in expected, "Idempotency-Key" in names, key) + if key in expected: + demanded = parameter(self.openapi, key[0].lower(), key[1], "Idempotency-Key") + self.assertTrue(demanded["required"]) + self.assertEqual(schema, demanded["schema"]) + self.assertIn("idempotency.expired", demanded["description"]) + self.assertIn("idempotency.key-reused", demanded["description"]) + + def test_a_release_uses_the_hold_itself_as_its_idempotency_key(self) -> None: + """No caller key means a reused key is unreachable on this route.""" + key = ("DELETE", "/v1/holds/{hold_id}") + codes = documented_codes(self.openapi, key) + self.assertIn("idempotency.expired", codes) + self.assertNotIn("idempotency.key-reused", codes) + self.assertIn("own identifier is the idempotency key", + operation(self.openapi, key)["description"]) + self.assertNotIn("Idempotency-Key", + {item["name"] for item in operation(self.openapi, key)["parameters"]}) + + def test_a_request_body_is_demanded_exactly_where_the_handler_extracts_one(self) -> None: + expected = { + ("POST", "/v1/holds"): "AdmissionRequest", + ("POST", "/v1/appointments"): "CreateAppointmentRequest", + ("POST", "/v1/appointments/{appointment_id}/reschedule"): "RescheduleAppointmentRequest", + ("POST", "/v1/appointments/{appointment_id}/cancel"): "CancelAppointmentRequest", + } + for key in GENERATOR.ROUTE_HANDLERS: + documented = operation(self.openapi, key) + self.assertEqual(key in expected, "requestBody" in documented, key) + if key in expected: + reference = documented["requestBody"]["content"]["application/json"]["schema"]["$ref"] + self.assertEqual(f"#/components/schemas/{expected[key]}", reference) + codes = documented_codes(self.openapi, key) + self.assertIn("request.invalid", codes, key) + self.assertIn("request.unprocessable", codes, key) + self.assertIn("request.unsupported-media-type", codes, key) + + def test_success_statuses_come_from_the_handlers(self) -> None: + expected = { + ("POST", "/v1/holds"): "201", + ("POST", "/v1/appointments"): "201", + ("DELETE", "/v1/holds/{hold_id}"): "204", + } + for key in GENERATOR.ROUTE_HANDLERS: + statuses = { + status + for status in operation(self.openapi, key)["responses"] + if int(status) < 400 + } + self.assertEqual({expected.get(key, "200")}, statuses, key) + + def test_a_fallible_service_method_always_documents_an_unavailable_dependency(self) -> None: + for key in GENERATOR.ROUTE_HANDLERS: + codes = documented_codes(self.openapi, key) + if key in {("GET", "/healthz"), ("GET", "/v1/scheduling")}: + self.assertNotIn("service.unavailable", codes, key) + else: + self.assertIn("service.unavailable", codes, key) + self.assertIn("Retry-After", operation(self.openapi, key)["responses"]["503"]["headers"]) + + def test_infrastructure_routes_are_open_and_distinguish_live_from_ready(self) -> None: + health = operation(self.openapi, ("GET", "/healthz")) + ready = operation(self.openapi, ("GET", "/readyz")) + self.assertEqual([], health["security"]) + self.assertEqual([], ready["security"]) + self.assertNotIn("requestBody", health) + self.assertNotIn("requestBody", ready) + self.assertNotIn("503", health["responses"]) + self.assertIn("503", ready["responses"]) + self.assertNotIn("401", health["responses"]) + self.assertNotIn("401", ready["responses"]) + self.assertIn("No authentication", health["description"]) + self.assertIn("service.unavailable", ready["description"]) + + def test_only_an_authenticated_operation_answers_a_bearer_challenge(self) -> None: + for key in GENERATOR.ROUTE_HANDLERS: + item = operation(self.openapi, key) + codes = documented_codes(self.openapi, key) + challenge = item["responses"].get("401", {}).get("headers", {}).get("WWW-Authenticate") + if item["x-scheduling-authority"] == "unauthenticated": + self.assertNotIn("authentication.refused", codes, key) + self.assertNotIn("profile.not-authorized", codes, key) + self.assertIsNone(challenge, key) + else: + self.assertIn("authentication.refused", codes, key) + self.assertIn("profile.not-authorized", codes, key) + self.assertEqual("Bearer", challenge["schema"]["const"], key) + + def test_every_answer_carries_the_trace_and_the_no_store_policy(self) -> None: + for path in self.openapi["paths"].values(): + for item in path.values(): + for response in item["responses"].values(): + self.assertEqual("#/components/headers/TraceparentHeader", + response["headers"]["traceparent"]["$ref"]) + self.assertEqual("#/components/headers/CacheControlHeader", + response["headers"]["cache-control"]["$ref"]) + self.assertEqual({"const": "no-store"}, + self.openapi["components"]["headers"]["CacheControlHeader"]["schema"]) + + def test_owned_appointment_reads_never_disclose_existence(self) -> None: + for key in ( + ("GET", "/v1/appointments/{appointment_id}"), + ("GET", "/v1/appointments/{appointment_id}/history"), + ): + codes = documented_codes(self.openapi, key) + self.assertIn("operation.not-authorized", codes, key) + self.assertNotIn("request.not-found", codes, key) + self.assertIn("never disclosed", operation(self.openapi, key)["description"]) + + def test_the_cancellation_guard_never_names_the_policy_revision(self) -> None: + key = ("POST", "/v1/appointments/{appointment_id}/cancel") + codes = documented_codes(self.openapi, key) + self.assertIn("cancellation.cutoff-passed", codes) + self.assertIn("revision.mismatch", codes) + self.assertNotIn("policy.changed", codes) + self.assertIn("never by the policy revision", operation(self.openapi, key)["description"]) + + def test_a_reschedule_answers_a_moved_policy_but_never_a_failed_precondition(self) -> None: + key = ("POST", "/v1/appointments/{appointment_id}/reschedule") + codes = documented_codes(self.openapi, key) + self.assertIn("policy.changed", codes) + self.assertIn("revision.mismatch", codes) + self.assertNotIn("precondition.failed", codes) + + def test_only_a_confirming_create_answers_an_expired_hold(self) -> None: + expected = {("POST", "/v1/appointments")} + for key in GENERATOR.ROUTE_HANDLERS: + self.assertEqual( + key in expected, + "hold.expired" in documented_codes(self.openapi, key), + key, + ) + + def test_only_availability_answers_an_unknown_offering_as_a_failed_precondition(self) -> None: + expected = { + ("GET", "/v1/availability"), + ("GET", "/v1/availability/explain"), + ("POST", "/v1/holds"), + ("POST", "/v1/appointments"), + } + for key in GENERATOR.ROUTE_HANDLERS: + self.assertEqual( + key in expected, + "precondition.failed" in documented_codes(self.openapi, key), + key, + ) + + def test_admission_refusals_travel_only_with_the_commitment_routes(self) -> None: + admission = { + "booking.duplicate-active", + "capacity.exhausted", + "capability.unmatched", + "horizon.outside", + "location.closed", + "party.capacity-inadequate", + "policy.changed", + "precondition.required", + "prerequisite.missing", + "schedule.unpublished", + } + commitments = { + ("POST", "/v1/holds"), + ("POST", "/v1/appointments"), + ("POST", "/v1/appointments/{appointment_id}/reschedule"), + } + for key in GENERATOR.ROUTE_HANDLERS: + codes = documented_codes(self.openapi, key) + self.assertEqual(key in commitments, bool(admission & codes), key) + + def test_resource_unavailability_is_disclosed_by_the_explain_path_only(self) -> None: + explain = operation(self.openapi, ("GET", "/v1/availability/explain")) + self.assertIn("resource.unavailable is disclosed here", explain["description"]) + self.assertIn("capacity.exhausted", explain["description"]) + documented = { + code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes + } + self.assertNotIn("resource.unavailable", documented) + self.assertIn("resource.unavailable", self.openapi["x-registry-scheduling-reserved-problems"]) + + def test_the_catalogue_and_availability_reads_are_cursor_bound(self) -> None: + for key in ( + ("GET", "/v1/services"), + ("GET", "/v1/offerings"), + ("GET", "/v1/resources"), + ("GET", "/v1/locations"), + ("GET", "/v1/availability"), + ("GET", "/v1/appointments/{appointment_id}/history"), + ): + names = {item["name"]: item for item in operation(self.openapi, key)["parameters"]} + self.assertIn("cursor", names, key) + self.assertIn("limit", names, key) + self.assertFalse(names["cursor"]["required"]) + self.assertFalse(names["limit"]["required"]) + self.assertEqual(256, names["cursor"]["schema"]["maxLength"]) + self.assertIn("cursor.expired", names["cursor"]["description"]) + codes = documented_codes(self.openapi, key) + self.assertIn("cursor.expired", codes, key) + self.assertIn("cursor.invalid", codes, key) + self.assertEqual({"type": "integer", "minimum": 1, "maximum": 200, "default": 50}, + names["limit"]["schema"]) + + def test_the_security_scheme_tells_the_three_profiles_apart(self) -> None: + scheme = self.openapi["components"]["securitySchemes"]["bearerAuth"] + self.assertEqual({"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, + {key: scheme[key] for key in ("type", "scheme", "bearerFormat")}) + self.assertIn("scheduling-read", scheme["description"]) + self.assertIn("scheduling-explain", scheme["description"]) + self.assertIn("no scope", scheme["description"]) + self.assertIn("complete task grant", scheme["description"]) + self.assertIn("partial grant claims", scheme["description"]) + + def test_mutating_routes_name_the_five_task_grant_actions(self) -> None: + described = " ".join( + operation(self.openapi, key)["description"] + for key in GENERATOR.ROUTE_HANDLERS + if operation(self.openapi, key)["x-scheduling-authority"] == "task-grant" + ) + for action in ( + "hold.create", + "hold.release", + "appointment.create", + "appointment.reschedule", + "appointment.cancel", + ): + self.assertIn(action, described) + self.assertIn("hold.create, hold.release, appointment.create, appointment.reschedule, appointment.cancel", + self.openapi["components"]["securitySchemes"]["bearerAuth"]["description"]) + + def test_the_document_carries_no_tag_list_and_no_timestamp(self) -> None: + self.assertNotIn("tags", self.openapi) + self.assertEqual("3.1.0", self.openapi["openapi"]) + self.assertEqual("Registry Scheduling API", self.openapi["info"]["title"]) + self.assertEqual("v1alpha1", self.openapi["info"]["version"]) + self.assertEqual( + [{ + "url": "https://scheduling.example.test", + "description": "Operator-managed TLS endpoint in front of the private Scheduling runtime.", + }], + self.openapi["servers"], + ) + self.assertEqual({"name": "Apache-2.0", "identifier": "Apache-2.0"}, + self.openapi["info"]["license"]) + self.assertNotRegex(self.committed, r"20\d\d-\d\d-\d\d") + + def test_wire_documents_match_the_rust_structs_field_for_field(self) -> None: + schemas = self.openapi["components"]["schemas"] + wire = GENERATOR.production_source(ROOT, GENERATOR.WIRE_SOURCE) + for rust_name, schema_name in GENERATOR.SCHEMA_STRUCTS[GENERATOR.WIRE_SOURCE].items(): + self.assertEqual( + {GENERATOR.camel_case(field) for field in GENERATOR.rust_struct_fields(wire, rust_name)}, + set(schemas[schema_name]["properties"]), + rust_name, + ) + + def test_dto_drift_fails_the_generator(self) -> None: + bad_openapi = copy.deepcopy(self.openapi) + del bad_openapi["components"]["schemas"]["OfferingDocument"]["properties"]["window"] + with self.assertRaisesRegex(ValueError, "DTO shape drifted from OfferingDocument"): + GENERATOR.verify_dto_schemas(ROOT, bad_openapi) + + def test_query_parameter_drift_fails_the_generator(self) -> None: + bad_openapi = copy.deepcopy(self.openapi) + parameters = bad_openapi["paths"]["/v1/services"]["get"]["parameters"] + bad_openapi["paths"]["/v1/services"]["get"]["parameters"] = [ + item for item in parameters if item["name"] != "limit" + ] + with self.assertRaisesRegex(ValueError, "OpenAPI query parameters drifted"): + GENERATOR.verify_handler_wiring(ROOT, bad_openapi["paths"], self.variants) + + def test_a_drifted_problem_response_fails_the_generator(self) -> None: + bad_openapi = copy.deepcopy(self.openapi) + bad_openapi["paths"]["/v1/services"]["get"]["responses"]["409"] = copy.deepcopy( + self.openapi["paths"]["/v1/holds"]["post"]["responses"]["409"] + ) + with self.assertRaisesRegex(ValueError, "per-operation problem mapping drifted"): + GENERATOR.verify_operation_problems( + bad_openapi, self.contract, self.catalog, self.variants + ) + + def test_a_page_is_its_items_and_the_cursor_that_continues_it(self) -> None: + schemas = self.openapi["components"]["schemas"] + wire = GENERATOR.production_source(ROOT, GENERATOR.WIRE_SOURCE) + page_fields = { + GENERATOR.camel_case(field) + for field in GENERATOR.rust_struct_fields(wire, "PageDocument") + } + self.assertEqual({"items", "nextCursor"}, page_fields) + for name in ( + "ServicePage", + "OfferingPage", + "ResourcePage", + "LocationPage", + "AvailabilityPage", + "AppointmentHistoryPage", + ): + self.assertEqual(page_fields, set(schemas[name]["properties"]), name) + self.assertEqual(["items", "nextCursor"], sorted(schemas[name]["required"]), name) + self.assertEqual({"anyOf": [{"type": "string"}, {"type": "null"}]}, + schemas[name]["properties"]["nextCursor"]) + self.assertEqual("#/components/schemas/AvailabilityEntry", + schemas["AvailabilityPage"]["properties"]["items"]["items"]["$ref"]) + + def test_the_offering_projection_keeps_its_mode_vocabulary_and_its_nulls(self) -> None: + schemas = self.openapi["components"]["schemas"] + offering = schemas["OfferingDocument"] + wire = GENERATOR.production_source(ROOT, GENERATOR.WIRE_SOURCE) + self.assertEqual({"exact-time", "arrival-window"}, set(offering["properties"]["mode"]["enum"])) + self.assertEqual( + set(GENERATOR.rust_kebab_case_unit_enum_values(wire, "SchedulingModeDocument")), + set(offering["properties"]["mode"]["enum"]), + ) + for field in ("durationMinutes", "bufferBeforeMinutes", "bufferAfterMinutes", + "startIncrementMinutes", "maxRecipients", "window"): + self.assertNotIn(field, offering["required"], field) + self.assertEqual({"type": "null"}, offering["properties"][field]["anyOf"][1], field) + self.assertIn("null, never zero", self.openapi["paths"]["/v1/offerings"]["get"]["description"]) + + def test_an_appointment_state_is_the_closed_vocabulary_the_wire_names(self) -> None: + schemas = self.openapi["components"]["schemas"] + wire = GENERATOR.production_source(ROOT, GENERATOR.WIRE_SOURCE) + self.assertEqual({"confirmed", "cancelled"}, set(schemas["AppointmentState"]["enum"])) + self.assertEqual( + set(GENERATOR.rust_kebab_case_unit_enum_values(wire, "AppointmentStateDocument")), + set(schemas["AppointmentState"]["enum"]), + ) + + def test_availability_entries_discriminate_on_the_wire_kind_tag(self) -> None: + schemas = self.openapi["components"]["schemas"] + entry = schemas["AvailabilityEntry"] + self.assertEqual(["AvailabilitySlot", "AvailabilityWindow"], + [variant["$ref"].rsplit("/", 1)[1] for variant in entry["oneOf"]]) + self.assertEqual("kind", entry["discriminator"]["propertyName"]) + self.assertEqual("slot", schemas["AvailabilitySlot"]["properties"]["kind"]["const"]) + self.assertEqual("window", schemas["AvailabilityWindow"]["properties"]["kind"]["const"]) + self.assertEqual({"kind", "start", "end", "free"}, set(schemas["AvailabilitySlot"]["required"])) + self.assertEqual({"kind", "window", "start", "end", "remaining"}, + set(schemas["AvailabilityWindow"]["required"])) + self.assertIn("channelRemaining", schemas["AvailabilityWindow"]["properties"]) + + def test_the_explain_projection_leaves_a_clean_answer_undefended(self) -> None: + explain = self.openapi["components"]["schemas"]["ExplainDocument"] + self.assertEqual(["offering", "start"], sorted(explain["required"])) + for field in ("publicCode", "detailedCode", "explanation"): + self.assertNotIn(field, explain["required"], field) + self.assertNotIn("anyOf", explain["properties"][field], field) + self.assertIn("answers with no codes at all", + self.openapi["paths"]["/v1/availability/explain"]["get"]["description"]) + + def test_a_create_carries_exactly_one_of_hold_or_admission(self) -> None: + create = self.openapi["components"]["schemas"]["CreateAppointmentRequest"] + self.assertEqual({"hold", "admission"}, set(create["properties"])) + self.assertEqual(1, create["minProperties"]) + self.assertIn("Exactly one of hold or admission", create["description"]) + self.assertIn("Both, or neither, is precondition.failed", create["description"]) + + def test_the_documented_bounds_are_the_rust_ones(self) -> None: + naming = GENERATOR.production_source(ROOT, GENERATOR.NAMING_SOURCE) + cursors = GENERATOR.production_source(ROOT, GENERATOR.CURSORS_SOURCE) + service = GENERATOR.production_source(ROOT, GENERATOR.SERVICE_SOURCE) + http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) + httpsec = GENERATOR.production_source(ROOT, GENERATOR.HTTPSEC_SOURCE) + self.assertIn('pub const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key";', naming) + self.assertIn("pub const MAXIMUM_IDEMPOTENCY_KEY_BYTES: usize = 128;", naming) + self.assertIn("byte.is_ascii_graphic()", http) + self.assertIn("value.len() > MAXIMUM_IDEMPOTENCY_KEY_BYTES", http) + self.assertIn("pub const CURSOR_LIFETIME_MINUTES: i64 = 15;", cursors) + self.assertIn("const MAXIMUM_CURSOR_BYTES: usize = 256;", cursors) + self.assertIn("pub const DEFAULT_PAGE_LIMIT: usize = 50;", service) + self.assertIn("pub const MAXIMUM_PAGE_LIMIT: usize = 200;", service) + self.assertIn("const MAXIMUM_AVAILABILITY_SPAN_DAYS: i64 = 62;", service) + self.assertIn("pub const DEFAULT_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024;", httpsec) + for action in ("hold.create", "hold.release", "appointment.create", + "appointment.reschedule", "appointment.cancel"): + self.assertIn(f'"{action}"', service) + self.assertIn("15-minute", parameter(self.openapi, "get", "/v1/services", "cursor")["description"]) + self.assertIn("62 days", self.openapi["paths"]["/v1/availability"]["get"]["description"]) + + def test_the_request_edge_rejection_map_is_the_rust_one(self) -> None: + http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) + for text in ( + "StatusCode::BAD_REQUEST => Some(ProblemCode::RequestInvalid)", + "StatusCode::PAYLOAD_TOO_LARGE => Some(ProblemCode::RequestBodyTooLarge)", + "StatusCode::UNSUPPORTED_MEDIA_TYPE => Some(ProblemCode::RequestUnsupportedMediaType)", + "StatusCode::UNPROCESSABLE_ENTITY => Some(ProblemCode::RequestUnprocessable)", + "problem_response(ProblemCode::RequestNotFound)", + "problem_response(ProblemCode::RequestMethodNotAllowed)", + ): + self.assertIn(text, http) + for key in GENERATOR.ROUTE_HANDLERS: + self.assertIn("request.body-too-large", documented_codes(self.openapi, key), key) + self.assertIn("request.method-not-allowed", documented_codes(self.openapi, key), key) + + def test_the_problem_envelope_is_the_platform_one(self) -> None: + problem = self.openapi["components"]["schemas"]["Problem"] + self.assertEqual({"type", "title", "status", "detail", "code", "traceId"}, + set(problem["properties"])) + self.assertEqual(["code", "detail", "status", "title", "traceId", "type"], + sorted(problem["required"])) + self.assertEqual(32, len(problem["properties"]["code"]["enum"])) + httpsec = GENERATOR.production_source(ROOT, GENERATOR.HTTPSEC_SOURCE) + http = GENERATOR.production_source(ROOT, GENERATOR.HTTP_SOURCE) + body = GENERATOR.rust_struct_fields(httpsec, "ProblemBody") + self.assertEqual({"type_uri", "title", "status", "detail", "code", "trace_id"}, body) + self.assertIn('CACHE_CONTROL,\n "no-store"', http) + self.assertIn('RETRY_AFTER,\n "5"', http) + self.assertIn('"Bearer"', http) + + def test_the_committed_document_is_the_one_this_generator_writes(self) -> None: + self.assertEqual(self.committed, json.dumps(self.openapi, indent=2, sort_keys=True) + "\n") + + +if __name__ == "__main__": + unittest.main() From 2a4dec59d2d8289a1914c8e4f554d69f00c2ae49 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 12:32:06 +0700 Subject: [PATCH 023/115] docs(scheduling): publish the Scheduling API reference on the docs site The development docset gains the Registry Scheduling product's API reference lane: the narrative contract page (authority profiles, task grants, idempotency, revision rules, problem codes, route families), the generated operations pages rendered from the product OpenAPI artifact, and the problem-code table generated from scheduling-api.yaml and cross-checked against the product problem catalogue by the new scheduling-api-reference test. Registration follows the Casework pattern: repo-docs and the latest docset name the product, fetch-openapi pulls its committed artifact, redocly and the starlight-openapi plugin render it, and docsets without the product (every archived set predating it) redirect the page to the current docset instead of serving it. The information-architecture test pins the product order, the docset gating, and the fetch rule. Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 35 ++++ docs/site/redocly.yaml | 2 + docs/site/scripts/fetch-openapi.mjs | 5 +- docs/site/scripts/generate-data.mjs | 1 + .../site/scripts/generated-api-bases.test.mjs | 4 + .../scripts/information-architecture.test.mjs | 53 +++-- .../scripts/scheduling-api-reference.test.mjs | 57 ++++++ .../src/components/ApiReferenceTable.astro | 9 +- .../reference/apis/registry-scheduling.mdx | 189 ++++++++++++++++++ docs/site/src/data/docsets.yaml | 3 + docs/site/src/data/repo-docs.yaml | 10 + docs/site/src/data/scheduling-api.yaml | 36 ++++ docs/site/src/lib/generated-api-bases.mjs | 7 +- 13 files changed, 393 insertions(+), 18 deletions(-) create mode 100644 docs/site/scripts/scheduling-api-reference.test.mjs create mode 100644 docs/site/src/content/docs/reference/apis/registry-scheduling.mdx create mode 100644 docs/site/src/data/scheduling-api.yaml diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index 5cedc351b3..7e1bd4285f 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -65,6 +65,7 @@ export function resolveDocsetBuildContext(docsets, env = process.env) { const isSearchExcludedBuild = isHistoricalArchiveBuild || selectedDocset.availability === 'unreleased'; const hasCasework = Boolean(selectedDocset.products?.['registry-casework']); + const hasScheduling = Boolean(selectedDocset.products?.['registry-scheduling']); const hasRender = Boolean(selectedDocset.products?.['registry-render']); const currentDocset = docsets.docsets.find((entry) => entry.id === docsets.current); if (!currentDocset) throw new Error(`current docs docset "${docsets.current}" not found`); @@ -84,6 +85,7 @@ export function resolveDocsetBuildContext(docsets, env = process.env) { isHistoricalArchiveBuild, isSearchExcludedBuild, hasCasework, + hasScheduling, hasRender, internalRedirect, currentDocsetRedirect, @@ -97,6 +99,7 @@ const { isHistoricalArchiveBuild, isSearchExcludedBuild, hasCasework, + hasScheduling, hasRender, internalRedirect, currentDocsetRedirect, @@ -132,6 +135,28 @@ const caseworkOpenApiSchema = { operations: { labels: /** @type {'path'} */ ('path'), badges: true }, }, }; +const schedulingOpenApiSchema = { + base: 'reference/apis/scheduling', + schema: './openapi/registry-scheduling.openapi.json', + sidebar: { + label: 'API operations', + collapsed: true, + operations: { labels: /** @type {'path'} */ ('path'), badges: true }, + }, +}; +const schedulingRoutes = [ + '/reference/apis/registry-scheduling/', +]; +/** + * @param {boolean} hasScheduling + * @param {(path: string) => string} currentDocsetRedirect + */ +export function schedulingRedirects(hasScheduling, currentDocsetRedirect) { + if (hasScheduling) return {}; + return Object.fromEntries(schedulingRoutes.flatMap((route) => [ + [route, currentDocsetRedirect(route)], + ])); +} const caseworkRoutes = [ '/start/casework/', '/tutorials/first-casework/', @@ -170,6 +195,7 @@ export default defineConfig({ ...buildNotaryRetirementRedirects(currentDocsetRedirect), ...buildRelayV2RetirementRedirects(currentDocsetRedirect), ...caseworkRedirects(hasCasework, currentDocsetRedirect), + ...schedulingRedirects(hasScheduling, currentDocsetRedirect), '/start/': internalRedirect('/'), '/start/see-it-live/': internalRedirect('/'), // Retired product choosers. The homepage chooses between the products, so @@ -308,6 +334,7 @@ export default defineConfig({ }, }, ...[caseworkOpenApiSchema].filter(() => hasCasework), + ...[schedulingOpenApiSchema].filter(() => hasScheduling), ]), ], defaultLocale: 'root', @@ -544,6 +571,14 @@ export default defineConfig({ { label: 'Client API reference', slug: 'reference/client-api' }, ], }] : []), + ...(hasScheduling ? [{ + label: 'Registry Scheduling', + collapsed: true, + items: [ + { label: 'API contract', slug: 'reference/apis/registry-scheduling' }, + ...openAPISidebarGroups.slice(2, 3), + ], + }] : []), ...(hasRender ? [{ label: 'Registry Render', collapsed: true, diff --git a/docs/site/redocly.yaml b/docs/site/redocly.yaml index 00d60d4366..795160c6d4 100644 --- a/docs/site/redocly.yaml +++ b/docs/site/redocly.yaml @@ -3,6 +3,8 @@ extends: apis: registry-casework: root: openapi/registry-casework.openapi.json + registry-scheduling: + root: openapi/registry-scheduling.openapi.json registry-evidence: root: openapi/registry-evidence.openapi.json rules: diff --git a/docs/site/scripts/fetch-openapi.mjs b/docs/site/scripts/fetch-openapi.mjs index 24bc2c4964..9a694e5285 100644 --- a/docs/site/scripts/fetch-openapi.mjs +++ b/docs/site/scripts/fetch-openapi.mjs @@ -42,6 +42,7 @@ const cacheRoot = resolve(root, '.repo-docs-cache'); // aggregated API reference; others are skipped. const SPEC_SOURCES = { 'registry-casework': 'products/casework/generated/registry-casework.openapi.json', + 'registry-scheduling': 'products/scheduling/generated/registry-scheduling.openapi.json', 'registry-evidence': 'products/evidence/generated/registry-evidence.openapi.json', }; @@ -117,7 +118,9 @@ async function main() { let written = 0; for (const [repoId, specPath] of Object.entries(SPEC_SOURCES)) { - if (repoId === 'registry-casework' && !docset.products[repoId]) { + // Casework and Scheduling first enter the docset line after the archives + // that predate them, so a docset without the product pins no spec for it. + if ((repoId === 'registry-casework' || repoId === 'registry-scheduling') && !docset.products[repoId]) { console.log(`Skipped ${repoId} OpenAPI spec absent from docset ${docset.id}.`); continue; } diff --git a/docs/site/scripts/generate-data.mjs b/docs/site/scripts/generate-data.mjs index a0aaaffc76..654f9ca221 100644 --- a/docs/site/scripts/generate-data.mjs +++ b/docs/site/scripts/generate-data.mjs @@ -30,6 +30,7 @@ const required = { 'breg-events': ['id', 'title', 'columns', 'rows'], 'breg-api': ['id', 'title', 'columns', 'rows'], 'casework-api': ['id', 'title', 'columns', 'rows'], + 'scheduling-api': ['id', 'title', 'columns', 'rows'], }; const generated = []; diff --git a/docs/site/scripts/generated-api-bases.test.mjs b/docs/site/scripts/generated-api-bases.test.mjs index 63f0e33fa4..1465c850e8 100644 --- a/docs/site/scripts/generated-api-bases.test.mjs +++ b/docs/site/scripts/generated-api-bases.test.mjs @@ -42,6 +42,8 @@ test('a generated base and its descendants are recognised', () => { assert.equal(isGeneratedApiPath('/reference/apis/evidence/operations/gethealth/'), true); assert.equal(isGeneratedApiDir('reference/apis/casework'), true); assert.equal(isGeneratedApiPath('/reference/apis/casework/operations/health/'), true); + assert.equal(isGeneratedApiDir('reference/apis/scheduling'), true); + assert.equal(isGeneratedApiPath('/reference/apis/scheduling/operations/gethealthz/'), true); }); test('the hand-authored narrative pages keep their Markdown twin', () => { @@ -52,6 +54,8 @@ test('the hand-authored narrative pages keep their Markdown twin', () => { assert.equal(isGeneratedApiPath('/reference/apis/registry-evidence/'), false); assert.equal(isGeneratedApiDir('reference/apis/registry-casework'), false); assert.equal(isGeneratedApiPath('/reference/apis/registry-casework/'), false); + assert.equal(isGeneratedApiDir('reference/apis/registry-scheduling'), false); + assert.equal(isGeneratedApiPath('/reference/apis/registry-scheduling/'), false); assert.equal(isGeneratedApiDir('reference/apis'), false); assert.equal(isGeneratedApiPath('/reference/apis/'), false); }); diff --git a/docs/site/scripts/information-architecture.test.mjs b/docs/site/scripts/information-architecture.test.mjs index 36a8e38eb1..c56af41dd9 100644 --- a/docs/site/scripts/information-architecture.test.mjs +++ b/docs/site/scripts/information-architecture.test.mjs @@ -23,6 +23,13 @@ const resolveDocsetBuildContext = new Function( const caseworkRedirects = new Function( `${caseworkRoutesSource}; ${redirectsSource.replace(/^export /, '')}; return caseworkRedirects;`, )(); +const schedulingRedirectsSource = configSource.match(/export function schedulingRedirects[\s\S]*?^}\n/m)?.[0]; +const schedulingRoutesSource = configSource.match(/const schedulingRoutes = \[[\s\S]*?\];/)?.[0]; +assert.ok(schedulingRedirectsSource && schedulingRoutesSource, + 'could not isolate Scheduling docset routing'); +const schedulingRedirects = new Function( + `${schedulingRoutesSource}; ${schedulingRedirectsSource.replace(/^export /, '')}; return schedulingRedirects;`, +)(); const homepageSource = readFileSync(resolve(siteRoot, 'src/content/docs/index.mdx'), 'utf8'); const validationSource = readFileSync( resolve(siteRoot, 'src/content/docs/verify/index.mdx'), @@ -58,11 +65,18 @@ const caseworkOpenApiSchema = { schema: './openapi/registry-casework.openapi.json', sidebar: { label: 'API operations', collapsed: true }, }; +const schedulingOpenApiSchema = { + base: 'reference/apis/scheduling', + schema: './openapi/registry-scheduling.openapi.json', + sidebar: { label: 'API operations', collapsed: true }, +}; const apiSchemas = new Function( 'hasCasework', + 'hasScheduling', 'caseworkOpenApiSchema', + 'schedulingOpenApiSchema', `return ${apiConfigSource};`, -)(true, caseworkOpenApiSchema); +)(true, true, caseworkOpenApiSchema, schedulingOpenApiSchema); const generatedAPI = apiSchemas.map((schema) => ({ ...schema.sidebar, items: [{ label: 'Generated tag', items: [] }], @@ -74,6 +88,7 @@ const sidebarFactory = new Function( 'openAPISidebarGroups', 'flattenSidebarGroups', 'hasCasework', + 'hasScheduling', 'hasRender', `return [${sidebarSource}];`, ); @@ -87,7 +102,7 @@ const sidebarArguments = [ generatedAPI, flattenSidebarGroups, ]; -const sidebar = sidebarFactory(...sidebarArguments, true, true); +const sidebar = sidebarFactory(...sidebarArguments, true, true, true); function section(label) { const group = sidebar.find((item) => item.label === label); @@ -199,6 +214,7 @@ test('uses the product navigation in its published order', () => { 'Registry Relay', 'Base Registry Engine', 'Registry Casework', + 'Registry Scheduling', 'Registry Render', 'Registry Discovery', 'Operations', @@ -212,30 +228,40 @@ test('selects Casework routes, sidebar, and API from the docset product manifest current: 'latest', released: 'v0.29.0', docsets: [ - { id: 'latest', status: 'current', availability: 'unreleased', path: '/dev/', products: { 'registry-casework': { ref: 'HEAD' }, 'registry-render': { ref: 'HEAD' } } }, + { id: 'latest', status: 'current', availability: 'unreleased', path: '/dev/', products: { 'registry-casework': { ref: 'HEAD' }, 'registry-scheduling': { ref: 'HEAD' }, 'registry-render': { ref: 'HEAD' } } }, { id: 'v0.29.0', status: 'archived', availability: 'released', path: '/v/0.29.0/', products: {} }, { id: 'v0.30.0', status: 'archived', availability: 'candidate', path: '/v/0.30.0/', products: { 'registry-casework': { ref: 'v0.30.0' } } }, ], }; - for (const [id, env, hasCasework, hasRender] of [ - ['latest', {}, true, true], - ['v0.29.0', {}, false, false], - ['v0.30.0', {}, true, false], - ['v0.30.0', { DOCS_RELEASED_ARCHIVE: 'true' }, true, false], + for (const [id, env, hasCasework, hasScheduling, hasRender] of [ + ['latest', {}, true, true, true], + ['v0.29.0', {}, false, false, false], + ['v0.30.0', {}, true, false, false], + ['v0.30.0', { DOCS_RELEASED_ARCHIVE: 'true' }, true, false, false], ]) { const context = resolveDocsetBuildContext(docsets, { DOCS_DOCSET: id, ...env }); assert.equal(context.hasCasework, hasCasework, id); + assert.equal(context.hasScheduling, hasScheduling, id); assert.equal(context.hasRender, hasRender, id); - const docsetSidebar = sidebarFactory(...sidebarArguments, context.hasCasework, context.hasRender); + const docsetSidebar = sidebarFactory( + ...sidebarArguments, + context.hasCasework, + context.hasScheduling, + context.hasRender, + ); assert.equal(docsetSidebar.some((item) => item.label === 'Registry Casework'), hasCasework, id); + assert.equal(docsetSidebar.some((item) => item.label === 'Registry Scheduling'), hasScheduling, id); assert.equal(docsetSidebar.some((item) => item.label === 'Registry Render'), hasRender, id); - assert.equal(new Function('hasCasework', 'caseworkOpenApiSchema', `return ${apiConfigSource};`) - (context.hasCasework, caseworkOpenApiSchema).length, hasCasework ? 2 : 1, id); + assert.equal(new Function('hasCasework', 'hasScheduling', 'caseworkOpenApiSchema', 'schedulingOpenApiSchema', `return ${apiConfigSource};`) + (context.hasCasework, context.hasScheduling, caseworkOpenApiSchema, schedulingOpenApiSchema).length, + 1 + Number(hasCasework) + Number(hasScheduling), id); const redirects = caseworkRedirects(context.hasCasework, context.currentDocsetRedirect); assert.equal(redirects['/operate/casework/'] !== undefined, !hasCasework, id); assert.equal(redirects['/tutorials/first-casework.md'] !== undefined, !hasCasework, id); + const schedulingRedirectsResult = schedulingRedirects(context.hasScheduling, context.currentDocsetRedirect); + assert.equal(schedulingRedirectsResult['/reference/apis/registry-scheduling/'] !== undefined, !hasScheduling, id); } - assert.match(fetchOpenapiSource, /repoId === 'registry-casework' && !docset\.products\[repoId\]/); + assert.match(fetchOpenapiSource, /\(repoId === 'registry-casework' \|\| repoId === 'registry-scheduling'\) && !docset\.products\[repoId\]/); for (const route of [ '/start/casework/', '/tutorials/first-casework/', @@ -247,6 +273,8 @@ test('selects Casework routes, sidebar, and API from the docset product manifest assert.match(configSource, new RegExp(`'${route}'`)); } assert.match(configSource, /\.\.\.caseworkRedirects\(hasCasework, currentDocsetRedirect\)/); + assert.match(configSource, /'\/reference\/apis\/registry-scheduling\/'/); + assert.match(configSource, /\.\.\.schedulingRedirects\(hasScheduling, currentDocsetRedirect\)/); }); test('starts with only Start expanded and all secondary groups collapsed', () => { @@ -322,6 +350,7 @@ test('uses the formal product names for top-level sections', () => { 'Registry Relay', 'Base Registry Engine', 'Registry Casework', + 'Registry Scheduling', 'Registry Render', 'Registry Discovery', ]) { diff --git a/docs/site/scripts/scheduling-api-reference.test.mjs b/docs/site/scripts/scheduling-api-reference.test.mjs new file mode 100644 index 0000000000..cf77270fac --- /dev/null +++ b/docs/site/scripts/scheduling-api-reference.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { parse } from 'yaml'; + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); +const tables = parse(read('../src/data/scheduling-api.yaml')); +const page = read('../src/content/docs/reference/apis/registry-scheduling.mdx'); + +test('every ApiReferenceTable id used in the Scheduling reference exists in scheduling-api.yaml', () => { + const referenced = [...page.matchAll(/ match[1]); + assert.ok(referenced.includes('problem-codes'), 'reference/apis/registry-scheduling.mdx should render the problem-codes table'); + assert.equal( + [...page.matchAll(/ table.id)); + for (const id of referenced) { + assert.ok(ids.has(id), `${id} is referenced by in reference/apis/registry-scheduling.mdx but missing from scheduling-api.yaml`); + } +}); + +test('scheduling-api tables are rectangular with non-empty columns and rows', () => { + assert.equal(new Set(tables.map((table) => table.id)).size, tables.length); + for (const table of tables) { + assert.ok(table.columns.length > 0, `${table.id} has no columns`); + assert.ok(table.rows.length > 0, `${table.id} has no rows`); + for (const row of table.rows) { + assert.equal(row.length, table.columns.length, `${table.id} has a row whose cell count does not match its column count`); + } + } +}); + +test('scheduling-api.json is the generated output of scheduling-api.yaml', () => { + assert.deepEqual(JSON.parse(read('../src/data/generated/scheduling-api.json')), tables); +}); + +test('every problem code row matches the product problem catalogue', () => { + const document = JSON.parse(read('../../../products/scheduling/generated/registry-scheduling.openapi.json')); + const catalogue = new Map(); + for (const schema of Object.values(document.components.schemas)) { + for (const part of schema.allOf ?? []) { + const code = part.properties?.code?.const; + if (code) catalogue.set(code, part.properties.status.const); + } + } + assert.ok(catalogue.size >= 30, 'the generated document should carry the full problem catalogue'); + const rows = tables.find((table) => table.id === 'problem-codes').rows; + assert.equal(rows.length, catalogue.size, 'the table and the catalogue hold the same number of codes'); + for (const [code, status, when] of rows) { + const bareCode = code.replace(/`/g, ''); + assert.ok(catalogue.has(bareCode), `${bareCode} appears in scheduling-api.yaml but not in the product catalogue`); + assert.equal(String(catalogue.get(bareCode)), status, `${bareCode} lists status ${status} but the catalogue pins ${catalogue.get(bareCode)}`); + assert.ok(when.length > 0, `${bareCode} has an empty when sentence`); + } +}); diff --git a/docs/site/src/components/ApiReferenceTable.astro b/docs/site/src/components/ApiReferenceTable.astro index 1cf45fb151..ec14288807 100644 --- a/docs/site/src/components/ApiReferenceTable.astro +++ b/docs/site/src/components/ApiReferenceTable.astro @@ -1,13 +1,18 @@ --- import bregTables from '../data/generated/breg-api.json'; import caseworkTables from '../data/generated/casework-api.json'; +import schedulingTables from '../data/generated/scheduling-api.json'; interface Props { id: string; - source?: 'breg-api' | 'casework-api'; + source?: 'breg-api' | 'casework-api' | 'scheduling-api'; } -const sources = { 'breg-api': bregTables, 'casework-api': caseworkTables }; +const sources = { + 'breg-api': bregTables, + 'casework-api': caseworkTables, + 'scheduling-api': schedulingTables, +}; const source = Astro.props.source ?? 'breg-api'; const table = sources[source].find((entry) => entry.id === Astro.props.id); if (!table) throw new Error(`Unknown ${source} reference table: ${Astro.props.id}`); diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx new file mode 100644 index 0000000000..4c11a2b002 --- /dev/null +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -0,0 +1,189 @@ +--- +title: Registry Scheduling API +description: Authority profiles, task grants, idempotency, problem codes, and route families for the generated Registry Scheduling OpenAPI operations. +status: current +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-09-15" +doc_type: reference +locale: en +standards_referenced: + - openapi +--- + +import ApiReferenceTable from '../../../../components/ApiReferenceTable.astro'; + +Registry Scheduling serves one HTTP surface for the published catalogue, availability, holds, and +appointments. Every call presents a bearer access token, and one of three authority profiles decides +what that token must carry: a read scope for catalogue and owned-record reads, a separate explain scope +for the diagnostic availability read, or a complete task grant for every commitment. The contract is +unreleased and carries no frozen compatibility promise. + +[Open the Registry Scheduling API operations](../scheduling/) for every route, parameter, schema, and +reachable problem code. This page holds the authority, revision, and recovery rules those operations +assume. + +## Contract boundary + +Every request presents `Authorization: Bearer ` holding an RFC 9068 access token, `typ: +at+jwt`, whose claims name the caller's actor kind in `registry_actor_kind` as `human`, `agent`, or +`service`. Liveness and readiness are the only routes that take no token. + +{/* Evidence: crates/registry-scheduling/src/auth.rs, authenticate_mutate() and validate_compact_access_token; + crates/registry-platform-oidc/src/authorization_claims.rs, actor_kind(). */} + +The four caller-driven commitments carry an `Idempotency-Key` the caller chooses, bounded to 128 +bytes: creating a hold, creating an appointment, rescheduling one, and cancelling one. A retry under +the same key with the same request returns the stored first answer, and the same key with a different +request is refused with `idempotency.key-reused`. Releasing a hold carries no caller key: the hold's +own identifier is the idempotency key, so a retried release replays the first answer without the +caller holding anything back. + +{/* Evidence: crates/registry-scheduling/src/http.rs, idempotency_key() and router(); + crates/registry-scheduling/src/service.rs, release_hold(); + crates/registry-scheduling-core/src/naming.rs, MAXIMUM_IDEMPOTENCY_KEY_BYTES. */} + +Observed revisions travel in request bodies, not in an `If-Match` header: an admission names the +`policyRevision` it resolved the offering under, and a reschedule or cancel names the appointment +revision it acted on. A stale revision is refused with `policy.changed`, `revision.mismatch`, or +`precondition.failed`, and the caller reloads the catalogue and tries again. + +{/* Evidence: crates/registry-scheduling-core/src/model.rs, AdmissionRequest; + crates/registry-scheduling-core/src/admission.rs, check_duplicate(); + products/scheduling/generated/registry-scheduling.openapi.json. */} + +A refusal answers `application/problem+json` with `type`, `title`, `status`, `detail`, `code`, and +`traceId`. The `type` is the code with each dot replaced by a slash under +`https://id.registrystack.org/problems/registry-scheduling/`, and `detail` is the fixed sentence that +belongs to the code. No field repeats a submitted value. Every response, refusals included, carries +`Cache-Control: no-store` and propagates or mints a `traceparent` value. + +{/* Evidence: crates/registry-platform-httpsec/src/server.rs, security_headers() and ProblemBody; + crates/registry-scheduling-core/src/problem.rs, ProblemCode. */} + +Availability pages list a slot only while it holds a free unit: a slot whose members are all +committed, held, or closed leaves the page rather than appearing with zero free. A caller watching a +slot disappear therefore reads the same fact as one reading `free: 0`. + +{/* Evidence: crates/registry-scheduling/src/service.rs, exact_time_slots(). */} + +## Authority profiles + +The three profiles are disjoint. A token carrying a product scope does not thereby gain a commitment +path, and a task grant does not thereby gain a catalogue read. + +| Profile | Routes | The token must carry | +| --- | --- | --- | +| Catalogue read | `/v1/scheduling`, `/v1/services`, `/v1/offerings`, `/v1/resources`, `/v1/locations`, `/v1/availability`, one appointment read, its history | The configured read scope, `scheduling-read` by default, in `registry_scopes` | +| Explain | `/v1/availability/explain` | The separately configured explain scope, `scheduling-explain` by default, which is never the read scope | +| Commitment | Hold create and release, appointment create, reschedule, cancel | A complete task grant; no product scope | + +{/* Evidence: crates/registry-scheduling/src/auth.rs, authenticate_read(), authenticate_explain(), and + authenticate_mutate(); crates/registry-scheduling/src/http.rs, router(). */} + +The explain path is separately authorized because it can name the member-level cause +`resource.unavailable`. The public availability path answers the same state as `capacity.exhausted` +and names no member. + +{/* Evidence: crates/registry-scheduling-core/src/admission.rs, detailed_code(); + products/scheduling/generated/registry-scheduling.openapi.json. */} + +## Task grants + +A commitment carries no product scope. Its authority is a task grant inside the access token: the six +`registry_grant_*` members that make a grant detectable, plus `registry_purpose` and +`registry_approver`, bound to the verified client and to this deployment's audience before the caller +reaches the service. An incomplete grant set is refused as a credentials problem +(`authentication.refused`), never honored partially. + +{/* Evidence: crates/registry-platform-oidc/src/authorization_claims.rs, grant_claims(), core_grant_names(), + and verify_context(); crates/registry-scheduling/src/auth.rs, verified_caller(). */} + +The grant's bounds carry one permission per location, and a permission names an offering's service, an +offering's location, and the actions it allows: + +- `hold.create` and `hold.release` +- `appointment.create`, `appointment.reschedule`, and `appointment.cancel` + +A permission matches an offering only when its service and location both equal the offering's own and +the action is listed. The match is exact: a broader service or location never covers a narrower one, +and the grant's client must equal the client the deployment verified. + +{/* Evidence: crates/registry-scheduling/src/service.rs, require_permission(), HOLD_CREATE_ACTION, + HOLD_RELEASE_ACTION, APPOINTMENT_CREATE_ACTION, APPOINTMENT_RESCHEDULE_ACTION, and + APPOINTMENT_CANCEL_ACTION; crates/registry-platform-oidc/src/authorization_claims.rs, + SchedulingPermission. */} + +The grant is re-checked inside the capacity transaction, after the caller's request has been read and +immediately before the claim commits, so an expired or narrowed grant cannot commit capacity even when +its token was valid at the door. A grant that does not cover the request answers +`operation.not-authorized`. The audit journal records `authorization.allowed` or +`authorization.refused` for that check as an audit reason, never as a problem code. + +{/* Evidence: crates/registry-scheduling/src/store.rs, check_grant_current(); + crates/registry-scheduling/src/service.rs, require_permission(). */} + +## Problem codes + +Every Scheduling problem response carries one `code` from this closed catalogue, always answered with +the HTTP status listed beside it. + +Two codes are reserved: `eligibility.unavailable` and `hook.unavailable` are defined, titled, and +status-pinned because the reserved lifecycle-hook ABI `registry.scheduling-hook/v1` will produce them, +and no path in this milestone answers them yet. + +{/* Evidence: crates/registry-scheduling-core/src/problem.rs, ProblemCode; + products/scheduling/generated/registry-scheduling.openapi.json, + x-registry-scheduling-reserved-problems. */} + + + +{/* Problem code table generated from docs/site/src/data/scheduling-api.yaml by + docs/site/scripts/generate-data.mjs. Run npm run generate from docs/site. */} + +{/* Evidence: products/scheduling/generated/registry-scheduling.openapi.json; + docs/site/src/data/scheduling-api.yaml. */} + +## Route families + +The served routes group into these families. A family name grants nothing on its own; authority is +checked per route. + +| Family | Base paths | Authority profile | +| --- | --- | --- | +| Service description | `/v1/scheduling` | Catalogue read | +| Catalogue | `/v1/services`, `/v1/offerings`, `/v1/resources`, `/v1/locations` | Catalogue read | +| Availability | `/v1/availability`, `/v1/availability/explain` | Catalogue read; Explain | +| Holds | `/v1/holds`, `/v1/holds/{holdId}` | Commitment | +| Appointments | `/v1/appointments`, `/v1/appointments/{appointmentId}`, its `reschedule`, `cancel`, and `history` | Commitment for the mutations; Catalogue read for the reads | +| Liveness and readiness | `/healthz`, `/readyz` | Any caller, without a token | + +{/* Evidence: crates/registry-scheduling/src/http.rs, router(); + crates/registry-scheduling-core/src/naming.rs, SCHEDULING_PATH, SERVICES_PATH, OFFERINGS_PATH, + RESOURCES_PATH, LOCATIONS_PATH, AVAILABILITY_PATH, AVAILABILITY_EXPLAIN_PATH, HOLDS_PATH, and + APPOINTMENTS_PATH. */} + +## Source of truth + +The implementation is the source of truth, and the generated document is the published form of it. +The product generator builds the OpenAPI document from the Rust-owned route inventory, authority +profiles, wire schemas, and the exported problem catalogue, and the product check regenerates the +document and refuses byte drift. A running Scheduling process serves the routes in that document, and +does not serve the document itself. + +{/* Evidence: products/scheduling/scripts/{generate_openapi.py,check-checkpoint.sh}; + products/scheduling/generated/registry-scheduling.openapi.json. */} + +The problem code table is generated from maintained data whose codes and statuses match the generated +document. The route families table and the header rules are maintained by hand against the cited +source. + +{/* Evidence: docs/site/src/data/scheduling-api.yaml; + products/scheduling/scripts/test_generate_openapi.py. */} + +## Next + +- [Registry Scheduling API operations](../scheduling/) for every route, parameter, and schema. +- [schedulingctl command reference](../../cli/schedulingctl/) for the authoring and packaging commands + that produce the policy a deployment serves. diff --git a/docs/site/src/data/docsets.yaml b/docs/site/src/data/docsets.yaml index d49a40c69b..56ea023b20 100644 --- a/docs/site/src/data/docsets.yaml +++ b/docs/site/src/data/docsets.yaml @@ -26,6 +26,9 @@ docsets: registry-casework: version: main source (unreleased) ref: HEAD + registry-scheduling: + version: main source (unreleased) + ref: HEAD registry-render: version: main source (unreleased) ref: HEAD diff --git a/docs/site/src/data/repo-docs.yaml b/docs/site/src/data/repo-docs.yaml index 28999854d5..72fd410ff2 100644 --- a/docs/site/src/data/repo-docs.yaml +++ b/docs/site/src/data/repo-docs.yaml @@ -5,6 +5,16 @@ # standards_referenced and last_reviewed so future current-page reviews cannot # rewrite historical metadata. repos: + # Scheduling is unreleased MVP work. Only its product-owned OpenAPI artifact + # is pulled; the API reference page beside it is hand-authored, and no other + # Scheduling doc lane exists yet. + registry-scheduling: + remote: https://github.com/registrystack/registry-stack + ref: HEAD + version: main source (unreleased) + local: ../.. + openapi: products/scheduling/generated/registry-scheduling.openapi.json + # Casework first enters an archived docset at v0.30.0. Its product-owned # OpenAPI artifact is rendered directly; adopter prose remains hand-authored # in the Casework product lane rather than duplicating the long product README. diff --git a/docs/site/src/data/scheduling-api.yaml b/docs/site/src/data/scheduling-api.yaml new file mode 100644 index 0000000000..4a3ae00f61 --- /dev/null +++ b/docs/site/src/data/scheduling-api.yaml @@ -0,0 +1,36 @@ +- id: problem-codes + title: Problem codes + columns: [Code, Status, When] + rows: + - ['`authentication.refused`', '401', 'The bearer credential is missing, invalid, or expired. Sign in again.'] + - ['`booking.duplicate-active`', '409', 'An active booking already holds this party''s duplicate key. Cancel or complete it before booking again.'] + - ['`cancellation.cutoff-passed`', '409', 'The cancellation cutoff for this appointment has passed.'] + - ['`capability.unmatched`', '422', 'No backing member carries every capability the offering requires.'] + - ['`capacity.exhausted`', '409', 'The supply is fully committed for the requested interval. Choose another time.'] + - ['`cursor.expired`', '410', 'A page cursor has expired. Start again without a cursor and deduplicate entries by `id`.'] + - ['`cursor.invalid`', '400', 'A page cursor is malformed or was issued for a different request.'] + - ['`eligibility.unavailable`', '503', 'A required eligibility check could not run. Reserved: no milestone path answers it yet.'] + - ['`hold.expired`', '410', 'The hold expired before confirmation. Its capacity is bookable again; start a new request.'] + - ['`hold.released`', '409', 'The claim is not an active hold. It may already be confirmed or released.'] + - ['`hook.unavailable`', '503', 'A required lifecycle hook could not run, so the request was refused rather than half-applied. Reserved: no milestone path answers it yet.'] + - ['`horizon.outside`', '422', 'The requested start is earlier than the lead time allows or further ahead than the horizon allows.'] + - ['`idempotency.expired`', '410', 'The stored response for the idempotency key has expired. Reconcile the original operation before choosing a new key.'] + - ['`idempotency.key-reused`', '409', 'An idempotency key was presented for a different request.'] + - ['`location.closed`', '409', 'A closure covers the requested start. Choose a start outside the closure.'] + - ['`operation.not-authorized`', '403', 'The caller''s task grant does not name this operation, or the deployment''s client does not match the grant''s client.'] + - ['`party.capacity-inadequate`', '422', 'The party is larger than the offering can ever serve, or its size falls outside the published units.'] + - ['`policy.changed`', '412', 'The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.'] + - ['`precondition.failed`', '412', 'The appointment changed since the caller loaded it. Reload and try again.'] + - ['`precondition.required`', '428', 'The mutation requires the revision the caller loaded, or the duplicate key the offering keys on.'] + - ['`prerequisite.missing`', '422', 'The party is missing a prerequisite the offering requires.'] + - ['`profile.not-authorized`', '403', 'The caller holds no profile that authorizes this request, for example a read scope check on a mutation route.'] + - ['`request.body-too-large`', '413', 'The request body exceeds the accepted size.'] + - ['`request.invalid`', '400', 'The request could not be read as a Scheduling request.'] + - ['`request.method-not-allowed`', '405', 'The route exists but not for this method.'] + - ['`request.not-found`', '404', 'The requested route does not exist.'] + - ['`request.unprocessable`', '422', 'The request body could not be processed.'] + - ['`request.unsupported-media-type`', '415', 'The request body is not JSON.'] + - ['`resource.unavailable`', '409', 'Every capable member is unavailable for this interval. The separately authorized explain path reports this code; the public path answers `capacity.exhausted`.'] + - ['`revision.mismatch`', '412', 'The window revision changed before the request committed. Reload the catalogue and try again.'] + - ['`schedule.unpublished`', '422', 'No published schedule serves that start. Choose a start on the published grid.'] + - ['`service.unavailable`', '503', 'Scheduling storage is unavailable. Retry after the service recovers.'] diff --git a/docs/site/src/lib/generated-api-bases.mjs b/docs/site/src/lib/generated-api-bases.mjs index b99813ec9b..4b6934a237 100644 --- a/docs/site/src/lib/generated-api-bases.mjs +++ b/docs/site/src/lib/generated-api-bases.mjs @@ -8,13 +8,14 @@ * this list, so registering a new API reference in astro.config.mjs is the only * place that has to learn about it. * - * These are the generated bases, not the hand-authored narrative page - * reference/apis/registry-evidence and reference/apis/registry-casework, which - * keep their .md. + * These are the generated bases, not the hand-authored narrative pages + * reference/apis/registry-evidence, reference/apis/registry-casework, and + * reference/apis/registry-scheduling, which keep their .md. */ export const GENERATED_API_BASES = [ 'reference/apis/evidence', 'reference/apis/casework', + 'reference/apis/scheduling', ]; /** True when a dist-relative page directory is a generated API route. */ From 28543c53824bd2f1e14395d48bea2da84b3c97a1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 13:58:27 +0700 Subject: [PATCH 024/115] fix(ci): register the five Scheduling crates in the Rust shard inventory Workspace.__init__ in ci_changes.py asserts the SHARDS inventory against the Cargo workspace and raises when a crate is unlisted. ci_event_routing.py constructs a Workspace unconditionally in the changes job, so the raise failed the entire PR gate closed with no test run. Add registry-platform-calendar to the platform shard: it is a shared, domain-neutral primitive (chrono/chrono-tz/thiserror only) consumed by registry-casework-core alongside the runtime and core crates, the same role every other registry-platform-* crate plays. Add a new scheduling shard mirroring casework's shape (core, runtime, ctl, client; Scheduling has no breg-adapter or node/py binding crates yet) for registry-scheduling-core, registry-scheduling, registry-schedulingctl, and registry-scheduling-client. python3 -m unittest discover -s .github/scripts -p 'test_ci_changes.py' goes from one error (raises before any test body runs) to 95 passing. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index f14984db01..13cb854259 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -24,6 +24,7 @@ "registry-platform-audit", "registry-platform-authcommon", "registry-platform-buildinfo", + "registry-platform-calendar", "registry-platform-canonical-json", "registry-platform-config", "registry-platform-crypto", @@ -66,6 +67,12 @@ "registry-casework-client-node", "registry-casework-client-py", ), + "scheduling": ( + "registry-scheduling-core", + "registry-scheduling", + "registry-schedulingctl", + "registry-scheduling-client", + ), "stack-client": ("registry-record", "registry-stack-client"), "evidence": ( "registry-evidence", From 4af803f2e5faab162b3906bc8bf4b91594e1b2db Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 14:02:19 +0700 Subject: [PATCH 025/115] test(scheduling): require a database for the runtime commitment suite A suite that passes with no database proves nothing. The fixture now expects SCHEDULING_TEST_DATABASE_URL the way the sibling records-apply suite already does, so an absent database fails the binary instead of reporting ten skipped successes. Signed-off-by: Jeremi Joslin --- .../tests/postgres_commitments.rs | 69 +++++-------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index aeaa4de364..efb45b57df 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -8,9 +8,9 @@ //! Every test runs in its own schema inside the database named by //! `SCHEDULING_TEST_DATABASE_URL`, adopted under one scheduling id, seeded //! with one location, two pools of one member each, and driven through the -//! same router the runtime serves. Without the variable each test skips with -//! a notice: a skipped test binary is not database verification, so the gate -//! that matters runs with the variable set. +//! same router the runtime serves. A test binary that passes because its +//! database URL is absent is not database verification, so the variable is +//! required: without it every test in this file fails on the spot. use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; @@ -172,7 +172,7 @@ impl Fixture { /// A fresh deployment in its own schema: migrated, adopted, seeded, published /// with every pool the policy names, and serving through the same router the /// runtime serves. -async fn fixture() -> Option { +async fn fixture() -> Fixture { let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); let mut pool_ids: Vec = policy .offerings @@ -186,10 +186,9 @@ async fn fixture() -> Option { /// A fresh deployment whose publication anchored exactly `pool_ids`, so a /// test can pin what happens when a policy names a pool no anchor covers. -async fn fixture_anchoring(pool_ids: &[String]) -> Option { - let Ok(base) = std::env::var("SCHEDULING_TEST_DATABASE_URL") else { - return None; - }; +async fn fixture_anchoring(pool_ids: &[String]) -> Fixture { + let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") + .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); let schema = format!("scheduling_{}", Uuid::new_v4().simple()); let separator = if base.contains('?') { '&' } else { '?' }; let scoped = format!("{base}{separator}options=-csearch_path%3D{schema}"); @@ -273,13 +272,13 @@ async fn fixture_anchoring(pool_ids: &[String]) -> Option { authenticator: Arc::new(authenticator()), store: store.clone(), }); - Some(Fixture { + Fixture { http, store, revision: u64::try_from(revision).expect("a bounded policy revision"), reader: reader_token(), agent: agent_token(), - }) + } } fn authenticator() -> SchedulingAuthenticator { @@ -499,10 +498,7 @@ async fn booked(fx: &Fixture, from_minutes: i64, to_minutes: i64, key: &str) -> #[tokio::test] async fn a_hold_confirms_into_an_appointment_with_attributable_history() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; let slot = first_slot(&fx, OFFERING, 90, 200).await; let (status, hold) = fx .post( @@ -580,10 +576,7 @@ async fn a_hold_confirms_into_an_appointment_with_attributable_history() { #[tokio::test] async fn a_direct_create_books_without_a_hold_and_exhausts_capacity() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; let slot = first_slot(&fx, OFFERING, 300, 440).await; let (status, appointment) = fx .post( @@ -614,10 +607,7 @@ async fn a_direct_create_books_without_a_hold_and_exhausts_capacity() { #[tokio::test] async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; let from = first_slot(&fx, OFFERING, 300, 440).await; let (status, appointment) = fx .post( @@ -666,10 +656,7 @@ async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() #[tokio::test] async fn cancellation_respects_the_cutoff_and_replays_its_verdict() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; // Near: inside the four-hour cutoff. Far: clear of it. let (near_id, near_revision) = booked(&fx, 90, 200, "cancel-near").await; let (status, refused) = fx @@ -755,10 +742,7 @@ async fn cancellation_respects_the_cutoff_and_replays_its_verdict() { #[tokio::test] async fn a_release_returns_capacity_answers_replay_and_blocks_confirmation() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; let slot = first_slot(&fx, OFFERING, 300, 440).await; let (status, hold) = fx .post( @@ -799,10 +783,7 @@ async fn a_release_returns_capacity_answers_replay_and_blocks_confirmation() { #[tokio::test] async fn an_expired_hold_returns_capacity_and_refuses_confirmation() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; let slot = first_slot(&fx, OFFERING, 90, 200).await; let (status, hold) = fx .post( @@ -846,10 +827,7 @@ async fn an_expired_hold_returns_capacity_and_refuses_confirmation() { #[tokio::test] async fn cursors_page_their_own_listing_and_refuse_foreign_contexts() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; let (status, first) = fx.get("/v1/services?limit=1", &fx.reader).await; assert_eq!(status, StatusCode::OK); assert_eq!(first["items"].as_array().unwrap().len(), 1); @@ -902,10 +880,7 @@ async fn cursors_page_their_own_listing_and_refuse_foreign_contexts() { #[tokio::test] async fn the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; let slot = first_slot(&fx, OFFERING, 300, 440).await; let (status, problem) = fx.request("GET", "/v1/services", "", None, None).await; @@ -980,10 +955,7 @@ async fn a_commitment_against_an_unanchored_pool_refuses_loudly() { // still answer, while a commitment that must lock the unanchored pool // refuses rather than transacting without the anchor it serializes // against, and the refusal leaves no stored attempt behind. - let Some(fx) = fixture_anchoring(&["north-counter".to_owned()]).await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture_anchoring(&["north-counter".to_owned()]).await; let slot = first_slot(&fx, SECOND_OFFERING, 300, 440).await; let (status, problem) = fx .post( @@ -1016,10 +988,7 @@ async fn a_commitment_against_an_unanchored_pool_refuses_loudly() { /// silently. #[tokio::test] async fn adoption_binds_one_identity_and_refuses_a_second() { - let Some(fx) = fixture().await else { - eprintln!("skipping: SCHEDULING_TEST_DATABASE_URL is not set"); - return; - }; + let fx = fixture().await; fx.store .adopt(SCHEDULING_ID) .await From 9a6045fe37b1e079dd5fa5dac199c8da09862353 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 14:02:23 +0700 Subject: [PATCH 026/115] fix(platform-calendar): name no caller's vocabulary in a gap or fold refusal The shared calendar crate reported a Casework noun for every product: a Scheduling opening pattern landing in a daylight-saving gap read back a refusal about a due time. The instant the conversion refused is a local time whatever the caller calls it. Signed-off-by: Jeremi Joslin --- crates/registry-platform-calendar/src/lib.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/registry-platform-calendar/src/lib.rs b/crates/registry-platform-calendar/src/lib.rs index cda2ea3423..649ff02e33 100644 --- a/crates/registry-platform-calendar/src/lib.rs +++ b/crates/registry-platform-calendar/src/lib.rs @@ -49,9 +49,9 @@ pub enum CalendarEvaluationError { InvalidWarningOffset, #[error("reminders[].workingDaysBefore must be between 1 and 3650")] InvalidReminderOffset, - #[error("the calculated local due time does not exist in the calendar timezone")] + #[error("the calculated local time does not exist in the calendar timezone")] NonexistentLocalTime, - #[error("the calculated local due time is ambiguous in the calendar timezone")] + #[error("the calculated local time is ambiguous in the calendar timezone")] AmbiguousLocalTime, #[error("working-day calendar arithmetic overflowed")] Overflow, @@ -211,6 +211,22 @@ mod tests { ); } + /// The crate serves every product's calendar, so a gap or fold refusal + /// names the instant it could not convert and no caller's authoring + /// vocabulary: a Casework deadline and a Scheduling opening pattern read + /// the same sentence. + #[test] + fn a_gap_or_fold_refusal_names_no_caller_s_vocabulary() { + assert_eq!( + CalendarEvaluationError::NonexistentLocalTime.to_string(), + "the calculated local time does not exist in the calendar timezone" + ); + assert_eq!( + CalendarEvaluationError::AmbiguousLocalTime.to_string(), + "the calculated local time is ambiguous in the calendar timezone" + ); + } + #[test] fn local_interval_reports_its_own_invalid_inputs() { assert_eq!( From 5be9eeb5c9086232c5eada966a0ad936447aa0d8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 14:03:51 +0700 Subject: [PATCH 027/115] fix(scheduling): resolve one location's openings into a canonical published schedule Resolution concatenated each opening pattern's own expansion, so the list an evaluator reads kept the order the openings happened to be authored in. Both answers taken from that list are properties of a single interval, so the order decided which start times were on the grid, and an appointment running from one opening straight into the next was refused as unpublished though the location never closed across it. Sorting and joining intervals that overlap or meet answers the coverage question the same way for the evaluator and for the fixture harness. Signed-off-by: Jeremi Joslin --- .../registry-scheduling-core/src/resolve.rs | 186 +++++++++++++++++- 1 file changed, 185 insertions(+), 1 deletion(-) diff --git a/crates/registry-scheduling-core/src/resolve.rs b/crates/registry-scheduling-core/src/resolve.rs index f6a5cb04c5..23451acae9 100644 --- a/crates/registry-scheduling-core/src/resolve.rs +++ b/crates/registry-scheduling-core/src/resolve.rs @@ -34,6 +34,17 @@ pub enum ResolveError { /// Expand every authored opening at one location into concrete UTC intervals, /// layering the location's dated exceptions and each opening's holiday set. +/// +/// The result is the canonical published schedule: sorted, disjoint, and with +/// intervals that overlap or meet joined into one, so authoring order is +/// never observable. That matters twice over, because both answers an +/// evaluator reads from this list are properties of a single interval: an +/// appointment is served when one interval covers it, and its start grid is +/// anchored to the start of the interval that covers it. Concatenating each +/// pattern's own expansion would let the order two openings happen to be +/// listed in decide which starts a caller may book, and would refuse an +/// appointment running across two openings the location publishes as +/// continuous. pub fn location_open_intervals( policy: &SchedulingPolicy, location_id: &str, @@ -70,7 +81,24 @@ pub fn location_open_intervals( }; all.extend(expand_weekly_openings(&pattern, exceptions, &revision)?); } - Ok(all) + Ok(merge_intervals(all)) +} + +/// Sort intervals and join every pair that overlaps or meets into one. +/// +/// Meeting counts as continuous: a location whose morning pattern ends where +/// its afternoon pattern begins never closed between them, and an hour of that +/// day is one published hour however many patterns authored it. +fn merge_intervals(mut intervals: Vec) -> Vec { + intervals.sort_by_key(|interval| (interval.start, interval.end)); + let mut merged: Vec = Vec::with_capacity(intervals.len()); + for interval in intervals { + match merged.last_mut() { + Some(last) if interval.start <= last.end => last.end = last.end.max(interval.end), + _ => merged.push(interval), + } + } + merged } /// Whether the union of `intervals` covers the whole span `[start, end)`. @@ -122,3 +150,159 @@ pub fn location_closure_intervals( } Ok(closures) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::parse_policy_yaml; + + fn opening(id: &str, start: &str, end: &str) -> String { + format!( + " - id: {id}\n location: north-counter\n holidaySet: office-holidays\n weekdays: [mon]\n startTime: \"{start}\"\n endTime: \"{end}\"\n effectiveFrom: \"2026-10-05\"\n effectiveUntil: \"2026-10-05\"\n because: Counter opening hours reviewed by the office manager.\n" + ) + } + + fn policy_with(openings: &str) -> SchedulingPolicy { + let text = format!( + r#" +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: north-counter + because: A 30-minute update at an interchangeable station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 30 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: +{openings}windows: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"# + ); + parse_policy_yaml(&text).expect("the policy parses") + } + + fn resolve(policy: &SchedulingPolicy) -> Vec { + location_open_intervals(policy, "north-counter", "Asia/Bangkok", &[]) + .expect("the openings expand") + } + + fn instant(value: &str) -> DateTime { + value.parse().expect("an RFC 3339 instant") + } + + /// The start grid an offering publishes is anchored to the opening that + /// covers the request, so the order two openings happen to be authored in + /// may never decide which starts a caller can book. + #[test] + fn overlapping_openings_resolve_the_same_whichever_order_they_are_authored_in() { + let early_first = policy_with(&format!( + "{}{}", + opening("early", "09:00", "17:00"), + opening("late", "09:10", "17:00") + )); + let late_first = policy_with(&format!( + "{}{}", + opening("late", "09:10", "17:00"), + opening("early", "09:00", "17:00") + )); + assert_eq!(resolve(&early_first), resolve(&late_first)); + // One covering interval anchored at the earliest published start. + assert_eq!( + resolve(&early_first), + vec![CalendarInterval { + start: instant("2026-10-05T02:00:00Z"), + end: instant("2026-10-05T10:00:00Z"), + }] + ); + } + + /// A location that opens straight from one authored pattern into the next + /// never closed between them, so the two resolve into one continuous + /// interval and an appointment may run across the join. + #[test] + fn abutting_openings_resolve_into_one_continuous_interval() { + let policy = policy_with(&format!( + "{}{}", + opening("morning", "09:00", "12:30"), + opening("afternoon", "12:30", "17:00") + )); + assert_eq!( + resolve(&policy), + vec![CalendarInterval { + start: instant("2026-10-05T02:00:00Z"), + end: instant("2026-10-05T10:00:00Z"), + }] + ); + } + + /// Resolution answers the coverage question once. An evaluator asking for + /// the single interval that covers an appointment and a fixture asking + /// whether the union covers it read the same published schedule, so a span + /// the union covers always lies inside one resolved interval. + #[test] + fn a_covered_span_always_lies_inside_one_resolved_interval() { + let policy = policy_with(&format!( + "{}{}", + opening("morning", "09:00", "12:30"), + opening("afternoon", "12:30", "17:00") + )); + let open = resolve(&policy); + // 12:15 to 12:45 local Bangkok runs across the join between the two + // authored patterns. + let start = instant("2026-10-05T05:15:00Z"); + let end = instant("2026-10-05T05:45:00Z"); + assert!(covers_span(&open, start, end)); + assert!(open + .iter() + .any(|interval| interval.start <= start && end <= interval.end)); + + // A span reaching past the published hours is covered by neither. + let past_close = instant("2026-10-05T10:15:00Z"); + assert!(!covers_span(&open, start, past_close)); + assert!(!open + .iter() + .any(|interval| interval.start <= start && past_close <= interval.end)); + } + + /// Two openings the day apart stay two intervals: merging joins what a + /// location publishes as continuous, never what it publishes as separate. + #[test] + fn openings_that_do_not_meet_stay_separate_intervals() { + let policy = policy_with(&format!( + "{}{}", + opening("morning", "09:00", "12:00"), + opening("afternoon", "13:00", "17:00") + )); + let open = resolve(&policy); + assert_eq!(open.len(), 2); + assert_eq!(open[0].end, instant("2026-10-05T05:00:00Z")); + assert_eq!(open[1].start, instant("2026-10-05T06:00:00Z")); + } +} From 9d5de9208bb8ce351fc28959bdcb0dac1365a767 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 14:53:03 +0700 Subject: [PATCH 028/115] fix(scheduling): keep the reschedule exclusion inside the runtime's transaction The admission request carried a caller-settable rescheduleOf that excluded any live claim from the capacity checks, so naming any active claim's id on a create path double-booked that member, bypassed the duplicate-booking refusal, and inflated window headroom. The mirror defect cut the other way: the reschedule transaction never supplied the appointment's own id, so moving an appointment inside the interval it already held was answered capacity.exhausted unless the caller volunteered the id themselves. The wire type no longer carries the field: AdmissionRequest drops rescheduleOf and strict request parsing refuses a body that names one, so no create path can be handed another party's allocation to ignore. The evaluators take the exclusion as their own argument, and the only supplier is the reschedule transaction, which reads the appointment's claim id from the row it locks. The authored fixture grammar keeps rescheduleOf, because a fixture case legitimately says this case reschedules that one; the harness passes the named claim as the evaluator argument and now refuses a dangling reference instead of replaying it as an ordinary booking. Fixture case names must also be unique, because one reschedule answering to two names frees two allocations. While the hash tuple is open, capabilities and prerequisites are sorted before hashing: two spellings of one set are one request, and a retry that reorders them replays instead of executing twice. Security review notes: this moves a caller-controlled input out of the capacity check entirely, which is a narrowing of what a request can express on every commitment route (hold create, appointment create, hold confirm). The reschedule path gains the self-exclusion it was missing, and the exclusion it now supplies is read under the supply lock from the appointment row the transaction holds, never from the request. Test evidence: the core pins the refused wire shape and the order-insensitive hash; the PostgreSQL suite pins a reschedule that moves inside its own occupied interval (admitted now, where it was capacity.exhausted before) and a create naming a live claim (422 request.unprocessable, and the standing appointment still holds its slot). The OpenAPI document drops the field with the wire type. Signed-off-by: Jeremi Joslin --- .../tests/http_boundary.rs | 13 +- .../registry-scheduling-core/src/admission.rs | 139 ++++++------ .../registry-scheduling-core/src/fixture.rs | 147 ++++++++++++- crates/registry-scheduling-core/src/model.rs | 73 ++++++- crates/registry-scheduling-core/src/wire.rs | 1 - crates/registry-scheduling/src/service.rs | 6 +- crates/registry-scheduling/src/store.rs | 25 ++- .../tests/postgres_commitments.rs | 198 +++++++++++++++++- .../registry-scheduling.openapi.json | 10 - .../scheduling/scripts/generate_openapi.py | 1 - 10 files changed, 508 insertions(+), 105 deletions(-) diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs index 23b3a97fd9..67d7775022 100644 --- a/crates/registry-scheduling-client/tests/http_boundary.rs +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -148,7 +148,6 @@ fn admission() -> AdmissionRequest { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), - reschedule_of: None, } } @@ -546,8 +545,7 @@ async fn appointment_commands_carry_their_keys_and_exact_bodies() { .expect("appointment"); assert_eq!(read.value.revision, 2); - let mut reschedule_admission = admission(); - reschedule_admission.reschedule_of = Some("claim-appt-1".to_owned()); + let reschedule_admission = admission(); let moved = client .reschedule_appointment( auth(&token), @@ -599,6 +597,15 @@ async fn appointment_commands_carry_their_keys_and_exact_bodies() { serde_json::from_slice(&observations[2].body).expect("sent reschedule"); assert_eq!(sent_reschedule.admission, reschedule_admission); assert_eq!(sent_reschedule.observed_revision, 2); + // The wire request carries no exclusion of its own: the runtime supplies + // the appointment's own claim from inside the reschedule transaction, so + // the serialized admission names no claim to leave out of the check. + let sent_reschedule_value: serde_json::Value = + serde_json::from_slice(&observations[2].body).expect("sent reschedule as json"); + assert!(!sent_reschedule_value["admission"] + .as_object() + .expect("the admission is an object") + .contains_key("rescheduleOf")); assert_eq!(observations[3].uri, "/v1/appointments/appt-1/cancel"); assert_eq!(observations[3].headers["idempotency-key"], "cancel-1"); let sent_cancel: CancelAppointmentRequest = diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index b71fcacede..bb1b3c7ec3 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -155,9 +155,17 @@ pub struct WindowContext<'a> { /// horizon, party bounds, prerequisites, duplicate key, published schedule /// coverage including the start grid, member capability and availability, /// then member occupancy including buffers. +/// +/// `exclude` is the one standing claim this request replaces: a reschedule +/// must not compete with the appointment it is moving. It is never a value a +/// caller sends. The runtime supplies the appointment's own id from inside +/// the commitment transaction, and offline replay supplies the claim the +/// authored case names, so no create path can name another party's +/// allocation out of the capacity check. pub fn evaluate_exact_time_admission( context: &ExactTimeContext<'_>, request: &AdmissionRequest, + exclude: Option<&str>, ) -> Result { let ExactTimeContext { offering, @@ -184,7 +192,7 @@ pub fn evaluate_exact_time_admission( return Err(AdmissionRefusal::PartyCapacityInadequate); } check_prerequisites(offering, request)?; - check_duplicate(offering, request, snapshot, *now)?; + check_duplicate(offering, request, snapshot, exclude, *now)?; // Interval arithmetic that leaves representable time is a horizon // refusal, never a mislabeled capacity answer: a start whose service or @@ -268,7 +276,7 @@ pub fn evaluate_exact_time_admission( &member.resource_id, occupied_start, occupied_end, - request.reschedule_of.as_deref(), + exclude, *now, ) }) @@ -298,9 +306,14 @@ pub fn evaluate_exact_time_admission( /// channel subquota and the total window capacity are both checked, and a /// party the published units could never carry is refused as inadequate /// rather than as exhausted. +/// +/// `exclude` carries the same meaning it carries for an exact-time request: +/// the one standing claim this request replaces, supplied by the runtime or +/// by offline replay and never by a caller. pub fn evaluate_window_admission( context: &WindowContext<'_>, request: &AdmissionRequest, + exclude: Option<&str>, ) -> Result { let WindowContext { offering, @@ -337,7 +350,7 @@ pub fn evaluate_window_admission( return Err(AdmissionRefusal::PartyCapacityInadequate); } check_prerequisites(offering, request)?; - check_duplicate(offering, request, snapshot, *now)?; + check_duplicate(offering, request, snapshot, exclude, *now)?; if let Some(channel) = &request.channel { if let Some(subquota) = window @@ -345,19 +358,14 @@ pub fn evaluate_window_admission( .iter() .find(|subquota| subquota.channel.as_str() == channel.as_str()) { - let allocated = snapshot.window_units_allocated( - &window.id, - Some(channel), - request.reschedule_of.as_deref(), - *now, - ); + let allocated = + snapshot.window_units_allocated(&window.id, Some(channel), exclude, *now); if allocated.saturating_add(required) > subquota.units { return Err(AdmissionRefusal::CapacityExhausted); } } } - let allocated = - snapshot.window_units_allocated(&window.id, None, request.reschedule_of.as_deref(), *now); + let allocated = snapshot.window_units_allocated(&window.id, None, exclude, *now); if allocated.saturating_add(required) > window.units { return Err(AdmissionRefusal::CapacityExhausted); } @@ -435,6 +443,7 @@ fn check_duplicate( offering: &OfferingPolicy, request: &AdmissionRequest, snapshot: &LedgerSnapshot, + exclude: Option<&str>, now: DateTime, ) -> Result<(), AdmissionRefusal> { if offering.duplicate_active_key.is_none() { @@ -445,7 +454,7 @@ fn check_duplicate( // key: skipping the check would let the same party hold two active // bookings by omission. None => Err(AdmissionRefusal::DuplicateKeyRequired), - Some(key) if snapshot.duplicate_active(key, request.reschedule_of.as_deref(), now) => { + Some(key) if snapshot.duplicate_active(key, exclude, now) => { Err(AdmissionRefusal::DuplicateActiveBooking) } Some(_) => Ok(()), @@ -573,7 +582,6 @@ mod tests { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), - reschedule_of: None, } } @@ -620,7 +628,7 @@ mod tests { let now = utc(4, 4, 0); let context = exact_context(&offering, &exact, members(), &snapshot, now); - let first = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30))) + let first = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30)), None) .expect("the first caller is admitted"); assert_eq!(first.resource.as_deref(), Some("station-1")); @@ -632,9 +640,10 @@ mod tests { first.occupied_end, )); let second_context = exact_context(&offering, &exact, members(), &after, now); - let second = evaluate_exact_time_admission(&second_context, &exact_request(utc(5, 2, 30))) - .map(|admission| admission.resource) - .map_err(|refusal| refusal.public_code()); + let second = + evaluate_exact_time_admission(&second_context, &exact_request(utc(5, 2, 30)), None) + .map(|admission| admission.resource) + .map_err(|refusal| refusal.public_code()); assert_eq!(second, Ok(Some("station-2".to_owned()))); // With one station left occupied at the same displayed time, the @@ -647,7 +656,8 @@ mod tests { utc(5, 3, 5), )); let third_context = exact_context(&offering, &exact, members(), &after, now); - let third = evaluate_exact_time_admission(&third_context, &exact_request(utc(5, 2, 30))); + let third = + evaluate_exact_time_admission(&third_context, &exact_request(utc(5, 2, 30)), None); assert_eq!( third.err().map(|refusal| refusal.public_code()), Some(ProblemCode::CapacityExhausted) @@ -677,14 +687,14 @@ mod tests { // A 02:30 start displays as touching, but its 02:25 buffer start // overlaps both claims' cleanup, so every member is blocked. - let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30))); + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30)), None); assert_eq!( result.err().map(|refusal| refusal.public_code()), Some(ProblemCode::CapacityExhausted) ); // At 03:00, with the increment grid observed, both stations are free. - let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0))); + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0)), None); assert!(result.is_ok()); } @@ -700,17 +710,14 @@ mod tests { let now = utc(4, 4, 0); let context = exact_context(&offering, &exact, members(), &snapshot, now); - let fresh = evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0))); + let fresh = evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0)), None); assert_eq!( fresh.err().map(|refusal| refusal.public_code()), Some(ProblemCode::BookingDuplicateActive) ); - let rescheduling = AdmissionRequest { - reschedule_of: Some("claim-1".to_owned()), - ..exact_request(utc(5, 3, 0)) - }; - let moved = evaluate_exact_time_admission(&context, &rescheduling); + let moved = + evaluate_exact_time_admission(&context, &exact_request(utc(5, 3, 0)), Some("claim-1")); assert!(moved.is_ok(), "{moved:?}"); } @@ -726,7 +733,7 @@ mod tests { duplicate_key: None, ..exact_request(utc(5, 2, 30)) }; - let refusal = evaluate_exact_time_admission(&context, &request) + let refusal = evaluate_exact_time_admission(&context, &request, None) .expect_err("refused for the missing key"); assert_eq!(refusal.public_code(), ProblemCode::PreconditionRequired); assert_eq!(refusal.detailed_code(), ProblemCode::PreconditionRequired); @@ -747,7 +754,7 @@ mod tests { let at_the_edge = now + Duration::minutes(5); let context = exact_context(&offering, &exact, members(), &snapshot, now); assert_eq!( - evaluate_exact_time_admission(&context, &exact_request(at_the_edge)).err(), + evaluate_exact_time_admission(&context, &exact_request(at_the_edge), None).err(), Some(AdmissionRefusal::HorizonOutside { kind: HorizonKind::TooFar }) @@ -767,7 +774,7 @@ mod tests { ..exact_context(&offering, &exact, members(), &snapshot, now) }; assert_eq!( - evaluate_exact_time_admission(&context, &exact_request(start)).err(), + evaluate_exact_time_admission(&context, &exact_request(start), None).err(), Some(AdmissionRefusal::HorizonOutside { kind: HorizonKind::TooFar }) @@ -785,7 +792,7 @@ mod tests { ..exact_context(&offering, &exact, members(), &snapshot, now) }; assert_eq!( - evaluate_exact_time_admission(&context, &exact_request(start)).err(), + evaluate_exact_time_admission(&context, &exact_request(start), None).err(), Some(AdmissionRefusal::HorizonOutside { kind: HorizonKind::TooSoon }) @@ -803,7 +810,7 @@ mod tests { let now = utc(4, 4, 0); let context = exact_context(&offering, &exact, members(), &snapshot, now); assert_eq!( - evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30))) + evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 30)), None) .err() .map(|refusal| refusal.public_code()), Some(ProblemCode::ScheduleUnpublished) @@ -831,20 +838,20 @@ mod tests { let now = utc(4, 4, 0); let context = exact_context(&offering, &exact, members(), &snapshot, now); - let same_slot = AdmissionRequest { - reschedule_of: Some("claim-1".to_owned()), - ..exact_request(utc(5, 2, 0)) - }; - let result = evaluate_exact_time_admission(&context, &same_slot); + // The exclusion is the evaluator's own argument, not something the + // request carries, so this is the shape the runtime calls. + let result = + evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0)), Some("claim-1")); assert!(result.is_ok(), "{result:?}"); assert_eq!(result.unwrap().resource.as_deref(), Some("station-1")); - // Another caller at 02:00 without the exclusion is refused. + // The identical request without the exclusion is refused, which is + // what a create path is: no caller can ask for one. let other = AdmissionRequest { duplicate_key: Some("subject:two".to_owned()), ..exact_request(utc(5, 2, 0)) }; - let result = evaluate_exact_time_admission(&context, &other); + let result = evaluate_exact_time_admission(&context, &other, None); assert_eq!( result.err().map(|refusal| refusal.public_code()), Some(ProblemCode::CapacityExhausted) @@ -880,7 +887,7 @@ mod tests { let context = exact_context(&offering, &exact, members(), &snapshot, now); // The expired hold neither blocks the slot nor blocks the duplicate // key: its capacity is bookable again. - let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0))); + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0)), None); assert!(result.is_ok(), "{result:?}"); } @@ -905,7 +912,7 @@ mod tests { }; let (offering, exact) = exact_offering(); let context = exact_context(&offering, &exact, members(), &snapshot, now); - let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0))); + let result = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 0)), None); // station-1 is held; station-2 still serves the party. assert_eq!( result.map(|admission| admission.resource), @@ -925,7 +932,7 @@ mod tests { // Too soon: 02:00 is inside the 60-minute lead time at 04:00? No: it // is in the past, which is the strongest form of too soon. - let past = evaluate_exact_time_admission(&context, &exact_request(utc(4, 4, 30))); + let past = evaluate_exact_time_admission(&context, &exact_request(utc(4, 4, 30)), None); assert!(matches!( past.err(), Some(AdmissionRefusal::HorizonOutside { @@ -939,14 +946,14 @@ mod tests { ..exact_request(utc(5, 2, 30)) }; assert!(matches!( - evaluate_exact_time_admission(&context, &far).err(), + evaluate_exact_time_admission(&context, &far, None).err(), Some(AdmissionRefusal::HorizonOutside { kind: HorizonKind::TooFar }) )); // Off-grid start: no published schedule serves 02:15. - let off_grid = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 15))); + let off_grid = evaluate_exact_time_admission(&context, &exact_request(utc(5, 2, 15)), None); assert_eq!( off_grid.err().map(|refusal| refusal.public_code()), Some(ProblemCode::ScheduleUnpublished) @@ -954,7 +961,7 @@ mod tests { // Off the published day entirely. let unpublished_day = - evaluate_exact_time_admission(&context, &exact_request(utc(9, 2, 30))); + evaluate_exact_time_admission(&context, &exact_request(utc(9, 2, 30)), None); assert_eq!( unpublished_day.err().map(|refusal| refusal.public_code()), Some(ProblemCode::ScheduleUnpublished) @@ -976,7 +983,8 @@ mod tests { closures: &closures, ..exact_context(&offering, &exact, members(), &snapshot, now) }; - let closed = evaluate_exact_time_admission(&closed_context, &exact_request(utc(5, 2, 30))); + let closed = + evaluate_exact_time_admission(&closed_context, &exact_request(utc(5, 2, 30)), None); assert_eq!( closed.err().map(|refusal| refusal.public_code()), Some(ProblemCode::LocationClosed) @@ -991,7 +999,7 @@ mod tests { ..exact_request(utc(5, 2, 30)) }; assert_eq!( - evaluate_exact_time_admission(&context, &party) + evaluate_exact_time_admission(&context, &party, None) .err() .map(|refusal| refusal.public_code()), Some(ProblemCode::PartyCapacityInadequate) @@ -1011,7 +1019,7 @@ mod tests { let wanting_context = exact_context(&wanting, &exact_wanting, &capable_members, &snapshot, now); assert_eq!( - evaluate_exact_time_admission(&wanting_context, &exact_request(utc(5, 2, 30))) + evaluate_exact_time_admission(&wanting_context, &exact_request(utc(5, 2, 30)), None) .err() .map(|refusal| refusal.public_code()), Some(ProblemCode::CapabilityUnmatched) @@ -1026,9 +1034,12 @@ mod tests { }]; let unavailable_context = exact_context(&wanting, &exact_wanting, &unavailable, &snapshot, now); - let refused = - evaluate_exact_time_admission(&unavailable_context, &exact_request(utc(5, 2, 30))) - .expect_err("refused"); + let refused = evaluate_exact_time_admission( + &unavailable_context, + &exact_request(utc(5, 2, 30)), + None, + ) + .expect_err("refused"); assert_eq!(refused.public_code(), ProblemCode::CapacityExhausted); assert_eq!(refused.detailed_code(), ProblemCode::ResourceUnavailable); } @@ -1045,7 +1056,7 @@ mod tests { ..exact_request(utc(5, 2, 30)) }; assert_eq!( - evaluate_exact_time_admission(&context, &stale).err(), + evaluate_exact_time_admission(&context, &stale, None).err(), Some(AdmissionRefusal::PolicyChanged) ); } @@ -1065,7 +1076,7 @@ mod tests { ..exact_request(utc(5, 2, 30)) }; assert_eq!( - evaluate_exact_time_admission(&context, &request).err(), + evaluate_exact_time_admission(&context, &request, None).err(), Some(AdmissionRefusal::PrerequisiteMissing { missing: vec!["case:approved-application".to_owned()] }) @@ -1133,7 +1144,6 @@ mod tests { window_revision: Some(2), capabilities: Vec::new(), prerequisites: Vec::new(), - reschedule_of: None, } } @@ -1155,7 +1165,7 @@ mod tests { now, }; let admission = - evaluate_window_admission(&context, &window_request(2, 3)).expect("admitted"); + evaluate_window_admission(&context, &window_request(2, 3), None).expect("admitted"); assert_eq!(admission.units, 2); } @@ -1178,7 +1188,7 @@ mod tests { policy_revision: 1, now, }; - let result = evaluate_window_admission(&context, &window_request(2, 2)); + let result = evaluate_window_admission(&context, &window_request(2, 2), None); assert_eq!( result.err().map(|refusal| refusal.public_code()), Some(ProblemCode::CapacityExhausted) @@ -1190,7 +1200,7 @@ mod tests { channel: None, ..window_request(1, 1) }; - let single = evaluate_window_admission(&context, &unchanneled); + let single = evaluate_window_admission(&context, &unchanneled, None); assert_eq!(single.map(|admission| admission.units), Ok(1)); } @@ -1222,14 +1232,14 @@ mod tests { }; // Four recipients are above the highest band: inadequate party. assert_eq!( - evaluate_window_admission(&context, &window_request(4, 4)) + evaluate_window_admission(&context, &window_request(4, 4), None) .err() .map(|refusal| refusal.public_code()), Some(ProblemCode::PartyCapacityInadequate) ); // Two recipients fall in the band at 2 units, exactly the published 2: // admitted, not inadequate. - assert!(evaluate_window_admission(&context, &window_request(2, 2)).is_ok()); + assert!(evaluate_window_admission(&context, &window_request(2, 2), None).is_ok()); } #[test] @@ -1250,7 +1260,7 @@ mod tests { policy_revision: 1, now, }; - let result = evaluate_window_admission(&context, &window_request(1, 1)); + let result = evaluate_window_admission(&context, &window_request(1, 1), None); assert_eq!( result.err().map(|refusal| refusal.public_code()), Some(ProblemCode::CapacityExhausted) @@ -1277,7 +1287,7 @@ mod tests { ..window_request(1, 1) }; assert!(matches!( - evaluate_window_admission(&context, &stale_window).err(), + evaluate_window_admission(&context, &stale_window, None).err(), Some(AdmissionRefusal::RevisionMismatch { observed: 2 }) )); @@ -1286,7 +1296,7 @@ mod tests { ..stale_window }; assert_eq!( - evaluate_window_admission(&context, &stale_both).err(), + evaluate_window_admission(&context, &stale_both, None).err(), Some(AdmissionRefusal::PolicyChanged) ); } @@ -1349,6 +1359,7 @@ mod tests { let first = evaluate_exact_time_admission( &context, &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 5, 30, 0).unwrap()), + None, ) .expect("the first 01:30 admits"); assert_eq!( @@ -1358,6 +1369,7 @@ mod tests { let second = evaluate_exact_time_admission( &context, &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 6, 30, 0).unwrap()), + None, ) .expect("the second 01:30 admits"); assert_eq!( @@ -1369,7 +1381,7 @@ mod tests { // published schedule serves it, whatever the wall clock says. let outside = Utc.with_ymd_and_hms(2026, 11, 1, 7, 30, 0).unwrap(); assert_eq!( - evaluate_exact_time_admission(&context, &exact_request(outside)) + evaluate_exact_time_admission(&context, &exact_request(outside), None) .err() .map(|refusal| refusal.public_code()), Some(ProblemCode::ScheduleUnpublished) @@ -1436,7 +1448,7 @@ mod tests { // Inside the closed stretch: a closure, not an unpublished schedule. let closed_start = Utc.with_ymd_and_hms(2026, 11, 1, 4, 30, 0).unwrap(); assert_eq!( - evaluate_exact_time_admission(&context, &exact_request(closed_start)) + evaluate_exact_time_admission(&context, &exact_request(closed_start), None) .err() .map(|refusal| refusal.public_code()), Some(ProblemCode::LocationClosed) @@ -1445,6 +1457,7 @@ mod tests { let served = evaluate_exact_time_admission( &context, &exact_request(Utc.with_ymd_and_hms(2026, 11, 1, 5, 15, 0).unwrap()), + None, ) .expect("the post-closure grid serves"); assert_eq!( diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs index 55c433b832..6929e92f07 100644 --- a/crates/registry-scheduling-core/src/fixture.rs +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -17,7 +17,9 @@ use registry_platform_calendar::CalendarEvaluationError; use crate::admission::{ evaluate_exact_time_admission, evaluate_window_admission, ExactTimeContext, WindowContext, }; -use crate::model::{AdmissionRequest, LedgerClaim, LedgerKind, LedgerSnapshot, SchedulingFacts}; +use crate::model::{ + AdmissionRequest, LedgerClaim, LedgerKind, LedgerSnapshot, PartyCounts, SchedulingFacts, +}; use crate::naming::{SCHEDULING_FIXTURE_API_VERSION, SCHEDULING_FIXTURE_KIND}; use crate::policy::{SchedulingMode, SchedulingPolicy}; use crate::problem::ProblemCode; @@ -40,12 +42,72 @@ pub enum FixtureExpectation { }, } +/// One replayed request as an author writes it. +/// +/// This is the authoring form of an admission request, not the wire form. It +/// carries everything the wire request carries plus `rescheduleOf`, the claim +/// this case replaces: a fixture legitimately says "this case reschedules that +/// one", where an HTTP caller may not, because on the wire the exclusion is +/// the runtime's to supply from inside its own transaction. +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FixtureAdmissionRequest { + pub offering: String, + pub start: DateTime, + pub party: PartyCounts, + pub channel: Option, + pub duplicate_key: Option, + pub policy_revision: u64, + pub window_revision: Option, + pub capabilities: Vec, + pub prerequisites: Vec, + /// The claim this case replaces. Its allocation is left out of this + /// case's conflict checks and removed when the case is admitted, so a + /// reschedule never competes with what it replaces. + pub reschedule_of: Option, +} + +impl FixtureAdmissionRequest { + /// The wire request this authored case makes, without the exclusion: + /// that travels beside it, as the evaluator's own argument. + /// + /// Both sides are written out in full on purpose. Adding a field to + /// either shape stops compiling here until the author decides what the + /// other shape does with it. + #[must_use] + pub fn admission(&self) -> AdmissionRequest { + let Self { + offering, + start, + party, + channel, + duplicate_key, + policy_revision, + window_revision, + capabilities, + prerequisites, + reschedule_of: _, + } = self; + AdmissionRequest { + offering: offering.clone(), + start: *start, + party: *party, + channel: channel.clone(), + duplicate_key: duplicate_key.clone(), + policy_revision: *policy_revision, + window_revision: *window_revision, + capabilities: capabilities.clone(), + prerequisites: prerequisites.clone(), + } + } +} + /// One replayed request and its expectation. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct FixtureCase { pub name: String, - pub request: AdmissionRequest, + pub request: FixtureAdmissionRequest, pub expect: FixtureExpectation, } @@ -114,6 +176,10 @@ pub enum ReplayError { UnknownLocation { case: String, location: String }, #[error("case {case} needs window {window}, which the policy does not publish")] UnknownWindow { case: String, window: String }, + #[error("case name {case} is used more than once; case names identify claims")] + DuplicateCaseName { case: String }, + #[error("case {case} reschedules claim {claim}, which no claim in the ledger carries")] + UnknownRescheduleTarget { case: String, claim: String }, #[error("case {case} expects code {code}, which is outside the closed vocabulary")] UnknownExpectationCode { case: String, code: String }, #[error( @@ -148,6 +214,19 @@ impl SchedulingFixture { { return Err(ReplayError::UnsupportedEnvelope); } + // A replayed claim is identified by its case name, and a reschedule + // names the claim it replaces. Two cases under one name would answer + // to each other's id: the second silently erases the first's claim, + // and one reschedule frees two allocations. + let mut seen: Vec<&str> = Vec::with_capacity(self.cases.len()); + for case in &self.cases { + if seen.contains(&case.name.as_str()) { + return Err(ReplayError::DuplicateCaseName { + case: case.name.clone(), + }); + } + seen.push(&case.name); + } for case in &self.cases { if let FixtureExpectation::Refused { code } = &case.expect { match ProblemCode::from_code(code) { @@ -332,7 +411,21 @@ fn replay_case( case: &FixtureCase, snapshot: &mut LedgerSnapshot, ) -> Result { - let request = &case.request; + let request = &case.request.admission(); + // A reschedule of a claim the ledger does not carry excludes nothing and + // replaces nothing, so it would quietly replay as an ordinary booking and + // pass against an expectation it never tested. It is an authoring error, + // not a caller's refusal. + let exclude = case.request.reschedule_of.as_deref(); + if let Some(target) = exclude { + if !snapshot.claims.iter().any(|claim| claim.id == target) { + return Err(CaseStoppage::Replay(ReplayError::UnknownRescheduleTarget { + case: case.name.clone(), + claim: target.to_owned(), + })); + } + } + let offering = policy .offering(&request.offering) .ok_or(CaseStoppage::Refusal( @@ -388,12 +481,12 @@ fn replay_case( policy_revision: revision, now: fixture.now, }; - let admitted = evaluate_exact_time_admission(&context, request)?; + let admitted = evaluate_exact_time_admission(&context, request, exclude)?; let supply = admitted .resource .clone() .expect("an exact-time admission names its member"); - record_admission(&case.name, &admitted, &supply, request, snapshot); + record_admission(&case.name, &admitted, &supply, &case.request, snapshot); Ok(admitted) } SchedulingMode::ArrivalWindow => { @@ -418,10 +511,10 @@ fn replay_case( policy_revision: revision, now: fixture.now, }; - let admitted = evaluate_window_admission(&context, request)?; + let admitted = evaluate_window_admission(&context, request, exclude)?; // A window claim occupies the window itself, so later cases sum // against the window's published units. - record_admission(&case.name, &admitted, &window.id, request, snapshot); + record_admission(&case.name, &admitted, &window.id, &case.request, snapshot); Ok(admitted) } } @@ -434,7 +527,7 @@ fn record_admission( case: &str, admitted: &crate::admission::Admission, supply_id: &str, - request: &AdmissionRequest, + request: &FixtureAdmissionRequest, snapshot: &mut LedgerSnapshot, ) { if let Some(replaced) = &request.reschedule_of { @@ -675,6 +768,44 @@ cases: .all(|outcome| outcome.status == CaseStatus::Pass)); } + /// COR-6. A replayed claim is identified by its case name, so two cases + /// under one name answer to each other's id: the second erases the + /// first's claim and one reschedule frees two allocations. + #[test] + fn duplicate_case_names_are_refused_before_any_case_runs() { + let policy = policy_for_fixture(); + let mut fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + let repeated = fixture.cases[0].name.clone(); + fixture.cases[1].name = repeated.clone(); + assert_eq!( + fixture.check(&policy), + Err(ReplayError::DuplicateCaseName { + case: repeated.clone() + }) + ); + assert_eq!( + fixture.replay(&policy), + Err(ReplayError::DuplicateCaseName { case: repeated }) + ); + } + + /// COR-7. A reschedule naming a claim the ledger does not carry excludes + /// nothing and replaces nothing, so it would replay as an ordinary + /// booking and pass against an expectation it never tested. + #[test] + fn a_reschedule_of_a_claim_the_ledger_does_not_carry_is_reported() { + let policy = policy_for_fixture(); + let mut fixture = parse_fixture_yaml(exact_time_fixture_yaml()).expect("parses"); + fixture.cases[0].request.reschedule_of = Some("booking-404".to_owned()); + assert_eq!( + fixture.replay(&policy), + Err(ReplayError::UnknownRescheduleTarget { + case: fixture.cases[0].name.clone(), + claim: "booking-404".to_owned(), + }) + ); + } + #[test] fn broken_fixtures_stop_before_running() { let policy = policy_for_fixture(); diff --git a/crates/registry-scheduling-core/src/model.rs b/crates/registry-scheduling-core/src/model.rs index ed7fdaa766..cd8c45bc63 100644 --- a/crates/registry-scheduling-core/src/model.rs +++ b/crates/registry-scheduling-core/src/model.rs @@ -307,10 +307,6 @@ pub struct AdmissionRequest { /// The party's held prerequisite references, matched against the /// offering's requirements. pub prerequisites: Vec, - /// The claim a reschedule replaces. Its allocation is excluded from the - /// conflict checks for this request so a reschedule never competes with - /// the appointment it replaces. - pub reschedule_of: Option, } /// The idempotent hash of an admission request. @@ -319,8 +315,17 @@ pub struct AdmissionRequest { /// never change a stored hash. The same request always hashes identically; /// the same idempotency key with a different payload hashes differently, which /// is exactly the distinction a retry must be able to make. +/// +/// Capabilities and prerequisites are sets the caller happens to send in an +/// order, so they are sorted before hashing: two spellings of one set are one +/// request, and a retry that reorders them replays rather than executing a +/// second time. Repeats are kept, because a hash never edits its input. #[must_use] pub fn admission_request_hash(request: &AdmissionRequest) -> String { + let mut capabilities = request.capabilities.clone(); + capabilities.sort(); + let mut prerequisites = request.prerequisites.clone(); + prerequisites.sort(); let fixed = ( &request.offering, request.start.to_rfc3339(), @@ -330,9 +335,8 @@ pub fn admission_request_hash(request: &AdmissionRequest) -> String { &request.duplicate_key, request.policy_revision, request.window_revision, - &request.capabilities, - &request.prerequisites, - &request.reschedule_of, + &capabilities, + &prerequisites, ); let value = serde_json::to_value(&fixed).expect("the fixed tuple always serializes"); let canonical = registry_platform_canonical_json::canonicalize_json(&value) @@ -374,7 +378,6 @@ mod tests { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), - reschedule_of: None, } } @@ -582,11 +585,11 @@ mod tests { }; assert_ne!(base, admission_request_hash(&other_offering)); - let rescheduling = AdmissionRequest { - reschedule_of: Some("claim-9".to_owned()), + let other_channel = AdmissionRequest { + channel: Some("assisted".to_owned()), ..request() }; - assert_ne!(base, admission_request_hash(&rescheduling)); + assert_ne!(base, admission_request_hash(&other_channel)); } #[test] @@ -633,4 +636,52 @@ mod tests { assert!(serde_norway::from_str::("recipients: 1\nattendees: 2\n").is_ok()); assert!(serde_norway::from_str::("recipients: 1\n").is_err()); } + + /// The wire request carries no exclusion of its own. A caller who names a + /// claim to leave out of the capacity check is refused at the edge, so no + /// create path can be handed another caller's allocation to ignore. + #[test] + fn an_admission_request_refuses_a_caller_supplied_exclusion() { + let body = "offering: registry-update-30\nstart: 2026-10-05T02:00:00Z\n\ + party:\n recipients: 1\n attendees: 1\npolicyRevision: 4\n\ + capabilities: []\nprerequisites: []\n"; + assert!(serde_norway::from_str::(body).is_ok()); + assert!( + serde_norway::from_str::(&format!("{body}rescheduleOf: claim-1\n")) + .is_err(), + "the exclusion is the runtime's to supply, never the caller's" + ); + } + + /// Capabilities and prerequisites are sets the caller happens to send in + /// an order. Two spellings of the same set are the same request, so a + /// retry that reorders them replays instead of executing again. + #[test] + fn set_order_never_changes_the_request_hash() { + let ordered = AdmissionRequest { + capabilities: vec!["cap-a".to_owned(), "cap-b".to_owned()], + prerequisites: vec!["proof-a".to_owned(), "proof-b".to_owned()], + ..request() + }; + let reordered = AdmissionRequest { + capabilities: vec!["cap-b".to_owned(), "cap-a".to_owned()], + prerequisites: vec!["proof-b".to_owned(), "proof-a".to_owned()], + ..request() + }; + assert_eq!( + admission_request_hash(&ordered), + admission_request_hash(&reordered) + ); + + // Membership still counts: a set with a different member is a + // different request. + let widened = AdmissionRequest { + capabilities: vec!["cap-a".to_owned(), "cap-c".to_owned()], + ..ordered.clone() + }; + assert_ne!( + admission_request_hash(&ordered), + admission_request_hash(&widened) + ); + } } diff --git a/crates/registry-scheduling-core/src/wire.rs b/crates/registry-scheduling-core/src/wire.rs index 9d0f852c8d..b9b6b08bfd 100644 --- a/crates/registry-scheduling-core/src/wire.rs +++ b/crates/registry-scheduling-core/src/wire.rs @@ -353,7 +353,6 @@ mod tests { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), - reschedule_of: None, }; let direct = CreateAppointmentRequest { diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index d3007d4a28..ece6d4ccc9 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -410,7 +410,6 @@ impl SchedulingService { window_revision: None, capabilities: Vec::new(), prerequisites: Vec::new(), - reschedule_of: None, }; let refusal = match self.supply(offering).await? { ResolvedSupply::ExactTime { @@ -445,6 +444,9 @@ impl SchedulingService { now, }, &probe, + // A probe replaces nothing; it explains the start as an + // ordinary booking would meet it. + None, ) .err() } @@ -465,6 +467,8 @@ impl SchedulingService { now, }, &probe, + // A probe replaces nothing here either. + None, ) .err() } diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index e0b88eab78..c762552952 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -779,7 +779,7 @@ impl PostgresStore { { return Err(CommitError::HoldCeiling); } - let admission = evaluate(offering, supply, request, &snapshot, &commitment)?; + let admission = evaluate(offering, supply, request, &snapshot, &commitment, None)?; let expires_at = commitment .now .checked_add_signed(TimeDelta::minutes(i64::from(ttl_minutes))) @@ -863,7 +863,7 @@ impl PostgresStore { } check_grant_current(&commitment)?; let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; - let admission = evaluate(offering, supply, request, &snapshot, &commitment)?; + let admission = evaluate(offering, supply, request, &snapshot, &commitment, None)?; let appointment_id = Uuid::new_v4(); let claim = transaction .insert_claim(&NewClaim { @@ -1164,7 +1164,18 @@ impl PostgresStore { if appointment.actor != commitment.actor { return Err(CommitError::Unauthorized); } - let admission = evaluate(offering, supply, request, &snapshot, &commitment)?; + // The appointment's own allocation is the exclusion: a reschedule + // never competes with the booking it moves. It is read from the row + // this transaction locked, never from the request. + let own_claim_id = appointment.claim_id.to_string(); + let admission = evaluate( + offering, + supply, + request, + &snapshot, + &commitment, + Some(&own_claim_id), + )?; let next_revision = appointment.revision + 1; transaction .move_claim(&ClaimMove { @@ -1717,12 +1728,18 @@ fn check_grant_current(commitment: &Commitment<'_>) -> Result<(), CommitError> { } /// The in-memory evaluator call, step 4. +/// +/// `exclude` is the one standing claim this commitment replaces, supplied +/// only by the reschedule transaction from the appointment row it locks. A +/// create path passes `None`: the wire request cannot carry an exclusion, so +/// no caller can name another party's allocation out of the check. fn evaluate( offering: ®istry_scheduling_core::OfferingPolicy, supply: &SupplyContext<'_>, request: ®istry_scheduling_core::AdmissionRequest, snapshot: &LedgerSnapshot, commitment: &Commitment<'_>, + exclude: Option<&str>, ) -> Result { match supply { SupplyContext::ExactTime { @@ -1742,6 +1759,7 @@ fn evaluate( now: commitment.now, }, request, + exclude, ), SupplyContext::Window { window, @@ -1758,6 +1776,7 @@ fn evaluate( now: commitment.now, }, request, + exclude, ), } } diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index efb45b57df..43b243efc6 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -39,6 +39,10 @@ const SECRET: &[u8] = b"01234567890123456789012345678901"; const SCHEDULING_ID: &str = "commitments-test"; const OFFERING: &str = "registry-update-30"; const SECOND_OFFERING: &str = "registry-review-45"; +/// An offering whose service runs twice its start increment, so consecutive +/// published starts overlap each other. It is the only shape that can move an +/// appointment inside the interval it already holds. +const OVERLAPPING_OFFERING: &str = "registry-update-60"; /// One plain booking grid: slots every 30 minutes around the clock, every /// day, one hour of lead time, a four-hour cancellation cutoff, and a @@ -46,8 +50,10 @@ const SECOND_OFFERING: &str = "registry-review-45"; /// use, all at least 110 minutes wide, certain to contain a bookable start /// regardless of when the test runs, and the single member per pool makes /// every commitment's capacity effect total: a booked or held slot is not -/// availability. The second offering sells a second pool so one fixture can -/// also pin a commitment against a pool the publication never anchored. +/// availability. A third offering serves an hour on the same pool at the same +/// half-hour increment, so its published starts overlap one another. The last +/// offering sells a second pool so one fixture can also pin a commitment +/// against a pool the publication never anchored. const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 kind: SchedulingPolicyPackage scheduling: {id: commitments-test, version: 1} @@ -84,6 +90,24 @@ offerings: maxRecipients: 1 requiresCapabilities: [] prerequisites: [] + - id: registry-update-60 + service: registry-update + label: 60-minute counter update + mode: exact-time + location: north-counter + because: test + cancellationCutoffMinutes: 240 + exactTime: + durationMinutes: 60 + bufferBeforeMinutes: 0 + bufferAfterMinutes: 0 + leadTimeMinutes: 60 + horizonDays: 60 + pool: north-counter + startIncrementMinutes: 30 + maxRecipients: 1 + requiresCapabilities: [] + prerequisites: [] - id: registry-review-45 service: registry-review label: 45-minute record review @@ -388,7 +412,10 @@ fn agent_token() -> String { token(claims) } -/// A complete admission request for `offering` at `slot`. +/// A complete admission request for `offering` at `slot`. The wire shape +/// carries no `rescheduleOf`: the exclusion is the runtime's to supply from +/// inside the reschedule transaction, and a caller who sends one is refused +/// as a malformed request. fn admission(fx: &Fixture, offering: &str, slot: DateTime) -> Value { json!({ "offering": offering, @@ -400,7 +427,6 @@ fn admission(fx: &Fixture, offering: &str, slot: DateTime) -> Value { "windowRevision": null, "capabilities": [], "prerequisites": [], - "rescheduleOf": null, }) } @@ -477,6 +503,47 @@ async fn slot_offered( }) } +/// The first published start of `offering` whose next start is published too. +/// Both are inside one opening, so an appointment on the first can move onto +/// the second, and for an offering serving longer than its start increment +/// the two intervals overlap. +async fn first_overlapping_pair( + fx: &Fixture, + offering: &str, + from_minutes: i64, + to_minutes: i64, +) -> (DateTime, DateTime) { + let (status, page) = fx + .get( + &availability_uri(offering, from_minutes, to_minutes), + &fx.reader, + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "availability answers over the window" + ); + let starts: Vec> = page["items"] + .as_array() + .into_iter() + .flatten() + .filter(|entry| entry["kind"] == "slot") + .filter_map(|entry| entry["start"].as_str()) + .filter_map(|start| DateTime::parse_from_rfc3339(start).ok()) + .map(|start| start.with_timezone(&Utc)) + .collect(); + starts + .iter() + .find_map(|start| { + let next = *start + TimeDelta::minutes(30); + starts.contains(&next).then_some((*start, next)) + }) + .unwrap_or_else(|| { + panic!("two consecutive bookable starts inside [{from_minutes}, {to_minutes}] minutes") + }) +} + /// Book directly on a fresh slot of the default offering and return the /// appointment. async fn booked(fx: &Fixture, from_minutes: i64, to_minutes: i64, key: &str) -> (String, u64) { @@ -654,6 +721,129 @@ async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() assert_eq!(problem["code"], "revision.mismatch"); } +/// COR-1. A reschedule must never compete with the appointment it is moving. +/// The runtime supplies the appointment's own allocation as the exclusion +/// from inside the commitment transaction, so a move that overlaps the +/// interval the appointment already holds is admitted rather than answered +/// `capacity.exhausted`. +#[tokio::test] +async fn a_reschedule_moves_inside_the_interval_the_appointment_already_holds() { + let fx = fixture().await; + let (from, onto) = first_overlapping_pair(&fx, OVERLAPPING_OFFERING, 300, 900).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "self-overlap-create", + json!({"hold": null, "admission": admission(&fx, OVERLAPPING_OFFERING, from)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + let observed = appointment["revision"].as_u64().unwrap(); + + // The new interval starts inside the one the appointment already holds. + // The only claim standing in its way is its own. + let (status, moved) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "self-overlap-move", + json!({ + "observedRevision": observed, + "admission": admission(&fx, OVERLAPPING_OFFERING, onto), + }), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "a reschedule excludes its own allocation: {moved}" + ); + assert_eq!(moment(&moved, "start"), onto); + + // The degenerate case: a move onto the start the appointment already + // holds, entirely inside its own occupied interval. + let (status, again) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "self-overlap-restate", + json!({ + "observedRevision": observed + 1, + "admission": admission(&fx, OVERLAPPING_OFFERING, onto), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "a move onto its own start: {again}"); + assert_eq!(moment(&again, "start"), onto); + assert_eq!(again["revision"], json!(observed + 2)); +} + +/// BL-1. The exclusion belongs to the runtime, never to the caller. A create +/// path handed a live claim's id is refused at the edge, so no caller can +/// name another party's allocation out of the capacity check. +#[tokio::test] +async fn a_create_cannot_name_a_live_claim_to_leave_out_of_the_capacity_check() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "bypass-standing", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let standing = appointment["appointmentId"].as_str().unwrap().to_owned(); + + let mut named = admission(&fx, OFFERING, slot); + named["rescheduleOf"] = json!(standing); + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "bypass-create", + json!({"hold": null, "admission": named.clone()}), + ) + .await; + // The wire shape no longer declares the field, so a body carrying it is + // refused as unprocessable by the strict request parsing every create + // route already answers through. + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "the create path refuses a caller-named exclusion: {problem}" + ); + assert_eq!(problem["code"], "request.unprocessable"); + + let (status, problem) = fx.post("/v1/holds", &fx.agent, "bypass-hold", named).await; + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "the hold path refuses it too: {problem}" + ); + assert_eq!(problem["code"], "request.unprocessable"); + + // The same second caller without the field meets the standing + // appointment, which is the whole capacity of this pool. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "bypass-plain", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "capacity.exhausted"); + assert!( + !slot_offered(&fx, OFFERING, 300, 440, slot).await, + "the slot the standing appointment {standing} holds is not availability" + ); +} + #[tokio::test] async fn cancellation_respects_the_cutoff_and_replays_its_verdict() { let fx = fixture().await; diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json index 72910d7295..f702403c42 100644 --- a/products/scheduling/generated/registry-scheduling.openapi.json +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -61,16 +61,6 @@ }, "type": "array" }, - "rescheduleOf": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, "start": { "format": "date-time", "type": "string" diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py index 1a86a87a85..7b8172982f 100644 --- a/products/scheduling/scripts/generate_openapi.py +++ b/products/scheduling/scripts/generate_openapi.py @@ -457,7 +457,6 @@ def schemas(problem_entries: list[dict]) -> dict: "windowRevision": nullable(revision), "capabilities": array(text), "prerequisites": array(text), - "rescheduleOf": nullable(text), }, ["offering", "start", "party", "policyRevision", "capabilities", "prerequisites"], ) From 28577e3d93c2f1dd9c15f352372fb2164ed11d45 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:12:08 +0700 Subject: [PATCH 029/115] fix(scheduling): assess a subquota a proposal introduces, not only ones it keeps Signed-off-by: Jeremi Joslin --- crates/registry-scheduling-core/src/policy.rs | 95 +++++++++++++++++-- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 3dffa5699c..458bb78a1e 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -827,11 +827,12 @@ pub struct CapacityReduction { /// A normal capacity reduction that would leave confirmed bookings and live /// holds above the proposed capacity is rejected: it comes back here as a /// finding with the deficit, never as a successful validation. Both levels of -/// published capacity are assessed: the window total, and each channel -/// subquota, which is its own committed slice a proposal may strand even when -/// the total is unchanged. An emergency reduction is a different operation -/// entirely, recorded as an incident with its affected bookings, and does not -/// pass through this assessment. +/// published capacity are assessed on both sides of the proposal: the window +/// total, and each channel subquota, which is its own committed slice a +/// proposal may strand by reducing it, by dropping it, or by introducing it +/// below what the channel already holds. An emergency reduction is a different +/// operation entirely, recorded as an incident with its affected bookings, and +/// does not pass through this assessment. pub fn assess_publication_impact( current: &SchedulingPolicy, proposed: &SchedulingPolicy, @@ -879,6 +880,35 @@ pub fn assess_publication_impact( }); } } + // A subquota that exists only in the proposal introduces a ceiling + // the channel never had, so the commitments it already holds are + // assessed against it: a slice below them strands them exactly as a + // reduction of an existing slice would. + if let Some(proposed) = proposed_window { + for subquota in &proposed.subquotas { + let already_published = window + .subquotas + .iter() + .any(|current| current.channel == subquota.channel); + if already_published { + continue; + } + let committed_channel = snapshot.window_units_allocated( + &window.id, + Some(subquota.channel.as_str()), + None, + now, + ); + if committed_channel > subquota.units { + reductions.push(CapacityReduction { + window: window.id.clone(), + channel: Some(subquota.channel), + committed_units: committed_channel, + proposed_units: subquota.units, + }); + } + } + } } reductions } @@ -1000,7 +1030,7 @@ mod tests { use chrono::TimeZone as _; fn utc(hour: u32, minute: u32) -> DateTime { - Utc.with_ymd_and_hms(2026, 10, 5, hour, minute, 0).unwrap() + Utc.with_ymd_and_hms(2026, 10, 10, hour, minute, 0).unwrap() } pub fn minimal_exact_time_policy() -> SchedulingPolicy { @@ -1507,6 +1537,59 @@ holdPolicy: assert!(assess_publication_impact(¤t, &widened, &snapshot, now).is_empty()); } + /// BL-2: a subquota that exists only in the proposal was never assessed, + /// so a publication could add a channel slice below what that channel + /// already holds and strand its commitments silently. + #[test] + fn an_added_subquota_never_strands_its_channel_at_publication() { + let mut current = household_window_policy(); + current.windows[0].subquotas.clear(); + let now = utc(6, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim], + }; + + // Two public units already stand; the proposal publishes a public + // slice of one for the first time. + let mut proposal = current.clone(); + proposal.windows[0].subquotas = vec![WindowSubquota { + id: "public-quota".to_owned(), + channel: Channel::Public, + units: 1, + because: "One public unit in the revised block.".to_owned(), + }]; + assert_eq!( + assess_publication_impact(¤t, &proposal, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: Some(Channel::Public), + committed_units: 2, + proposed_units: 1, + }] + ); + + // A slice that still covers what the channel holds passes. + let mut covered = current.clone(); + covered.windows[0].subquotas = vec![WindowSubquota { + id: "public-quota".to_owned(), + channel: Channel::Public, + units: 2, + because: "Two public units in the revised block.".to_owned(), + }]; + assert!(assess_publication_impact(¤t, &covered, &snapshot, now).is_empty()); + } + #[test] fn declared_hooks_are_refused_because_nothing_can_run_them() { let mut policy = minimal_exact_time_policy(); From 37ee865ca406ea435f02ff691c0fe903d49094fc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:13:52 +0700 Subject: [PATCH 030/115] fix(scheduling): compare standing claims against the proposed window interval Signed-off-by: Jeremi Joslin --- crates/registry-scheduling-core/src/policy.rs | 180 ++++++++++++++++-- 1 file changed, 168 insertions(+), 12 deletions(-) diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 458bb78a1e..87bfb7a766 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -830,9 +830,12 @@ pub struct CapacityReduction { /// published capacity are assessed on both sides of the proposal: the window /// total, and each channel subquota, which is its own committed slice a /// proposal may strand by reducing it, by dropping it, or by introducing it -/// below what the channel already holds. An emergency reduction is a different -/// operation entirely, recorded as an incident with its affected bookings, and -/// does not pass through this assessment. +/// below what the channel already holds. A window the proposal keeps is +/// assessed over the interval it now publishes: a claim whose interval the +/// new window no longer covers is a commitment the proposal strands against +/// zero, and no longer capacity the moved window must cover. An emergency +/// reduction is a different operation entirely, recorded as an incident with +/// its affected bookings, and does not pass through this assessment. pub fn assess_publication_impact( current: &SchedulingPolicy, proposed: &SchedulingPolicy, @@ -843,7 +846,19 @@ pub fn assess_publication_impact( for window in ¤t.windows { let proposed_window = proposed.window(&window.id); let proposed_units = proposed_window.map_or(0, |proposed| proposed.units); - let committed = snapshot.window_units_allocated(&window.id, None, None, now); + // A window the proposal drops has no interval left to meet, so every + // claim counts against the zero it proposes. + let committed = match proposed_window { + Some(proposed) => units_committed_within( + snapshot, + &window.id, + None, + proposed.start, + proposed.end, + now, + ), + None => snapshot.window_units_allocated(&window.id, None, None, now), + }; if proposed_units < window.units && committed > proposed_units { reductions.push(CapacityReduction { window: window.id.clone(), @@ -852,6 +867,21 @@ pub fn assess_publication_impact( proposed_units, }); } + if let Some(proposed) = proposed_window { + // The commitments the proposed interval no longer covers are + // stranded: the proposal publishes no capacity where they stand, + // which is a reduction to zero for them. + let stranded = + units_committed_outside(snapshot, &window.id, proposed.start, proposed.end, now); + if stranded > 0 { + reductions.push(CapacityReduction { + window: window.id.clone(), + channel: None, + committed_units: stranded, + proposed_units: 0, + }); + } + } // A subquota dropped from the proposal proposes zero for its channel. for subquota in &window.subquotas { let proposed_subquota = proposed_window @@ -865,12 +895,22 @@ pub fn assess_publication_impact( if proposed_subquota >= subquota.units { continue; } - let committed_channel = snapshot.window_units_allocated( - &window.id, - Some(subquota.channel.as_str()), - None, - now, - ); + let committed_channel = match proposed_window { + Some(proposed) => units_committed_within( + snapshot, + &window.id, + Some(subquota.channel.as_str()), + proposed.start, + proposed.end, + now, + ), + None => snapshot.window_units_allocated( + &window.id, + Some(subquota.channel.as_str()), + None, + now, + ), + }; if committed_channel > proposed_subquota { reductions.push(CapacityReduction { window: window.id.clone(), @@ -893,10 +933,12 @@ pub fn assess_publication_impact( if already_published { continue; } - let committed_channel = snapshot.window_units_allocated( + let committed_channel = units_committed_within( + snapshot, &window.id, Some(subquota.channel.as_str()), - None, + proposed.start, + proposed.end, now, ); if committed_channel > subquota.units { @@ -913,6 +955,49 @@ pub fn assess_publication_impact( reductions } +/// Recipient units standing against a window's supply at `now`, counting only +/// claims whose occupied interval meets the half-open interval +/// `[start, end)`, so a proposal that moves or shortens the window is assessed +/// over the interval it now publishes. +fn units_committed_within( + snapshot: &crate::model::LedgerSnapshot, + window_id: &str, + channel: Option<&str>, + start: DateTime, + end: DateTime, + now: DateTime, +) -> u32 { + snapshot + .consuming(now) + .filter(|claim| { + claim.supply_id == window_id + && channel.is_none_or(|wanted| claim.channel.as_deref() == Some(wanted)) + && claim.start < end + && start < claim.end + }) + .map(|claim| claim.units) + // Saturating, for the same reason the snapshot's own sum saturates: + // a total past u32 units must refuse, never wrap toward zero. + .fold(0, u32::saturating_add) +} + +/// Recipient units standing against a window's supply at `now` whose occupied +/// interval meets nothing of the half-open interval `[start, end)`: the +/// commitments a proposal publishing that interval no longer covers. +fn units_committed_outside( + snapshot: &crate::model::LedgerSnapshot, + window_id: &str, + start: DateTime, + end: DateTime, + now: DateTime, +) -> u32 { + snapshot + .consuming(now) + .filter(|claim| claim.supply_id == window_id && !(claim.start < end && start < claim.end)) + .map(|claim| claim.units) + .fold(0, u32::saturating_add) +} + fn service_ids_contains(policy: &SchedulingPolicy, id: &str) -> bool { policy.services.iter().any(|service| service.id == id) } @@ -1590,6 +1675,77 @@ holdPolicy: assert!(assess_publication_impact(¤t, &covered, &snapshot, now).is_empty()); } + /// BL-3: a window moved under the same id was assessed by supply id + /// alone, so a proposal could move every standing commitment out of its + /// window and pass as safe. Each standing claim's interval is now + /// compared against the proposed window's interval. + #[test] + fn moving_a_published_window_reports_no_reduction() { + let current = household_window_policy(); + let now = utc(6, 0); + let claim = LedgerClaim { + id: "claim-1".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim.clone()], + }; + + // The window moves two months under the same id; the two standing + // units fall outside the published interval and are stranded against + // the zero capacity that proposal leaves where they stand. + let mut moved = current.clone(); + moved.windows[0].start = Utc.with_ymd_and_hms(2026, 12, 24, 8, 0, 0).unwrap(); + moved.windows[0].end = Utc.with_ymd_and_hms(2026, 12, 24, 10, 0, 0).unwrap(); + assert_eq!( + assess_publication_impact(¤t, &moved, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: None, + committed_units: 2, + proposed_units: 0, + }] + ); + + // A shortened interval strands only the claims it no longer covers: + // the morning claim still meets the window, the late one does not. + let late = LedgerClaim { + id: "claim-2".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("assisted".to_owned()), + start: utc(9, 30), + end: utc(10, 0), + units: 1, + duplicate_key: None, + expires_at: None, + }; + let snapshot = crate::model::LedgerSnapshot { + claims: vec![claim, late], + }; + let mut shortened = current.clone(); + shortened.windows[0].end = utc(9, 0); + assert_eq!( + assess_publication_impact(¤t, &shortened, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: None, + committed_units: 1, + proposed_units: 0, + }] + ); + + // A window that stays put reports nothing: its claims still meet it. + assert!(assess_publication_impact(¤t, ¤t.clone(), &snapshot, now).is_empty()); + } + #[test] fn declared_hooks_are_refused_because_nothing_can_run_them() { let mut policy = minimal_exact_time_policy(); From f66312c76f3162c3599ac06f2dc5000efcead120 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:15:51 +0700 Subject: [PATCH 031/115] fix(scheduling): refuse a staffing pool that an exact-time offering also sells Signed-off-by: Jeremi Joslin --- crates/registry-scheduling-core/src/policy.rs | 79 +++++++++++++++---- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 87bfb7a766..91bd39c097 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -225,10 +225,16 @@ pub enum LeftoverCapacityPolicy { /// The staffing block a window's supply is carved from, when the window is /// backed by the same concrete resources an exact-time pool sells. /// -/// `reserved_members` is the partition: how many pool members the window's -/// supply is attributed to. A window that names a pool without a partition -/// claims the whole block, so any exact-time offering on the same pool is -/// selling the same staffing twice and is rejected at publication. +/// A pool that backs a window may not also back an exact-time offering: a +/// window claim occupies the window's own supply while an exact-time claim +/// occupies the member it books, so the two modes would double-book the same +/// staffing in a ledger that cannot see the conflict. That mix is refused at +/// publication whatever this block declares. +/// +/// `reserved_members` is the partition among windows: how many pool members +/// the window's supply is attributed to. A window that names a pool without a +/// partition claims the whole block, so two overlapping windows each claiming +/// the whole pool are double-counting and are rejected at publication. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WindowStaffing { @@ -745,16 +751,28 @@ impl SchedulingPolicy { &format!("{path}.staffing.because"), findings, ); + // A pool that backs a window may not also back an exact-time + // offering: the two modes count the same staffing differently, so + // the mix would double-book it in a ledger that cannot see the + // conflict. No authored partition attributes the supply across + // the two modes, so the refusal stands whatever this block + // declares. + let mixed_modes = self.offerings.iter().any(|offering| { + offering + .exact_time + .as_ref() + .is_some_and(|exact| exact.pool == staffing.pool) + }); + if mixed_modes { + findings.push(SchedulingDiagnostic::new( + format!("{path}.staffing.pool"), + PolicyCheckReason::SharedSupplyUnpartitioned, + )); + } // An unpartitioned staffing claim owns the whole pool block, so - // any exact-time offering on the same pool would be selling the - // same staffing twice. + // two overlapping windows each claiming the whole pool are + // double-counting the same staffing. if staffing.reserved_members.is_none() { - let shared = self.offerings.iter().any(|offering| { - offering - .exact_time - .as_ref() - .is_some_and(|exact| exact.pool == staffing.pool) - }); let doubly_claimed = self.windows.iter().any(|other| { other.id != window.id && other.staffing.as_ref().is_some_and(|other_staffing| { @@ -764,7 +782,7 @@ impl SchedulingPolicy { && window.start < other.end }) }); - if shared || doubly_claimed { + if doubly_claimed { findings.push(SchedulingDiagnostic::new( format!("{path}.staffing.reservedMembers"), PolicyCheckReason::SharedSupplyUnpartitioned, @@ -1413,6 +1431,13 @@ holdPolicy: /// AT-22, static half: an unpartitioned staffing claim over a pool that an /// exact-time offering also sells is rejected at publication. + /// + /// COR-8 widened the refusal to the mode mix itself: a window claim + /// occupies the window's own supply while an exact-time claim occupies the + /// member it books, so a pool backing both modes double-books the same + /// staffing in a ledger that cannot see the conflict. No authored + /// partition attributes the supply across the two modes, so the mix is + /// refused whether the block declares one or not. #[test] fn an_unpartitioned_shared_staffing_block_is_rejected() { let mut policy = household_window_policy(); @@ -1447,16 +1472,36 @@ holdPolicy: }); let findings = policy.check(); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); - assert!(rendered.contains( - &"windows[0].staffing.reservedMembers: shared-supply-unpartitioned".to_owned() - )); + assert!( + rendered.contains(&"windows[0].staffing.pool: shared-supply-unpartitioned".to_owned()) + ); - // An attributable partition makes the share explicit and passes. + // A partition does not attribute the supply across modes, so the mix + // is refused with the partition declared. policy.windows[0].staffing = Some(WindowStaffing { pool: "officer-pool".to_owned(), reserved_members: Some(2), because: "Two of the four duty officers staff the block.".to_owned(), }); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"windows[0].staffing.pool: shared-supply-unpartitioned".to_owned()) + ); + + // A partitioned staffing block over a pool no exact-time offering + // sells makes the share explicit and passes. + policy.windows[0].staffing = Some(WindowStaffing { + pool: "hall-officers".to_owned(), + reserved_members: Some(2), + because: "Two of the four duty officers staff the block.".to_owned(), + }); + assert!(policy.check().is_empty(), "{:?}", policy.check()); + + // A window with no staffing block draws on no pool at all, so it + // carries no edge an exact-time pool could share: the mode mix a + // staffing block would create cannot exist without one. + policy.windows[0].staffing = None; assert!(policy.check().is_empty(), "{:?}", policy.check()); } From 4c1adaf6c8073f2b84177d7ffd465169425559fb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:18:08 +0700 Subject: [PATCH 032/115] feat(scheduling): emit a runtime configuration example beside each init project An adopter could not reach a running server from tracked material: init wrote an authored policy and fixtures but no operator document, so the path from schedulingctl init to scheduling serve stopped at a blank page. Both templates now write a complete commented runtime.example.yaml, and init names the copy-and-edit step in its next guidance. The example names no project path, so one document serves both templates and the committed standalone-exact-time example stays byte-identical to what init writes, which the checkpoint diff already enforces. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/project.rs | 68 ++++++++- .../registry-schedulingctl/src/templates.rs | 131 ++++++++++++++++-- .../runtime.example.yaml | 90 ++++++++++++ 3 files changed, 278 insertions(+), 11 deletions(-) create mode 100644 products/scheduling/examples/standalone-exact-time/runtime.example.yaml diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index f6c98261c0..8184910b36 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -60,7 +60,10 @@ pub(super) fn init(project: &Path, template: &str) -> Result { "template": template, "project": project, "created": created, - "next": ["Run schedulingctl check PROJECT, then schedulingctl test PROJECT."], + "next": [ + "Run schedulingctl check PROJECT, then schedulingctl test PROJECT.", + "Copy runtime.example.yaml to runtime.yaml, set its absolute paths, and run scheduling migrate and scheduling serve with it.", + ], })) } @@ -331,6 +334,7 @@ fn effective(policy: &SchedulingPolicy) -> Value { #[cfg(test)] mod tests { use super::*; + use registry_scheduling::config::RuntimeConfig; /// A tempdir holding one freshly initialized template project, plus the /// paths the tempdir keeps alive. @@ -350,9 +354,14 @@ mod tests { assert_eq!(report["template"], "standalone-arrival-window"); assert_eq!( report["created"], - json!(["scheduling.yaml", "fixtures/household-morning.yaml"]) + json!([ + "scheduling.yaml", + "runtime.example.yaml", + "fixtures/household-morning.yaml" + ]) ); assert!(project.join(AUTHORED_POLICY_FILE).is_file()); + assert!(project.join("runtime.example.yaml").is_file()); assert!(project.join("fixtures/household-morning.yaml").is_file()); let error = init(&project, "standalone-arrival-window").unwrap_err(); assert!(error.to_string().contains("never overwrites")); @@ -360,6 +369,61 @@ mod tests { assert!(error.to_string().contains("standalone-arrival-window")); } + /// The example's placeholder roots, replaced with real absolute paths so + /// the emitted document can be loaded and checked as it stands. + const EXAMPLE_PACKAGE_ROOT: &str = "/srv/registry-scheduling/package"; + const EXAMPLE_STATE_ROOT: &str = "/var/lib/registry-scheduling"; + + #[test] + fn init_writes_a_runtime_example_beside_the_authored_project() { + let mut examples = Vec::new(); + for template in ["standalone-exact-time", "standalone-arrival-window"] { + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + let report = init(&project, template).unwrap(); + assert!( + report["created"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry == "runtime.example.yaml"), + "{template}: {report}" + ); + let text = fs::read_to_string(project.join("runtime.example.yaml")).unwrap(); + // Nothing in the document names the directory it was written + // beside, so the committed example can never drift from what an + // adopter initializes. + assert!(!text.contains(root.path().to_str().unwrap())); + examples.push(text); + } + // One document serves both templates. + assert_eq!(examples[0], examples[1]); + + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + init(&project, "standalone-exact-time").unwrap(); + let text = fs::read_to_string(project.join("runtime.example.yaml")) + .unwrap() + .replace( + EXAMPLE_PACKAGE_ROOT, + project.to_str().expect("utf-8 project path"), + ) + .replace( + EXAMPLE_STATE_ROOT, + root.path().to_str().expect("utf-8 root path"), + ); + let runtime = root.path().join("runtime.yaml"); + fs::write(&runtime, &text).unwrap(); + let config = RuntimeConfig::load(&runtime).expect("the emitted example loads"); + assert_eq!( + config.policy_path(), + project.join(AUTHORED_POLICY_FILE), + "the example selects the project beside it" + ); + assert_eq!(config.retention.attempt_receipt_days, 7); + assert!(config.destinations.reminders.is_none()); + } + #[test] fn check_reports_the_effective_policy_and_no_findings_for_a_template() { let (_root, project) = initialized("standalone-exact-time"); diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs index e5d1cc3aab..fea6cc575d 100644 --- a/crates/registry-schedulingctl/src/templates.rs +++ b/crates/registry-schedulingctl/src/templates.rs @@ -389,12 +389,109 @@ cases: units: 1 "#; +/// The operator runtime configuration example every template writes beside +/// the authored policy. It names no project path, so one document serves every +/// template and the committed example can never drift from what an adopter +/// initializes; the absolute paths an operator replaces are placeholders. +pub(super) const RUNTIME_EXAMPLE: &str = r#"# A complete Scheduling runtime configuration: the document +# `scheduling serve --runtime-config` reads and `scheduling migrate` reads. +# Copy it to runtime.yaml, set every absolute path to this deployment's real +# location, and point the secret references at secrets the configured provider +# can resolve. Scheduling reads the authored policy at +# package.root/scheduling.yaml and refuses to start when that file is missing +# or does not pass its checks. +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +package: + # The selected package always contains the policy at scheduling.yaml; no + # second selector can override it. Under + # listener.tlsTermination: operator-controlled-upstream the directory must + # also contain the scheduling.package.json manifest `schedulingctl package` + # writes; development loopback may select an unpackaged project directory. + root: /srv/registry-scheduling/package +listener: + bind: 127.0.0.1:8105 + tlsTermination: development-loopback + networkExposure: private-address + # `operator-controlled-upstream` is the production value: the runtime itself + # stays plaintext and an operator-managed TLS edge terminates transport in + # front of it. `development-loopback` is direct plaintext, refused on any + # non-loopback bind. `networkExposure: container-private` additionally + # permits an unspecified bind for a listener kept on a private container + # network. +secretProviders: + file: + # Every secret:file reference resolves under this root. A secret file must + # be a regular file owned by the runtime user, with mode 0400 or 0600 and + # exactly one hard link, carrying non-empty text of at most 64 KiB without + # NUL bytes; `openssl rand -hex 32` generates the audit key. + root: /run/secrets/registry-scheduling + # Declare `environment: {}` to enable secret:env/NAME references. The + # runtime does not fall back from one provider to another; every configured + # reference must resolve under a provider declared here. + # environment: {} +database: + # The least-privilege service connection and the operator-run migration + # connection, each a PostgreSQL URL held in the secret provider. The + # runtime requires TLS on the database connection: it sets sslmode Require + # and refuses a connection that cannot be verified. + runtimeUrlRef: secret:file/runtime-database-url + migrationUrlRef: secret:file/migration-database-url + # The PEM bundle of the private CA in front of PostgreSQL, when the + # deployment terminates database TLS with one. + # trustedRootCertificateRef: secret:file/postgres-ca.pem + # `testOnlyPlaintext: true` is the one plaintext escape, and only a build + # carrying the `postgres-test` feature accepts it: a production build + # refuses the setting at startup instead of opening an unencrypted + # connection. + # testOnlyPlaintext: true +authentication: + oidc: + issuer: https://identity.example.test/realms/registry + audience: urn:example:scheduling + # The claim carrying the accepted OAuth scopes. The default is + # `registry_scopes` for deployments that omit this field; stock ThunderID + # emits `scope`. + scopeClaim: scope + # The read scope defaults to `scheduling-read` and the explain scope to + # `scheduling-explain`; the two must differ. `allowedClients` names the + # client ids the runtime admits and is empty by default, which admits + # every client the issuer verifies. + # allowedClients: [scheduling-booking-agent] + # Signing keys come from issuer discovery by default. Declare the static + # alternative instead when the runtime cannot reach the issuer's discovery + # document, when the deployment is air-gapped, or when a test issuer's + # keys are pinned by hand. It does no rotation of its own: rolling a key + # means replacing the referenced document and restarting Scheduling. + # jwksSource: + # kind: static + # documentRef: secret:file/jwks.json +audit: + path: /var/lib/registry-scheduling/audit.ndjson + hashKeyRef: secret:file/scheduling-audit-key +destinations: {} + # Where due reminder intents are delivered as CloudEvents 1.0 events. An + # absent reminders destination is a supported deployment: the intents stay + # readable in the outbox and are marked local, never pretended delivered. + # reminders: + # url: https://bus.example.test/scheduling-reminders + # bearerTokenRef: secret:file/reminder-bearer-token +retention: + # The one retention period the sweep enforces today, and it covers + # idempotency attempt receipts and listing cursors and nothing else: + # appointment, history, outbox, and audit retention are deferred. Seven days + # is the default floor, not a recommendation; a jurisdiction's retention + # schedule approves the deployed value, and the audit journal records it. + attemptReceiptDays: 7 +"#; + /// The files one template writes, relative to the project directory, in /// write order. The first entry is always the authored policy. pub(super) fn template_files(template: &str) -> Option> { match template { "standalone-exact-time" => Some(vec![ ("scheduling.yaml", EXACT_TIME_POLICY), + ("runtime.example.yaml", RUNTIME_EXAMPLE), ("fixtures/counter-stations.yaml", COUNTER_STATIONS_FIXTURE), ( "fixtures/fold-day-rebooking.yaml", @@ -403,6 +500,7 @@ pub(super) fn template_files(template: &str) -> Option Some(vec![ ("scheduling.yaml", ARRIVAL_WINDOW_POLICY), + ("runtime.example.yaml", RUNTIME_EXAMPLE), ("fixtures/household-morning.yaml", HOUSEHOLD_MORNING_FIXTURE), ]), _ => None, @@ -447,12 +545,10 @@ mod tests { for template in TEMPLATE_NAMES { let files = template_files(template).unwrap(); let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); - for (relative, contents) in &files[1..] { - assert!( - relative.starts_with("fixtures/") && relative.ends_with(".yaml"), - "{relative}" - ); - let fixture = parse_fixture_yaml(contents).expect("the template fixture parses"); + for (relative, contents) in template_fixtures(&files) { + let fixture = parse_fixture_yaml(contents).unwrap_or_else(|error| { + panic!("{relative}: the template fixture parses: {error}") + }); assert_eq!(fixture.api_version, SCHEDULING_FIXTURE_API_VERSION); assert_eq!(fixture.kind, SCHEDULING_FIXTURE_KIND); fixture @@ -469,9 +565,11 @@ mod tests { for template in TEMPLATE_NAMES { let files = template_files(template).unwrap(); let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); - for (relative, contents) in &files[1..] { + for (relative, contents) in template_fixtures(&files) { let fixture = parse_fixture_yaml(contents).expect("the template fixture parses"); - let outcomes = fixture.replay(&policy).expect("the fixture replays"); + let outcomes = fixture + .replay(&policy) + .unwrap_or_else(|error| panic!("{relative}: the fixture replays: {error}")); assert!( outcomes .iter() @@ -482,14 +580,29 @@ mod tests { } } + /// The template's replay fixtures, in write order. The runtime example is + /// written beside the policy and is not a fixture. + fn template_fixtures<'a>( + files: &'a [(&'static str, &'static str)], + ) -> Vec<(&'static str, &'a str)> { + files + .iter() + .filter(|(relative, _)| { + relative.starts_with("fixtures/") && relative.ends_with(".yaml") + }) + .map(|(relative, contents)| (*relative, *contents)) + .collect() + } + #[test] - fn the_exact_time_template_carries_both_counter_and_fold_fixtures() { + fn the_exact_time_template_carries_the_example_and_both_fixtures() { let files = template_files("standalone-exact-time").unwrap(); let names: Vec<&str> = files.into_iter().map(|(relative, _)| relative).collect(); assert_eq!( names, vec![ "scheduling.yaml", + "runtime.example.yaml", "fixtures/counter-stations.yaml", "fixtures/fold-day-rebooking.yaml", ] diff --git a/products/scheduling/examples/standalone-exact-time/runtime.example.yaml b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml new file mode 100644 index 0000000000..41c463c0bf --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml @@ -0,0 +1,90 @@ +# A complete Scheduling runtime configuration: the document +# `scheduling serve --runtime-config` reads and `scheduling migrate` reads. +# Copy it to runtime.yaml, set every absolute path to this deployment's real +# location, and point the secret references at secrets the configured provider +# can resolve. Scheduling reads the authored policy at +# package.root/scheduling.yaml and refuses to start when that file is missing +# or does not pass its checks. +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +package: + # The selected package always contains the policy at scheduling.yaml; no + # second selector can override it. Under + # listener.tlsTermination: operator-controlled-upstream the directory must + # also contain the scheduling.package.json manifest `schedulingctl package` + # writes; development loopback may select an unpackaged project directory. + root: /srv/registry-scheduling/package +listener: + bind: 127.0.0.1:8105 + tlsTermination: development-loopback + networkExposure: private-address + # `operator-controlled-upstream` is the production value: the runtime itself + # stays plaintext and an operator-managed TLS edge terminates transport in + # front of it. `development-loopback` is direct plaintext, refused on any + # non-loopback bind. `networkExposure: container-private` additionally + # permits an unspecified bind for a listener kept on a private container + # network. +secretProviders: + file: + # Every secret:file reference resolves under this root. A secret file must + # be a regular file owned by the runtime user, with mode 0400 or 0600 and + # exactly one hard link, carrying non-empty text of at most 64 KiB without + # NUL bytes; `openssl rand -hex 32` generates the audit key. + root: /run/secrets/registry-scheduling + # Declare `environment: {}` to enable secret:env/NAME references. The + # runtime does not fall back from one provider to another; every configured + # reference must resolve under a provider declared here. + # environment: {} +database: + # The least-privilege service connection and the operator-run migration + # connection, each a PostgreSQL URL held in the secret provider. The + # runtime requires TLS on the database connection: it sets sslmode Require + # and refuses a connection that cannot be verified. + runtimeUrlRef: secret:file/runtime-database-url + migrationUrlRef: secret:file/migration-database-url + # The PEM bundle of the private CA in front of PostgreSQL, when the + # deployment terminates database TLS with one. + # trustedRootCertificateRef: secret:file/postgres-ca.pem + # `testOnlyPlaintext: true` is the one plaintext escape, and only a build + # carrying the `postgres-test` feature accepts it: a production build + # refuses the setting at startup instead of opening an unencrypted + # connection. + # testOnlyPlaintext: true +authentication: + oidc: + issuer: https://identity.example.test/realms/registry + audience: urn:example:scheduling + # The claim carrying the accepted OAuth scopes. The default is + # `registry_scopes` for deployments that omit this field; stock ThunderID + # emits `scope`. + scopeClaim: scope + # The read scope defaults to `scheduling-read` and the explain scope to + # `scheduling-explain`; the two must differ. `allowedClients` names the + # client ids the runtime admits and is empty by default, which admits + # every client the issuer verifies. + # allowedClients: [scheduling-booking-agent] + # Signing keys come from issuer discovery by default. Declare the static + # alternative instead when the runtime cannot reach the issuer's discovery + # document, when the deployment is air-gapped, or when a test issuer's + # keys are pinned by hand. It does no rotation of its own: rolling a key + # means replacing the referenced document and restarting Scheduling. + # jwksSource: + # kind: static + # documentRef: secret:file/jwks.json +audit: + path: /var/lib/registry-scheduling/audit.ndjson + hashKeyRef: secret:file/scheduling-audit-key +destinations: {} + # Where due reminder intents are delivered as CloudEvents 1.0 events. An + # absent reminders destination is a supported deployment: the intents stay + # readable in the outbox and are marked local, never pretended delivered. + # reminders: + # url: https://bus.example.test/scheduling-reminders + # bearerTokenRef: secret:file/reminder-bearer-token +retention: + # The one retention period the sweep enforces today, and it covers + # idempotency attempt receipts and listing cursors and nothing else: + # appointment, history, outbox, and audit retention are deferred. Seven days + # is the default floor, not a recommendation; a jurisdiction's retention + # schedule approves the deployed value, and the audit journal records it. + attemptReceiptDays: 7 From 7fb772875df306390ee2a7adee5171858a5e109e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:18:19 +0700 Subject: [PATCH 033/115] docs(scheduling): document every runtime configuration block and its default RUNTIME-CONFIG.md walks the operator document the way the runtime reads it: package selection, the listener's TLS termination and network exposure rules, the closed secret-provider declaration and the secret reference grammar, the database connections, token verification, the audit journal, the reminder destination, and retention. Two obligations are stated outright rather than implied. Database transport security is mandatory: the runtime sets sslmode Require and the only plaintext escape is testOnlyPlaintext on a postgres-test build, which a production build refuses at startup. Retention covers idempotency attempt receipts and listing cursors and nothing else; appointment, history, outbox, and audit retention are deferred. Signed-off-by: Jeremi Joslin --- products/scheduling/RUNTIME-CONFIG.md | 113 ++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 products/scheduling/RUNTIME-CONFIG.md diff --git a/products/scheduling/RUNTIME-CONFIG.md b/products/scheduling/RUNTIME-CONFIG.md new file mode 100644 index 0000000000..2f0c1aae79 --- /dev/null +++ b/products/scheduling/RUNTIME-CONFIG.md @@ -0,0 +1,113 @@ +# Scheduling runtime configuration + +Scheduling reads one versioned operator document selected with +`scheduling --runtime-config ABSOLUTE_FILE serve` or `migrate`. The selected +file path and every operated resource path are absolute. Local development +tooling may resolve paths before it writes the file. + +The closed envelope is: + +```yaml +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +``` + +`package.root` selects one directory. The runtime always loads +`package.root/scheduling.yaml`; no second project selector can override it. +With `listener.tlsTermination: operator-controlled-upstream`, the directory +must also contain a matching `scheduling.package.json`. Development loopback +may select an authored project directory without that manifest. + +`listener` is required. `listener.bind` is one numeric socket address, +including bracketed IPv6 forms, and defaults to `127.0.0.1:8105` when omitted +from the listener block. `listener.tlsTermination` is required. Use +`operator-controlled-upstream` behind an operator-managed TLS edge or +`development-loopback` for direct local development, which the runtime refuses +on any non-loopback bind. `listener.networkExposure` defaults to +`private-address`; `container-private` permits an unspecified bind only for a +listener kept on a private container network. A public unicast address is +refused under every combination: Scheduling does not serve a public interface +by itself. + +`secretProviders` explicitly enables each accepted reference form. Declare +`file: {root: ABSOLUTE_DIRECTORY}` before using `secret:file/name`. Declare +`environment: {}` before using `secret:env/NAME`. The runtime does not fall +back from one provider to another. The maintained example uses mounted files. + +A secret reference is `secret:env/NAME` with an uppercase environment name, or +`secret:file/name` with a lowercase file name, each of at most 128 bytes. A +resolved value is non-empty text of at most 64 KiB without NUL bytes. A secret +file must be a regular file owned by the runtime user, with mode 0400 or 0600 +and exactly one hard link; `openssl rand -hex 32` generates the audit key. + +`database.runtimeUrlRef` supplies the least-privilege service connection and +`database.migrationUrlRef` supplies the operator-run migration connection. +Transport security on the database connection is not optional: Scheduling sets +`sslmode` to `Require` on both connections and refuses a connection it cannot +protect. `database.trustedRootCertificateRef` is optional and selects the PEM +bundle of a private CA in front of PostgreSQL. The one plaintext escape is +`database.testOnlyPlaintext: true`, and only a build carrying the +`postgres-test` feature accepts it; a production build refuses the setting at +startup instead of opening an unencrypted connection. A test build that skips +because its database URL is absent is not database verification, and the +escape never turns a deployed runtime into a plaintext client. + +`authentication.oidc` requires `issuer` and `audience`. `jwksSource` defaults +to discovery and can instead select a static `documentRef`, which does no +rotation of its own: rolling a key means replacing the referenced document and +restarting Scheduling. `jwksUri` overrides the discovery document's JWKS +address. `scopeClaim` defaults to `registry_scopes` for compatibility with +existing deployments; stock ThunderID emits `scope`, so the maintained example +and `schedulingctl init` set that explicit override. `readsScope` defaults to +`scheduling-read` and `explainScope` to `scheduling-explain`; the two must +differ, because the explain path can name member-level causes the public +availability read never discloses. `allowedClients` is empty by default, which +admits every client the issuer verifies; list the exact client ids to narrow +that. Scopes authorize reads only. Every commitment takes its authority from a +task grant instead, which [TASK_GRANTS.md](TASK_GRANTS.md) documents. + +`audit.path` is the absolute JSONL journal path. `audit.hashKeyRef` supplies +its keyed-chain secret. Every commitment and every authorization refusal is +journaled there, and the publication loop is the one worker whose lag bounds +the record's durability. An expired hold writes its history entry as +`system` and no audit row. + +`destinations` is optional. `destinations.reminders` is the one place due +reminder intents are delivered, as CloudEvents 1.0 events over HTTPS POST with +`bearerTokenRef` as the optional bearer secret. The URL is one reviewed +absolute endpoint without userinfo, query, or fragment; plain `http` is +accepted only for an explicit loopback host, so a plaintext destination can +never silently point at another machine. Delivery is attempted once per claim +with exponential backoff from half a minute capped at an hour; a transport +failure schedules a retry, an authorization or routing refusal holds the +intent as failed for the operator, and eight failed attempts hold it. An +absent reminders destination is a supported deployment: the intents stay +readable in the outbox and are marked local, never pretended delivered. The +README documents the envelope and the event types. + +`retention.attemptReceiptDays` is the one retention period the sweep enforces, +and it covers idempotency attempt receipts and listing cursors and nothing +else. Appointment, history, outbox, and audit retention are deferred: nothing +in this milestone sweeps committed scheduling data, and this reference says so +explicitly rather than implying a sweep that does not run. The default is +seven days, and it is a floor, not a recommendation: a jurisdiction's +retention schedule approves the deployed value, and the audit journal records +it. An idempotency receipt past its period is erased, so an exact retry after +expiry answers `idempotency.expired` with HTTP 410 instead of replaying the +first answer; a listing cursor expires after its own fifteen minutes and is +then forgotten. Both sweeps run in the same retention worker, on a fixed +cadence that is not operator-tunable configuration. + +See the complete maintained +[`runtime.example.yaml`](examples/standalone-exact-time/runtime.example.yaml), +which is exactly what `schedulingctl init` writes beside an authored project, +and the generated editor schema at +[`generated/runtime/runtime.schema.json`](generated/runtime/runtime.schema.json). +Regenerate the schema from the owning Rust type with: + +```sh +cargo run --locked -p registry-scheduling --features schema \ + --example runtime-schema -- --output products/scheduling/generated/runtime +cargo test --locked -p registry-scheduling --features schema \ + schema::tests::committed_runtime_schema_matches_generated_bytes +``` From 74cc2c3ed3fa8e674e6bee4070d8fb908ed1fa7c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:20:36 +0700 Subject: [PATCH 034/115] docs(scheduling): document the task-grant shape and how the runtime matches it TASK_GRANTS.md records the scheduling bounds an issuer mints, the closed action vocabulary, and the cardinality and value bounds a signed claim may carry, then states exactly where the runtime enforces them: the service, location, and action match happens once before the capacity transaction opens, and only the grant's expiry is read again inside it. The audit record's pseudonymized fields and the deferral the milestone does not keep are named beside them. Signed-off-by: Jeremi Joslin --- products/scheduling/TASK_GRANTS.md | 119 +++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 products/scheduling/TASK_GRANTS.md diff --git a/products/scheduling/TASK_GRANTS.md b/products/scheduling/TASK_GRANTS.md new file mode 100644 index 0000000000..cdc531cf25 --- /dev/null +++ b/products/scheduling/TASK_GRANTS.md @@ -0,0 +1,119 @@ +# Task grants for scheduling commitments + +A task grant gives an institutional agent bounded authority to commit +Scheduling capacity under its own principal. It does not make the agent the +human who approved the task, it carries no catalogue or availability read +authority, and it never decides eligibility: whether a party may hold a +service remains with the source system that owns the rule. + +The three authority profiles are disjoint. A token carrying a product scope +does not thereby gain a commitment path, and a task grant does not thereby +gain a catalogue read. Scopes authorize reads and the separately configured +explain scope authorizes the diagnostic read; every commitment takes its +authority from the grant alone. + +## The claim set + +A commitment presents no product scope. Its authority is the grant inside the +access token: the six `registry_grant_*` members (`registry_grant_id`, +`registry_grant_source_issuer`, `registry_grant_client`, +`registry_grant_resource`, `registry_grant_exp`, `registry_grant_bounds`), +plus `registry_purpose` and `registry_approver`. A token carrying none of +them is an ordinary standing token; a token carrying some of them is refused +as a malformed credential rather than honored partially. + +Before the caller reaches the service, the runtime binds the grant to two +facts it verified itself: the grant's client must equal the client the +deployment verified (`azp` or `client_id`), and the grant's resource must +equal this deployment's configured OIDC audience. A grant minted for another +product's resource is a profile refusal here, and the closed union of bounds +makes it one in every verifier: an unknown bounds tag is a malformed claim, +so a grant minted for one product can never be interpreted by another +product's runtime. + +## The scheduling bounds + +The bounds are `{"type": "scheduling", "permissions": [...]}`. One permission +names a service, a location, and the actions it allows there: + +```json +{ + "type": "scheduling", + "permissions": [ + { + "service": "registry-update", + "location": "bangkok-counter", + "actions": ["appointment.create", "appointment.reschedule"] + } + ] +} +``` + +The action vocabulary is closed, and it is the vocabulary the runtime knows: + +- `hold.create` and `hold.release` +- `appointment.create`, `appointment.reschedule`, and `appointment.cancel` + +The bounds a signed claim may carry are bounded exactly as BREG's are: + +- at most 64 permissions, each a unique `(service, location)` pair; +- at most 32 actions per permission, each at most 128 bytes; +- service and location values of at most 512 bytes, without whitespace, + control characters, or a `*`. + +Wildcards are refused, unknown members are refused at every level rather than +ignored, and duplicate actions or duplicate pairs are refused. Nothing about a +permission is optional: an empty `actions` list is malformed, not a grant to +read. + +An issuer may mint the `scheduling` tag only once every verifier it targets +understands it. A verifier built before the tag existed rejects the whole +grant as malformed, which is the intended fail-closed direction; an operator +rolling a verifier back past that point needs to know it. The review record +in `crates/registry-platform-oidc/README.md` carries the threat model and the +release note to update when the first accepting release is cut. + +## How the runtime matches a permission + +A permission matches an offering only when its service equals the offering's +service, its location equals the offering's location, and its action list +names the operation the route performs. The match is exact: a broader service +or location never covers a narrower one, and no rule maps one location onto +another. A grant that does not cover the request answers +`operation.not-authorized` and nothing is written. + +The bounds are matched once, at the service, before the capacity transaction +opens. Inside the transaction, immediately before the claim commits, exactly +one fact is read again: the grant's own expiry, against the clock that +commitment is decided under. A grant that expired between the door and the +commit cannot take capacity, and the attempt answers +`operation.not-authorized` like any other authority refusal. + +The bounds themselves are not read again at that point, and this milestone +has no revocation check, so a grant narrowed or withdrawn at the issuer after +its token was minted keeps the authority its token carries until the token or +the grant deadline passes. Keep grant deadlines short. Re-checking the full +bounds inside the capacity transaction is a recorded deferral, not a promise +this milestone keeps. + +## What the audit journal records + +Every commitment and every refused commitment writes one authorization record +under `authorization.allowed` or `authorization.refused`. The principal, +client, grant, and approver are keyed pseudonyms scoped to the request's +reference class, the purpose is recorded as presence only and never as a +value, and the record adds the grant's source issuer and its deadline. No +bound value, service, location, or action appears in the journal. + +## Where a grant comes from + +Scheduling verifies grants; it does not approve them. A grant is minted by the +token-exchange issuer from a template a current human holder approved, and the +approver's identity travels in the claim. The approval surface, the template +grammar, the exchange flow, and revocation are documented in +[`../casework/TASK_GRANTS.md`](../casework/TASK_GRANTS.md); Scheduling is a +relying party of that authority and inherits no Casework authorization. + +Scheduling has no route that books on another party's behalf, and a grant does +not create one: the Phase 1 scope records guest booking, a holder booking for +someone else, as an explicit exclusion. From f0692f392a55f443bd144686857954e1614af460 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:20:45 +0700 Subject: [PATCH 035/115] docs(scheduling): add the product changelog and contributor orientation CHANGELOG.md opens an Unreleased section that states the MVP surface and its recorded deferrals. AGENTS.md orients a contributor: which artifacts in this folder are generated, which gates to run, and the example rule that keeps the committed standalone project identical to what init writes. Signed-off-by: Jeremi Joslin --- products/scheduling/AGENTS.md | 30 ++++++++++++++++++++++++++++++ products/scheduling/CHANGELOG.md | 26 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 products/scheduling/AGENTS.md create mode 100644 products/scheduling/CHANGELOG.md diff --git a/products/scheduling/AGENTS.md b/products/scheduling/AGENTS.md new file mode 100644 index 0000000000..306de9476c --- /dev/null +++ b/products/scheduling/AGENTS.md @@ -0,0 +1,30 @@ +# Registry Scheduling product + +This directory owns the Scheduling product contract, generated API +description, checkpoint examples, and product-local verification scripts. +Read the workspace `AGENTS.md`, this file, and `README.md` before changing the +product. + +- Treat `generated/registry-scheduling.openapi.json` as generated output. + Change `scripts/generate_openapi.py`, then run it and its `--check` mode. +- Treat `generated/runtime/runtime.schema.json` the same way: it is derived + from the strict `RuntimeConfig` types, and the regeneration command is in + `RUNTIME-CONFIG.md`. +- Keep `contracts/security-invariant-matrix.yaml` and + `contracts/security-test-traceability.yaml` scoped to implemented Scheduling + behavior. A mapped test is not evidence that the test ran. +- Preserve the source-neutral boundary: no scheduling crate, directly or + through any shared dependency, reaches a Base Registry Engine, Casework, or + Evidence crate, and none of those products reach a scheduling crate. Run + `scripts/check_dependency_direction.py` after dependency changes. +- `examples/standalone-exact-time/` is exactly what + `schedulingctl init --template standalone-exact-time` writes, and the + checkpoint fails if the two drift apart. When `schedulingctl init` starts + emitting or changing a file, copy that output into the committed example in + the same commit. +- Keep authored examples and the checkpoint demo small. Do not add a + later-wave surface to close a documentation obligation; record the exclusion + instead. +- PostgreSQL verification requires a separate disposable database and the + `postgres-test` feature. A test binary that skips because its database URL + is absent is not database verification. diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md new file mode 100644 index 0000000000..97e29b1bad --- /dev/null +++ b/products/scheduling/CHANGELOG.md @@ -0,0 +1,26 @@ +# Registry Scheduling changelog + +## Unreleased + +- Publish the scheduling MVP: published openings, exact-time offerings over + interchangeable resource pools, published arrival windows with channel + subquotas, holds, and accountable appointments, over PostgreSQL. The + contract is unreleased and carries no frozen compatibility promise. +- Keep the capacity ledger inside the runtime's own transaction. A hold or + appointment is created, moved, or released only inside that transaction, and + no other product may write the ledger; eligibility stays with the source + system. +- Authorize every commitment with a task grant whose scheduling bounds name + the offering's service, its location, and the action, bounded to 64 + permissions of 32 actions with no wildcard. Only the grant's expiry is + re-checked inside the capacity transaction. +- Emit each committed scheduling change as a CloudEvents 1.0 event through the + outbox to a configured reminder destination, and keep intents readable in + place when no destination is configured. +- Retain idempotency attempt receipts for the configured period and forget + listing cursors after fifteen minutes. Appointment, history, outbox, and + audit retention are deferred. +- Provide `schedulingctl init`, `check`, `test`, `explain`, and `package`, and + write a complete `runtime.example.yaml` beside every initialized project. +- Carry the product's own contract checks, security-invariant matrix, and + offline authoring journey under this folder. From bd350d3a8c57154bb49645f223cee3eed22d12c6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:26:01 +0700 Subject: [PATCH 036/115] docs(scheduling): state the product boundary, its exclusions, and its events The product README was a folder map. It now carries the obligations the review left on it: the capacity-ledger boundary sentence, a paragraph on what Scheduling is not, the CloudEvents envelope and its four types with the outbox as the boundary when no destination is configured, the Phase 1 scope and its tracked exclusions including guest booking, and the subquota rule stated as ceilings rather than reserved floors. A contributor now also finds SCHEDULING_TEST_DATABASE_URL beside the PostgreSQL suites that need it, and the path from init to a running server runs through tracked material. Signed-off-by: Jeremi Joslin --- products/scheduling/README.md | 200 ++++++++++++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 12 deletions(-) diff --git a/products/scheduling/README.md b/products/scheduling/README.md index e0e7e82256..5c404f223d 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -1,31 +1,207 @@ -# Registry Scheduling product material +# Registry Scheduling Registry Scheduling gives a registry a coordinated booking surface: published openings, exact-time offerings over interchangeable resource pools, published arrival windows with channel subquotas, holds, and accountable bookings, over PostgreSQL. The runtime is `crates/registry-scheduling`; the source-neutral model and evaluators live in `crates/registry-scheduling-core`; adopter -tooling is `crates/registry-schedulingctl` (`schedulingctl`). +tooling is `crates/registry-schedulingctl` (`schedulingctl`); the bounded Rust +client is `crates/registry-scheduling-client`. + +The contract is unreleased and carries no frozen compatibility promise. + +## Boundary + +Scheduling owns its capacity ledger absolutely. A hold or an appointment is +created, moved, or released only inside the Scheduling runtime's own capacity +transaction, and no other product may write that ledger, directly or through +a shared database. Another product may publish bookable supply for work it +governs, or show an appointment Scheduling holds, without either side gaining +the other's authority: eligibility stays with the source system, and the +authority to commit capacity stays with the task grant Scheduling verifies. + +## What Scheduling is not + +The product is a capacity ledger over anchored supply, and nothing else. It is +not a source of eligibility: whether a party may hold a service is decided by +the system that owns that rule, and Scheduling has no eligibility route. It is +not a queue: nothing here coordinates source-owned work items, which is +Registry Casework's surface. It is not a calendar product: openings, windows, +and closures are published facts an operator anchors, and Scheduling holds no +personal calendar and no invitation lifecycle. It is not a reminder delivery +product: the runtime records due reminder intents and delivers them to one +operator-configured destination, while the message content, the channel, and +the notification bus stay outside Scheduling. + +## Start from an authored project + +Create a starter, inspect its effective configuration, and replay its +fixtures: + +```sh +schedulingctl init ./scheduling --template standalone-exact-time +schedulingctl check ./scheduling +schedulingctl test ./scheduling +schedulingctl explain ./scheduling +``` + +Check reports `complete` or `incomplete` with field-addressed findings. Test +repeats that authoring status and replays the project's bounded synthetic +fixtures offline; a passing report carries the `offline_synthetic` proof +boundary and `productionClosure: false`, so it does not establish source +reachability or deployment readiness. Explain publishes what the runtime would +serve: identity, policy digest, offerings, windows with their subquotas, and +the hold policy. + +`schedulingctl init` writes `runtime.example.yaml` beside the policy: a +complete, commented operator document. Copy it to `runtime.yaml`, set its +absolute paths and secret references, and read +[RUNTIME-CONFIG.md](RUNTIME-CONFIG.md) for every block, field, and default. +Then apply the live environment records and start: + +```sh +scheduling --runtime-config "$PWD/runtime.yaml" migrate +schedulingctl records apply "$PWD/runtime.yaml" "$PWD/records.yaml" +scheduling --runtime-config "$PWD/runtime.yaml" serve +``` + +`migrate` applies the schema and binds the database to the policy's +scheduling id, which every later start verifies before it writes anything. +`records apply` is the one attributable operator write of a deployment's +environment records: the locations with their time zones, the resource pools +and their members, and the dated exceptions such as closures. The policy +references that supply by identifier; it does not embed it. Database +transport security is not configurable in production: the runtime requires +TLS on both connections, and the plaintext escape is a `postgres-test` build +switch documented in [RUNTIME-CONFIG.md](RUNTIME-CONFIG.md). + +The served routes are the published catalogue, availability and the +separately authorized explain read, holds, and appointments with their +reschedule, cancel, and history routes. Every call presents a bearer access +token, and one of three authority profiles decides what it must carry; the +published reference is +[Registry Scheduling API](https://docs.registrystack.org/reference/apis/registry-scheduling/). + +## Task grants + +Reads take a scope. Every commitment takes a task grant whose scheduling +bounds name the offering's service, its location, and the action, and the +runtime matches all three exactly before the capacity transaction opens. The +bounds, the closed action vocabulary, the value limits, and the one fact the +transaction re-reads are documented in +[TASK_GRANTS.md](TASK_GRANTS.md). Scheduling verifies grants; it does not +approve them, and the approval surface stays with the product that holds the +human relationship. + +## Events + +The runtime speaks to one other system: reminder dispatch. A commitment mints +the reminder intents its offering declares, each due a fixed number of minutes +before the appointment, and a worker renders every due intent as one +CloudEvents 1.0 event in canonical JSON and POSTs it to the configured +reminder destination with `content-type: application/cloudevents+json`, +exactly once per claim. A transport failure schedules an exponential retry, a +refusal the retry classes do not cover holds the intent for the operator, and +eight failed attempts hold it. + +Every event carries the same envelope: + +| Field | Value | +| --- | --- | +| `specversion` | `1.0` | +| `id` | the outbox intent's identifier | +| `source` | the deployment's `scheduling.id` from the policy, and nothing else | +| `type` | `org.registrystack.scheduling.` plus the intent's purpose | +| `time` | when the intent fell due, RFC 3339 | +| `data` | the payload the runtime committed | + +There are four purposes, so four types: `confirmation` and `change` carry the +committed appointment's identifier, revision, offering, start, end, and the +policy revision it was committed under; `cancellation` carries the +identifier, revision, and reason; `reminder` carries the identifier, +revision, offering, start, and how many minutes before the start it was +minted for. + +With no reminder destination configured, the intents stay readable in place: +they are written to the outbox and marked local, never pretended delivered. +Adopting the product does not require wiring a notification bus first. + +## Phase 1 scope and exclusions + +Phase 1 is one booking surface over anchored supply. It excludes, as tracked +exclusions rather than accidents: + +- Reassignment: no route moves an appointment to another party, and no + evaluator takes a substitute recipient. +- Attendance routes: no check-in, arrival, or fulfilment surface exists, and + the runtime records nothing about whether a party attended. +- Closure routes: closures are environment records the operator applies, and + no route creates or lifts one. +- Remediation workflows: a refused or expired commitment ends at its problem + answer, and nothing reopens it. +- Reports: no occupancy, utilization, or waiting-list reporting surface. +- Guest booking: a holder other than the verified caller cannot book on + someone's behalf. Every commitment's grant names the caller, and the party + a request carries is the party the caller commits. + +`externalReferences` and the integration seams that would attach a Scheduling +appointment to another product's record are Phase 4 work. They are absent from +the wire types today, and adding them is a contract change, not a fill-in. + +## Arrival windows and channel subquotas + +A published window carries a total unit budget and, optionally, subquotas +keyed by booking channel. A subquota is a per-channel ceiling, not a reserved +floor: the evaluator checks the named channel's allocation against its +subquota, then checks the request against the window's published total, so an +unclaimed subquota stays available to the window's other channels up to that +total. A channel the policy declares but gives no subquota draws from the +total alone, and a request naming an undeclared channel is refused. + +Nothing in the current grammar reserves capacity for a channel. Reserved +floors are an open Phase 2 product question: they change what "the last unit" +means for a walk-in, and they are not a bug to fix here. + +## Product material and gates This folder holds the product's own contracts, examples, fixtures, and gates. -The database-free checkpoint runs the dependency-direction gate and the whole -offline authoring journey: +The database-free checkpoint runs the contract checks, the dependency-direction +gate, and the whole offline authoring journey: ```bash cargo build --locked -p registry-schedulingctl products/scheduling/scripts/check-checkpoint.sh ``` -For PostgreSQL execution, destructive test suites require a separate disposable -database and the `postgres-test` feature; a test binary that skips because its -database URL is absent is not database verification. - `examples/standalone-exact-time/` is exactly what `schedulingctl init --template standalone-exact-time` writes, and the -checkpoint fails if the two drift apart. +checkpoint fails if the two drift apart. The runtime configuration schema is +derived from the strict Rust types, never hand-edited; its generator command +is in [RUNTIME-CONFIG.md](RUNTIME-CONFIG.md). + +For PostgreSQL execution, the destructive test suites require a separate +disposable database and the `postgres-test` feature: + +```bash +SCHEDULING_TEST_DATABASE_URL=postgres://user:password@host/database \ + cargo test --locked -p registry-scheduling --features postgres-test \ + --test postgres_commitments +``` + +`SCHEDULING_TEST_DATABASE_URL` names that disposable database for both +PostgreSQL suites, `registry-scheduling`'s commitment suite and +`registry-schedulingctl`'s records-apply suite. Both reset the `public` +schema, so never point either at retained operator data, and never point both +at one database. A test binary that skips because its database URL is absent +is not database verification. + +## Dependency boundary No scheduling crate, directly or through any shared dependency, may reach a Base Registry Engine, Casework, or Evidence crate, and none of those products -may reach a scheduling crate. The source-neutral core sits at the bottom of -the product depending on no other scheduling crate, and the client shares the -core without ever linking the runtime or adopter tooling. +may reach a scheduling crate. The restriction is scoped to the MVP, because +the crates that would compose across those boundaries do not exist yet; the +workspace `AGENTS.md` records what changes when one does. The source-neutral +core sits at the bottom of the product depending on no other scheduling +crate, and the client shares the core without ever linking the runtime or +adopter tooling. From fafd8c5f57ebc81253ae1944cf470ce757870b05 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:28:20 +0700 Subject: [PATCH 037/115] docs: list Registry Scheduling among the stack's products The root README still said six products and never named schedulingctl, so a reader could not tell the booking surface shipped on the same train as the others. Scheduling joins the product list, the adopter tool sentence, the installer commands and their install-directory variables, the container image list, and the repository layout. Signed-off-by: Jeremi Joslin --- README.md | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 25fc39706a..297c2e6d17 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ release manifests, and docs. ## What It Includes -Registry Stack ships six installable products on one release train. Each has +Registry Stack ships seven installable products on one release train. Each has its own deployment contract and adopter tooling, and each one is optional. - **Base Registry Engine:** a configuration-defined writable registry backed by @@ -66,12 +66,18 @@ its own deployment contract and adopter tooling, and each one is optional. and small human decisions requested by another service. It can run with a governed Base Registry Engine source or in standalone hosted mode. Docs: [Registry Casework overview](https://docs.registrystack.org/dev/start/casework/). +- **Registry Scheduling:** a coordinated booking surface over anchored supply: + published openings, exact-time offerings, arrival windows with channel + subquotas, holds, and accountable appointments, with every capacity decision + made inside one transaction under a task grant. + Docs: [Registry Scheduling API](https://docs.registrystack.org/reference/apis/registry-scheduling/). Evidence Gateway can use a Base Registry Engine or Registry Relay API as one of its fixed sources, and keeps its own authorization either way. The stack also includes Registry Platform shared primitives, the `bregctl`, `caseworkctl`, -`relayctl`, `evidencectl`, and `discoveryctl` adopter tools, unified Node.js and -Python clients, and release tooling for validating the public source model. +`schedulingctl`, `relayctl`, `evidencectl`, and `discoveryctl` adopter tools, +unified Node.js and Python clients, and release tooling for validating the +public source model. ### Install a released build @@ -82,6 +88,9 @@ curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/downl # Registry Casework: casework and caseworkctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/casework-install.sh | bash +# Registry Scheduling: scheduling and schedulingctl +curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/scheduling-install.sh | bash + # Registry Relay: relay and relayctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bash @@ -91,13 +100,13 @@ curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/downl Each installer verifies the binaries against the published `SHA256SUMS` before writing them to `$HOME/.local/bin`, or to the directory `BREG_INSTALL_DIR`, -`CASEWORK_INSTALL_DIR`, `RELAY_INSTALL_DIR`, or `EVIDENCECTL_INSTALL_DIR` -names. Registry Discovery and Registry Manifest publish a binary and no -installer: download `discovery--linux-amd64` or +`CASEWORK_INSTALL_DIR`, `SCHEDULING_INSTALL_DIR`, `RELAY_INSTALL_DIR`, or +`EVIDENCECTL_INSTALL_DIR` names. Registry Discovery and Registry Manifest +publish a binary and no installer: download `discovery--linux-amd64` or `registry-manifest--linux-amd64` from the [release page](https://github.com/registrystack/registry-stack/releases) and check it against the release checksum chain. Container images for `breg`, -`casework`, `relay`, `evidence`, and `discovery` are published as +`casework`, `scheduling`, `relay`, `evidence`, and `discovery` are published as `ghcr.io/registrystack/:`. Which platforms each artifact supports, and what is not supported, is recorded in [known limitations](https://docs.registrystack.org/dev/explanation/known-limitations/#platform-support). @@ -128,13 +137,14 @@ flowchart LR ## Repository Layout - `crates/`: Rust crates and runnable binaries for Base Registry Engine, - Registry Relay, Evidence Gateway, Registry Casework, Registry Discovery, - Registry Manifest, Registry Platform, and the `bregctl`, - `caseworkctl`, `relayctl`, `evidencectl`, and `discoveryctl` adopter tools. - Base Registry Engine lives in `crates/registry-breg` with one `breg` - binary, Evidence Gateway in `crates/registry-evidence` with one `evidence` - binary, and Registry Casework in `crates/registry-casework` with one - `casework` binary. + Registry Relay, Evidence Gateway, Registry Casework, Registry Scheduling, + Registry Discovery, Registry Manifest, Registry Platform, and the `bregctl`, + `caseworkctl`, `schedulingctl`, `relayctl`, `evidencectl`, and `discoveryctl` + adopter tools. Base Registry Engine lives in `crates/registry-breg` with one + `breg` binary, Evidence Gateway in `crates/registry-evidence` with one + `evidence` binary, Registry Casework in `crates/registry-casework` with one + `casework` binary, and Registry Scheduling in `crates/registry-scheduling` + with one `scheduling` binary. - `products/`: product-owned docs, examples, Docker inputs, specs, security material, scripts, performance harnesses, and fixtures that are not normal workspace crates. From 691ea6c53805eef5da0976910acfd8d4639b847a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 15:33:49 +0700 Subject: [PATCH 038/115] fix(scheduling): close the channel vocabulary a subquota is drawn from A window subquota caps the channel it names, but the request's channel was a free-form string matched by exact spelling: a caller whose spelling matched no subquota skipped the cap entirely and drew from the window's total, so the slice that governed everyone else did not govern them. The channel vocabulary is in fact closed by the Channel type; only the request side was open. The vocabulary now governs the request the same way it governs the policy: a channel a subquota can never name is refused with request.unprocessable before any capacity is counted, and a policy may declare the subset it serves, which check holds every subquota to and the evaluator holds every request to. An absent declaration declares nothing beyond the vocabulary, so existing policies that never declared one are unaffected. The evaluator refusal reuses the existing code rather than adding one: a well-formed request refused by policy semantics is the request.unprocessable family, beside schedule.unpublished and party.capacity-inadequate, and no new problem code joins the closed vocabulary. Test evidence: the misspelled channel that once escaped its subquota, and a vocabulary member a declared set excludes, are both refused inside the window evaluator; the declared channel still meets its full slice; check refuses a subquota on an undeclared channel and a declaration naming one channel twice, and passes the same subquotas when nothing is declared. Signed-off-by: Jeremi Joslin --- .../registry-scheduling-core/src/admission.rs | 96 ++++++++++++++++++- .../src/diagnostics.rs | 4 + .../registry-scheduling-core/src/fixture.rs | 1 + crates/registry-scheduling-core/src/policy.rs | 85 ++++++++++++++++ crates/registry-scheduling/src/runtime.rs | 1 + crates/registry-scheduling/src/service.rs | 18 +++- crates/registry-scheduling/src/store.rs | 3 + 7 files changed, 198 insertions(+), 10 deletions(-) diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index bb1b3c7ec3..128454c35d 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -22,7 +22,7 @@ use chrono::{DateTime, Duration, Utc}; use registry_platform_calendar::CalendarInterval; use crate::model::{AdmissionRequest, LedgerClaim, LedgerKind, LedgerSnapshot, PoolMember}; -use crate::policy::{ExactTimeOffering, OfferingPolicy, PublishedWindow}; +use crate::policy::{Channel, ExactTimeOffering, OfferingPolicy, PublishedWindow}; use crate::problem::ProblemCode; use crate::units::UnitsEvaluationError; @@ -72,6 +72,8 @@ pub enum AdmissionRefusal { PartyCapacityInadequate, #[error("the party is missing required prerequisites")] PrerequisiteMissing { missing: Vec }, + #[error("the request names a channel the closed vocabulary or the policy does not serve")] + ChannelUnknown, #[error("no backing member carries the required capabilities")] CapabilityUnmatched, #[error("an active booking already holds this party's duplicate key")] @@ -101,6 +103,7 @@ impl AdmissionRefusal { Self::LocationClosed { .. } => ProblemCode::LocationClosed, Self::PartyCapacityInadequate { .. } => ProblemCode::PartyCapacityInadequate, Self::PrerequisiteMissing { .. } => ProblemCode::PrerequisiteMissing, + Self::ChannelUnknown => ProblemCode::RequestUnprocessable, Self::CapabilityUnmatched { .. } => ProblemCode::CapabilityUnmatched, Self::DuplicateActiveBooking { .. } => ProblemCode::BookingDuplicateActive, Self::DuplicateKeyRequired { .. } => ProblemCode::PreconditionRequired, @@ -145,6 +148,12 @@ pub struct WindowContext<'a> { pub horizon_days: u32, pub snapshot: &'a LedgerSnapshot, pub policy_revision: u64, + /// The channels the policy declares it serves. The channel vocabulary + /// itself is closed; a declaration narrows it to the served subset, and + /// a request naming a channel outside either is refused before any + /// capacity is counted. An empty slice declares nothing beyond the + /// vocabulary. + pub channels: &'a [Channel], pub now: DateTime, } @@ -322,6 +331,7 @@ pub fn evaluate_window_admission( horizon_days, snapshot, policy_revision, + channels, now, } = context; @@ -352,14 +362,21 @@ pub fn evaluate_window_admission( check_prerequisites(offering, request)?; check_duplicate(offering, request, snapshot, exclude, *now)?; - if let Some(channel) = &request.channel { + if let Some(name) = &request.channel { + // The channel vocabulary is closed, and a policy that declares the + // channels it serves narrows it further. A name outside either once + // matched no subquota and drew from the window's total unconstrained, + // so it is refused before any capacity is counted. + let channel = Channel::from_name(name).ok_or(AdmissionRefusal::ChannelUnknown)?; + if !channels.is_empty() && !channels.contains(&channel) { + return Err(AdmissionRefusal::ChannelUnknown); + } if let Some(subquota) = window .subquotas .iter() - .find(|subquota| subquota.channel.as_str() == channel.as_str()) + .find(|subquota| subquota.channel == channel) { - let allocated = - snapshot.window_units_allocated(&window.id, Some(channel), exclude, *now); + let allocated = snapshot.window_units_allocated(&window.id, Some(name), exclude, *now); if allocated.saturating_add(required) > subquota.units { return Err(AdmissionRefusal::CapacityExhausted); } @@ -1162,6 +1179,7 @@ mod tests { horizon_days: 60, snapshot: &snapshot, policy_revision: 1, + channels: &[], now, }; let admission = @@ -1186,6 +1204,7 @@ mod tests { horizon_days: 60, snapshot: &snapshot, policy_revision: 1, + channels: &[], now, }; let result = evaluate_window_admission(&context, &window_request(2, 2), None); @@ -1228,6 +1247,7 @@ mod tests { horizon_days: 60, snapshot: &snapshot, policy_revision: 1, + channels: &[], now, }; // Four recipients are above the highest band: inadequate party. @@ -1258,6 +1278,7 @@ mod tests { horizon_days: 60, snapshot: &snapshot, policy_revision: 1, + channels: &[], now, }; let result = evaluate_window_admission(&context, &window_request(1, 1), None); @@ -1267,6 +1288,70 @@ mod tests { ); } + /// COR-3 / D5(a): the channel vocabulary is closed, and a policy that + /// declares the channels it serves narrows it further. A spelling + /// outside the vocabulary, or a vocabulary member the deployment does + /// not serve, once matched no subquota and drew from the window's total + /// unconstrained. + #[test] + fn a_channel_the_policy_does_not_serve_is_refused_not_let_loose() { + let offering = arrival_offering(); + let window = window(); + // Public subquota of 2, already fully allocated. + let snapshot = LedgerSnapshot { + claims: vec![window_claim("claim-1", 2, "public")], + }; + let now = utc(4, 9, 0); + let context = WindowContext { + offering: &offering, + window: &window, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + // The vocabulary itself refuses a misspelled channel: "Public" is + // not a channel any subquota can name, so it may not book as one. + let misspelled = AdmissionRequest { + channel: Some("Public".to_owned()), + ..window_request(1, 1) + }; + assert_eq!( + evaluate_window_admission(&context, &misspelled, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::RequestUnprocessable) + ); + + // A policy that declares only the public channel refuses a request + // naming another vocabulary member, whatever its slice would be. + let narrowed = WindowContext { + channels: &[Channel::Public], + ..context + }; + let undeclared = AdmissionRequest { + channel: Some("urgent".to_owned()), + ..window_request(1, 1) + }; + assert_eq!( + evaluate_window_admission(&narrowed, &undeclared, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::RequestUnprocessable) + ); + + // The declared channel itself still meets its subquota: the public + // slice is full, so the same refusal the plain test expects. + assert_eq!( + evaluate_window_admission(&narrowed, &window_request(1, 1), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::CapacityExhausted) + ); + } + #[test] fn window_revisions_must_match_and_policy_change_wins() { let offering = arrival_offering(); @@ -1280,6 +1365,7 @@ mod tests { horizon_days: 60, snapshot: &snapshot, policy_revision: 1, + channels: &[], now, }; let stale_window = AdmissionRequest { diff --git a/crates/registry-scheduling-core/src/diagnostics.rs b/crates/registry-scheduling-core/src/diagnostics.rs index 1b1008ed06..5aef363fcc 100644 --- a/crates/registry-scheduling-core/src/diagnostics.rs +++ b/crates/registry-scheduling-core/src/diagnostics.rs @@ -31,6 +31,9 @@ pub enum PolicyCheckReason { UnknownWindow, /// A referenced holiday set does not exist. UnknownHolidaySet, + /// A subquota names a channel outside the package's declared channel + /// set. + UnknownChannel, /// A field required by the offering's scheduling mode is absent. MissingModeField, /// A field that belongs to the other scheduling mode is present. @@ -68,6 +71,7 @@ impl PolicyCheckReason { Self::UnknownOffering => "unknown-offering", Self::UnknownWindow => "unknown-window", Self::UnknownHolidaySet => "unknown-holiday-set", + Self::UnknownChannel => "unknown-channel", Self::MissingModeField => "missing-mode-field", Self::WrongModeField => "wrong-mode-field", Self::InvalidBound => "invalid-bound", diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs index 6929e92f07..2e266d538b 100644 --- a/crates/registry-scheduling-core/src/fixture.rs +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -509,6 +509,7 @@ fn replay_case( horizon_days: arrival.horizon_days, snapshot, policy_revision: revision, + channels: &policy.channels, now: fixture.now, }; let admitted = evaluate_window_admission(&context, request, exclude)?; diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 91bd39c097..69cf329590 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -198,6 +198,21 @@ impl Channel { Self::WalkIn => "walk-in", } } + + /// The channel a name names, or `None` when the name is outside the + /// closed vocabulary. The vocabulary is the one thing every spelling of + /// a channel is measured against, whether a policy declares its served + /// subset or not. + #[must_use] + pub fn from_name(name: &str) -> Option { + match name { + "public" => Some(Self::Public), + "assisted" => Some(Self::Assisted), + "urgent" => Some(Self::Urgent), + "walk-in" => Some(Self::WalkIn), + _ => None, + } + } } /// A non-overlapping reserved slice of a window's published capacity. @@ -295,6 +310,13 @@ pub struct SchedulingPolicy { pub holiday_sets: Vec, pub openings: Vec, pub windows: Vec, + /// The channels this deployment declares it serves. The vocabulary is + /// closed by the `Channel` type itself; a declaration narrows it to the + /// served subset, every subquota must draw on a declared channel, and a + /// request naming anything else is refused. An absent declaration + /// declares nothing beyond the vocabulary. + #[serde(default)] + pub channels: Vec, pub hold_policy: HoldPolicy, #[serde(default)] pub hooks: Vec, @@ -376,6 +398,19 @@ impl SchedulingPolicy { check_collection_bound(&self.openings, "openings", &mut findings); check_collection_bound(&self.windows, "windows", &mut findings); + // A declared channel set is a closed list of the channels this + // deployment serves; naming one twice declares it once. + let mut declared_channels: Vec = Vec::new(); + for (index, channel) in self.channels.iter().enumerate() { + if declared_channels.contains(channel) { + findings.push(SchedulingDiagnostic::new( + format!("channels[{index}]"), + PolicyCheckReason::DuplicateIdentifier, + )); + } + declared_channels.push(*channel); + } + let mut service_ids = Vec::new(); for (index, service) in self.services.iter().enumerate() { let path = format!("services[{index}]"); @@ -735,6 +770,14 @@ impl SchedulingPolicy { PolicyCheckReason::DuplicateIdentifier, )); } + // A declared set is closed: a subquota may not reserve capacity + // for a channel the deployment does not say it serves. + if !self.channels.is_empty() && !self.channels.contains(&subquota.channel) { + findings.push(SchedulingDiagnostic::new( + format!("{subquota_path}.channel"), + PolicyCheckReason::UnknownChannel, + )); + } channels.push(subquota.channel); } if subquota_units > window.units { @@ -1429,6 +1472,48 @@ holdPolicy: ); } + /// COR-3 / D5(a): a declared channel set is closed. A subquota may not + /// reserve capacity for a channel the deployment does not say it serves, + /// and a declaration naming one channel twice declares it once. An + /// absent declaration declares nothing beyond the vocabulary. + #[test] + fn a_subquota_may_not_draw_on_an_undeclared_channel() { + let mut policy = household_window_policy(); + // The window serves public and assisted; the deployment declares + // only assisted. + policy.channels = vec![Channel::Assisted]; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"windows[0].subquotas[0].channel: unknown-channel".to_owned()), + "{rendered:?}" + ); + assert!(!rendered + .iter() + .any(|finding| finding.starts_with("windows[0].subquotas[1].channel:"))); + + // With no declaration, the vocabulary alone governs and the same + // subquotas check clean. + policy.channels = Vec::new(); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered + .iter() + .all(|finding| !finding.ends_with("unknown-channel")), + "{rendered:?}" + ); + + // A repeated declaration is a duplicate identifier. + policy.channels = vec![Channel::Public, Channel::Public]; + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!( + rendered.contains(&"channels[1]: duplicate-identifier".to_owned()), + "{rendered:?}" + ); + } + /// AT-22, static half: an unpartitioned staffing claim over a pool that an /// exact-time offering also sells is rejected at publication. /// diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index ceea31205d..310b0a9355 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -919,6 +919,7 @@ mod tests { holiday_sets: Vec::new(), openings: Vec::new(), windows: Vec::new(), + channels: Vec::new(), hold_policy: HoldPolicy { ttl_minutes: 10, max_per_caller: 2, diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index ece6d4ccc9..301a9a1682 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -22,11 +22,12 @@ use registry_scheduling_core::{ admission_request_hash, evaluate_exact_time_admission, evaluate_window_admission, location_closure_intervals, location_open_intervals, type_uri, AdmissionRequest, AppointmentDocument, AppointmentHistoryEntryDocument, AppointmentStateDocument, - AvailabilityEntry, CancelAppointmentRequest, CreateAppointmentRequest, ExactTimeContext, - ExactTimeOffering, LedgerKind, LedgerSnapshot, OfferingDocument, OfferingPolicy, PageDocument, - PoolMember, ProblemCode, PublishedWindow, ReminderDocument, RescheduleAppointmentRequest, - ResourceDocument, SchedulingFacts, SchedulingMode, SchedulingModeDocument, SchedulingPolicy, - SchedulingServiceDocument, ServiceDocument, WindowContext, WindowDocument, + AvailabilityEntry, CancelAppointmentRequest, Channel, CreateAppointmentRequest, + ExactTimeContext, ExactTimeOffering, LedgerKind, LedgerSnapshot, OfferingDocument, + OfferingPolicy, PageDocument, PoolMember, ProblemCode, PublishedWindow, ReminderDocument, + RescheduleAppointmentRequest, ResourceDocument, SchedulingFacts, SchedulingMode, + SchedulingModeDocument, SchedulingPolicy, SchedulingServiceDocument, ServiceDocument, + WindowContext, WindowDocument, }; use serde_json::{json, Value}; use sha2::{Digest as _, Sha256}; @@ -350,6 +351,7 @@ impl SchedulingService { window, lead_time_minutes, horizon_days, + .. } => { let snapshot = self.store.window_snapshot(&window.id, now).await?; window_entries( @@ -454,6 +456,7 @@ impl SchedulingService { window, lead_time_minutes, horizon_days, + channels, } => { let snapshot = self.store.window_snapshot(&window.id, now).await?; evaluate_window_admission( @@ -464,6 +467,7 @@ impl SchedulingService { horizon_days, snapshot: &snapshot, policy_revision: self.revision(), + channels: &channels, now, }, &probe, @@ -1278,6 +1282,7 @@ enum ResolvedSupply { window: PublishedWindow, lead_time_minutes: u32, horizon_days: u32, + channels: Vec, }, } @@ -1336,6 +1341,7 @@ impl ResolvedSupply { window: window.clone(), lead_time_minutes: arrival.lead_time_minutes, horizon_days: arrival.horizon_days, + channels: policy.channels.clone(), }) } _ => Err(operator_gap("one scheduling mode")), @@ -1359,10 +1365,12 @@ impl ResolvedSupply { window, lead_time_minutes, horizon_days, + channels, } => SupplyContext::Window { window, lead_time_minutes: *lead_time_minutes, horizon_days: *horizon_days, + channels: channels.as_slice(), }, } } diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index c762552952..2ad25b96b9 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -183,6 +183,7 @@ pub enum SupplyContext<'p> { window: &'p registry_scheduling_core::PublishedWindow, lead_time_minutes: u32, horizon_days: u32, + channels: &'p [registry_scheduling_core::Channel], }, } @@ -1765,6 +1766,7 @@ fn evaluate( window, lead_time_minutes, horizon_days, + channels, } => evaluate_window_admission( ®istry_scheduling_core::WindowContext { offering, @@ -1773,6 +1775,7 @@ fn evaluate( horizon_days: *horizon_days, snapshot, policy_revision: u64::try_from(commitment.policy_revision).unwrap_or(u64::MAX), + channels, now: commitment.now, }, request, From a0c221742cd71a1d01662feb158d728053bfd932 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 16:55:50 +0700 Subject: [PATCH 039/115] fix(scheduling): refuse a leftover capacity policy nothing reads A window declared where its leftover capacity goes when it closes, check validated the declaration, and no code ever read it. An adopter could author a promise the runtime does not keep and get no warning. Make the field optional and have check refuse a window that declares one, the way a declared hook is already refused for having no engine. The field stays on the type so the refusal can name it, and the starter template and the authored test documents stop declaring it. Signed-off-by: Jeremi Joslin --- .../registry-scheduling-core/src/admission.rs | 6 +-- .../src/diagnostics.rs | 4 ++ .../registry-scheduling-core/src/fixture.rs | 1 - crates/registry-scheduling-core/src/policy.rs | 39 +++++++++++++++---- .../registry-schedulingctl/src/templates.rs | 1 - 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index 128454c35d..d0e03b2ae5 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -483,8 +483,8 @@ mod tests { use super::*; use crate::model::PartyCounts; use crate::policy::{ - ArrivalOffering, Channel, ExactTimeOffering, LeftoverCapacityPolicy, OfferingPolicy, - PublishedWindow, SchedulingMode, WindowSubquota, + ArrivalOffering, Channel, ExactTimeOffering, OfferingPolicy, PublishedWindow, + SchedulingMode, WindowSubquota, }; use crate::units::{BandedInput, RequiredUnitsPolicy}; use chrono::TimeZone as _; @@ -1119,7 +1119,7 @@ mod tests { units: 2, because: "Most households book the public channel.".to_owned(), }], - leftover: LeftoverCapacityPolicy::BecomesWalkIn, + leftover: None, staffing: None, because: "The Saturday morning household block.".to_owned(), } diff --git a/crates/registry-scheduling-core/src/diagnostics.rs b/crates/registry-scheduling-core/src/diagnostics.rs index 5aef363fcc..947707517d 100644 --- a/crates/registry-scheduling-core/src/diagnostics.rs +++ b/crates/registry-scheduling-core/src/diagnostics.rs @@ -55,6 +55,9 @@ pub enum PolicyCheckReason { UnsupportedHookAbi, /// This version has no hook engine, so a declared hook can never run. HooksUnsupported, + /// This version reads no leftover policy, so a declared one never + /// applies. + LeftoverUnsupported, } impl PolicyCheckReason { @@ -81,6 +84,7 @@ impl PolicyCheckReason { Self::SharedSupplyUnpartitioned => "shared-supply-unpartitioned", Self::UnsupportedHookAbi => "unsupported-hook-abi", Self::HooksUnsupported => "hooks-unsupported", + Self::LeftoverUnsupported => "leftover-unsupported", } } } diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs index 2e266d538b..d1b02dd328 100644 --- a/crates/registry-scheduling-core/src/fixture.rs +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -1054,7 +1054,6 @@ windows: channel: assisted units: 1 because: Assisted bookings hold a protected unit. - leftover: becomes-walk-in because: The Saturday morning household block. holdPolicy: ttlMinutes: 10 diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 69cf329590..0552567c8d 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -225,15 +225,17 @@ pub struct WindowSubquota { pub because: String, } -/// What happens to a window's leftover capacity when the window closes. The -/// policy must say; there is no default and no borrowing from the next -/// window. +/// What a window says happens to its leftover capacity when it closes. The +/// declaration names an intent; this version of the runtime reads none of +/// it, so `check` refuses a window that declares one rather than accept a +/// promise nothing keeps. There is no default and no borrowing from the +/// next window. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum LeftoverCapacityPolicy { - /// Leftover units lapse with the window. + /// Leftover units would lapse with the window. ExpiresUnused, - /// Leftover units become available to the walk-in channel. + /// Leftover units would become available to the walk-in channel. BecomesWalkIn, } @@ -272,7 +274,11 @@ pub struct PublishedWindow { pub units: u32, pub units_policy: RequiredUnitsPolicy, pub subquotas: Vec, - pub leftover: LeftoverCapacityPolicy, + /// Declared intent for the window's leftover capacity. Nothing reads it + /// in this version, so a declaration is refused at authoring; the field + /// stays so a refusal can name it. + #[serde(default)] + pub leftover: Option, pub staffing: Option, pub because: String, } @@ -725,6 +731,13 @@ impl SchedulingPolicy { } findings.extend(window.units_policy.check(&format!("{path}.unitsPolicy"))); + if window.leftover.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.leftover"), + PolicyCheckReason::LeftoverUnsupported, + )); + } + match self.offering(&window.offering) { None => findings.push(SchedulingDiagnostic::new( format!("{path}.offering"), @@ -1292,7 +1305,6 @@ windows: channel: assisted units: 1 because: Assisted bookings hold a protected unit. - leftover: becomes-walk-in because: The Saturday morning household block, sized for two officers. holdPolicy: ttlMinutes: 10 @@ -1611,7 +1623,7 @@ holdPolicy: because: "One unit per party.".to_owned(), }, subquotas: Vec::new(), - leftover: LeftoverCapacityPolicy::ExpiresUnused, + leftover: None, staffing: Some(WindowStaffing { pool: "officer-pool".to_owned(), reserved_members: None, @@ -1894,6 +1906,17 @@ holdPolicy: assert!(rendered.contains(&"hooks[0].abi: unsupported-hook-abi".to_owned())); } + #[test] + fn a_declared_leftover_policy_is_refused_because_nothing_reads_it() { + let mut policy = household_window_policy(); + assert!(policy.check().is_empty()); + + policy.windows[0].leftover = Some(LeftoverCapacityPolicy::BecomesWalkIn); + let findings = policy.check(); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert!(rendered.contains(&"windows[0].leftover: leftover-unsupported".to_owned())); + } + #[test] fn identifiers_dates_and_times_follow_their_grammars() { assert!(valid_identifier("north-counter-2")); diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs index fea6cc575d..9a366b3893 100644 --- a/crates/registry-schedulingctl/src/templates.rs +++ b/crates/registry-schedulingctl/src/templates.rs @@ -322,7 +322,6 @@ windows: channel: assisted units: 1 because: Assisted bookings hold a protected unit. - leftover: becomes-walk-in because: The Saturday morning household block, sized for two officers. holdPolicy: ttlMinutes: 10 From 58380ce68ce3def83d7ba28211053d15813b7eb9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:01:18 +0700 Subject: [PATCH 040/115] feat(scheduling): hold the product's security invariants in a checked contract Scheduling shipped with no contracts material and no contracts gate, so nothing stated what the product's security invariants were and nothing noticed when one weakened. Four of the other products carry a security invariant matrix; Casework is the outlier that carries no check script. Add both. The matrix names, per invariant, the threat, the enforcement point, the refusal, and a negative test that must exist and be selectable. The recorded decisions carry the deliberate choices, including the ones an untracked file had recorded wrongly. The review notes carry the narrative AGENTS.md requires for security-sensitive changes, in tracked material, because commit messages do not survive a squash. The gate refuses a dangling test reference, so a row cannot outlive the proof it cites. Signed-off-by: Jeremi Joslin --- products/scheduling/SECURITY-REVIEW-NOTES.md | 138 +++++++ .../contracts/recorded-decisions.yaml | 151 +++++++ .../contracts/security-invariant-matrix.yaml | 376 ++++++++++++++++++ .../contracts/security-test-traceability.yaml | 123 ++++++ .../scheduling/scripts/check-contracts.sh | 10 + .../scripts/test_validate_contracts.py | 300 ++++++++++++++ .../scheduling/scripts/validate_contracts.py | 258 ++++++++++++ 7 files changed, 1356 insertions(+) create mode 100644 products/scheduling/SECURITY-REVIEW-NOTES.md create mode 100644 products/scheduling/contracts/recorded-decisions.yaml create mode 100644 products/scheduling/contracts/security-invariant-matrix.yaml create mode 100644 products/scheduling/contracts/security-test-traceability.yaml create mode 100755 products/scheduling/scripts/check-contracts.sh create mode 100644 products/scheduling/scripts/test_validate_contracts.py create mode 100644 products/scheduling/scripts/validate_contracts.py diff --git a/products/scheduling/SECURITY-REVIEW-NOTES.md b/products/scheduling/SECURITY-REVIEW-NOTES.md new file mode 100644 index 0000000000..70557dc970 --- /dev/null +++ b/products/scheduling/SECURITY-REVIEW-NOTES.md @@ -0,0 +1,138 @@ +# Registry Scheduling security review notes + +Review notes for the security-sensitive Scheduling changes, held in tracked +material because commit messages do not survive a squash. Each section names +the change by its subject, the threat it answers, the defaults it ships, and +the tests that pin it. The security-invariant matrix in `contracts/` is the +row-per-invariant baseline; this file is the narrative behind the decisions +and the deferrals the matrix records. + +## The task-grant bounds in the platform + +The change `feat(auth): add scheduling bounds to task grants` carries its +review record in the `registry-platform-oidc` crate README (threat, wire +format, issuer and verifier compatibility, test evidence). It is not +restated here. In short: `GrantBounds::Scheduling` is a closed tagged union +with `deny_unknown_fields`, cardinality and length bounds (64 permissions, +32 actions each, 512-byte service and location values, unique +service-and-location pairs), and a `Debug` implementation that redacts +every value. A scheduling grant parsed by the BReg binding is refused, and +a test in `registry-breg` pins that refusal. + +## The runtime edge: authentication, audit, listeners, and defaults + +The change `feat(scheduling): serve the runtime edge with auth, workers, +and schema` introduced the HTTP edge, the authenticator, the audit +pipeline, the worker supervision, the listener policy, and the retention +defaults. AGENTS.md requires review notes for changes of this class; these +are they. + +### Threat + +A Scheduling deployment answers over HTTP to callers holding bearer +tokens, commits capacity inside PostgreSQL transactions, and writes an +audit journal. The threats this surface answers: + +1. **A caller without a complete task grant books capacity.** The edge + authenticates the bearer token against a pinned JWKS, requires the + profile scope for every read and a separately configured explain scope + for explanations, and requires a complete scheduling grant (service, + location, actions) for every commitment. A partially formed grant set + is refused as a credentials problem, never honoured partially. +2. **A token that was valid at the door commits after its grant lapsed.** + The grant's expiry is re-checked inside the capacity transaction, + immediately before the claim commits, against the same observed now the + rest of the transaction decided under. The full bounds are matched once + at the service before the transaction opens; re-reading them inside + would only defend against narrowing at the issuer, which needs a + revocation path this milestone does not have (matrix deferral + SCHEDULING-DEF-01, recorded in the published reference). +3. **A deployment is reached over a public interface by accident.** The + listener must bind a private address or an explicitly declared + container network; anything else refuses to start. +4. **A commitment leaves no accountable trace.** Every ledger-decided + commitment and refusal writes an audit row with a pseudonymized + principal, and the audit rows chain by hash. A permission mismatch + refused at the service, before the transaction opens, writes no audit + row today (SCHEDULING-DEF-02): the compensating control is that the + refusal answer is drawn from a closed vocabulary and says nothing about + which bound failed, so probing the edge yields nothing beyond allowed + or not allowed. +5. **A refusal discloses supply or another caller's booking.** Admission + refusals project through a public code; the member-level cause stays + behind the separately authorized explain path. + +### Defaults + +- The database connection requires TLS (`SslMode::Require`); the plaintext + escape exists only behind the `postgres-test` feature for disposable + test databases. +- The listener refuses public addresses absent an explicit container + network declaration. +- Every answer carries `Cache-Control: no-store`, the security headers + without HSTS (TLS termination is the deployer's), a 1 MiB body ceiling, + and the caller's trace identifier when a trace is presented. +- Retention defaults (attempt receipt days, cursor minutes) are + placeholders: a jurisdiction must approve real retention periods before + production use. Retention sweeps idempotency attempt receipts and + cursors only; appointments, history, outbox rows, and audit rows are + never swept in this milestone (SCHEDULING-DEF-06, recorded in + `RUNTIME-CONFIG.md`). +- Reminder intents with no configured destination stay local and readable + in place; nothing is delivered by default. + +### Test evidence + +`crates/registry-scheduling/src/auth.rs` pins the partial-grant refusal, +the foreign-resource profile refusal, the unknown-key credential refusal, +and the explain scope separation. `crates/registry-scheduling/src/config.rs` +pins the listener policy, the static JWKS shape (unique named asymmetric +keys), the verified-package requirement in production, and that typed parse +paths never echo a rejected value. `crates/registry-scheduling/src/http.rs` +pins the problem rendering of the closed vocabulary, framework rejection +normalization, no-store and trace stamping, and the idempotency key bounds. +The PostgreSQL suite in `crates/registry-scheduling/tests/` pins the edge +refusing unauthenticated callers and unauthorized mutations end to end. + +## The reschedule exclusion + +The change `fix(scheduling): keep the reschedule exclusion inside the +runtime's transaction` removed the caller-settable `rescheduleOf` from the +wire admission request and made the exclusion an evaluator argument that +only the reschedule transaction supplies, from the appointment row it +locks. Before it, naming any live claim's identifier on a create path +excluded that claim from every capacity check; the review classed it a +merge blocker. The threat, the wire change, and the test evidence are +recorded in that change's own notes; the standing invariant is matrix row +SCHEDULING-SEC-17. + +## Known deferrals + +The matrix records five deferrals with their compensating controls. They +are restated here only as the index the matrix's `recordedIn` points at: + +- **SCHEDULING-DEF-02, audit rows for pre-transaction permission + refusals.** Grant-scope probing against the edge is invisible to the + journal. Compensating control: closed-vocabulary refusals that name no + failing bound. +- **SCHEDULING-DEF-03, caller string ceilings below the body limit.** The + idempotency key and page limit are bounded; capabilities, prerequisites, + channel, duplicate key, and reason are bounded only by the 1 MiB body + ceiling and the database CHECK constraints, and an over-long reason + surfaces as a service problem on that caller's own request. Tier 2 work + bounds them at the edge with `request.invalid`. +- **SCHEDULING-DEF-04, the channel is not bound to the verified caller.** + A caller entitled to book may choose which subquota it draws from. The + policy declares a closed channel set and every subquota is a ceiling + inside the published total, so a mis-chosen channel cannot oversell the + window. Binding the channel to the verified client is a product + decision for a later phase. +- **SCHEDULING-DEF-05, no database backstop under the duplicate-key + guard.** The guard runs inside the same capacity transaction as the + claim it protects, so a concurrent pair cannot straddle it; the + supporting index is partial rather than unique. +- **SCHEDULING-DEF-06, retention scope.** Recorded in + `RUNTIME-CONFIG.md`, which states plainly what the sweeps cover. + +A change that closes one of these promotes the matrix entry in the same +commit and rewrites this section with it. diff --git a/products/scheduling/contracts/recorded-decisions.yaml b/products/scheduling/contracts/recorded-decisions.yaml new file mode 100644 index 0000000000..03f54adc09 --- /dev/null +++ b/products/scheduling/contracts/recorded-decisions.yaml @@ -0,0 +1,151 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: scheduling +scope: mvp +purpose: >- + Behaviour a reader could reasonably mistake for an oversight, recorded as a + deliberate choice with the reasoning that settled it. A later change may + overturn any of these, but it overturns the entry in the same commit rather + than rediscovering the question. +decisions: + - id: SCHEDULING-DEC-01 + subject: Releasing a hold whose lifetime has passed answers 204 + decision: >- + A release names the hold's state, not its clock. The release path admits + any claim that is still a hold in the active state and closes it as + released, so a hold whose lifetime has passed but whose row the sweeper + has not yet touched releases cleanly and answers 204 No Content. + reasoning: >- + Consumption is a live predicate, never a stored counter: every capacity + and quota query counts a hold only while its state is active and its + expiry is still ahead of the transaction's clock. An expired hold has + therefore already stopped consuming capacity everywhere capacity is + computed, whether or not any process has written the expired state yet. + Releasing it can return nothing, because nothing is outstanding, so + there is no second decrement to protect against. Answering a refusal + instead would tell the caller to keep a hold it has correctly finished + with, and would make the caller's answer depend on whether a background + sweep happened to have run. + alsoTrue: >- + A release carries no caller-chosen idempotency key: the hold's own + identifier is the key, so a retried release replays the first answer. A + hold that does not exist, or one no longer in the active state, answers + hold.released rather than a not-found, which keeps the answer the same + whether the identifier was never valid or was merely finished. A hold + belonging to another caller is refused inside the transaction, where the + refusal is audited. + evidence: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_expired_hold_returns_capacity_and_refuses_confirmation} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_release_returns_capacity_answers_replay_and_blocks_confirmation} + - {path: crates/registry-scheduling-core/src/admission.rs, name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed} + - id: SCHEDULING-DEC-02 + subject: Cancellation never answers policy.changed + decision: >- + The cancel path does not read the policy revision at all. It is guarded + by the observed appointment revision, the caller's ownership, and the + cancellation cutoff, and by nothing else. + reasoning: >- + Cancellation releases capacity; it evaluates no admission and resolves no + offering against a policy, so there is no policy-shaped decision left to + fence. An appointment stays cancellable under whatever policy currently + governs its offering. The alternative traps a caller in a booking it + wants to shed because the operator republished the policy underneath it, + which is a worse outcome for both sides than letting the seat go back. + contrast: >- + Reschedule does check, and deliberately lets the policy guard outrank the + revision guard, because a reschedule re-evaluates admission against the + policy. Hold creation, direct appointment creation, and hold confirmation + check for the same reason. + evidence: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cancellation_respects_the_cutoff_and_replays_its_verdict} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones} + - {path: crates/registry-scheduling-core/src/admission.rs, name: policy_change_outranks_a_window_revision_mismatch} + - id: SCHEDULING-DEC-03 + subject: The commit-error projection lives in the service, not beside the enum + decision: >- + CommitError is declared in the runtime's store module, where the + transaction raises it, and the single projection from it to a public + problem code lives in the service module, in one exhaustive match with no + wildcard arm. + reasoning: >- + The projection is a disclosure decision, and disclosure decisions belong + on the layer that answers the caller. The store layer raises what went + wrong; the service layer decides what the caller is allowed to learn from + it, which is the same layer that projects an admission refusal through + its public code and decides which refusals are audited. Moving the + projection next to the enum would put a caller-facing choice in the + module that talks to PostgreSQL. The source-neutral core stays free of + every HTTP library and owns only the numeric status pinned to each + problem code, so one binding cannot answer a different status from + another. + safeguard: >- + The match is exhaustive over a closed enum that is not marked + non-exhaustive, so a tenth variant fails the build rather than falling + through to a wrong status, and there is exactly one projection site. + evidence: + - {path: crates/registry-scheduling/src/service.rs, name: commit_errors_project_to_their_public_codes} + - {path: crates/registry-scheduling/src/service.rs, name: problem_receipts_carry_the_pinned_problem_without_a_trace} + - id: SCHEDULING-DEC-04 + subject: Subquotas are ceilings, not reserved floors + decision: >- + A channel subquota caps how much of a published window that channel may + take. It reserves nothing for that channel: an unclaimed subquota is + available to the window's other channels up to the published total. + reasoning: >- + A ceiling is the bound that prevents one channel exhausting a window; a + floor is a different product promise, with its own failure mode when the + reserved share goes unused. This milestone implements and tests the + ceiling. Reserved floors stay an open product question rather than an + implied behaviour. + evidence: + - {path: crates/registry-scheduling-core/src/admission.rs, name: subquota_channels_are_checked_before_the_total} + - {path: crates/registry-scheduling-core/src/policy.rs, name: subquota_slices_may_not_overdraw_the_window} + - id: SCHEDULING-DEC-05 + subject: A pool serving both an exact-time offering and a window is refused + decision: >- + The authoring check refuses a resource pool that serves an exact-time + offering and a published window at the same time. That is not a supported + configuration in this milestone. + reasoning: >- + The two modes count the same staffing differently, so a shared pool would + need ledger-level partition enforcement to stay honest. Refusing the + configuration at authoring time is a promise the product can keep now; + enforcing the partition in the ledger is later work, and until it exists + a deployment must not be able to author its way into the unsound shape. + evidence: + - {path: crates/registry-scheduling-core/src/policy.rs, name: an_unpartitioned_shared_staffing_block_is_rejected} + - id: SCHEDULING-DEC-06 + subject: Listing cursors carry no actor binding field + decision: >- + A stored cursor holds its identity, its listing context, its position, + and its expiry. It is not keyed by the caller. + reasoning: >- + Both listing call sites are safe without it: catalogue availability is + public to any reader with the read scope, and appointment history sits + behind the ownership check, which is re-applied before the cursor is + honoured rather than trusted from the cursor. The invariant is therefore + held by the call sites, which is why the contract is stated where a + reader of the cursor module will see it. A third listing that is neither + public nor ownership-checked must add the binding rather than assume it. + evidence: + - {path: crates/registry-scheduling/src/cursors.rs, name: a_cursor_binds_to_its_listing_context_and_expires} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cursors_page_their_own_listing_and_refuse_foreign_contexts} + - id: SCHEDULING-DEC-07 + subject: Member selection reads under the pool lock without SKIP LOCKED + decision: >- + Selecting a member for an exact-time commitment locks the pool row the + offering anchors on, then reads the pool's members and their occupancy + with plain queries inside that lock. The member rows themselves are + never locked. + reasoning: >- + One pool row is the serialization point for every offering that sells + that pool, so every commitment against the pool runs one at a time and + the member selection cannot race another selection. SKIP LOCKED on the + member rows would add machinery without adding correctness while the + pool lock already excludes every competitor, and it would invite the + false reading that members are independently selectable. The cost is + that a hot pool serializes every commitment on one row, which is the + same cost the window anchor already carries; if a pool ever becomes + that hot, the anchor is the thing to revisit, not the member query. + evidence: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_direct_create_books_without_a_hold_and_exhausts_capacity} + - {path: crates/registry-scheduling-core/src/admission.rs, name: the_second_caller_against_the_last_unit_is_refused} diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml new file mode 100644 index 0000000000..ea3499a62d --- /dev/null +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -0,0 +1,376 @@ +apiVersion: registry.registrystack.org/product-contract/v1 +product: scheduling +scope: mvp +evidenceStatus: source-mapped-not-executed +evidenceNote: >- + Test references identify maintained proof obligations. This file does not + assert that any test ran or passed in a particular review. Rows whose named + test lives in a PostgreSQL suite are proven only when that suite runs against + a disposable database under the postgres-test feature. +reviewRule: >- + This matrix is the baseline every later Scheduling change is reviewed + against. A change that weakens a row rewrites the row in the same commit and + says why. A row may not lose its negative test, and a deferred entry may not + be quietly promoted to an enforced row without the test that earns it. +invariants: + - id: SCHEDULING-SEC-01 + state: enforced + threat: >- + Two callers competing for the last unit both commit, or a hold and a + direct booking consume the same member, so the deployment oversells + capacity it cannot serve. + enforcementPoint: >- + One PostgreSQL capacity transaction per commitment that reads the ledger + snapshot, evaluates admission against it, and writes the claim before it + commits. + refusal: >- + Commit exactly one winner and refuse every competing claim with + capacity.exhausted; never write a partial claim. + negativeTest: + path: crates/registry-scheduling-core/src/admission.rs + name: the_second_caller_against_the_last_unit_is_refused + - id: SCHEDULING-SEC-02 + state: enforced + threat: >- + Capacity is committed over supply no operator anchored, so the runtime + books a pool or window that does not exist in this deployment. + enforcementPoint: >- + Offering anchors resolved from runtime records inside the capacity + transaction, not from the authored policy alone. + refusal: >- + Refuse the commitment loudly rather than inventing supply, and name the + unanchored reference in the operator's diagnostics, never to the caller. + negativeTest: + path: crates/registry-scheduling/tests/postgres_commitments.rs + name: a_commitment_against_an_unanchored_pool_refuses_loudly + - id: SCHEDULING-SEC-03 + state: partial + threat: >- + A token that passed the door commits capacity after the task grant behind + it has expired. + enforcementPoint: >- + check_grant_current inside the capacity transaction, immediately before + the claim commits, against the clock that commitment is decided under. + refusal: >- + Refuse with operation.not-authorized and write the authorization.refused + audit row, leaving no claim behind. + partialNote: >- + Only the grant's expiry is read again inside the transaction. The bounds + themselves, the service, the location, the action, and the client, are + matched once at the service before the transaction opens, and this + milestone has no revocation check, so a grant narrowed or withdrawn at the + issuer keeps the authority its token carries until the token or the grant + deadline passes. See deferred entry SCHEDULING-DEF-01. + negativeTest: + path: crates/registry-scheduling/tests/postgres_commitments.rs + name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations + - id: SCHEDULING-SEC-04 + state: enforced + threat: >- + A catalogue read scope, an explain scope, or a partially formed grant + reaches a commitment route and books capacity. + enforcementPoint: >- + Three disjoint authority profiles in auth.rs: a read scope, a separately + configured explain scope that is never the read scope, and a complete + task grant bound to the verified client and this deployment's audience. + refusal: >- + Refuse an incomplete grant set as a credentials problem and never honour + it partially; refuse a grant naming another resource as a profile + refusal. + negativeTest: + path: crates/registry-scheduling/src/auth.rs + name: a_partial_grant_is_refused_as_invalid_claims + - id: SCHEDULING-SEC-05 + state: enforced + threat: >- + A grant for a neighbouring service, location, or action covers this + offering because the match widens. + enforcementPoint: >- + require_permission in service.rs, matching one permission whose service + and location both equal the offering's own and whose action list contains + the requested action. + refusal: >- + Answer operation.not-authorized; a broader service or location never + covers a narrower one and no action is inferred from another. + negativeTest: + path: crates/registry-scheduling/src/auth.rs + name: a_grant_naming_another_resource_is_a_profile_refusal + - id: SCHEDULING-SEC-06 + state: enforced + threat: >- + A grant minted for one product is spent on another because the grant + union leaks across product boundaries. + enforcementPoint: >- + The closed GrantBounds tagged union in registry-platform-oidc, with + deny_unknown_fields, pinned value and cardinality bounds, and a redacting + Debug. + refusal: >- + Parse a foreign grant as a grant that carries no permission for this + product and refuse it before any authorization decision or store access. + negativeTest: + path: crates/registry-breg/src/task_grant/tests.rs + name: a_scheduling_grant_is_refused_by_the_breg_binding + - id: SCHEDULING-SEC-07 + state: enforced + threat: >- + A public availability or refusal answer names the member, the resource, + or the neighbouring booking that caused it, letting a caller enumerate + supply or other people's appointments. + enforcementPoint: >- + problem_of projecting every admission refusal through public_code, and a + separately authorized explain path for the member-level cause. + refusal: >- + Answer the public code for the same state and name no member; keep the + detailed code behind the explain scope. + negativeTest: + path: crates/registry-scheduling/src/service.rs + name: the_detailed_code_stays_behind_the_explain_path + - id: SCHEDULING-SEC-08 + state: enforced + threat: >- + One caller reads or pages another caller's appointment, history, or + listing by guessing an identifier or replaying a cursor. + enforcementPoint: >- + owned_booking on the appointment read and history paths, and cursors that + are opaque version 4 UUIDs bound to their listing context and expiring + fifteen minutes out, with ownership re-checked before a cursor is + honoured. + refusal: >- + Answer the same value-free absence for a foreign record as for a missing + one, and refuse a cursor presented against a different context. + negativeTest: + path: crates/registry-scheduling/tests/postgres_commitments.rs + name: cursors_page_their_own_listing_and_refuse_foreign_contexts + - id: SCHEDULING-SEC-09 + state: enforced + threat: >- + A retried commitment books twice, or a replayed key under a different + body commits a second decision under the first one's answer. + enforcementPoint: >- + A caller-chosen Idempotency-Key bounded to 128 graphical bytes on the + four caller-driven commitments, hashed with the canonical request, and + the hold's own identifier as the key for a release. + refusal: >- + Replay the stored first answer for the same key and body, refuse the same + key under a different body with idempotency.key-reused, and answer the + expired code once the receipt has been tombstoned. + negativeTest: + path: crates/registry-scheduling/src/http.rs + name: the_idempotency_key_is_present_bounded_and_graphical + - id: SCHEDULING-SEC-10 + state: enforced + threat: >- + A caller commits against a policy or an appointment that changed under + it, so the booking rests on supply or terms the operator has withdrawn. + enforcementPoint: >- + The policy revision the request names, compared against the revision this + process serves, and the observed appointment revision carried in the + request body; both are fenced inside the capacity transaction, after the + supply anchor is locked and before the claim is written. + refusal: >- + Refuse a stale policy revision with policy.changed ahead of any narrower + mismatch, and refuse a stale appointment revision with revision.mismatch. + scopeNote: >- + Cancellation is deliberately outside this row: it releases capacity and + is guarded by the observed appointment revision and the cutoff, never by + the policy revision, so an appointment stays cancellable under whatever + policy currently governs its offering. See recorded-decisions.yaml, + SCHEDULING-DEC-02. + negativeTest: + path: crates/registry-scheduling-core/src/admission.rs + name: policy_change_outranks_a_window_revision_mismatch + - id: SCHEDULING-SEC-11 + state: enforced + threat: >- + An expired hold keeps consuming capacity, or is confirmed into an + appointment after its window has passed. + enforcementPoint: >- + Hold lifetime evaluated against the commitment's clock in the ledger, + never against a sweeper having already run. + refusal: >- + Count an expired hold as consuming nothing and refuse its confirmation; + the sweeper is housekeeping, not the authority on expiry. + negativeTest: + path: crates/registry-scheduling-core/src/admission.rs + name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed + - id: SCHEDULING-SEC-12 + state: enforced + threat: >- + Credentials or refusal details cross a public plaintext edge, a browser + cache, or an unbounded framework response. + enforcementPoint: >- + A listener that must be private or an explicitly declared container + network, mandatory TLS termination, a 1 MiB body ceiling, security + headers without CORS, Cache-Control no-store on every answer, and one + problem rendering path that stamps the caller's trace. + refusal: >- + Refuse to start on a public listener, and normalize every framework + rejection into a closed problem rather than a framework error page. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: listener_requires_a_private_address_or_explicit_container_network + - id: SCHEDULING-SEC-13 + state: enforced + threat: >- + A submitted value, a secret, or a grant's contents is echoed back into a + problem document, a log line, a trace, or a configuration error. + enforcementPoint: >- + A closed problem vocabulary whose detail is fixed per code, value-free + Display implementations, a redacting Debug on the grant bounds, and a + configuration parse path that names the field without the value. + refusal: >- + Emit the closed category and nothing else; no field of a problem document + repeats a submitted value. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: typed_parse_path_does_not_echo_the_rejected_value + - id: SCHEDULING-SEC-14 + state: partial + threat: >- + A commitment decision leaves no accountable record, so a booking or a + refusal cannot be attributed afterwards. + enforcementPoint: >- + An authorization audit record built per commitment with a pseudonymized + principal, stamped with its identity exactly once, written for the + allowed case and for every refusal the ledger decides. + refusal: >- + Record authorization.allowed or authorization.refused as an audit reason, + never as a problem code, and never carry the caller's raw identifier. + partialNote: >- + A permission mismatch refused at the service, before the capacity + transaction opens, writes no audit row today, so grant-scope enumeration + against the edge is not visible in the journal. See deferred entry + SCHEDULING-DEF-02. + negativeTest: + path: crates/registry-scheduling/src/runtime.rs + name: a_pending_audit_record_is_stamped_with_its_identity_exactly_once + - id: SCHEDULING-SEC-15 + state: enforced + threat: >- + An unverified, drifted, or failing policy package is served, so the + deployment answers under terms nobody reviewed. + enforcementPoint: >- + Startup package verification in production, the authoring exception + confined to a loopback listener, and the authoring checks re-run before + the policy reaches the runtime. + refusal: >- + Refuse to start rather than serve an unverified package or a policy that + carries findings. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: production_requires_a_verified_package_while_loopback_accepts_authoring + - id: SCHEDULING-SEC-16 + state: enforced + threat: >- + An operator-pinned JWKS selects unintended verification material because + two keys share a name or a symmetric key slips in. + enforcementPoint: >- + Static JWKS validation before the verifier is constructed. + refusal: >- + Refuse the document unless every key is asymmetric and uniquely named. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: static_jwks_requires_unique_named_asymmetric_keys + - id: SCHEDULING-SEC-17 + state: enforced + threat: >- + A caller supplies a field the wire contract does not declare, such as the + reschedule exclusion the store owns, and changes how the request is + evaluated. + enforcementPoint: >- + Strict request parsing across the core's ledger and request shapes, with + unknown fields refused rather than ignored. + refusal: >- + Refuse the request as malformed; the exclusion is supplied by the store + from inside the reschedule transaction and never by the caller. + negativeTest: + path: crates/registry-scheduling-core/src/model.rs + name: an_admission_request_refuses_a_caller_supplied_exclusion + - id: SCHEDULING-SEC-18 + state: enforced + threat: >- + A scheduling crate reaches a Base Registry Engine, Casework, or Evidence + crate, or one of those products reaches a scheduling crate, and the + capacity ledger acquires a second writer or a foreign protocol type. + enforcementPoint: >- + The dependency-direction gate over the Cargo metadata forward closure of + every scheduling crate, in both directions. + refusal: >- + Fail the product check on a direct or transitive forbidden dependency. + negativeTest: + path: products/scheduling/scripts/test_dependency_direction.py + name: test_product_dependency_on_scheduling_is_rejected +deferred: + - id: SCHEDULING-DEF-01 + subject: Full task-grant bounds re-checked inside the capacity transaction + state: deferred + reason: >- + The grant's bounds travel in the presented token and are matched once at + the service. Re-reading them inside the transaction only defends against + a grant narrowed at the issuer between the door and the commit, which + needs a revocation or introspection path this milestone does not have. + The expiry, which is the fact that does change on its own, is re-checked. + compensatingControl: >- + Short grant deadlines. The published reference states the limit plainly + rather than claiming the bounds are re-read. + recordedIn: docs/site/src/content/docs/reference/apis/registry-scheduling.mdx + - id: SCHEDULING-DEF-02 + subject: Audit rows for permission refusals decided before the transaction + state: deferred + reason: >- + Refusals the ledger decides are audited; a permission mismatch refused at + the service is not, so an attacker probing grant scope against the edge + leaves no journal trail. + compensatingControl: >- + The refusal is answered from a closed vocabulary and carries no + information about which bound failed, so probing yields nothing beyond + allowed or not allowed. + recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md + - id: SCHEDULING-DEF-03 + subject: Caller string ceilings above the body limit + state: deferred + reason: >- + The idempotency key and the page limit are bounded, and the whole body is + capped at 1 MiB, but capabilities, prerequisites, channel, duplicate key, + and reason are not individually bounded at the edge. A reason over the + database ceiling surfaces as a service-unavailable problem rather than a + request problem. + compensatingControl: >- + The 1 MiB body ceiling and the database CHECK constraints bound the + damage to a caller-triggered 5xx on that caller's own request. + recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md + - id: SCHEDULING-DEF-04 + subject: Channel bound to the verified caller + state: deferred + reason: >- + The channel that selects a window subquota is asserted by the caller and + persisted verbatim. It is not derived from the verified client or the + grant, so a caller entitled to book at all may choose which subquota it + draws from. + compensatingControl: >- + The policy declares a closed channel set, an undeclared channel is + refused with a typed code, and every subquota is a ceiling inside the one + published total, so a mis-chosen channel cannot oversell the window. + recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md + - id: SCHEDULING-DEF-05 + subject: Database backstop under the duplicate-key guard + state: deferred + reason: >- + The duplicate guard is evaluated in the admission path against the active + claims it reads. The supporting index is partial rather than unique, so + the guard rests on the transaction rather than on a database constraint. + compensatingControl: >- + The guard runs inside the same capacity transaction as the claim it + protects, so a concurrent pair cannot straddle it. + recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md + - id: SCHEDULING-DEF-06 + subject: Appointment, history, outbox, and audit retention + state: deferred + reason: >- + Retention windows for committed scheduling data are a product question + this milestone does not answer. Idempotency receipts do expire and + tombstone; nothing else is swept. + compensatingControl: >- + The configuration reference says so explicitly rather than implying a + sweep that does not run. + recordedIn: products/scheduling/RUNTIME-CONFIG.md diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml new file mode 100644 index 0000000000..ae57f9422d --- /dev/null +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -0,0 +1,123 @@ +contract: registry.scheduling.security-test-traceability/v1 +scope: mvp +executionStatus: not-recorded +note: >- + Every entry names the tests that carry its invariant. Tests under + crates/registry-scheduling/tests run only with the postgres-test feature and + a disposable database; tests in schema.rs run only under the schema feature. + This file records the obligation, not a passing run. +entries: + - id: SCHEDULING-SEC-01 + tests: + - {path: crates/registry-scheduling-core/src/admission.rs, name: the_second_caller_against_the_last_unit_is_refused} + - {path: crates/registry-scheduling-core/src/admission.rs, name: a_party_that_does_not_fit_remaining_capacity_is_refused_whole} + - {path: crates/registry-scheduling-core/src/model.rs, name: a_confirmed_booking_consumes_but_an_expired_hold_does_not} + - {path: crates/registry-scheduling-core/src/model.rs, name: supply_occupancy_is_half_open_and_honors_the_excluded_claim} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_direct_create_books_without_a_hold_and_exhausts_capacity} + - id: SCHEDULING-SEC-02 + tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_commitment_against_an_unanchored_pool_refuses_loudly} + - {path: crates/registry-scheduling/src/runtime.rs, name: the_offering_anchors_are_collected_without_duplicates} + - id: SCHEDULING-SEC-03 + tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: expiry_uses_earlier_token_or_grant_deadline_and_refuses_exact_deadline} + - id: SCHEDULING-SEC-04 + tests: + - {path: crates/registry-scheduling/src/auth.rs, name: a_partial_grant_is_refused_as_invalid_claims} + - {path: crates/registry-scheduling/src/auth.rs, name: a_complete_task_grant_is_extracted_and_bound_to_the_deployment} + - {path: crates/registry-scheduling/src/auth.rs, name: a_read_without_the_reads_scope_is_a_profile_refusal_but_still_mutates} + - {path: crates/registry-scheduling/src/auth.rs, name: the_explain_path_demands_its_own_scope} + - {path: crates/registry-scheduling/src/config.rs, name: explain_and_read_scopes_must_differ_and_retention_must_be_positive} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: any_partial_core_grant_set_is_rejected} + - id: SCHEDULING-SEC-05 + tests: + - {path: crates/registry-scheduling/src/auth.rs, name: a_grant_naming_another_resource_is_a_profile_refusal} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_permission_cardinality_bounds_are_enforced} + - id: SCHEDULING-SEC-06 + tests: + - {path: crates/registry-breg/src/task_grant/tests.rs, name: a_scheduling_grant_is_refused_by_the_breg_binding} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: grant_bounds_variants_are_distinct_and_strict} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_value_boundaries_and_unknown_fields_are_enforced} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: the_scheduling_tag_round_trips_through_serde} + - id: SCHEDULING-SEC-07 + tests: + - {path: crates/registry-scheduling/src/service.rs, name: the_detailed_code_stays_behind_the_explain_path} + - {path: crates/registry-scheduling/src/service.rs, name: commit_errors_project_to_their_public_codes} + - {path: crates/registry-scheduling/src/service.rs, name: a_fully_occupied_slot_is_not_availability} + - {path: crates/registry-scheduling-core/src/admission.rs, name: refusals_carry_their_distinguished_public_codes} + - {path: crates/registry-scheduling-core/src/problem.rs, name: exactly_one_code_is_detailed_only} + - id: SCHEDULING-SEC-08 + tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cursors_page_their_own_listing_and_refuse_foreign_contexts} + - {path: crates/registry-scheduling/src/cursors.rs, name: a_cursor_binds_to_its_listing_context_and_expires} + - {path: crates/registry-scheduling/src/cursors.rs, name: cursor_expiry_is_fifteen_minutes_out} + - {path: crates/registry-scheduling/src/cursors.rs, name: positions_round_trip_and_refuse_foreign_shapes} + - id: SCHEDULING-SEC-09 + tests: + - {path: crates/registry-scheduling/src/http.rs, name: the_idempotency_key_is_present_bounded_and_graphical} + - {path: crates/registry-scheduling/src/http.rs, name: a_replayed_refusal_re_renders_its_pinned_code_under_the_current_trace} + - {path: crates/registry-scheduling/src/http.rs, name: a_replayed_release_answers_an_empty_204_again} + - {path: crates/registry-scheduling/src/service.rs, name: a_success_replay_receipt_projects_through_the_same_document} + - {path: crates/registry-scheduling-core/src/model.rs, name: the_same_request_always_hashes_identically} + - {path: crates/registry-scheduling-core/src/model.rs, name: set_order_never_changes_the_request_hash} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cancellation_respects_the_cutoff_and_replays_its_verdict} + - id: SCHEDULING-SEC-10 + tests: + - {path: crates/registry-scheduling-core/src/admission.rs, name: policy_change_outranks_a_window_revision_mismatch} + - {path: crates/registry-scheduling-core/src/admission.rs, name: window_revisions_must_match_and_policy_change_wins} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones} + - id: SCHEDULING-SEC-11 + tests: + - {path: crates/registry-scheduling-core/src/admission.rs, name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed} + - {path: crates/registry-scheduling-core/src/admission.rs, name: a_live_hold_still_consumes} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_expired_hold_returns_capacity_and_refuses_confirmation} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_release_returns_capacity_answers_replay_and_blocks_confirmation} + - id: SCHEDULING-SEC-12 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: listener_requires_a_private_address_or_explicit_container_network} + - {path: crates/registry-scheduling/src/http.rs, name: answers_carry_no_store_and_the_callers_trace} + - {path: crates/registry-scheduling/src/http.rs, name: framework_rejections_normalize_to_request_problems} + - {path: crates/registry-scheduling/src/http.rs, name: an_unknown_route_answers_the_pinned_problem_under_the_callers_trace} + - {path: crates/registry-scheduling/src/http.rs, name: an_unauthenticated_problem_challenges_for_a_bearer} + - {path: crates/registry-scheduling/src/http.rs, name: an_unavailable_problem_names_a_retry_interval} + - {path: crates/registry-scheduling-client/tests/http_boundary.rs, name: oversized_answers_stay_bounded} + - id: SCHEDULING-SEC-13 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: typed_parse_path_does_not_echo_the_rejected_value} + - {path: crates/registry-scheduling/src/http.rs, name: every_problem_of_the_closed_vocabulary_renders_its_pinned_document} + - {path: crates/registry-scheduling/src/http.rs, name: a_receipt_in_no_known_shape_is_never_shown_to_the_caller} + - {path: crates/registry-scheduling-core/src/diagnostics.rs, name: reasons_render_as_closed_slugs} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_debug_redacts_service_location_and_actions} + - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: grant_debug_redacts_claim_values} + - {path: crates/registry-scheduling-client/tests/http_boundary.rs, name: edge_answers_stay_edge_talk} + - id: SCHEDULING-SEC-14 + tests: + - {path: crates/registry-scheduling/src/runtime.rs, name: a_pending_audit_record_is_stamped_with_its_identity_exactly_once} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_hold_confirms_into_an_appointment_with_attributable_history} + - id: SCHEDULING-SEC-15 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: production_requires_a_verified_package_while_loopback_accepts_authoring} + - {path: crates/registry-scheduling/src/config.rs, name: a_policy_that_fails_its_checks_never_reaches_the_runtime} + - {path: crates/registry-scheduling-core/src/policy.rs, name: the_policy_digest_is_stable_and_moves_with_content} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: adoption_binds_one_identity_and_refuses_a_second} + - id: SCHEDULING-SEC-16 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: static_jwks_requires_unique_named_asymmetric_keys} + - {path: crates/registry-scheduling/src/auth.rs, name: a_credential_signed_by_an_unknown_key_is_refused} + - id: SCHEDULING-SEC-17 + tests: + - {path: crates/registry-scheduling-core/src/model.rs, name: an_admission_request_refuses_a_caller_supplied_exclusion} + - {path: crates/registry-scheduling-core/src/model.rs, name: ledger_and_request_shapes_refuse_unknown_fields} + - {path: crates/registry-scheduling-core/src/policy.rs, name: unknown_policy_fields_are_refused} + - {path: crates/registry-scheduling-core/src/policy.rs, name: collection_bounds_are_enforced} + - {path: crates/registry-scheduling-core/src/policy.rs, name: declared_hooks_are_refused_because_nothing_can_run_them} + - {path: crates/registry-scheduling-client/tests/http_boundary.rs, name: invalid_client_input_never_reaches_the_wire} + - id: SCHEDULING-SEC-18 + tests: + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_product_dependency_on_scheduling_is_rejected} + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_transitive_product_dependency_from_core_is_rejected} + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_evidence_dependency_from_client_is_rejected} + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_core_depending_on_the_runtime_is_rejected} + - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_client_depending_on_the_runtime_is_rejected} diff --git a/products/scheduling/scripts/check-contracts.sh b/products/scheduling/scripts/check-contracts.sh new file mode 100755 index 0000000000..d6ad6f6283 --- /dev/null +++ b/products/scheduling/scripts/check-contracts.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +export PYTHONDONTWRITEBYTECODE=1 + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +python3 "$script_dir/validate_contracts.py" +python3 -m unittest \ + "$script_dir/test_validate_contracts.py" + +echo "Scheduling product contracts passed" diff --git a/products/scheduling/scripts/test_validate_contracts.py b/products/scheduling/scripts/test_validate_contracts.py new file mode 100644 index 0000000000..5ddb1b222a --- /dev/null +++ b/products/scheduling/scripts/test_validate_contracts.py @@ -0,0 +1,300 @@ +"""Unit tests for the Scheduling contracts validator. + +The validator is exercised against synthetic trees so a broken contract +shape is caught without touching the real files, the same way the sibling +products test their contract tooling. +""" + +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path +import tempfile + +import yaml + +sys.dont_write_bytecode = True +_SCRIPT_PATH = Path(__file__).with_name("validate_contracts.py") +_SPEC = importlib.util.spec_from_file_location("scheduling_validate_contracts", _SCRIPT_PATH) +assert _SPEC is not None and _SPEC.loader is not None +validator = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(validator) + +RUST_TEST_FILE = """\ +#[test] +fn the_cited_test_exists() {} +""" + +PYTHON_TEST_FILE = """\ +import unittest + + +class Tests(unittest.TestCase): + def the_cited_test_exists(self) -> None: + pass +""" + +RECORDED_IN_FILE = "# Notes\n" + + +def matrix_row( + row_id: str = "SCHEDULING-SEC-01", + state: str = "enforced", + partial_note: str | None = None, + test_path: str = "crates/x/src/a.rs", + test_name: str = "the_cited_test_exists", +) -> dict: + row = { + "id": row_id, + "state": state, + "threat": "A threat.", + "enforcementPoint": "An enforcement point.", + "refusal": "A refusal.", + "negativeTest": {"path": test_path, "name": test_name}, + } + if partial_note is not None: + row["partialNote"] = partial_note + return row + + +def deferred_row( + row_id: str = "SCHEDULING-DEF-01", + recorded_in: str = "products/scheduling/NOTES.md", +) -> dict: + return { + "id": row_id, + "subject": "A subject", + "reason": "A reason.", + "compensatingControl": "A control.", + "recordedIn": recorded_in, + } + + +def traceability_entry( + entry_id: str = "SCHEDULING-SEC-01", + test_path: str = "crates/x/src/a.rs", + test_name: str = "the_cited_test_exists", +) -> dict: + return { + "id": entry_id, + "tests": [{"path": test_path, "name": test_name}], + } + + +def decision_row( + row_id: str = "SCHEDULING-DEC-01", + test_path: str = "crates/x/src/a.rs", + test_name: str = "the_cited_test_exists", +) -> dict: + return { + "id": row_id, + "subject": "A subject", + "decision": "A decision.", + "reasoning": "Reasoning.", + "evidence": [{"path": test_path, "name": test_name}], + } + + +def happy_tree() -> dict[str, dict]: + return { + "security-invariant-matrix.yaml": { + "apiVersion": "registry.registrystack.org/product-contract/v1", + "product": "scheduling", + "invariants": [matrix_row()], + "deferred": [deferred_row()], + }, + "security-test-traceability.yaml": { + "contract": "registry.scheduling.security-test-traceability/v1", + "entries": [traceability_entry()], + }, + "recorded-decisions.yaml": { + "apiVersion": "registry.registrystack.org/product-contract/v1", + "purpose": "A purpose.", + "decisions": [decision_row()], + }, + } + + +class Resolver: + """A synthetic repository tree backed by files on disk.""" + + def __init__(self, files: dict[str, str]) -> None: + self.root = Path(tempfile.mkdtemp(prefix="scheduling-contracts-test.")) + for relative, text in files.items(): + target = self.root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + + def __call__(self, path: str) -> str | None: + candidate = self.root / path + if not candidate.is_file(): + return None + return candidate.read_text(encoding="utf-8") + + +def validate_tree(tree: dict[str, dict], files: dict[str, str]) -> list[str]: + return validator.validate( + tree["security-invariant-matrix.yaml"], + tree["security-test-traceability.yaml"], + tree["recorded-decisions.yaml"], + Resolver(files), + ) + + +DEFAULT_FILES = { + "crates/x/src/a.rs": RUST_TEST_FILE, + "products/scheduling/NOTES.md": RECORDED_IN_FILE, +} + + +class MatrixValidation(unittest.TestCase): + def test_a_consistent_tree_has_no_violations(self) -> None: + self.assertEqual(validate_tree(happy_tree(), DEFAULT_FILES), []) + + def test_a_partial_invariant_without_its_note_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"][0]["state"] = "partial" + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue(any("partialNote" in violation or "partial invariant" in violation + for violation in violations), violations) + + def test_a_cited_test_the_file_does_not_define_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"][0]["negativeTest"]["name"] = ( + "a_test_that_does_not_exist" + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("defines no test function" in violation for violation in violations), + violations, + ) + + def test_a_cited_file_that_does_not_exist_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"][0]["negativeTest"]["path"] = ( + "crates/x/src/missing.rs" + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("does not exist" in violation for violation in violations), violations + ) + + def test_a_deferral_pointing_at_no_file_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["deferred"][0]["recordedIn"] = ( + "products/scheduling/ABSENT.md" + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("recordedIn file" in violation for violation in violations), violations + ) + + def test_a_duplicate_invariant_id_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"].append(matrix_row()) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("listed more than once" in violation for violation in violations), + violations, + ) + + +class TraceabilityValidation(unittest.TestCase): + def test_an_invariant_without_an_entry_is_refused(self) -> None: + tree = happy_tree() + tree["security-invariant-matrix.yaml"]["invariants"].append( + matrix_row(row_id="SCHEDULING-SEC-02", test_path="crates/x/src/a.rs") + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("SCHEDULING-SEC-02 has no entry" in violation for violation in violations), + violations, + ) + + def test_an_entry_matching_no_invariant_is_refused(self) -> None: + tree = happy_tree() + tree["security-test-traceability.yaml"]["entries"].append( + traceability_entry(entry_id="SCHEDULING-SEC-09") + ) + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("matches no matrix invariant" in violation for violation in violations), + violations, + ) + + def test_a_python_citation_resolves_through_def(self) -> None: + tree = happy_tree() + tree["security-test-traceability.yaml"]["entries"][0]["tests"] = [ + {"path": "products/x/test_y.py", "name": "the_cited_test_exists"} + ] + files = dict(DEFAULT_FILES) + files["products/x/test_y.py"] = PYTHON_TEST_FILE + self.assertEqual(validate_tree(tree, files), []) + + def test_a_citation_in_a_file_of_no_known_kind_is_refused(self) -> None: + tree = happy_tree() + tree["security-test-traceability.yaml"]["entries"][0]["tests"] = [ + {"path": "products/x/notes.txt", "name": "the_cited_test_exists"} + ] + files = dict(DEFAULT_FILES) + files["products/x/notes.txt"] = "fn the_cited_test_exists(\n" + violations = validate_tree(tree, files) + self.assertTrue( + any("neither Rust nor Python" in violation for violation in violations), + violations, + ) + + +class DecisionValidation(unittest.TestCase): + def test_a_decision_without_evidence_is_refused(self) -> None: + tree = happy_tree() + tree["recorded-decisions.yaml"]["decisions"][0]["evidence"] = [] + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("evidence test" in violation for violation in violations), violations + ) + + def test_a_decision_with_dangling_evidence_is_refused(self) -> None: + tree = happy_tree() + tree["recorded-decisions.yaml"]["decisions"][0]["evidence"][0]["name"] = "absent" + violations = validate_tree(tree, DEFAULT_FILES) + self.assertTrue( + any("defines no test function" in violation for violation in violations), + violations, + ) + + +class CommittedContracts(unittest.TestCase): + """The committed contract files themselves pass the validator.""" + + def test_committed_files_validate(self) -> None: + contracts_dir = Path(__file__).resolve().parent.parent / "contracts" + root = contracts_dir.parents[2] + + def resolve(path: str) -> str | None: + candidate = root / path + if not candidate.is_file(): + return None + return candidate.read_text(encoding="utf-8") + + loaded = {} + for name in ( + "security-invariant-matrix.yaml", + "security-test-traceability.yaml", + "recorded-decisions.yaml", + ): + with (contracts_dir / name).open(encoding="utf-8") as handle: + loaded[name] = yaml.safe_load(handle) + violations = validator.validate( + loaded["security-invariant-matrix.yaml"], + loaded["security-test-traceability.yaml"], + loaded["recorded-decisions.yaml"], + resolve, + ) + self.assertEqual(violations, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/products/scheduling/scripts/validate_contracts.py b/products/scheduling/scripts/validate_contracts.py new file mode 100644 index 0000000000..9aab5b19b8 --- /dev/null +++ b/products/scheduling/scripts/validate_contracts.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Validate the Scheduling product contract files against the code they cite. + +The three documents under products/scheduling/contracts/ are the review +baseline for every later Scheduling change. This validator holds them to +the shape the product committed to: + +- every security invariant names a threat, an enforcement point, a + refusal, and a negative test; +- every cited test exists in the cited file, as a Rust test function or a + Python test method, which is what makes it selectable by its own runner + (cargo test , unittest ); +- the traceability document covers exactly the matrix's invariants; +- every recorded decision cites evidence that exists; +- every deferral names the tracked file that records it, and that file + exists. + +The check is source-mapped, not executed: rows whose tests live in a +PostgreSQL suite are proven when that suite runs, and the matrix says so +in its own evidence note. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import Callable + +import yaml + +CONTRACT_API_VERSION = "registry.registrystack.org/product-contract/v1" +TRACEABILITY_CONTRACT = "registry.scheduling.security-test-traceability/v1" +SECURITY_ID = re.compile(r"^SCHEDULING-SEC-\d{2}$") +DEFERRED_ID = re.compile(r"^SCHEDULING-DEF-\d{2}$") +DECISION_ID = re.compile(r"^SCHEDULING-DEC-\d{2}$") + +TextResolver = Callable[[str], str | None] + + +def _non_empty(value: object) -> bool: + return isinstance(value, str) and value.strip() != "" + + +def _cited_test_violations( + label: str, path: str | None, name: str | None, resolve: TextResolver +) -> list[str]: + """One cited test: the file must exist and must define the name.""" + if not _non_empty(path) or not _non_empty(name): + return [f"{label}: a cited test needs both a path and a name"] + if path.startswith("/") or ".." in Path(path).parts: + return [f"{label}: cited path {path!r} must be a repository-relative path"] + text = resolve(path) + if text is None: + return [f"{label}: cited file {path} does not exist"] + if path.endswith(".rs"): + if f"fn {name}(" not in text: + return [f"{label}: {path} defines no test function {name}"] + elif path.endswith(".py"): + if f"def {name}(" not in text: + return [f"{label}: {path} defines no test method {name}"] + else: + return [f"{label}: cited file {path} is neither Rust nor Python"] + return [] + + +def matrix_violations(matrix: dict, resolve: TextResolver) -> list[str]: + violations: list[str] = [] + if matrix.get("apiVersion") != CONTRACT_API_VERSION: + violations.append(f"matrix: apiVersion must be {CONTRACT_API_VERSION}") + if matrix.get("product") != "scheduling": + violations.append("matrix: product must be scheduling") + + invariants = matrix.get("invariants") + if not isinstance(invariants, list) or not invariants: + return violations + ["matrix: no invariants listed"] + + ids: set[str] = set() + for row in invariants: + row_id = row.get("id") + label = f"matrix {row_id}" + if not isinstance(row_id, str) or not SECURITY_ID.match(row_id): + violations.append(f"matrix: invariant id {row_id!r} is not SCHEDULING-SEC-NN") + continue + if row_id in ids: + violations.append(f"{label}: listed more than once") + ids.add(row_id) + for field in ("threat", "enforcementPoint", "refusal"): + if not _non_empty(row.get(field)): + violations.append(f"{label}: {field} is required and empty") + state = row.get("state") + if state not in ("enforced", "partial"): + violations.append(f"{label}: state must be enforced or partial, not {state!r}") + if state == "partial" and not _non_empty(row.get("partialNote")): + violations.append(f"{label}: a partial invariant must say what is missing") + test = row.get("negativeTest") + if not isinstance(test, dict): + violations.append(f"{label}: negativeTest with path and name is required") + else: + violations.extend( + _cited_test_violations(label, test.get("path"), test.get("name"), resolve) + ) + + deferred = matrix.get("deferred", []) + deferred_ids: set[str] = set() + if not isinstance(deferred, list): + violations.append("matrix: deferred must be a list") + deferred = [] + for row in deferred: + row_id = row.get("id") + label = f"matrix {row_id}" + if not isinstance(row_id, str) or not DEFERRED_ID.match(row_id): + violations.append(f"matrix: deferred id {row_id!r} is not SCHEDULING-DEF-NN") + continue + if row_id in deferred_ids: + violations.append(f"{label}: listed more than once") + deferred_ids.add(row_id) + for field in ("subject", "reason", "compensatingControl", "recordedIn"): + if not _non_empty(row.get(field)): + violations.append(f"{label}: {field} is required and empty") + recorded_in = row.get("recordedIn") + if _non_empty(recorded_in): + if resolve(recorded_in) is None: + violations.append(f"{label}: recordedIn file {recorded_in} does not exist") + return violations + + +def traceability_violations( + traceability: dict, matrix: dict, resolve: TextResolver +) -> list[str]: + violations: list[str] = [] + if traceability.get("contract") != TRACEABILITY_CONTRACT: + violations.append(f"traceability: contract must be {TRACEABILITY_CONTRACT}") + entries = traceability.get("entries") + if not isinstance(entries, list) or not entries: + return violations + ["traceability: no entries listed"] + + cited: dict[str, list[str]] = {} + for entry in entries: + entry_id = entry.get("id") + label = f"traceability {entry_id}" + if not isinstance(entry_id, str) or not SECURITY_ID.match(entry_id): + violations.append(f"traceability: entry id {entry_id!r} is not SCHEDULING-SEC-NN") + continue + tests = entry.get("tests") + if not isinstance(tests, list) or not tests: + violations.append(f"{label}: at least one test must be cited") + continue + cited[entry_id] = [] + for test in tests: + if not isinstance(test, dict): + violations.append(f"{label}: every cited test is a path and name pair") + continue + cited[entry_id].append(str(test.get("name"))) + violations.extend( + _cited_test_violations(label, test.get("path"), test.get("name"), resolve) + ) + + matrix_ids = { + row.get("id") for row in matrix.get("invariants", []) if isinstance(row, dict) + } + for missing in sorted(matrix_ids - set(cited)): + violations.append(f"traceability: invariant {missing} has no entry") + for extra in sorted(set(cited) - matrix_ids): + violations.append(f"traceability: entry {extra} matches no matrix invariant") + return violations + + +def decisions_violations(decisions: dict, resolve: TextResolver) -> list[str]: + violations: list[str] = [] + if decisions.get("apiVersion") != CONTRACT_API_VERSION: + violations.append(f"decisions: apiVersion must be {CONTRACT_API_VERSION}") + if not _non_empty(decisions.get("purpose")): + violations.append("decisions: purpose is required and empty") + entries = decisions.get("decisions") + if not isinstance(entries, list) or not entries: + return violations + ["decisions: no decisions listed"] + + seen: set[str] = set() + for row in entries: + row_id = row.get("id") + label = f"decision {row_id}" + if not isinstance(row_id, str) or not DECISION_ID.match(row_id): + violations.append(f"decisions: id {row_id!r} is not SCHEDULING-DEC-NN") + continue + if row_id in seen: + violations.append(f"{label}: listed more than once") + seen.add(row_id) + for field in ("subject", "decision", "reasoning"): + if not _non_empty(row.get(field)): + violations.append(f"{label}: {field} is required and empty") + evidence = row.get("evidence") + if not isinstance(evidence, list) or not evidence: + violations.append(f"{label}: at least one evidence test must be cited") + continue + for test in evidence: + if not isinstance(test, dict): + violations.append(f"{label}: every evidence test is a path and name pair") + continue + violations.extend( + _cited_test_violations(label, test.get("path"), test.get("name"), resolve) + ) + return violations + + +def validate( + matrix: dict, traceability: dict, decisions: dict, resolve: TextResolver +) -> list[str]: + return [ + *matrix_violations(matrix, resolve), + *traceability_violations(traceability, matrix, resolve), + *decisions_violations(decisions, resolve), + ] + + +def main() -> int: + root = Path(__file__).resolve().parents[3] + contracts = root / "products" / "scheduling" / "contracts" + + def resolve(path: str) -> str | None: + candidate = root / path + if not candidate.is_file(): + return None + return candidate.read_text(encoding="utf-8") + + failures: list[str] = [] + loaded: dict[str, dict] = {} + for name in ( + "security-invariant-matrix.yaml", + "security-test-traceability.yaml", + "recorded-decisions.yaml", + ): + try: + with (contracts / name).open(encoding="utf-8") as handle: + loaded[name] = yaml.safe_load(handle) + except FileNotFoundError: + failures.append(f"{name}: missing from {contracts}") + except yaml.YAMLError as error: + failures.append(f"{name}: not valid YAML: {error}") + + if not failures: + failures = validate( + loaded["security-invariant-matrix.yaml"], + loaded["security-test-traceability.yaml"], + loaded["recorded-decisions.yaml"], + resolve, + ) + + if failures: + for failure in failures: + print(f"contracts: {failure}", file=sys.stderr) + return 1 + print("Scheduling product contracts are internally consistent and source-mapped.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d6920d9ce1c3e5f3df574b344185fb9072698d1f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:01:44 +0700 Subject: [PATCH 041/115] docs: bound scheduling composition and ledger authority in agent guidance The boundary sentence forbade any scheduling crate from reaching a BReg, Casework, or Evidence crate in either direction. That is right for the MVP but forbids composition the product design permits, so a later adapter crate would read it as a rule it was breaking rather than one it relaxes. Scope the sentence to the MVP, say which crates would relax it and when, and state the composition rule the file never carried: a product may show an appointment Scheduling holds without gaining authority to commit capacity, and Scheduling owns its capacity ledger absolutely. Correct the grant sentence to match what the runtime does, and list the contracts gate in the verify block. Signed-off-by: Jeremi Joslin --- AGENTS.md | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d0552a97a..8ee885b45c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,14 @@ The products compose without merging their boundaries. Evidence may use a Base Registry Engine route or a Relay-protected API as a fixed HTTP source and inherits neither one's authorization. Casework may present source-owned work from a Base Registry Engine through its adapter, while current source -visibility and action authority remain with that registry. +visibility and action authority remain with that registry. Registry Scheduling +may publish bookable supply for work another product governs, and another +product may show an appointment Scheduling holds, without either side gaining +the other's authority: eligibility stays with the source, and the authority to +commit capacity stays with the task grant Scheduling verifies. Scheduling owns +its capacity ledger absolutely. A hold or an appointment is created, moved, or +released only inside the Scheduling runtime's own capacity transaction, and no +other product may write to that ledger, directly or through a shared database. A Base Registry Engine governed action may also consume an Evidence assertion as an ordinary relying party: a Rhai handler declaring handler ABI @@ -140,14 +147,28 @@ live under `products/scheduling`. Scheduling sells capacity only over supply the operator anchored: resource pools and published windows are runtime records the policy references by identifier, a hold or appointment is a claim against that supply inside one -capacity transaction, and every mutation carries a task grant re-checked -inside that transaction against the offering's service, location, and action. -Reminder delivery and retention are outbox work, never inline side effects, -and the runtime has no scripting engine: the reserved hook ABI +capacity transaction, and every mutation carries a task grant matched against +the offering's service, location, and action before that transaction opens, +with the grant's expiry re-checked inside it immediately before the claim +commits. Reminder delivery and retention are outbox work, never inline side +effects, and the runtime has no scripting engine: the reserved hook ABI `registry.scheduling-hook/v1` exists on the authored form only. The source-neutral core and the client must not depend on the runtime or another -product's protocol types, and no scheduling crate may reach a BReg, Casework, -or Evidence crate in either direction. +product's protocol types. + +For the MVP, no scheduling crate may reach a BReg, Casework, or Evidence crate +in either direction, and the dependency-direction gate enforces that on the +whole forward closure. The restriction is scoped to the MVP because the crates +that would compose across those boundaries do not exist yet: a +`registry-scheduling-casework` adapter would depend on +`registry-casework-client`, a `registry-scheduling-breg` adapter on +`registry-breg-client`, and the runtime may one day consume an Evidence +assertion as an ordinary relying party through `registry-evidence-client` and +`registry-evidence-verifier`, the way a Base Registry Engine governed action +already does. Adding such a crate relaxes this sentence here and in the gate, +in the same change; until then the sentence stands as written and no local +exception may be taken to it. The runtime, the source-neutral core, the client, +and adopter tooling stay on the strict side of it in every case. Registry Discovery is a curated index over public provider descriptions, not a trust broker, authorization service, protocol adapter, or data proxy. @@ -406,6 +427,7 @@ Registry Scheduling product and gates: ```bash cargo build --locked -p registry-schedulingctl products/scheduling/scripts/check-checkpoint.sh +products/scheduling/scripts/check-contracts.sh SCHEDULING_TEST_DATABASE_URL= cargo test --locked \ -p registry-scheduling --features postgres-test --test postgres_commitments python3 products/identifiers/scripts/generate.py --check-references @@ -413,8 +435,12 @@ python3 products/identifiers/scripts/generate.py --check-references The checkpoint script runs the database-free checks; the PostgreSQL suite needs a disposable database named by `SCHEDULING_TEST_DATABASE_URL` under the -same `postgres-test` caveat. The runtime configuration schema is regenerated, -never hand-edited: +same `postgres-test` caveat. The contracts check holds +`products/scheduling/contracts/` against the code and the published reference: +every security invariant names a threat, an enforcement point, a refusal, and +a negative test that exists and is selectable by its own runner, and the +recorded decisions stay in step with the security review notes. The runtime +configuration schema is regenerated, never hand-edited: ```bash cargo run -p registry-scheduling --features schema --example runtime-schema -- \ From b724798358650dffbeeccc6d0467603475cdb520 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:01:51 +0700 Subject: [PATCH 042/115] docs(scheduling): state what the in-transaction grant re-check covers The published page claimed an expired or narrowed grant could not commit capacity even when its token was valid at the door. Only the grant's expiry is re-checked inside the capacity transaction; the bounds are matched once at the service, and there is no revocation check in this milestone. Say that. The expiry re-check is real and worth stating, so keep it, and name the rest as a recorded deferral with the operational advice it implies. Signed-off-by: Jeremi Joslin --- .../reference/apis/registry-scheduling.mdx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx index 4c11a2b002..29e51dcbfb 100644 --- a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -115,14 +115,24 @@ and the grant's client must equal the client the deployment verified. APPOINTMENT_CANCEL_ACTION; crates/registry-platform-oidc/src/authorization_claims.rs, SchedulingPermission. */} -The grant is re-checked inside the capacity transaction, after the caller's request has been read and -immediately before the claim commits, so an expired or narrowed grant cannot commit capacity even when -its token was valid at the door. A grant that does not cover the request answers -`operation.not-authorized`. The audit journal records `authorization.allowed` or -`authorization.refused` for that check as an audit reason, never as a problem code. - -{/* Evidence: crates/registry-scheduling/src/store.rs, check_grant_current(); - crates/registry-scheduling/src/service.rs, require_permission(). */} +The grant's bounds are matched once, at the service, before the capacity transaction opens. A grant +that does not cover the request answers `operation.not-authorized` and nothing is written. Inside the +transaction, immediately before the claim commits, exactly one fact is read again: the grant's own +expiry, against the clock that commitment is decided under. A grant that expired between the door and +the commit therefore cannot take capacity, and the attempt answers `operation.not-authorized` like any +other authority refusal. + +The bounds themselves are not read again at that point, and this milestone has no revocation check, so +a grant narrowed or withdrawn at the issuer after its token was minted keeps the authority its token +carries until the token or the grant deadline passes. Keep grant deadlines short. Re-checking the full +bounds inside the capacity transaction is a recorded deferral, not a promise this milestone keeps. + +The audit journal records `authorization.allowed` or `authorization.refused` for the commitment as an +audit reason, never as a problem code. + +{/* Evidence: crates/registry-scheduling/src/store.rs, check_grant_current() checks grant_exp_unix and + nothing else; crates/registry-scheduling/src/service.rs, require_permission() and problem_of(); + products/scheduling/contracts/security-invariant-matrix.yaml, SCHEDULING-SEC-03. */} ## Problem codes From a765f2ffd541ee0a17df05519ab9e8ad085bed60 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:04:12 +0700 Subject: [PATCH 043/115] ci(scheduling): run the product's gates and database suite on every change Scheduling had no job in root CI and no rows in the gates inventory, so nothing the branch added was verified and nothing noticed the omission. A product whose gates never run is a product whose later phases inherit untested ground. Add the contracts job and a PostgreSQL job with a service container, so the runtime commitment suite runs against a real database now that it refuses to skip without one. Route products/scheduling changes to the scheduling shard, and add the inventory rows naming those steps. Rows and steps land together because a row naming a step that does not exist fails the inventory check. Add the shard to the nightly coverage expectations it is now part of. Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 5 ++ .github/scripts/test_ci_changes.py | 14 +++- .github/scripts/test_nightly_rust_coverage.py | 1 + .github/workflows/ci.yml | 80 +++++++++++++++++++ release/scripts/check-gates-inventory.py | 60 +++++++++++++- release/scripts/test_registry_release.py | 2 + 6 files changed, 160 insertions(+), 2 deletions(-) diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index 13cb854259..1750a92e62 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -101,6 +101,7 @@ RELAY_CLIENT_PACKAGES = frozenset(SHARDS["relay-client"]) BREG_PACKAGES = frozenset(SHARDS["breg"]) CASEWORK_PACKAGES = frozenset(SHARDS["casework"]) +SCHEDULING_PACKAGES = frozenset(SHARDS["scheduling"]) STACK_CLIENT_PACKAGES = frozenset(SHARDS["stack-client"]) # These are the cross-product semantic commitments implemented independently by @@ -707,6 +708,8 @@ def classify( seeds.update(BREG_PACKAGES) elif path.startswith("products/casework/"): seeds.update(CASEWORK_PACKAGES) + elif path.startswith("products/scheduling/"): + seeds.update(SCHEDULING_PACKAGES) elif path.startswith("products/identifiers/"): # The catalog gate compiles its focused Relay V2 exporter. # Catalog-only tooling does not require the full Rust matrix. @@ -959,7 +962,9 @@ def classify( for path in paths ), "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), + "scheduling_contracts": bool(affected & SCHEDULING_PACKAGES), "casework_postgres": bool(affected & CASEWORK_PACKAGES), + "scheduling_postgres": bool(affected & SCHEDULING_PACKAGES), "release_tool": release_tool, "release_source_proof": release_source_proof, "docs": docs, diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index b54c588b3c..5bd3054f52 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -273,6 +273,12 @@ def test_deferred_ci_work_keeps_its_selector_and_explicit_status_guard( ) -> None: selectors = { "casework-postgres": "needs.changes.outputs.casework_postgres == 'true'", + "scheduling-postgres": ( + "needs.changes.outputs.scheduling_postgres == 'true'" + ), + "scheduling-contracts": ( + "needs.changes.outputs.scheduling_contracts == 'true'" + ), "platform-fuzz": "needs.changes.outputs.platform == 'true'", "platform-coverage": "needs.changes.outputs.platform == 'true'", "rust-quality": "needs.changes.outputs.rust == 'true'", @@ -338,6 +344,8 @@ def test_ci_scheduling_graph_retains_every_job_and_aggregate_dependency( "identifiers", "rust-result", "casework-postgres", + "scheduling-contracts", + "scheduling-postgres", "release-tool", "release-tool-required", "release-source-proof", @@ -371,6 +379,8 @@ def test_ci_scheduling_graph_retains_every_job_and_aggregate_dependency( "breg-wasm", "identifiers", "casework-postgres", + "scheduling-postgres", + "scheduling-contracts", ), "release-tool-required": ("changes", "release-tool"), "release-source-proof-required": ("changes", "release-source-proof"), @@ -394,6 +404,8 @@ def test_ci_scheduling_graph_retains_every_job_and_aggregate_dependency( "breg-wasm", "identifiers", "casework-postgres", + "scheduling-postgres", + "scheduling-contracts", "release-tool", "release-source-proof", "evidence-tutorials", @@ -449,7 +461,7 @@ def test_final_aggregate_flattens_rust_results_with_equivalent_outcomes( final_needs, previous_final_needs.difference({"rust-result"}).union(rust_needs), ) - self.assertEqual(29, len(final_needs)) + self.assertEqual(31, len(final_needs)) def embedded_python(job: dict[str, Any]) -> str: run = job["steps"][0]["run"] diff --git a/.github/scripts/test_nightly_rust_coverage.py b/.github/scripts/test_nightly_rust_coverage.py index 1b932b50ea..f88a2d032b 100644 --- a/.github/scripts/test_nightly_rust_coverage.py +++ b/.github/scripts/test_nightly_rust_coverage.py @@ -39,6 +39,7 @@ def test_all_live_shards_have_stable_flags_and_owned_packages(self) -> None: "relay-v2": "relay-v2", "breg": "breg", "casework": "casework", + "scheduling": "scheduling", "stack-client": "stack-client", "evidence": "evidence", "developer-tools": "developer-tools", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f771a99bca..640b11005c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,7 +58,9 @@ jobs: relay_client_contracts: ${{ steps.filter.outputs.relay_client_contracts }} breg_contracts: ${{ steps.filter.outputs.breg_contracts }} casework_postgres: ${{ steps.filter.outputs.casework_postgres }} + scheduling_postgres: ${{ steps.filter.outputs.scheduling_postgres }} evidence_contracts: ${{ steps.filter.outputs.evidence_contracts }} + scheduling_contracts: ${{ steps.filter.outputs.scheduling_contracts }} release_tool: ${{ steps.filter.outputs.release_tool }} release_source_proof: ${{ steps.filter.outputs.release_source_proof }} docs: ${{ steps.filter.outputs.docs }} @@ -711,6 +713,80 @@ jobs: CASEWORK_REVIEW_MIGRATION_TEST_DATABASE_URL: postgresql://casework:casework_test@localhost:${{ job.services.postgres.ports['5432'] }}/casework_review_migration run: cargo test --locked --profile ci -p registry-casework --features postgres-test --test review_migration_postgres + scheduling-contracts: + name: Scheduling product contracts + needs: changes + if: needs.changes.outputs.scheduling_contracts == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + submodules: false + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Scheduling contract consistency + run: products/scheduling/scripts/check-contracts.sh + + scheduling-postgres: + name: Scheduling PostgreSQL transactions + needs: [changes, rust-policy] + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.scheduling_postgres == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + services: + postgres: + image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 + env: + POSTGRES_DB: scheduling + POSTGRES_USER: scheduling + POSTGRES_PASSWORD: scheduling_test + options: >- + --health-cmd "pg_isready -U scheduling -d scheduling" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432/tcp + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + submodules: false + - name: Rust cache + uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Build the Scheduling authoring tool + run: cargo build --locked --profile ci -p registry-schedulingctl + - name: Verify Scheduling product contracts and offline authoring journeys + env: + SCHEDULINGCTL_BIN: ${{ github.workspace }}/target/ci/schedulingctl + run: products/scheduling/scripts/check-checkpoint.sh + - name: Verify commitment ledger transactions + env: + SCHEDULING_TEST_DATABASE_URL: postgresql://scheduling:scheduling_test@localhost:${{ job.services.postgres.ports['5432'] }}/scheduling + run: cargo test --locked --profile ci -p registry-scheduling --features postgres-test --test postgres_commitments + - name: Create isolated authoring-apply test database + env: + POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }} + run: docker exec "$POSTGRES_CONTAINER_ID" createdb -U scheduling scheduling_records_apply + - name: Verify offline authoring record application + env: + SCHEDULING_TEST_DATABASE_URL: postgresql://scheduling:scheduling_test@localhost:${{ job.services.postgres.ports['5432'] }}/scheduling_records_apply + run: cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test records_apply_postgres + breg-contracts: name: Base Registry Engine product contracts (${{ matrix.lane }}) needs: changes @@ -937,6 +1013,8 @@ jobs: - breg-wasm - identifiers - casework-postgres + - scheduling-postgres + - scheduling-contracts runs-on: ubuntu-slim env: RUST_JOB_RESULTS: ${{ toJSON(needs) }} @@ -1938,6 +2016,8 @@ jobs: - breg-wasm - identifiers - casework-postgres + - scheduling-postgres + - scheduling-contracts - release-tool - release-source-proof - evidence-tutorials diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 01420db459..6c7cf10574 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -16,6 +16,9 @@ CASEWORK_CHECKPOINT_RUNNER = ( ROOT / "products" / "casework" / "scripts" / "check-checkpoint.sh" ) +SCHEDULING_CHECKPOINT_RUNNER = ( + ROOT / "products" / "scheduling" / "scripts" / "check-checkpoint.sh" +) REQUIRED_GATES: tuple[tuple[str, str], ...] = ( ( @@ -273,6 +276,54 @@ "Casework Python client binding coverage", "registry-breg-client-py registry-casework-client-py", ), + ( + "Scheduling contract path filter", + "scheduling_contracts: ${{ steps.filter.outputs.scheduling_contracts }}", + ), + ( + "Scheduling contract gate", + "scheduling-contracts:\n name: Scheduling product contracts", + ), + ( + "Scheduling contract reproduction", + "run: products/scheduling/scripts/check-contracts.sh", + ), + ( + "Scheduling PostgreSQL path filter", + "scheduling_postgres: ${{ steps.filter.outputs.scheduling_postgres }}", + ), + ( + "Scheduling PostgreSQL gate", + "scheduling-postgres:\n name: Scheduling PostgreSQL transactions", + ), + ( + "Scheduling product checkpoint wrapper", + "run: products/scheduling/scripts/check-checkpoint.sh", + ), + ( + "Scheduling HTTP contract drift check", + "python3 products/scheduling/scripts/generate_openapi.py --check", + ), + ( + "Scheduling dependency-direction guard", + "python3 products/scheduling/scripts/check_dependency_direction.py", + ), + ( + "Scheduling product script tests", + "python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py'", + ), + ( + "Scheduling offline authoring journeys", + '"$schedulingctl_bin" test "$work/$template"', + ), + ( + "Scheduling commitment ledger suite", + "cargo test --locked --profile ci -p registry-scheduling --features postgres-test --test postgres_commitments", + ), + ( + "Scheduling authoring record application suite", + "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test records_apply_postgres", + ), ( "Release Linux Node client path filter", "release_linux_node_clients: ${{ steps.filter.outputs.release_linux_node_clients }}", @@ -1108,6 +1159,7 @@ def missing_gates( classifier_text: str | None = None, platform_fuzz_runner_text: str | None = None, casework_checkpoint_runner_text: str | None = None, + scheduling_checkpoint_runner_text: str | None = None, ) -> list[str]: if classifier_text is None: classifier_text = CI_CLASSIFIER.read_text(encoding="utf-8") @@ -1123,9 +1175,15 @@ def missing_gates( if CASEWORK_CHECKPOINT_RUNNER.is_file() else "" ) + if scheduling_checkpoint_runner_text is None: + scheduling_checkpoint_runner_text = ( + SCHEDULING_CHECKPOINT_RUNNER.read_text(encoding="utf-8") + if SCHEDULING_CHECKPOINT_RUNNER.is_file() + else "" + ) inventory_text = ( f"{workflow_text}\n{classifier_text}\n{platform_fuzz_runner_text}\n" - f"{casework_checkpoint_runner_text}" + f"{casework_checkpoint_runner_text}\n{scheduling_checkpoint_runner_text}" ) return [name for name, snippet in REQUIRED_GATES if snippet not in inventory_text] diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index c01721b5d4..3984b66b9a 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -1717,6 +1717,8 @@ def test_required_rust_context_aggregates_path_gated_shards(self) -> None: "relay-client-contracts", "relay-v2-contracts", "casework-postgres", + "scheduling-contracts", + "scheduling-postgres", }, set(rust_result["needs"]), ) From 314eaafeb60dfe6bc3461734cef27e30356c389b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:04:19 +0700 Subject: [PATCH 044/115] test(scheduling): make the checkpoint prove what it claims to check The checkpoint discarded every output and never ran a failing project, so it proved the commands exit zero and little else. A reviewer trusting it learned nothing about whether check finds anything or whether a refusal refuses. Assert on the outputs, run the schema drift test that no gate ran, and add a project that carries findings: the findings are visible, --deny-findings refuses, and a broken fixture fails. Add a banded-table journey whose policy digest proves the table was read. Signed-off-by: Jeremi Joslin --- .../scheduling/scripts/check-checkpoint.sh | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/products/scheduling/scripts/check-checkpoint.sh b/products/scheduling/scripts/check-checkpoint.sh index b3dbfe7915..6be9374d20 100755 --- a/products/scheduling/scripts/check-checkpoint.sh +++ b/products/scheduling/scripts/check-checkpoint.sh @@ -9,6 +9,13 @@ cd "$repo_root" python3 products/scheduling/scripts/generate_openapi.py --check python3 products/scheduling/scripts/check_dependency_direction.py python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py' +python3 products/scheduling/scripts/validate_contracts.py + +# The committed runtime configuration schema is reproduced by its generator, +# never hand-edited; the drift test runs only under the schema feature, so +# the checkpoint is the gate that runs it. +CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ + cargo test --locked --quiet -p registry-scheduling --features schema if [ ! -x "$schedulingctl_bin" ]; then echo "build schedulingctl first or set SCHEDULINGCTL_BIN" >&2 @@ -31,6 +38,71 @@ for template in standalone-exact-time standalone-arrival-window; do "$schedulingctl_bin" explain "$work/$template" >/dev/null done +# The journeys must answer what they claim to answer, not merely exit 0. +check_output=$("$schedulingctl_bin" check "$work/standalone-exact-time") +echo "$check_output" | grep -q '^status: complete' +test_output=$("$schedulingctl_bin" test "$work/standalone-exact-time") +echo "$test_output" | grep -q '^Offline synthetic fixtures passed.$' +echo "$test_output" | grep -q '^proofBoundary: offline_synthetic$' + +# A project that carries findings reports them: check exits 0 with findings +# by default, refuses under --deny-findings, and a broken fixture proof is +# never green. sed writes beside the file and mv replaces it, because sed -i +# spells differ between the development hosts and CI. +broken="$work/broken" +"$schedulingctl_bin" init "$broken" --template standalone-exact-time >/dev/null +sed 's/ttlMinutes: [0-9][0-9]*/ttlMinutes: 0/' \ + "$broken/scheduling.yaml" >"$broken/scheduling.yaml.next" +mv "$broken/scheduling.yaml.next" "$broken/scheduling.yaml" +findings=$("$schedulingctl_bin" check "$broken") +echo "$findings" | grep -q '^status: incomplete$' +echo "$findings" | grep -q '^finding ' +if "$schedulingctl_bin" check "$broken" --deny-findings >/dev/null 2>&1; then + echo 'check --deny-findings accepted a finding-carrying project' >&2 + exit 1 +fi +sed 's/offering: registry-update-30/offering: no-such-offering/' \ + "$broken/fixtures/counter-stations.yaml" >"$broken/fixtures/counter-stations.yaml.next" +mv "$broken/fixtures/counter-stations.yaml.next" "$broken/fixtures/counter-stations.yaml" +if "$schedulingctl_bin" test "$broken" >/dev/null 2>&1; then + echo 'test accepted a fixture naming an offering the policy does not publish' >&2 + exit 1 +fi + +# The banded units table is reachable from the authoring journey, not only +# from the core's unit tests: swapping the arrival template's per-recipient +# policy for a banded table must still check clean, and must change the +# policy digest, which proves the swap took and was read. +banded="$work/banded" +"$schedulingctl_bin" init "$banded" --template standalone-arrival-window >/dev/null +plain_digest=$("$schedulingctl_bin" check "$banded" | grep -o '"policyDigest":"[^"]*"') +python3 - "$banded/scheduling.yaml" <<'PY' +import sys +from pathlib import Path + +import yaml + +path = Path(sys.argv[1]) +document = yaml.safe_load(path.read_text(encoding="utf-8")) +for window in document["windows"]: + window["unitsPolicy"] = { + "kind": "bandedTable", + "input": "serviceRecipientCount", + "bands": [ + {"upTo": 2, "units": 1, "because": "One unit for one or two recipients."}, + {"upTo": 3, "units": 2, "because": "Two units for three recipients."}, + ], + "aboveHighestBand": {"policy": "refuse"}, + "because": "Household size decides the serving slots.", + } +path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") +PY +banded_digest=$("$schedulingctl_bin" check "$banded" | grep -o '"policyDigest":"[^"]*"') +if [ "$plain_digest" = "$banded_digest" ]; then + echo 'the banded units table did not change the policy the check read' >&2 + exit 1 +fi + # The package journey on a fresh project: the manifest the runtime verifies. "$schedulingctl_bin" init "$work/package" --template standalone-exact-time >/dev/null "$schedulingctl_bin" package "$work/package" >/dev/null From 4fc13aceb97abe8a32bf7dd01738eb47e6204e54 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:04:27 +0700 Subject: [PATCH 045/115] feat(scheduling): ship the runtime image and installer Scheduling had no container image and no installer, so a first deployment had nothing to deploy. The release manifest stays deferred, but the artifacts it would reference should exist before anyone needs them. Add Dockerfile.scheduling and install.sh following the Casework equivalents, with the libc preflight rendered by its generator rather than written by hand, and register the image with the Debian 13 image check. release/VERIFY.md's image list is deliberately untouched: it describes published releases, and Scheduling has none until the manifest lands. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/install.sh | 442 ++++++++++++++++++ release/docker/Dockerfile.scheduling | 44 ++ release/scripts/check-debian13-images.py | 6 + .../render-installer-libc-preflight.py | 1 + release/scripts/test_check_debian13_images.py | 2 + 5 files changed, 495 insertions(+) create mode 100755 crates/registry-scheduling/install.sh create mode 100644 release/docker/Dockerfile.scheduling diff --git a/crates/registry-scheduling/install.sh b/crates/registry-scheduling/install.sh new file mode 100755 index 0000000000..01bb4bfc4c --- /dev/null +++ b/crates/registry-scheduling/install.sh @@ -0,0 +1,442 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo="registrystack/registry-stack" +binaries=(scheduling schedulingctl) +# Publication packaging replaces this empty value with the asset's canonical tag. +default_version="" +script_name="${BASH_SOURCE[0]:-}" +script_name="${script_name##*/}" +filename_version="" +if [[ "$script_name" =~ ^scheduling-(v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))-install\.sh$ ]]; then + filename_version="${BASH_REMATCH[1]}" +fi +if [ -n "$default_version" ] && + [ -n "$filename_version" ] && + [ "$default_version" != "$filename_version" ]; then + echo "Refusing an installer whose embedded release does not match its filename." >&2 + exit 1 +fi +default_version="${default_version:-$filename_version}" +version="${SCHEDULING_VERSION:-$default_version}" +if [ -n "$default_version" ] && + [ -n "${SCHEDULING_VERSION:-}" ] && + [ "$SCHEDULING_VERSION" != "$default_version" ]; then + echo "Refusing a release override that does not match the released installer asset." >&2 + exit 1 +fi +install_dir="${SCHEDULING_INSTALL_DIR:-$HOME/.local/bin}" +asset_dir="${SCHEDULING_ASSET_DIR:-}" + +usage() { + cat </scheduling--install.sh | bash + +The installer verifies every downloaded release asset against the release's +SHA256SUMS before anything reaches the install directory, and installs the +two binaries together or not at all. It does not verify release authenticity. For +a higher-assurance installation, follow the release verification guide for the +pinned tag, then rerun with SCHEDULING_ASSET_DIR set to the verified +directory: + https://github.com/${repo}/blob//release/VERIFY.md + +Environment: + SCHEDULING_VERSION Registry Scheduling tag to install. A published installer + embeds its tag and refuses a different override. + SCHEDULING_INSTALL_DIR Install directory. Defaults to ~/.local/bin. + SCHEDULING_ASSET_DIR Read already-downloaded release assets from this directory + instead of downloading them. +EOF +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Scheduling installer needs '$1'." >&2 + exit 1 + fi +} + +if [ -z "$version" ]; then + echo "No Registry Scheduling tag is pinned for this installer copy." >&2 + echo "Set SCHEDULING_VERSION to a pinned vMAJOR.MINOR.PATCH tag, or run a" >&2 + echo "published scheduling--install.sh asset." >&2 + exit 1 +fi +if [[ ! "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Refusing a non-canonical Registry Scheduling tag." >&2 + echo "Use vMAJOR.MINOR.PATCH." >&2 + exit 1 +fi + +need uname +if [ -z "$asset_dir" ]; then + need curl +fi + +os="$(uname -s)" +arch="$(uname -m)" + +case "$os/$arch" in +Linux/x86_64 | Linux/amd64) + os_label="linux" + arch_label="amd64" + ;; +Linux/arm64 | Linux/aarch64) + os_label="linux" + arch_label="arm64" + ;; +Darwin/arm64 | Darwin/aarch64) + os_label="macos" + arch_label="arm64" + ;; +*) + printf 'No prebuilt Registry Scheduling asset is published for %s/%s.\n' "$os" "$arch" >&2 + printf 'Supported platforms: Linux amd64, Linux arm64, and macOS arm64.\n' >&2 + printf 'Check the published assets at https://github.com/%s/releases/tag/%s\n' \ + "$repo" "$version" >&2 + exit 1 + ;; +esac + + +# BEGIN generated libc preflight +# Generated from release/glibc-floor.env by +# release/scripts/render-installer-libc-preflight.py. Do not edit by hand. +# +# The published Linux binaries are dynamically linked against GNU libc. On a +# musl system, or on a glibc older than the floor below, they cannot start, and +# the dynamic linker only says so at the first run, long after this installer +# would have reported success. Refuse here instead, before anything downloads. +if [ "$os_label" = "linux" ]; then + libc_floor="2.35" + # musl's ldd exits non-zero when asked for a version, and this installer runs + # under pipefail, so its report is captured once here instead of being read + # through a pipeline whose status would hide the match. + libc_report="" + if command -v ldd >/dev/null 2>&1; then + libc_report="$(ldd --version 2>&1 || true)" + fi + # Only glibc's getconf answers GNU_LIBC_VERSION, so an answer names the libc + # this system runs on, whatever else is installed beside it: a musl loader + # kept for cross builds does not make a musl system. Without an answer, musl + # is recognised by its ldd report or its loader. The report is matched by a + # pattern rather than through grep, which can exit before the report is + # fully written and, under pipefail, lose the match. + detected_libc="" + if command -v getconf >/dev/null 2>&1; then + detected_libc="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}' || true)" + fi + musl_system=0 + if [ -z "$detected_libc" ]; then + case "$libc_report" in + *[Mm][Uu][Ss][Ll]*) musl_system=1 ;; + esac + if ls /lib/ld-musl-*.so.1 >/dev/null 2>&1; then + musl_system=1 + fi + fi + if [ "$musl_system" = 1 ]; then + printf 'No musl build of Registry Scheduling is published for this platform.\n' >&2 + printf 'Every published Linux binary needs GNU libc %s or newer, so none of them can start here.\n' \ + "$libc_floor" >&2 + printf 'Install on a GNU libc distribution, or run the published container images.\n' >&2 + printf 'If you need musl builds, ask for them at https://github.com/%s/issues so the demand is recorded.\n' \ + "$repo" >&2 + exit 1 + fi + if [ -z "$detected_libc" ]; then + detected_libc="$(printf '%s\n' "$libc_report" | awk 'NR == 1 {print $NF}')" + fi + case "$detected_libc" in + [0-9]*.[0-9]*) ;; + *) detected_libc="" ;; + esac + if [ -z "$detected_libc" ]; then + printf 'Could not read the GNU libc version of this system, so the %s floor is unchecked.\n' \ + "$libc_floor" >&2 + elif ! awk -v have="$detected_libc" -v floor="$libc_floor" ' + BEGIN { + split(have, h, ".") + split(floor, f, ".") + exit !(h[1] > f[1] || (h[1] == f[1] && h[2] >= f[2])) + } + '; then + printf 'This system has GNU libc %s. The published binaries of Registry Scheduling need %s or newer.\n' \ + "$detected_libc" "$libc_floor" >&2 + printf 'Nothing was installed: the binaries would fail to start with a dynamic linker error.\n' >&2 + printf 'Upgrade the distribution, or run the published container images.\n' >&2 + exit 1 + fi +fi + +# END generated libc preflight +base_url="https://github.com/${repo}/releases/download/${version}" +verify_url="https://github.com/${repo}/blob/${version}/release/VERIFY.md" +tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t scheduling)" + +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +download() { + local src="$1" + local dest="$2" + if [ -n "$asset_dir" ]; then + local name="${src##*/}" + if [ ! -f "$asset_dir/$name" ]; then + return 1 + fi + cp "$asset_dir/$name" "$dest" + else + curl -fsSL "$src" -o "$dest" 2>/dev/null + fi +} + +if [ -n "$asset_dir" ]; then + printf 'Installing verified local Registry Scheduling %s assets for %s/%s...\n' \ + "$version" "$os_label" "$arch_label" +else + printf 'Downloading Registry Scheduling %s for %s/%s...\n' \ + "$version" "$os_label" "$arch_label" +fi + +for binary in "${binaries[@]}"; do + asset="${binary}-${version}-${os_label}-${arch_label}" + if ! download "$base_url/$asset" "$tmpdir/$asset"; then + printf 'Could not read the published %s %s binary for %s/%s.\n' \ + "$binary" "$version" "$os_label" "$arch_label" >&2 + printf 'Check the published assets at https://github.com/%s/releases/tag/%s\n' \ + "$repo" "$version" >&2 + exit 1 + fi +done +if ! download "$base_url/SHA256SUMS" "$tmpdir/SHA256SUMS"; then + echo "Could not download SHA256SUMS for checksum verification." >&2 + exit 1 +fi + +sha256_file() { + local path="$1" + local result + if command -v shasum >/dev/null 2>&1; then + result="$(shasum -a 256 "$path")" + elif command -v sha256sum >/dev/null 2>&1; then + result="$(sha256sum "$path")" + else + echo "Scheduling installer needs 'shasum' or 'sha256sum' for checksum verification." >&2 + exit 1 + fi + printf '%s\n' "${result%% *}" +} + +verify_asset() { + local name="$1" + local expected_hash actual_hash + expected_hash="$(awk -v asset="$name" '$2 == asset {print $1}' "$tmpdir/SHA256SUMS")" + if [ -z "$expected_hash" ]; then + echo "SHA256SUMS has no entry for $name" >&2 + exit 1 + fi + actual_hash="$(sha256_file "$tmpdir/$name")" + if [ "$actual_hash" != "$expected_hash" ]; then + echo "Checksum verification failed for $name" >&2 + echo "Expected: $expected_hash" >&2 + echo "Actual: $actual_hash" >&2 + exit 1 + fi +} + +for binary in "${binaries[@]}"; do + verify_asset "${binary}-${version}-${os_label}-${arch_label}" +done +printf 'Integrity checks passed: %s binaries matched SHA256SUMS.\n' "${#binaries[@]}" +cat <&2 + exit 1 +fi + +# A one-time migration keeps existing direct binaries behind the same pointer +# before their stable command links are installed. At every point every command +# therefore resolves to the prior toolset, the new toolset, or neither. +if [ ! -L "$current_link" ]; then + previous_dir="$(mktemp -d "$install_dir/.scheduling-previous.XXXXXX")" + chmod 0755 "$previous_dir" + previous_count=0 + for binary in "${binaries[@]}"; do + if [ -e "$install_dir/$binary" ] && [ ! -d "$install_dir/$binary" ]; then + cp -p "$install_dir/$binary" "$previous_dir/$binary" + previous_count=$((previous_count + 1)) + fi + done + if [ "$previous_count" -gt 0 ]; then + ln -s "${previous_dir##*/}" "$link_stage_dir/current" + replace_path "$link_stage_dir/current" "$current_link" + else + rm -rf "$previous_dir" + fi +fi + +had_current_link=0 +if [ -L "$current_link" ]; then + had_current_link=1 + ln -s "$(readlink "$current_link")" "$link_stage_dir/previous-current" +fi + +for binary in "${binaries[@]}"; do + if [ -d "$install_dir/$binary" ] && [ ! -L "$install_dir/$binary" ]; then + echo "Refusing to replace a directory at $install_dir/$binary." >&2 + exit 1 + fi + command_target=".scheduling-current/$binary" + ln -s "$command_target" "$link_stage_dir/$binary" + if [ -L "$install_dir/$binary" ] && + [ "$(readlink "$install_dir/$binary")" = "$command_target" ]; then + # This command already belongs to Scheduling and changes version through + # the pointer below. Leave it untouched if that pointer switch fails. + rm "$link_stage_dir/$binary" + elif [ -L "$install_dir/$binary" ]; then + # Preserve the target text, including a dangling or relative link, so a + # failed adoption restores the other installer's exact command path. + ln -s "$(readlink "$install_dir/$binary")" "$link_stage_dir/previous-$binary" + elif [ -e "$install_dir/$binary" ]; then + # A regular command may be a local wrapper. Preserve its bytes and mode. + cp -p "$install_dir/$binary" "$link_stage_dir/previous-$binary" + fi +done + +adopted_binaries=() +rollback_adoptions() { + local rollback_failed=0 binary backup + # Clear first so EXIT cleanup never replays a partial rollback whose backup + # entries may already have been moved back into place. + transaction_active=0 + for binary in "${adopted_binaries[@]}"; do + backup="$link_stage_dir/previous-$binary" + if [ -L "$backup" ] || [ -e "$backup" ]; then + if ! replace_path "$backup" "$install_dir/$binary"; then + rollback_failed=1 + fi + elif ! rm -f "$install_dir/$binary"; then + rollback_failed=1 + fi + done + if [ "$had_current_link" -eq 1 ]; then + if ! replace_path "$link_stage_dir/previous-current" "$current_link"; then + rollback_failed=1 + fi + elif ! rm -f "$current_link"; then + rollback_failed=1 + fi + if [ "$rollback_failed" -eq 0 ]; then + # The new toolset is unreachable again and may be removed by cleanup. + install_complete=0 + else + link_stage_cleanup=0 + echo "The failed install could not restore every previous command path; inspect $install_dir and the retained backups under $link_stage_dir." >&2 + fi + return "$rollback_failed" +} + +# Every stable command link the pointer already resolves changes version through +# this one atomic rename. +ln -s "${stage_dir##*/}" "$link_stage_dir/current" +install_complete=1 +transaction_active=1 +replace_path "$link_stage_dir/current" "$current_link" + +# A command not already linked through Scheduling's pointer, such as a shared +# command owned by another product, is linked only now that the switch has made +# its target real. Installing that link earlier would mutate a working command +# even when the final pointer switch fails. Its staged link is still in place +# because the loop above left it there. +for binary in "${binaries[@]}"; do + if [ -L "$link_stage_dir/$binary" ]; then + # Record the attempt before moving: a failing mv may already have changed + # its destination, so rollback must restore this path as well. + adopted_binaries+=("$binary") + if replace_path "$link_stage_dir/$binary" "$install_dir/$binary"; then + : + else + adoption_status=$? + rollback_adoptions || true + exit "$adoption_status" + fi + fi +done +transaction_active=0 + +for binary in "${binaries[@]}"; do + printf '%s installed to %s\n' "$binary" "$install_dir/$binary" +done +cat <&2 ;; +esac diff --git a/release/docker/Dockerfile.scheduling b/release/docker/Dockerfile.scheduling new file mode 100644 index 0000000000..dd56eb7c90 --- /dev/null +++ b/release/docker/Dockerfile.scheduling @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e + +ARG SOURCE_DATE_EPOCH=0 + +FROM debian:trixie-slim@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258 AS runtime-root +ARG SOURCE_DATE_EPOCH + +ADD --checksum=sha256:967aa62605721081c3eb2a17650611a792aa802d76a6511d1840242623d204c9 https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_amd64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_amd64.deb +ADD --checksum=sha256:8784eda966b189c777a384dac5ce009e8fc9b52d006926c5a013e7fa8aa688cc https://snapshot.debian.org/archive/debian/20260913T000000Z/pool/main/g/glibc/libc6_2.41-12+deb13u4_arm64.deb /workspace/runtime-packages/libc6_2.41-12+deb13u4_arm64.deb + +# The policy package, the runtime configuration, and secrets are deployment +# inputs. The image carries only the runtime binary and license. Scheduling +# stores its capacity ledger in PostgreSQL; only its local append-only audit +# chain needs a writable persistent directory. +RUN --mount=type=bind,source=dist/image-bin,target=/workspace/image-bin \ + --mount=type=bind,source=LICENSE,target=/workspace/LICENSE \ + --mount=type=bind,source=release/scripts/install-runtime-libc6.sh,target=/workspace/install-runtime-libc6.sh,readonly \ + /workspace/install-runtime-libc6.sh /workspace/runtime-root /workspace/runtime-packages \ + && mkdir -p \ + /workspace/runtime-root/etc/registry-scheduling \ + /workspace/runtime-root/licenses/scheduling \ + /workspace/runtime-root/usr/local/bin \ + /workspace/runtime-root/var/lib/registry-scheduling/audit \ + && install -m 0755 /workspace/image-bin/scheduling /workspace/runtime-root/usr/local/bin/scheduling \ + && install -m 0644 /workspace/LICENSE /workspace/runtime-root/licenses/scheduling/LICENSE \ + && chown -R 65532:65532 /workspace/runtime-root/var/lib/registry-scheduling \ + && chmod 0700 /workspace/runtime-root/var/lib/registry-scheduling/audit \ + && find /workspace/runtime-root -exec touch -h --date="@${SOURCE_DATE_EPOCH}" {} + + +FROM gcr.io/distroless/cc-debian13:nonroot@sha256:c31ff9abcb1910f3ab25c7957bdaf0bfe12a01eb546e8df2282f1c8f682b606c AS runtime + +LABEL org.registrystack.runtime.uid="65532" \ + org.registrystack.runtime.gid="65532" + +COPY --from=runtime-root /workspace/runtime-root/ / + +WORKDIR /var/lib/registry-scheduling + +EXPOSE 8105 + +# Scheduling serves GET /healthz and GET /readyz. Distroless has no shell or +# HTTP client, so the deployment platform owns the probe. +ENTRYPOINT ["/usr/local/bin/scheduling"] +CMD ["--runtime-config", "/etc/registry-scheduling/runtime.yaml", "serve"] diff --git a/release/scripts/check-debian13-images.py b/release/scripts/check-debian13-images.py index ef6cf8000e..85b90849fb 100755 --- a/release/scripts/check-debian13-images.py +++ b/release/scripts/check-debian13-images.py @@ -84,6 +84,7 @@ Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), Path("release/docker/Dockerfile.relay"), + Path("release/docker/Dockerfile.scheduling"), ) # Adopter and development images. They build from source like the per-product @@ -175,6 +176,11 @@ "entrypoint": 'ENTRYPOINT ["/usr/local/bin/casework"]', "command": 'CMD ["--runtime-config", "/etc/registry-casework/runtime.yaml", "serve"]', }, + Path("release/docker/Dockerfile.scheduling"): { + "binary": "scheduling", + "entrypoint": 'ENTRYPOINT ["/usr/local/bin/scheduling"]', + "command": 'CMD ["--runtime-config", "/etc/registry-scheduling/runtime.yaml", "serve"]', + }, } FROM_RE = re.compile(r"^FROM\s+(?:--platform=\S+\s+)?(\S+)", re.MULTILINE) diff --git a/release/scripts/render-installer-libc-preflight.py b/release/scripts/render-installer-libc-preflight.py index 490fbe6414..45f022c600 100755 --- a/release/scripts/render-installer-libc-preflight.py +++ b/release/scripts/render-installer-libc-preflight.py @@ -38,6 +38,7 @@ def __init__(self, path: str, product: str) -> None: INSTALLERS = ( Installer("crates/registry-breg/install.sh", "the Base Registry Engine"), Installer("crates/registry-casework/install.sh", "Registry Casework"), + Installer("crates/registry-scheduling/install.sh", "Registry Scheduling"), Installer("crates/registry-evidencectl/install.sh", "the Evidence toolset"), Installer("crates/registry-relay-v2/install.sh", "Registry Relay"), ) diff --git a/release/scripts/test_check_debian13_images.py b/release/scripts/test_check_debian13_images.py index 0bcff6ad95..7258d5ff88 100644 --- a/release/scripts/test_check_debian13_images.py +++ b/release/scripts/test_check_debian13_images.py @@ -59,6 +59,7 @@ def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), Path("release/docker/Dockerfile.relay"), + Path("release/docker/Dockerfile.scheduling"), }, set(POLICY.DOCKERFILES), ) @@ -68,6 +69,7 @@ def test_official_runtime_images_are_required_maintained_surfaces(self) -> None: Path("release/docker/Dockerfile.evidence"), Path("release/docker/Dockerfile.breg"), Path("release/docker/Dockerfile.casework"), + Path("release/docker/Dockerfile.scheduling"), }, set(POLICY.HTTP_PROBE_DOCKERFILES), ) From b6a1f0d384dc66e646b0465a9d76a0140ec24876 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:15:14 +0700 Subject: [PATCH 046/115] refactor(casework): re-export only the calendar evaluator Casework uses Extracting the calendar primitives into a platform crate left Casework re-exporting that crate wholesale, so every symbol added there for another product joined Casework's public API without a decision. The weekly opening-pattern evaluator and its six types arrived that way, and the shared error enum grew from thirteen variants to thirty-one and stopped being Copy. Name the working-day symbols Casework's clocks actually evaluate. The weekly evaluator serves a different product and is not Casework's to publish. Signed-off-by: Jeremi Joslin --- crates/registry-casework-core/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/registry-casework-core/src/lib.rs b/crates/registry-casework-core/src/lib.rs index 2102974389..3d84908bce 100644 --- a/crates/registry-casework-core/src/lib.rs +++ b/crates/registry-casework-core/src/lib.rs @@ -27,7 +27,14 @@ pub use config::*; pub use http::*; pub use model::*; pub use policy::*; -pub use registry_platform_calendar::*; +// Casework's clocks are working-day deadlines, so it re-exports that +// evaluator and nothing else. The weekly opening-pattern evaluator in the +// same platform crate serves a different product and is not Casework's to +// publish. +pub use registry_platform_calendar::{ + evaluate_working_day_deadline, CalendarEvaluationError, HolidaySetRevision, WorkingCalendar, + WorkingDayDeadline, WorkingDayDeadlineRule, MAXIMUM_WORKING_DAY_OFFSET, +}; pub use registry_review_protocol as review_protocol; pub use registry_review_protocol::*; pub use review::*; From d969b8b82075381cf80ee58c8b2f92a7ca5133b4 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:23:14 +0700 Subject: [PATCH 047/115] fix(scheduling): admit only the RFC 9068 access-token type A deployment that names an access-token audience was also accepting a plain JWT typ header, so an ID token or any other JWT minted for that audience could book, reschedule or cancel an appointment. Derive the allowed typ list from the one access-token media type instead, which admits its two RFC 9068 spellings and nothing else. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/config.rs | 56 +++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index 3d79c5b350..a56c95d412 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -14,7 +14,8 @@ use registry_platform_config::{ SecretError, SecretProvider, SecretReference, SecretResolver, MAX_SECRET_BYTES, }; use registry_platform_oidc::{ - fetch_discovery, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, TokenVerifierConfig, + access_token_typ_set, fetch_discovery, JwksFetcher, JwksFetcherConfig, OidcDiscoveryConfig, + TokenVerifierConfig, }; use registry_scheduling_core::{ parse_policy_yaml, SchedulingPolicy, AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, @@ -222,6 +223,11 @@ pub struct OidcConfig { pub explain_scope: String, } +/// The RFC 9068 access-token media type this runtime verifies. The pair of +/// spellings it admits is derived, never authored, so no deployment can widen +/// it to an ordinary JWT. +const SCHEDULING_ACCESS_TOKEN_TYPE: &str = "at+jwt"; + fn default_scope_claim() -> String { "registry_scopes".to_owned() } @@ -605,15 +611,28 @@ impl RuntimeConfig { JwksFetcher::new_static(jwks, JwksFetcherConfig::defaults()) } }; - let verifier = TokenVerifierConfig::access_token_profile( + Ok((self.verifier_profile(), std::sync::Arc::new(fetcher))) + } + + /// The access-token verifier profile this deployment's OIDC settings + /// describe. + /// + /// RFC 9068 gives the access token one media type spelled two ways, + /// `at+jwt` and `application/at+jwt`, and requires a resource server to + /// accept that pair and refuse every other `typ`. The list comes from + /// [`access_token_typ_set`] so a plain `JWT` stays out: an ID token, a + /// UserInfo JWT or any other JWT minted for this audience is not an + /// access token, and admitting one would let a credential issued for + /// another purpose book, reschedule or cancel an appointment. + pub(crate) fn verifier_profile(&self) -> TokenVerifierConfig { + TokenVerifierConfig::access_token_profile( self.authentication.oidc.issuer.clone(), vec![self.authentication.oidc.audience.clone()], vec![Algorithm::RS256, Algorithm::ES256], - vec!["at+jwt".to_owned(), "JWT".to_owned()], + access_token_typ_set(SCHEDULING_ACCESS_TOKEN_TYPE), ) .with_scope_claim(self.authentication.oidc.scope_claim.clone()) - .with_allowed_clients(self.authentication.oidc.allowed_clients.clone()); - Ok((verifier, std::sync::Arc::new(fetcher))) + .with_allowed_clients(self.authentication.oidc.allowed_clients.clone()) } } @@ -795,6 +814,7 @@ impl RuntimeConfigError { #[cfg(test)] mod tests { use super::*; + use registry_platform_oidc::is_access_token_typ_pair; const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 kind: SchedulingPolicyPackage @@ -1012,6 +1032,32 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} } } + #[test] + fn the_access_token_profile_admits_only_the_rfc_9068_pair() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let operator = write_operator( + root.path(), + operator_value(&package, "development-loopback"), + ); + let config = RuntimeConfig::load(&operator).expect("configuration is accepted"); + let profile = config.verifier_profile(); + assert!( + is_access_token_typ_pair(&profile.allowed_typ), + "the access-token profile is not the RFC 9068 pair: {:?}", + profile.allowed_typ + ); + assert!( + !profile + .allowed_typ + .iter() + .any(|value| value.eq_ignore_ascii_case("JWT")), + "a plain JWT reaches the access-token profile: {:?}", + profile.allowed_typ + ); + } + #[test] fn listener_requires_a_private_address_or_explicit_container_network() { use std::net::{Ipv4Addr, Ipv6Addr}; From ed0ab0d53bcf6ff3ab714d8aed4e7c5fe0ea1cff Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:25:41 +0700 Subject: [PATCH 048/115] fix(scheduling): make a production deployment name the clients it admits An empty allowedClients list admits every client the issuer verifies, so a deployment that forgot the field accepted tokens minted for unrelated applications in the same realm. Refuse that configuration behind an operator-controlled terminator; development loopback keeps the empty list, because it is not a deployment another client can reach. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/config.rs | 56 +++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index a56c95d412..77436484b2 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -510,6 +510,16 @@ impl RuntimeConfig { { return Err(RuntimeConfigError::InvalidOidc); } + // An empty client list admits every client the issuer verifies, so a + // deployment that simply forgot the field would accept a token minted + // for an unrelated application in the same realm. Development loopback + // keeps that convenience; a deployment behind an operator-controlled + // terminator must name the clients it admits. + if self.listener.tls_termination == TlsTermination::OperatorControlledUpstream + && self.authentication.oidc.allowed_clients.is_empty() + { + return Err(RuntimeConfigError::InvalidOidc); + } if self.database.runtime_url_ref.is_empty() || self.database.migration_url_ref.is_empty() { return Err(RuntimeConfigError::InvalidDatabaseReference); } @@ -873,7 +883,8 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} }, "authentication": {"oidc": { "issuer": "https://identity.example.test", - "audience": "urn:example:scheduling" + "audience": "urn:example:scheduling", + "allowedClients": ["scheduling-booking-agent"] }}, "audit": {"path": root.join("audit.ndjson"), "hashKeyRef": "secret:file/audit"}, "destinations": {}, @@ -1032,6 +1043,49 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} } } + #[test] + fn a_production_deployment_must_name_the_clients_it_admits() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let manifest = PolicyPackageManifest::build(POLICY).unwrap(); + std::fs::write( + package.join(SCHEDULING_PACKAGE_MANIFEST_FILE), + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .unwrap(); + + let mut document = operator_value(&package, "operator-controlled-upstream"); + document["authentication"]["oidc"] + .as_object_mut() + .unwrap() + .remove("allowedClients"); + let operator = write_operator(root.path(), document); + assert!( + matches!( + RuntimeConfig::load(&operator), + Err(RuntimeConfigError::InvalidOidc) + ), + "a production deployment with no allowedClients was accepted" + ); + + let operator = write_operator( + root.path(), + operator_value(&package, "operator-controlled-upstream"), + ); + RuntimeConfig::load(&operator).expect("a named client list is accepted"); + + // Development loopback keeps the convenience: it is not a deployment + // an unrelated client can reach. + let mut document = operator_value(&package, "development-loopback"); + document["authentication"]["oidc"] + .as_object_mut() + .unwrap() + .remove("allowedClients"); + let operator = write_operator(root.path(), document); + RuntimeConfig::load(&operator).expect("development loopback stays permissive"); + } + #[test] fn the_access_token_profile_admits_only_the_rfc_9068_pair() { let root = tempfile::tempdir().unwrap(); From e07b13d0c08d7ef80a24d259e7fcc016b0f88f7f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:27:08 +0700 Subject: [PATCH 049/115] fix(scheduling): tombstone spent idempotency receipts instead of deleting them The retention sweep deleted expired attempt rows, so a key that had already answered re-executed as a fresh request once its period passed instead of being refused. The sweep now stamps the row erased and drops only the stored answer, the way Casework does, so the key stays spent: a retry after the period answers 410 idempotency.expired, and a different payload under the same key still answers 409 idempotency.key-reused because the erased row keeps the request it answered. Signed-off-by: Jeremi Joslin --- .../migrations/0001_scheduling.sql | 11 ++- crates/registry-scheduling/src/store.rs | 26 ++++-- .../tests/postgres_commitments.rs | 88 +++++++++++++++++++ 3 files changed, 117 insertions(+), 8 deletions(-) diff --git a/crates/registry-scheduling/migrations/0001_scheduling.sql b/crates/registry-scheduling/migrations/0001_scheduling.sql index d75027cc70..19c1a5e55f 100644 --- a/crates/registry-scheduling/migrations/0001_scheduling.sql +++ b/crates/registry-scheduling/migrations/0001_scheduling.sql @@ -142,13 +142,20 @@ CREATE TABLE IF NOT EXISTS scheduling_attempts ( request_hash text NOT NULL, state text NOT NULL CHECK (state IN ('completed','refused')), status_code integer NOT NULL, - receipt jsonb NOT NULL, + -- The retention sweep tombstones a receipt rather than deleting it: the + -- row keeps the key and the request it answered, and drops only the + -- answer. A key whose receipt is gone is spent, not free, so a retry + -- after the period is refused instead of executed a second time. + receipt jsonb, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, + erased_at timestamptz, + CHECK (erased_at IS NOT NULL OR receipt IS NOT NULL), UNIQUE (actor_issuer, actor_subject, scope, idempotency_key) ); CREATE INDEX IF NOT EXISTS scheduling_attempts_expiry_idx - ON scheduling_attempts(expires_at); + ON scheduling_attempts(expires_at) + WHERE erased_at IS NULL; CREATE TABLE IF NOT EXISTS scheduling_cursors ( cursor_id uuid PRIMARY KEY, diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 2ad25b96b9..9a913e03c4 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -1533,12 +1533,20 @@ impl PostgresStore { Ok(()) } - /// Erase idempotency receipts past their retention period. + /// Erase idempotency receipts past their retention period. The answer is + /// dropped, the row is kept and stamped erased: a key that answered once + /// stays spent, so a retry after the period is refused as expired rather + /// than executed again as a fresh request. + /// + /// This is the only retention the sweep enforces beside listing cursors. + /// Appointments, history, the delivery outbox and the audit journal are + /// not swept. pub async fn erase_expired_attempts(&self, now: DateTime) -> Result { let client = self.client().await?; Ok(client .execute( - "DELETE FROM scheduling_attempts WHERE expires_at <= $1", + "UPDATE scheduling_attempts SET erased_at=$1, receipt=NULL \ + WHERE expires_at <= $1 AND erased_at IS NULL", &[&now], ) .await?) @@ -1808,7 +1816,7 @@ async fn replay_stored_attempt( ) -> Result, StoreError> { let row = transaction .query_opt( - "SELECT request_hash, state, status_code, receipt, expires_at \ + "SELECT request_hash, state, status_code, receipt, expires_at, erased_at \ FROM scheduling_attempts \ WHERE actor_issuer=$1 AND actor_subject=$2 AND scope=$3 AND idempotency_key=$4", &[ @@ -1824,15 +1832,21 @@ async fn replay_stored_attempt( }; let stored_hash: String = row.get(0); let expires_at: DateTime = row.get(4); + let erased_at: Option> = row.get(5); + let receipt: Option = row.get(3); + // A different payload under a stored key is a reuse whether or not the + // answer is still held: the caller is told the key is not theirs to + // re-aim before being told the answer is gone. if stored_hash != commitment.request_hash { return Ok(Some(ReplayOutcome::Refused(CommitError::KeyReused))); } - if expires_at <= commitment.now { + let Some(receipt) = receipt.filter(|_| erased_at.is_none() && expires_at > commitment.now) + else { return Ok(Some(ReplayOutcome::Refused(CommitError::KeyExpired))); - } + }; Ok(Some(ReplayOutcome::Replay { status_code: u16::try_from(row.get::<_, i32>(2)).unwrap_or(500), - receipt: row.get(3), + receipt, })) } diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 43b243efc6..bc4fb1dc37 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -1193,3 +1193,91 @@ async fn adoption_binds_one_identity_and_refuses_a_second() { assert_eq!(status, StatusCode::OK); assert_eq!(document["schedulingId"], SCHEDULING_ID); } + +/// An idempotency key answers three ways over its life. The same request +/// replays the first answer verbatim. A different request under the same key +/// is refused as reused. A request retried after the receipt's retention +/// period is refused as expired, because the sweep tombstones the receipt +/// instead of deleting it: a deleted receipt would let the retry execute a +/// second time and book a second appointment. +#[tokio::test] +async fn an_idempotency_key_replays_refuses_a_reuse_and_expires_into_a_refusal() { + let fx = fixture().await; + let (first, second) = first_overlapping_pair(&fx, OFFERING, 90, 260).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, first)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + + // The same key with the same payload replays the stored answer. + let (status, replayed) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, first)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(replayed["appointmentId"], appointment_id); + + // The same key with a different payload is a reuse, not a replay. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, second)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "idempotency.key-reused"); + + // The retention sweep passes over the receipt's period. + let erased = fx + .store + .erase_expired_attempts(Utc::now() + TimeDelta::days(8)) + .await + .expect("the retention sweep runs"); + assert_eq!(erased, 1, "the sweep covers the one stored receipt"); + + // The key is spent, not forgotten: the retry is refused, and no second + // appointment was booked behind it. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, first)}), + ) + .await; + assert_eq!(status, StatusCode::GONE); + assert_eq!(problem["code"], "idempotency.expired"); + + // A different payload under the spent key is still a reuse: the erased + // receipt keeps the request it answered, so a conflict outranks expiry. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "idem-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, second)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "idempotency.key-reused"); + + // The original appointment stands untouched. + let (status, fetched) = fx + .get(&format!("/v1/appointments/{appointment_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched["state"], "confirmed"); + assert_eq!(moment(&fetched, "start"), first); +} From a857d6cbbcfb1069265db089a08f861065792da1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:30:42 +0700 Subject: [PATCH 050/115] fix(scheduling): refuse a raced idempotency key instead of failing the request The success path claimed the key with a plain INSERT while a unique constraint covers it, so two writers reaching one key at once turned the loser's unique violation into service.unavailable. The insert now takes the key with ON CONFLICT DO NOTHING and refuses as idempotency.key-reused when the key is already spoken for, the way the refusal path already did. The losing transaction rolls back whole, so the capacity it reached for stays free. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/store.rs | 52 ++++--- .../tests/postgres_commitments.rs | 137 ++++++++++++++---- 2 files changed, 141 insertions(+), 48 deletions(-) diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 9a913e03c4..352a241f32 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -2051,6 +2051,9 @@ trait CapacityStatements { payload: Value, ) -> Result<(), StoreError>; async fn suppress_pending_reminders(&self, claim_id: Uuid) -> Result<(), StoreError>; + /// Write the receipt this commitment answers a replay with. The key is + /// claimed here, at the end of the transaction, so a writer who finds it + /// already taken is refused rather than failed. async fn insert_attempt( &self, attempt_id: Uuid, @@ -2059,7 +2062,7 @@ trait CapacityStatements { state: AttemptState, status_code: u16, receipt: Value, - ) -> Result<(), StoreError>; + ) -> Result<(), CommitError>; async fn insert_audit(&self, event_id: Uuid, record: &Value) -> Result<(), StoreError>; async fn claim_in_transaction(&self, claim_id: Uuid) -> Result, StoreError>; async fn active_holds_by_caller( @@ -2245,25 +2248,34 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { state: AttemptState, status_code: u16, receipt: Value, - ) -> Result<(), StoreError> { - self.execute( - "INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ - idempotency_key, request_hash, state, status_code, receipt, expires_at) \ - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)", - &[ - &attempt_id, - &commitment.actor_issuer, - &commitment.actor_subject, - &scope, - &commitment.idempotency_key, - &commitment.request_hash, - &state.as_str(), - &i32::from(status_code), - &receipt, - &commitment.attempt_expires_at, - ], - ) - .await?; + ) -> Result<(), CommitError> { + let written = self + .execute( + "INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ + idempotency_key, request_hash, state, status_code, receipt, expires_at) \ + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT DO NOTHING", + &[ + &attempt_id, + &commitment.actor_issuer, + &commitment.actor_subject, + &scope, + &commitment.idempotency_key, + &commitment.request_hash, + &state.as_str(), + &i32::from(status_code), + &receipt, + &commitment.attempt_expires_at, + ], + ) + .await?; + if written == 0 { + // The replay read at the head of this transaction saw no stored + // attempt because the writer that owns the key had not committed + // yet. The key is not this caller's to answer under, and the + // whole transaction rolls back behind the refusal, so nothing was + // decided. + return Err(CommitError::KeyReused); + } Ok(()) } diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index bc4fb1dc37..3fe762ea39 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -133,6 +133,9 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} struct Fixture { http: Router, store: PostgresStore, + /// A session on the test's own schema, for the facts operator tooling + /// owns and for the rows a test needs to observe or hold directly. + admin: tokio_postgres::Client, revision: u64, /// A standing reader: the reads scope, no task grant. reader: String, @@ -140,6 +143,44 @@ struct Fixture { agent: String, } +/// Drive one request through the router. The free function takes the router +/// by value so a test can hold two requests in flight at once. +async fn send( + http: Router, + method: String, + uri: String, + token: String, + idempotency: Option, + body: Option, +) -> (StatusCode, Value) { + let mut builder = Request::builder() + .method(method.as_str()) + .uri(uri) + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json"); + if let Some(key) = idempotency { + builder = builder.header("idempotency-key", key); + } + let body = match body { + Some(value) => Body::from(value.to_string()), + None => Body::empty(), + }; + let response = http + .oneshot(builder.body(body).expect("a well-formed test request")) + .await + .expect("an in-process request always answers"); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1 << 20) + .await + .expect("a bounded test body"); + let value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).expect("a JSON test body") + }; + (status, value) +} + impl Fixture { async fn request( &self, @@ -149,34 +190,15 @@ impl Fixture { idempotency: Option<&str>, body: Option, ) -> (StatusCode, Value) { - let mut builder = Request::builder() - .method(method) - .uri(uri) - .header("authorization", format!("Bearer {token}")) - .header("content-type", "application/json"); - if let Some(key) = idempotency { - builder = builder.header("idempotency-key", key); - } - let body = match body { - Some(value) => Body::from(value.to_string()), - None => Body::empty(), - }; - let response = self - .http - .clone() - .oneshot(builder.body(body).expect("a well-formed test request")) - .await - .expect("an in-process request always answers"); - let status = response.status(); - let bytes = to_bytes(response.into_body(), 1 << 20) - .await - .expect("a bounded test body"); - let value = if bytes.is_empty() { - Value::Null - } else { - serde_json::from_slice(&bytes).expect("a JSON test body") - }; - (status, value) + send( + self.http.clone(), + method.to_owned(), + uri.to_owned(), + token.to_owned(), + idempotency.map(str::to_owned), + body, + ) + .await } async fn get(&self, uri: &str, token: &str) -> (StatusCode, Value) { @@ -299,6 +321,7 @@ async fn fixture_anchoring(pool_ids: &[String]) -> Fixture { Fixture { http, store, + admin, revision: u64::try_from(revision).expect("a bounded policy revision"), reader: reader_token(), agent: agent_token(), @@ -308,6 +331,7 @@ async fn fixture_anchoring(pool_ids: &[String]) -> Fixture { fn authenticator() -> SchedulingAuthenticator { let oidc = OidcConfig { allowed_clients: vec![CLIENT.to_owned()], + assertion_issuers: std::collections::BTreeMap::new(), issuer: ISSUER.to_owned(), audience: AUDIENCE.to_owned(), jwks_uri: None, @@ -1281,3 +1305,60 @@ async fn an_idempotency_key_replays_refuses_a_reuse_and_expires_into_a_refusal() assert_eq!(fetched["state"], "confirmed"); assert_eq!(moment(&fetched, "start"), first); } + +/// Two writers reaching the same idempotency key at once: one commits, and +/// the loser is told the key is already spoken for instead of being told the +/// service is unavailable. The loser's whole transaction rolls back, so the +/// slot it was reaching for stays free. +#[tokio::test] +async fn a_concurrent_writer_of_one_idempotency_key_is_refused_not_failed() { + let fx = fixture().await; + let (first, second) = first_overlapping_pair(&fx, OFFERING, 90, 260).await; + let (status, _) = fx + .post( + "/v1/appointments", + &fx.agent, + "race-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, first)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + // Another writer under the caller's own identity claims "race-2" and has + // not committed yet, so the request below reads no stored attempt and + // only meets the key at the moment it writes its own. + fx.admin + .batch_execute( + "BEGIN; \ + INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ + idempotency_key, request_hash, state, status_code, receipt, expires_at) \ + SELECT gen_random_uuid(), actor_issuer, actor_subject, scope, 'race-2', \ + 'a-different-request', state, status_code, receipt, expires_at \ + FROM scheduling_attempts WHERE idempotency_key = 'race-1'", + ) + .await + .expect("hold the idempotency key from another writer"); + + let contender = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + fx.agent.clone(), + Some("race-2".to_owned()), + Some(json!({"hold": null, "admission": admission(&fx, OFFERING, second)})), + )); + // The contender runs to the point where it writes its attempt and waits + // there on the key; releasing the other writer decides the race. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + fx.admin + .batch_execute("COMMIT") + .await + .expect("release the held idempotency key"); + let (status, problem) = contender.await.expect("the contending request answers"); + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "idempotency.key-reused"); + + // Losing the key decided nothing: the slot the contender reached for is + // still offered. + assert!(slot_offered(&fx, OFFERING, 90, 260, second).await); +} From 62af60b0764694de004e7360d35105e743a966b1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:32:47 +0700 Subject: [PATCH 051/115] feat(scheduling): bind exchanged credentials to declared assertion authorities The runtime had no way to say which assertion authority a client may exchange a subject token from, so the platform's assertion-issuer rule was a no-op and every authority the issuer federates was trusted. Declare the map in the runtime configuration under the same bounds the Casework runtime uses, carry it to the verifier, state it in the editor schema, and refuse an exchanged credential outright while a deployment has declared no authority at all. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/auth.rs | 72 ++++++++++- crates/registry-scheduling/src/config.rs | 112 +++++++++++++++++- crates/registry-scheduling/src/schema.rs | 40 ++++++- .../generated/runtime/runtime.schema.json | 20 ++++ 4 files changed, 239 insertions(+), 5 deletions(-) diff --git a/crates/registry-scheduling/src/auth.rs b/crates/registry-scheduling/src/auth.rs index 7d46ac0f04..9a3ca0d3a6 100644 --- a/crates/registry-scheduling/src/auth.rs +++ b/crates/registry-scheduling/src/auth.rs @@ -27,6 +27,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use registry_platform_authcommon::validate_compact_access_token; use registry_platform_oidc::{ actor_kind, grant_claims, ClaimNames, JwksFetcher, TokenVerifier, TokenVerifierConfig, + ASSERTION_ISSUER_CLAIM, }; use thiserror::Error; @@ -39,6 +40,8 @@ pub struct SchedulingAuthenticator { audience: String, reads_scope: String, explain_scope: String, + /// Whether the deployment declared any assertion authority at all. + binds_assertion_issuers: bool, } impl SchedulingAuthenticator { @@ -56,6 +59,7 @@ impl SchedulingAuthenticator { audience: oidc.audience.clone(), reads_scope: oidc.reads_scope.clone(), explain_scope: oidc.explain_scope.clone(), + binds_assertion_issuers: !oidc.assertion_issuers.is_empty(), } } @@ -100,6 +104,19 @@ impl SchedulingAuthenticator { tracing::debug!(error = %error, "the Scheduling bearer credential did not verify"); AuthenticationError::Refused })?; + // An exchanged token names the authority whose assertion produced it. + // The platform applies no rule while the deployment's map is empty, so + // an empty map would trust every authority the issuer federates. + // Refuse the exchange instead: a deployment that means to accept one + // says which authorities, and which client may present them. + if !self.binds_assertion_issuers + && verified.claims.extra.contains_key(ASSERTION_ISSUER_CLAIM) + { + tracing::debug!( + "an exchanged credential arrived at a deployment that declared no assertion authority" + ); + return Err(AuthenticationError::Profile); + } let kind = actor_kind(&verified.claims, &self.claim_names) .map_err(|_| AuthenticationError::Claims)?; let grant = grant_claims(&verified.claims, &self.claim_names, unix_now()) @@ -170,7 +187,7 @@ mod tests { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; use jsonwebtoken::{Algorithm, EncodingKey, Header}; - use registry_platform_oidc::{ActorKind, JwksFetcherConfig}; + use registry_platform_oidc::{ActorKind, JwksFetcherConfig, ASSERTION_ISSUER_CLAIM}; use serde_json::json; const ISSUER: &str = "https://task-token.test"; @@ -181,6 +198,7 @@ mod tests { fn oidc() -> OidcConfig { OidcConfig { allowed_clients: vec![CLIENT.to_owned()], + assertion_issuers: std::collections::BTreeMap::new(), issuer: ISSUER.to_owned(), audience: AUDIENCE.to_owned(), jwks_uri: None, @@ -206,6 +224,12 @@ mod tests { } fn authenticator() -> SchedulingAuthenticator { + authenticator_with(oidc()) + } + + /// Build the authenticator the way the runtime does, so the verifier and + /// the authenticator read the same deployment settings. + fn authenticator_with(oidc: OidcConfig) -> SchedulingAuthenticator { let verifier = TokenVerifierConfig::access_token_profile( ISSUER, vec![AUDIENCE.to_owned()], @@ -213,8 +237,9 @@ mod tests { vec!["at+jwt".to_owned()], ) .with_scope_claim("registry_scopes") - .with_allowed_clients(vec![CLIENT.to_owned()]); - SchedulingAuthenticator::new(&oidc(), verifier, keys()) + .with_allowed_clients(vec![CLIENT.to_owned()]) + .with_assertion_issuers(oidc.assertion_issuers.clone()); + SchedulingAuthenticator::new(&oidc, verifier, keys()) } /// Sign an access token, filling in the claims a real token always @@ -399,6 +424,47 @@ mod tests { )); } + #[tokio::test] + async fn an_exchanged_token_is_refused_until_its_authority_is_declared() { + const AUTHORITY: &str = "https://authority.example.test"; + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + ASSERTION_ISSUER_CLAIM: AUTHORITY, + })); + assert!( + matches!( + authenticator().authenticate_read(&credential).await, + Err(AuthenticationError::Profile) + ), + "an exchanged token was accepted by a deployment that declared no assertion authority" + ); + + let mut declared = oidc(); + declared.assertion_issuers = + std::collections::BTreeMap::from([(CLIENT.to_owned(), vec![AUTHORITY.to_owned()])]); + authenticator_with(declared.clone()) + .authenticate_read(&credential) + .await + .expect("a declared authority's exchanged token reads"); + + // The platform still refuses an authority the client may not exchange + // from once the map is populated. + let other = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + ASSERTION_ISSUER_CLAIM: "https://unrelated.example.test", + })); + assert!(matches!( + authenticator_with(declared).authenticate_read(&other).await, + Err(AuthenticationError::Refused) + )); + } + #[tokio::test] async fn a_credential_signed_by_an_unknown_key_is_refused() { let mut claims = json!({ diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index 77436484b2..d868dccce1 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -3,7 +3,7 @@ //! The operator runtime configuration document and the policy package //! identity it verifies. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -207,6 +207,16 @@ impl std::fmt::Debug for DatabaseConfig { pub struct OidcConfig { #[serde(default)] pub allowed_clients: Vec, + /// The assertion authorities each client may exchange a subject token + /// from, keyed by client identifier. + /// + /// A deployment that performs no token exchange leaves this empty. Once a + /// client is listed, a token it exchanged is accepted only for one of that + /// client's declared authorities, so an assertion minted by an unrelated + /// authority the issuer happens to federate cannot become a booking + /// credential here. + #[serde(default)] + pub assertion_issuers: BTreeMap>, pub issuer: String, pub audience: String, #[serde(default)] @@ -228,6 +238,14 @@ pub struct OidcConfig { /// it to an ordinary JWT. const SCHEDULING_ACCESS_TOKEN_TYPE: &str = "at+jwt"; +/// Bounds on the authored assertion-issuer map, matching the Casework +/// runtime's. They keep one operator document from becoming an unbounded +/// verifier input. +pub(crate) const MAXIMUM_ASSERTION_ISSUER_CLIENTS: usize = 64; +pub(crate) const MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES: usize = 128; +pub(crate) const MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT: usize = 16; +pub(crate) const MAXIMUM_ASSERTION_ISSUER_BYTES: usize = 512; + fn default_scope_claim() -> String { "registry_scopes".to_owned() } @@ -510,6 +528,7 @@ impl RuntimeConfig { { return Err(RuntimeConfigError::InvalidOidc); } + self.validate_assertion_issuers()?; // An empty client list admits every client the issuer verifies, so a // deployment that simply forgot the field would accept a token minted // for an unrelated application in the same realm. Development loopback @@ -549,6 +568,36 @@ impl RuntimeConfig { Ok(()) } + /// Refuse an assertion-issuer map with too many clients, an oversized + /// client key or issuer string, too many issuers listed for one client, or + /// a repeated issuer within one client's list. This runs at configuration + /// load, before any verifier is built, so an operator sees the refusal + /// without the runtime ever starting. + fn validate_assertion_issuers(&self) -> Result<(), RuntimeConfigError> { + let assertion_issuers = &self.authentication.oidc.assertion_issuers; + if assertion_issuers.len() > MAXIMUM_ASSERTION_ISSUER_CLIENTS { + return Err(RuntimeConfigError::InvalidOidc); + } + for (client, issuers) in assertion_issuers { + if client.is_empty() + || client.len() > MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES + || issuers.len() > MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT + { + return Err(RuntimeConfigError::InvalidOidc); + } + let mut seen = BTreeSet::new(); + for issuer in issuers { + if issuer.is_empty() + || issuer.len() > MAXIMUM_ASSERTION_ISSUER_BYTES + || !seen.insert(issuer) + { + return Err(RuntimeConfigError::InvalidOidc); + } + } + } + Ok(()) + } + fn validate_secret_references(&self) -> Result<(), RuntimeConfigError> { let mut references = vec![ ( @@ -643,6 +692,7 @@ impl RuntimeConfig { ) .with_scope_claim(self.authentication.oidc.scope_claim.clone()) .with_allowed_clients(self.authentication.oidc.allowed_clients.clone()) + .with_assertion_issuers(self.authentication.oidc.assertion_issuers.clone()) } } @@ -1086,6 +1136,66 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} RuntimeConfig::load(&operator).expect("development loopback stays permissive"); } + #[test] + fn a_declared_assertion_authority_binds_the_client_that_may_exchange_from_it() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let mut document = operator_value(&package, "development-loopback"); + document["authentication"]["oidc"]["assertionIssuers"] = serde_json::json!({ + "scheduling-booking-agent": ["https://authority.example.test"] + }); + let operator = write_operator(root.path(), document); + let config = RuntimeConfig::load(&operator).expect("an assertion authority is declarable"); + assert_eq!( + config.verifier_profile().assertion_issuers, + BTreeMap::from([( + "scheduling-booking-agent".to_owned(), + vec!["https://authority.example.test".to_owned()] + )]), + "the declared assertion authorities never reached the verifier" + ); + } + + #[test] + fn an_assertion_issuer_map_outside_its_bounds_is_refused() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let oversized_client = "c".repeat(MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES + 1); + let oversized_issuer = format!("https://{}", "a".repeat(MAXIMUM_ASSERTION_ISSUER_BYTES)); + let too_many_clients: serde_json::Map = (0 + ..=MAXIMUM_ASSERTION_ISSUER_CLIENTS) + .map(|index| { + ( + format!("client-{index}"), + serde_json::json!(["https://a.test"]), + ) + }) + .collect(); + let too_many_issuers: Vec = (0..=MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT) + .map(|index| format!("https://authority-{index}.test")) + .collect(); + for refused in [ + serde_json::json!({"": ["https://a.test"]}), + serde_json::json!({oversized_client: ["https://a.test"]}), + serde_json::json!({"client": [""]}), + serde_json::json!({"client": [oversized_issuer]}), + serde_json::json!({"client": ["https://a.test", "https://a.test"]}), + serde_json::Value::Object(too_many_clients), + serde_json::json!({"client": too_many_issuers}), + ] { + let mut document = operator_value(&package, "development-loopback"); + document["authentication"]["oidc"]["assertionIssuers"] = refused.clone(); + let operator = write_operator(root.path(), document); + let outcome = RuntimeConfig::load(&operator); + assert!( + matches!(outcome, Err(RuntimeConfigError::InvalidOidc)), + "an assertion-issuer map outside its bounds was accepted: {refused}" + ); + } + } + #[test] fn the_access_token_profile_admits_only_the_rfc_9068_pair() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/registry-scheduling/src/schema.rs b/crates/registry-scheduling/src/schema.rs index 4821a75693..0bf47cb422 100644 --- a/crates/registry-scheduling/src/schema.rs +++ b/crates/registry-scheduling/src/schema.rs @@ -23,7 +23,10 @@ use registry_scheduling_core::{ SCHEDULING_RUNTIME_SCHEMA_ID, }; -use crate::config::RuntimeConfig; +use crate::config::{ + RuntimeConfig, MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT, MAXIMUM_ASSERTION_ISSUER_BYTES, + MAXIMUM_ASSERTION_ISSUER_CLIENTS, MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES, +}; const SECRET_REFERENCE_SCHEMA_PATTERN: &str = "^(?:secret:env/[A-Z][A-Z0-9_]{0,127}|secret:file/[a-z][a-z0-9._-]{0,127})$"; @@ -88,6 +91,7 @@ fn install_runtime_constraints(schema: &mut Value) { ); } set_jwks_document_reference_constraints(schema); + set_assertion_issuer_bounds(schema); if let Some(providers) = schema .get_mut("$defs") .and_then(|definitions| definitions.get_mut("SecretProvidersConfig")) @@ -134,6 +138,40 @@ fn set_jwks_document_reference_constraints(schema: &mut Value) { } } +/// State the assertion-issuer map's authored bounds, the ones +/// `RuntimeConfig::check` refuses a document for exceeding. +fn set_assertion_issuer_bounds(schema: &mut Value) { + if let Some(property) = schema + .pointer_mut("/$defs/OidcConfig/properties/assertionIssuers") + .and_then(Value::as_object_mut) + { + property.insert( + "maxProperties".to_owned(), + Value::from(MAXIMUM_ASSERTION_ISSUER_CLIENTS), + ); + property.insert( + "propertyNames".to_owned(), + serde_json::json!({ + "minLength": 1, + "maxLength": MAXIMUM_ASSERTION_ISSUER_CLIENT_BYTES, + }), + ); + property.insert( + "additionalProperties".to_owned(), + serde_json::json!({ + "type": "array", + "maxItems": MAXIMUM_ASSERTION_ISSUERS_PER_CLIENT, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": MAXIMUM_ASSERTION_ISSUER_BYTES, + }, + }), + ); + } +} + /// A static JWKS document reference names its provider by its prefix, so a /// configuration carrying one must enable that provider. fn secret_provider_requirement(reference_pattern: &str, provider: &str) -> Value { diff --git a/products/scheduling/generated/runtime/runtime.schema.json b/products/scheduling/generated/runtime/runtime.schema.json index 1c194f7700..e3455bbb13 100644 --- a/products/scheduling/generated/runtime/runtime.schema.json +++ b/products/scheduling/generated/runtime/runtime.schema.json @@ -131,6 +131,26 @@ }, "type": "array" }, + "assertionIssuers": { + "additionalProperties": { + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 16, + "type": "array", + "uniqueItems": true + }, + "default": {}, + "description": "The assertion authorities each client may exchange a subject token\nfrom, keyed by client identifier.\n\nA deployment that performs no token exchange leaves this empty. Once a\nclient is listed, a token it exchanged is accepted only for one of that\nclient's declared authorities, so an assertion minted by an unrelated\nauthority the issuer happens to federate cannot become a booking\ncredential here.", + "maxProperties": 64, + "propertyNames": { + "maxLength": 128, + "minLength": 1 + }, + "type": "object" + }, "audience": { "type": "string" }, From 46d168d13069603428982c6cdda38df957f9ca6a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:35:27 +0700 Subject: [PATCH 052/115] fix(scheduling): admit commitments against the stored policy revision The policy-revision guard compared the request against a revision cached when the process started, so a policy published under a running runtime left policy.changed unable to fire and let a claim be written naming a revision that no longer stood. Every commitment that depends on the revision now reads it from scheduling_meta inside its own transaction, under a share lock, so a policy published concurrently waits behind the commitments in flight instead of moving under them. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/store.rs | 34 ++++++++-- .../tests/postgres_commitments.rs | 63 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 352a241f32..75079ec797 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -753,7 +753,7 @@ impl PostgresStore { request: ®istry_scheduling_core::AdmissionRequest, ttl_minutes: u32, max_per_caller: u32, - commitment: Commitment<'_>, + mut commitment: Commitment<'_>, ) -> Result { let mut client = self.client().await?; let transaction = client.transaction().await?; @@ -772,6 +772,7 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; + commitment.policy_revision = current_policy_revision(&transaction).await?; let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; if transaction .active_holds_by_caller(commitment.actor, commitment.now) @@ -844,7 +845,7 @@ impl PostgresStore { offering: ®istry_scheduling_core::OfferingPolicy, supply: &SupplyContext<'_>, request: ®istry_scheduling_core::AdmissionRequest, - commitment: Commitment<'_>, + mut commitment: Commitment<'_>, ) -> Result { let mut client = self.client().await?; let transaction = client.transaction().await?; @@ -863,6 +864,7 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; + commitment.policy_revision = current_policy_revision(&transaction).await?; let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; let admission = evaluate(offering, supply, request, &snapshot, &commitment, None)?; let appointment_id = Uuid::new_v4(); @@ -936,7 +938,7 @@ impl PostgresStore { hold_id: Uuid, offering: ®istry_scheduling_core::OfferingPolicy, supply: &SupplyContext<'_>, - commitment: Commitment<'_>, + mut commitment: Commitment<'_>, ) -> Result { let scope = format!("hold:{hold_id}:confirm"); let mut client = self.client().await?; @@ -956,6 +958,7 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; + commitment.policy_revision = current_policy_revision(&transaction).await?; // The snapshot is read under the lock for serialization order, but // admission is not re-evaluated: the hold's own reservation transfers // to the booking in this same transaction, so capacity does not @@ -1125,7 +1128,7 @@ impl PostgresStore { supply: &SupplyContext<'_>, request: ®istry_scheduling_core::AdmissionRequest, observed_revision: u64, - commitment: Commitment<'_>, + mut commitment: Commitment<'_>, ) -> Result { let scope = format!("appointment:{appointment_id}:reschedule"); let mut client = self.client().await?; @@ -1145,6 +1148,7 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; + commitment.policy_revision = current_policy_revision(&transaction).await?; let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; let appointment = transaction .claim_in_transaction(appointment_id) @@ -1806,6 +1810,28 @@ fn hold_ledger_claim(hold: &ClaimRow) -> LedgerClaim { } } +/// The policy revision the deployment is published under, read inside the +/// commitment's own transaction. A commitment is admitted against the stored +/// revision, never against one cached when the process started: operator +/// tooling and a rolling deploy both move it under a running process, and a +/// claim written under the cached number would name a policy that no longer +/// stands. +/// +/// The share lock holds it still for the life of the transaction, so a +/// policy published concurrently waits behind the commitments already in +/// flight instead of moving under them. +async fn current_policy_revision( + transaction: &deadpool_postgres::Transaction<'_>, +) -> Result { + let row = transaction + .query_one( + "SELECT policy_revision FROM scheduling_meta WHERE singleton FOR SHARE", + &[], + ) + .await?; + Ok(row.get(0)) +} + /// Replay a stored attempt: the same key with a different payload is /// refused as reused, an erased receipt as expired, and a retained one is /// answered exactly as it was. diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 3fe762ea39..ff4b4326a6 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -1362,3 +1362,66 @@ async fn a_concurrent_writer_of_one_idempotency_key_is_refused_not_failed() { // still offered. assert!(slot_offered(&fx, OFFERING, 90, 260, second).await); } + +/// The revision a commitment is admitted under is the one the deployment +/// stores, not the one this process read when it started. Operator tooling +/// and a rolling deploy both move the stored revision under a running +/// process, and a caller holding the older number is told the policy moved +/// rather than having a claim written under a revision that no longer +/// stands. +#[tokio::test] +async fn a_policy_applied_under_a_running_process_refuses_the_older_revision() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "moved-hold", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let hold_id = hold["holdId"].as_str().unwrap().to_owned(); + + // Operator tooling publishes a different policy against the same + // deployment, exactly as `schedulingctl` does beside a running runtime. + let moved = fx + .store + .apply_policy( + SCHEDULING_ID, + "a-policy-this-process-never-loaded", + &["north-counter".to_owned(), "two-counter".to_owned()], + &[], + ) + .await + .expect("publish a second policy revision"); + assert_eq!(moved, i64::try_from(fx.revision).unwrap() + 1); + + // The hold was admitted under the revision that has now moved, so + // confirming it is refused rather than booked under a stale policy. + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "moved-confirm", + json!({"hold": hold_id, "admission": null}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + assert_eq!(problem["code"], "policy.changed"); + + // So is a direct create carrying the revision availability advertised + // before the policy moved. + let other = first_slot(&fx, OFFERING, 300, 440).await; + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "moved-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + assert_eq!(problem["code"], "policy.changed"); +} From e24bb216162f9ac1be2074609319e42ebb7cb541 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:42:26 +0700 Subject: [PATCH 053/115] fix(scheduling): hold a failing reminder intent until its back-off passes The retry back-off was written into next_attempt_at and never read: the claim predicate filtered on the due time alone, so a failing intent was re-claimed on every tick and burned its attempts ceiling in seconds. The predicate now reads the back-off as well. The test policy grows an authored reminder offset, which is what mints an intent to fail at all. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/store.rs | 7 +- .../tests/postgres_commitments.rs | 76 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 75079ec797..5e451e6544 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -1390,7 +1390,11 @@ impl PostgresStore { Ok(rows.len() as u64) } - /// Claim due outbox intents for dispatch, marking their attempt. + /// Claim due outbox intents for dispatch, marking their attempt. An + /// intent is due when its own time has come and the back-off a failed + /// attempt wrote has passed: without the second half a failing intent + /// would be re-claimed on every tick and burn its attempts ceiling in + /// seconds. pub async fn claim_due_intents( &self, now: DateTime, @@ -1404,6 +1408,7 @@ impl PostgresStore { WHERE outbox_id IN (\ SELECT outbox_id FROM scheduling_outbox \ WHERE delivery_state='pending' AND due_at <= $1 \ + AND next_attempt_at <= $1 \ ORDER BY due_at LIMIT $2 FOR UPDATE SKIP LOCKED) \ RETURNING outbox_id, purpose, claim_id, appointment_revision, due_at, attempts, payload", &[&now, &limit, &now], diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index ff4b4326a6..594dba3fb4 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -88,6 +88,7 @@ offerings: pool: north-counter startIncrementMinutes: 30 maxRecipients: 1 + reminders: [{minutesBefore: 60, because: test}] requiresCapabilities: [] prerequisites: [] - id: registry-update-60 @@ -1425,3 +1426,78 @@ async fn a_policy_applied_under_a_running_process_refuses_the_older_revision() { assert_eq!(status, StatusCode::PRECONDITION_FAILED); assert_eq!(problem["code"], "policy.changed"); } + +/// A reminder intent is minted when the appointment commits, and a delivery +/// that failed waits out its back-off. Re-claiming a failing intent on every +/// tick would hammer the destination and burn the attempts ceiling in +/// seconds, so the claim predicate reads the back-off the retry wrote. +#[tokio::test] +async fn a_failing_reminder_intent_waits_out_its_back_off() { + let fx = fixture().await; + let (appointment_id, _) = booked(&fx, 90, 200, "reminder-1").await; + + // The commitment's own confirmation is due at once; the authored reminder + // offset is an hour before a start at least ninety minutes out, so the + // reminder is not due with it. + let now_due = fx + .store + .claim_due_intents(Utc::now(), 100) + .await + .expect("the delivery sweep runs"); + assert_eq!( + now_due + .iter() + .map(|intent| intent.purpose.as_str()) + .collect::>(), + vec!["confirmation"], + "a reminder ahead of its offset is not due yet" + ); + + let due = Utc::now() + TimeDelta::days(1); + let claimed: Vec<_> = fx + .store + .claim_due_intents(due, 100) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.purpose == "reminder") + .collect(); + assert_eq!( + claimed.len(), + 1, + "the commitment minted one reminder intent" + ); + assert_eq!(claimed[0].claim_id.to_string(), appointment_id); + assert_eq!(claimed[0].attempts, 1); + assert_eq!(claimed[0].payload["appointmentId"], appointment_id); + + // The send failed, so the intent goes back to pending behind a back-off. + fx.store + .retry_intent(claimed[0].outbox_id, due + TimeDelta::minutes(5), 8) + .await + .expect("the failed delivery is scheduled to retry"); + + let early: Vec<_> = fx + .store + .claim_due_intents(due + TimeDelta::seconds(2), 100) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.outbox_id == claimed[0].outbox_id) + .collect(); + assert!( + early.is_empty(), + "an intent inside its back-off is not claimed again" + ); + + let later: Vec<_> = fx + .store + .claim_due_intents(due + TimeDelta::minutes(6), 100) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.outbox_id == claimed[0].outbox_id) + .collect(); + assert_eq!(later.len(), 1, "the back-off passed, so the intent is due"); + assert_eq!(later[0].attempts, 2, "the second attempt is counted once"); +} From 27d9a8cd5b8b575a4f26a762ce609bc0b53db3e8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:43:33 +0700 Subject: [PATCH 054/115] fix(scheduling): answer a verifier outage with a retry, not a challenge A bearer is verified against the issuer's key material, so a key endpoint that cannot answer produced the same 401 as a forged token. That tells a caller holding a good credential that its credential is wrong, and sends it rotating tokens through an incident on this side. Classify the verifier's outcome instead: the fetch, read, parse and configuration failures are an outage and answer service.unavailable with Retry-After, and every judgement about the credential itself still answers 401. An unknown key id stays a refusal, so the key set cannot be probed for 503s, and an outcome the platform adds later is read as a refusal until it is classified here. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/auth.rs | 133 +++++++++++++++++- crates/registry-scheduling/src/http.rs | 42 ++++++ .../registry-scheduling.openapi.json | 25 ++++ .../scheduling/scripts/generate_openapi.py | 36 +++-- 4 files changed, 222 insertions(+), 14 deletions(-) diff --git a/crates/registry-scheduling/src/auth.rs b/crates/registry-scheduling/src/auth.rs index 9a3ca0d3a6..f00f74f8ce 100644 --- a/crates/registry-scheduling/src/auth.rs +++ b/crates/registry-scheduling/src/auth.rs @@ -26,8 +26,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use registry_platform_authcommon::validate_compact_access_token; use registry_platform_oidc::{ - actor_kind, grant_claims, ClaimNames, JwksFetcher, TokenVerifier, TokenVerifierConfig, - ASSERTION_ISSUER_CLAIM, + actor_kind, grant_claims, ClaimNames, JwksFetcher, OidcError, TokenVerifier, + TokenVerifierConfig, ASSERTION_ISSUER_CLAIM, }; use thiserror::Error; @@ -102,7 +102,7 @@ impl SchedulingAuthenticator { validate_compact_access_token(token).map_err(|_| AuthenticationError::Refused)?; let verified = self.verifier.verify(token).await.map_err(|error| { tracing::debug!(error = %error, "the Scheduling bearer credential did not verify"); - AuthenticationError::Refused + verifier_failure(&error) })?; // An exchanged token names the authority whose assertion produced it. // The platform applies no rule while the deployment's map is empty, so @@ -170,6 +170,39 @@ pub enum AuthenticationError { Claims, #[error("the caller's authentication profile is not authorized for this request")] Profile, + /// The verifier could not reach a verdict at all. + #[error("the credential could not be verified because this verifier cannot answer")] + Unavailable, +} + +/// Separate a verifier outage from a bad credential. +/// +/// A 401 tells a caller its credential is the problem, so it has to be +/// reserved for credentials. When the issuer's discovery document or key set +/// cannot be fetched, read, parsed or used, nothing has been learned about +/// the credential, and telling the caller to fix it would send it rotating a +/// perfectly good token through an incident that is ours. A deployment whose +/// own OIDC settings are contradictory is the same kind of failure. +fn verifier_failure(error: &OidcError) -> AuthenticationError { + match error { + OidcError::Transport(_) + | OidcError::BoundedRead(_) + | OidcError::FetchUrl(_) + | OidcError::HttpStatus(_) + | OidcError::InvalidUrl + | OidcError::Parse + | OidcError::InvalidJwk + | OidcError::EmptyKeySet + | OidcError::MissingIssuer + | OidcError::ConflictingEndpointConfiguration => AuthenticationError::Unavailable, + // Every other outcome is a judgement about the credential, including + // an unknown `kid`: a key the issuer never published is a forgery + // signal, not an outage, and answering 503 there would let a caller + // probe the key set. A variant added to the platform later is read as + // a credential refusal until it is classified here, so a new outcome + // can never widen the door. + _ => AuthenticationError::Refused, + } } /// The observation of now the grant's expiry is judged against, in the same @@ -187,8 +220,11 @@ mod tests { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::Utc; use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{ActorKind, JwksFetcherConfig, ASSERTION_ISSUER_CLAIM}; use serde_json::json; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; const ISSUER: &str = "https://task-token.test"; const AUDIENCE: &str = "urn:registry-scheduling:test"; @@ -230,6 +266,13 @@ mod tests { /// Build the authenticator the way the runtime does, so the verifier and /// the authenticator read the same deployment settings. fn authenticator_with(oidc: OidcConfig) -> SchedulingAuthenticator { + authenticator_over(oidc, keys()) + } + + /// The same deployment reading a different key source, so a test can put + /// the issuer's key endpoint out of service without changing anything + /// else about the caller or the profile. + fn authenticator_over(oidc: OidcConfig, keys: Arc) -> SchedulingAuthenticator { let verifier = TokenVerifierConfig::access_token_profile( ISSUER, vec![AUDIENCE.to_owned()], @@ -239,7 +282,7 @@ mod tests { .with_scope_claim("registry_scopes") .with_allowed_clients(vec![CLIENT.to_owned()]) .with_assertion_issuers(oidc.assertion_issuers.clone()); - SchedulingAuthenticator::new(&oidc, verifier, keys()) + SchedulingAuthenticator::new(&oidc, verifier, keys) } /// Sign an access token, filling in the claims a real token always @@ -465,6 +508,88 @@ mod tests { )); } + #[tokio::test] + async fn a_key_endpoint_outage_is_unavailable_not_a_refusal() { + // The issuer is up enough to answer, and answers that it cannot serve + // its key set. Nothing has been learned about the credential, so + // blaming it would send a caller holding a perfectly good token off + // to rotate credentials during an incident on this side. + let issuer_keys = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/jwks.json")) + .respond_with(ResponseTemplate::new(503)) + .mount(&issuer_keys) + .await; + let credential = token(json!({ + "sub": "principal-1", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + })); + let during_outage = authenticator_over( + oidc(), + Arc::new(JwksFetcher::new_with_fetch_url_policy( + format!("{}/jwks.json", issuer_keys.uri()), + JwksFetcherConfig::defaults(), + FetchUrlPolicy::dev(), + )), + ); + assert!( + matches!( + during_outage.authenticate_read(&credential).await, + Err(AuthenticationError::Unavailable) + ), + "a key endpoint that cannot answer was reported as a bad credential" + ); + } + + #[test] + fn only_a_verifier_that_reached_no_verdict_is_unavailable() { + for outage in [ + OidcError::HttpStatus(503), + OidcError::InvalidUrl, + OidcError::Parse, + OidcError::InvalidJwk, + OidcError::EmptyKeySet, + OidcError::MissingIssuer, + OidcError::ConflictingEndpointConfiguration, + ] { + assert_eq!( + verifier_failure(&outage), + AuthenticationError::Unavailable, + "{outage} was blamed on the credential" + ); + } + for refusal in [ + OidcError::IssuerMismatch { + expected: ISSUER.to_owned(), + actual: "https://elsewhere.test".to_owned(), + }, + OidcError::MalformedToken, + OidcError::AlgorithmNotAllowed, + OidcError::TokenTypeNotAllowed, + OidcError::MissingKid, + OidcError::KidTooLong, + // An unknown `kid` is a key the issuer never published, which is a + // forgery signal and not an outage. Answering 503 here would also + // hand a caller a probe for the key set. + OidcError::UnknownKid, + OidcError::TokenExpired, + OidcError::TokenNotYetValid, + OidcError::AudienceMismatch, + OidcError::SignatureInvalid, + OidcError::InvalidToken, + OidcError::ClientNotAllowed, + OidcError::AssertionIssuerNotAllowed, + ] { + assert_eq!( + verifier_failure(&refusal), + AuthenticationError::Refused, + "{refusal} was excused as an outage" + ); + } + } + #[tokio::test] async fn a_credential_signed_by_an_unknown_key_is_refused() { let mut claims = json!({ diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs index d22bc5a635..2da3401b20 100644 --- a/crates/registry-scheduling/src/http.rs +++ b/crates/registry-scheduling/src/http.rs @@ -493,6 +493,11 @@ fn authentication_problem(error: AuthenticationError) -> HttpError { HttpError(ProblemCode::AuthenticationRefused) } AuthenticationError::Profile => HttpError(ProblemCode::ProfileNotAuthorized), + // The verifier reached no verdict, so the credential is not what is + // wrong. Answering 401 here would challenge a caller holding a good + // token and invite it to rotate one during an outage that is ours; + // `service.unavailable` carries `Retry-After` instead. + AuthenticationError::Unavailable => HttpError(ProblemCode::ServiceUnavailable), } } @@ -819,6 +824,43 @@ mod tests { assert_eq!(response.headers().get(RETRY_AFTER).unwrap(), "5"); } + #[tokio::test] + async fn a_verifier_that_cannot_answer_is_unavailable_not_a_challenge() { + // A 401 tells a caller its credential is wrong. When the issuer's key + // material cannot be reached, nothing has been learned about the + // credential, so the caller is told to come back instead of being sent + // to rotate a working token. + let problem = authentication_problem(AuthenticationError::Unavailable); + assert!(matches!( + problem, + HttpError(ProblemCode::ServiceUnavailable) + )); + let response = REQUEST_TRACE + .scope(TraceContext::server_created(), async { + problem_response(problem.0) + }) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers().get(RETRY_AFTER).unwrap(), "5"); + assert!(response.headers().get(WWW_AUTHENTICATE).is_none()); + let body = body_json(response).await; + assert_eq!(body["code"], "service.unavailable"); + } + + #[test] + fn a_refused_credential_still_answers_a_challenge() { + for refusal in [AuthenticationError::Refused, AuthenticationError::Claims] { + assert!(matches!( + authentication_problem(refusal), + HttpError(ProblemCode::AuthenticationRefused) + )); + } + assert!(matches!( + authentication_problem(AuthenticationError::Profile), + HttpError(ProblemCode::ProfileNotAuthorized) + )); + } + fn stored_refusal(problem: ProblemCode) -> Value { // The receipt shape the service records under a caller's refused // idempotency key: the pinned problem, deliberately without a trace. diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json index f702403c42..ebb2eba35a 100644 --- a/products/scheduling/generated/registry-scheduling.openapi.json +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -5153,6 +5153,31 @@ "$ref": "#/components/headers/TraceparentHeader" } } + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemServiceUnavailable" + } + } + }, + "description": "Problem response: service.unavailable", + "headers": { + "Retry-After": { + "description": "Seconds before retrying the unavailable dependency; the runtime answers 5.", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } } }, "security": [ diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py index 7b8172982f..2ce2b6b1a1 100644 --- a/products/scheduling/scripts/generate_openapi.py +++ b/products/scheduling/scripts/generate_openapi.py @@ -173,9 +173,11 @@ # Every operation: the edge answers 405 for a foreign method and 413 for a body # over the router's limit. Every authenticated operation: a missing, invalid, # or expired bearer is authentication.refused, and a credential that does not -# carry the route's authority is profile.not-authorized. Every operation whose -# service method opens a store round trip can answer service.unavailable; the -# one that cannot (`SchedulingService::scheduling`) documents no problems. +# carry the route's authority is profile.not-authorized. Every authenticated +# operation can also answer service.unavailable, whether or not its service +# method opens a store round trip: verifying the bearer needs the issuer's key +# material, and an issuer that cannot be reached is an outage on this side, +# answered with Retry-After rather than a challenge the caller cannot satisfy. EDGE = ["request.body-too-large", "request.method-not-allowed"] AUTHENTICATION = ["authentication.refused", "profile.not-authorized"] JSON_BODY = [ @@ -206,7 +208,7 @@ OPERATION_PROBLEMS = { ("GET", "/healthz"): EDGE, ("GET", "/readyz"): EDGE + STORAGE, - ("GET", "/v1/scheduling"): EDGE + AUTHENTICATION, + ("GET", "/v1/scheduling"): EDGE + AUTHENTICATION + STORAGE, ("GET", "/v1/services"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, ("GET", "/v1/offerings"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, ("GET", "/v1/resources"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, @@ -1027,13 +1029,20 @@ def verify_handler_wiring( status for status in operation["responses"] if int(status) >= 400 ] unavailable = "503" in documented_problems - # An infallible service method can answer no store refusal, so its - # operation documents no service.unavailable; a fallible one must, - # because every one of them opens a store round trip. - if derived["service_infallible"] is True and unavailable: - raise ValueError(f"an infallible service method documents 503 for {key}") + unauthenticated = derived["authority"] == "unauthenticated" + # A fallible service method opens a store round trip, so its operation + # must document service.unavailable. An authenticated operation must + # document it too even when its service method cannot fail, because + # verifying the bearer reaches the issuer's key material and an issuer + # outage is answered with Retry-After, never with a challenge the + # caller cannot satisfy. Only an unauthenticated operation over an + # infallible service method answers no 503 at all. + if derived["service_infallible"] is True and unavailable and unauthenticated: + raise ValueError(f"an unauthenticated infallible operation documents 503 for {key}") if derived["service_infallible"] is False and not unavailable: raise ValueError(f"a fallible service method documents no 503 for {key}") + if not unauthenticated and not unavailable: + raise ValueError(f"an authenticated operation documents no 503 for {key}") if not documented_problems and derived["authority"] != "unauthenticated": raise ValueError(f"an authenticated operation answers no refusal for {key}") for code in derived["problem_codes"]: @@ -1110,7 +1119,14 @@ def verify_operation_problems( ) required = {"request.body-too-large", "request.method-not-allowed"} if operation["x-scheduling-authority"] != "unauthenticated": - required |= {"authentication.refused", "profile.not-authorized"} + # Authentication itself reaches the issuer's key material, so an + # authenticated operation answers service.unavailable even when it + # never opens the store. + required |= { + "authentication.refused", + "profile.not-authorized", + "service.unavailable", + } if operation.get("requestBody"): required |= {"request.invalid", "request.unprocessable", "request.unsupported-media-type"} if any(parameter["in"] == "query" for parameter in operation["parameters"]): From 192016b6f654085ebb6d9f6a0ca5d51b42aaeeab Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:46:23 +0700 Subject: [PATCH 055/115] fix(scheduling): refuse to judge an expiry the clock cannot date A clock reading that failed fell back to epoch zero, which is before every `exp` a credential carries. One unreadable clock therefore passed every expiry check at once: an expired task grant kept committing capacity for as long as the fallback held, and nothing in the deployment said so. Take the reading as a result instead. A reading that cannot be taken is an outage on this side, so it answers service.unavailable with Retry-After and records an error, and no credential is ever judged against a substituted time. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/auth.rs | 63 ++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/registry-scheduling/src/auth.rs b/crates/registry-scheduling/src/auth.rs index f00f74f8ce..3ce25ee291 100644 --- a/crates/registry-scheduling/src/auth.rs +++ b/crates/registry-scheduling/src/auth.rs @@ -119,7 +119,7 @@ impl SchedulingAuthenticator { } let kind = actor_kind(&verified.claims, &self.claim_names) .map_err(|_| AuthenticationError::Claims)?; - let grant = grant_claims(&verified.claims, &self.claim_names, unix_now()) + let grant = grant_claims(&verified.claims, &self.claim_names, unix_now()?) .map_err(|_| AuthenticationError::Claims)?; if let Some(grant) = &grant { grant @@ -207,11 +207,28 @@ fn verifier_failure(error: &OidcError) -> AuthenticationError { /// The observation of now the grant's expiry is judged against, in the same /// units the token's own `exp` carries. -fn unix_now() -> u64 { - SystemTime::now() +fn unix_now() -> Result { + unix_seconds(SystemTime::now()) +} + +/// Read one clock observation as seconds since the epoch. +/// +/// A reading that cannot be taken is refused rather than substituted. Epoch +/// zero is before every `exp` a credential carries, so falling back to it +/// would pass every expiry check at once: an expired grant would keep +/// committing capacity for as long as the clock stayed unreadable, and the +/// deployment would be told nothing. Nothing has been learned about the +/// credential either, so the answer is the outage answer, not a challenge. +fn unix_seconds(observed: SystemTime) -> Result { + observed .duration_since(UNIX_EPOCH) .map(|elapsed| elapsed.as_secs()) - .unwrap_or_default() + .map_err(|_| { + tracing::error!( + "the system clock reads before the Unix epoch; refusing to judge an expiry" + ); + AuthenticationError::Unavailable + }) } #[cfg(test)] @@ -223,6 +240,7 @@ mod tests { use registry_platform_httputil::FetchUrlPolicy; use registry_platform_oidc::{ActorKind, JwksFetcherConfig, ASSERTION_ISSUER_CLAIM}; use serde_json::json; + use std::time::Duration; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -508,6 +526,43 @@ mod tests { )); } + #[test] + fn an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it() { + // What the fallback reading would have meant. Epoch zero is before + // every `exp` a credential carries, so a grant that expired an hour + // ago still reads as live, and would keep committing capacity for as + // long as the clock stayed unreadable. + let names = ClaimNames::default(); + let now = Utc::now().timestamp(); + let mut expired = grant_claims_value(AUDIENCE); + expired + .as_object_mut() + .unwrap() + .insert("registry_grant_exp".to_owned(), json!(now - 3_600)); + let mut value = merged("scheduling-read", expired); + let object = value.as_object_mut().unwrap(); + object.insert("iss".to_owned(), json!(ISSUER)); + object.insert("aud".to_owned(), json!(AUDIENCE)); + object.insert("exp".to_owned(), json!(now + 300)); + let claims: registry_platform_oidc::Claims = serde_json::from_value(value).unwrap(); + assert!( + grant_claims(&claims, &names, 0).is_ok(), + "epoch zero would have passed a grant that expired an hour ago" + ); + assert!(grant_claims(&claims, &names, now as u64).is_err()); + + // So a reading that cannot be taken is refused outright. The caller is + // told to come back; it is never told its credential is good. + assert_eq!( + unix_seconds(UNIX_EPOCH + Duration::from_secs(1_700_000_000)), + Ok(1_700_000_000) + ); + assert_eq!( + unix_seconds(UNIX_EPOCH - Duration::from_secs(1)), + Err(AuthenticationError::Unavailable) + ); + } + #[tokio::test] async fn a_key_endpoint_outage_is_unavailable_not_a_refusal() { // The issuer is up enough to answer, and answers that it cannot serve From cf1280d6299ee0c34bea6f139f804d6bf9538291 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:47:15 +0700 Subject: [PATCH 056/115] fix(scheduling): refuse a records swap that strands a live claim records apply replaced the environment records wholesale with no lock and no referential check, so it could delete a pool member while a capacity transaction was evaluating against it, and could retire a station out from under an appointment that names it. The swap now takes every supply anchor first, which is what an in-flight commitment holds, and then refuses whole when a live appointment or hold occupies a resource the incoming records no longer carry. Retiring an idle resource is unaffected. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/store.rs | 55 +++++++ .../tests/postgres_commitments.rs | 147 +++++++++++++++++- 2 files changed, 201 insertions(+), 1 deletion(-) diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 5e451e6544..2dc6533237 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -63,6 +63,11 @@ pub enum StoreError { SecretConfiguration(String), #[error("the Scheduling database is not in the state this runtime expects")] Corrupt, + /// The environment records would retire resources that live appointments + /// or holds still occupy. The swap is refused whole, so the operator + /// either keeps the resource or closes what stands on it first. + #[error("the environment records retire {0}, which live appointments or holds still occupy")] + FactsInUse(String), #[error("the Scheduling query failed")] Query(#[from] tokio_postgres::Error), #[error("the pooled Scheduling connection could not be built or leased")] @@ -1942,6 +1947,23 @@ pub(crate) async fn replace_facts_in_transaction( audit_event: Uuid, audit_record: Value, ) -> Result<(), StoreError> { + // Take every supply anchor before reading a claim. A capacity + // transaction holds its own anchor from its snapshot until it commits, + // so holding all of them means no commitment is mid-evaluation against + // members this swap is about to delete, and a claim written a moment + // later is seen by the guard below rather than stranded behind it. A + // capacity transaction takes exactly one anchor and this one takes them + // in the anchor's own order, so the two cannot cycle. + transaction + .execute( + "SELECT supply_id FROM scheduling_supply ORDER BY supply_id FOR UPDATE", + &[], + ) + .await?; + let occupied = occupied_resources_retired_by(transaction, facts).await?; + if !occupied.is_empty() { + return Err(StoreError::FactsInUse(occupied.join(", "))); + } transaction .execute("DELETE FROM scheduling_pool_members", &[]) .await?; @@ -2016,6 +2038,39 @@ pub(crate) async fn replace_facts_in_transaction( Ok(()) } +/// The resources live claims occupy that the incoming records do not carry. +/// +/// An exact-time claim names its member in `supply_id`, so a swap that drops +/// a member out from under a live booking would leave that booking pointing +/// at a resource the deployment no longer has: invisible to every pool +/// snapshot, counted against nothing, and still promised to its caller. A +/// window claim names the window instead, which records never carry, so the +/// window anchors are excluded rather than reported as missing members. +async fn occupied_resources_retired_by( + transaction: &deadpool_postgres::Transaction<'_>, + facts: &SchedulingFacts, +) -> Result, StoreError> { + let incoming: Vec = facts + .pools + .iter() + .flat_map(|pool| pool.members.iter()) + .map(|member| member.resource_id.clone()) + .collect(); + let rows = transaction + .query( + "SELECT DISTINCT supply_id FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > now())) \ + AND supply_id <> ALL($1::text[]) \ + AND supply_id NOT IN \ + (SELECT supply_id FROM scheduling_supply WHERE kind='window') \ + ORDER BY supply_id", + &[&incoming], + ) + .await?; + Ok(rows.iter().map(|row| row.get(0)).collect()) +} + /// A claim about to be written. struct NewClaim<'c> { claim_id: Uuid, diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 594dba3fb4..58107f8ed6 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -26,7 +26,9 @@ use registry_scheduling::config::{DatabaseConfig, OidcConfig, OidcJwksSource}; use registry_scheduling::http::{router, HttpState}; use registry_scheduling::service::SchedulingService; use registry_scheduling::store::PostgresStore; -use registry_scheduling_core::parse_policy_yaml; +use registry_scheduling_core::{ + parse_policy_yaml, LocationRecord, PoolMember, ResourcePool, SchedulingFacts, +}; use serde_json::{json, Value}; use std::sync::Arc; use tower::ServiceExt; @@ -1501,3 +1503,146 @@ async fn a_failing_reminder_intent_waits_out_its_back_off() { assert_eq!(later.len(), 1, "the back-off passed, so the intent is due"); assert_eq!(later[0].attempts, 2, "the second attempt is counted once"); } + +/// The environment records the fixture seeds, minus the named resources: the +/// document `schedulingctl records apply` would send to retire a station. +fn records_without(retired: &[&str]) -> SchedulingFacts { + let member = |resource_id: &str| PoolMember { + resource_id: resource_id.to_owned(), + capabilities: Vec::new(), + available: true, + }; + SchedulingFacts { + locations: vec![LocationRecord { + id: "north-counter".to_owned(), + timezone: "UTC".to_owned(), + }], + pools: vec![ + ResourcePool { + id: "north-counter".to_owned(), + members: vec![member("station-1")], + }, + ResourcePool { + id: "two-counter".to_owned(), + members: vec![member("station-2")], + }, + ] + .into_iter() + .map(|pool| ResourcePool { + members: pool + .members + .into_iter() + .filter(|member| !retired.contains(&member.resource_id.as_str())) + .collect(), + ..pool + }) + .collect(), + exceptions: Vec::new(), + } +} + +fn operator_audit() -> Value { + json!({ + "actorKind": "operator", + "operation": "records.apply", + "outcome": "allowed", + "reason": "authorization.allowed", + }) +} + +#[tokio::test] +async fn a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, _) = fx + .post( + "/v1/appointments", + &fx.agent, + "records-1", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "the direct create commits"); + + let refusal = fx + .store + .replace_facts( + &records_without(&["station-1"]), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect_err("a swap that strands a live booking is refused"); + assert!( + refusal.to_string().contains("station-1"), + "{refusal} does not name the occupied resource" + ); + + // The refusal rolled the whole swap back, so the records stand as they + // were and the booking still occupies its slot. + let standing = fx.store.facts().await.expect("the records survive"); + assert_eq!( + standing + .pool("north-counter") + .map(|pool| pool.members.len()), + Some(1), + "the occupied member is still a member" + ); + assert!( + !slot_offered(&fx, OFFERING, 90, 200, slot).await, + "the booking still consumes its slot" + ); + + // Retiring an idle station is exactly what the command is for. + fx.store + .replace_facts( + &records_without(&["station-2"]), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("retiring an unoccupied resource commits"); + let standing = fx.store.facts().await.expect("the records are readable"); + assert_eq!( + standing.pool("two-counter").map(|pool| pool.members.len()), + Some(0), + "the idle member is gone" + ); +} + +#[tokio::test] +async fn a_records_swap_waits_for_the_capacity_transaction_holding_the_pool() { + let fx = fixture().await; + let mut admin = fx.admin; + + // Stand in for a commitment mid-evaluation: it holds the pool's supply + // anchor from its snapshot until it commits. + let holder = admin + .transaction() + .await + .expect("the stand-in capacity transaction opens"); + holder + .execute( + "SELECT supply_id FROM scheduling_supply WHERE supply_id='north-counter' FOR UPDATE", + &[], + ) + .await + .expect("the stand-in holds the pool anchor"); + + let store = fx.store.clone(); + let swap = tokio::spawn(async move { + store + .replace_facts(&records_without(&[]), Uuid::new_v4(), operator_audit()) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!( + !swap.is_finished(), + "the swap runs while a commitment holds the pool it would rewrite" + ); + + holder.commit().await.expect("the stand-in commits"); + swap.await + .expect("the swap task runs to completion") + .expect("the swap commits once the pool is free"); +} From 76b024fffcaf86ba3a776b5d25c305a88ac2397c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:48:04 +0700 Subject: [PATCH 057/115] fix(schedulingctl): report calendar-only fixture failures per fixture A daylight-saving gap or fold is invisible to `check`: an opening's hours carry no timezone, only a fixture's location record does. Before this change, a fixture that hit one during `test` aborted the whole command as a generic domain refusal, even though `check` had just called the same policy complete. `run_fixture` now reports a calendar-only replay refusal as a normal failed-fixture entry, the same way any other fixture failure is reported, instead of bailing the command. Also fix `package`'s policyDigest/packageDigest swap: the on-disk manifest calls its own byte-exact digest `policyDigest` (PolicyPackageManifest), so the report must mirror that key, not the policy's separate semantic digest. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/lib.rs | 5 +- crates/registry-schedulingctl/src/project.rs | 89 ++++++++++++++++++-- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index 74c6cc7db6..69d21bbcb4 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -787,7 +787,10 @@ mod tests { registry_scheduling::config::verify_policy_package(&policy_path, &policy_text) .unwrap() .expect("the written manifest verifies"); - assert_eq!(verified, report["packageDigest"].as_str().unwrap()); + // `verify_policy_package` returns the manifest's own byte-exact + // digest, which the on-disk manifest calls `policyDigest`; the + // report must mirror that naming, not the policy's semantic digest. + assert_eq!(verified, report["policyDigest"].as_str().unwrap()); // Pretty-printed, newline-terminated: a text document an operator // diffs. let bytes = std::fs::read(&manifest_path).unwrap(); diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index 8184910b36..316f85c564 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -13,8 +13,8 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; use registry_scheduling::config::{verify_policy_package, PolicyPackageManifest}; use registry_scheduling_core::{ - parse_fixture_yaml, parse_policy_yaml, CaseStatus, SchedulingDiagnostic, SchedulingFixture, - SchedulingPolicy, AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, + parse_fixture_yaml, parse_policy_yaml, CaseStatus, ReplayError, SchedulingDiagnostic, + SchedulingFixture, SchedulingPolicy, AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, }; use serde_json::{json, Value}; @@ -241,8 +241,13 @@ pub(super) fn package(project: &Path) -> Result { "command": "package", "project": project, "manifest": manifest_path, - "policyDigest": policy.policy_digest(), - "packageDigest": manifest.policy_digest, + // The manifest on disk names its own byte-exact digest `policyDigest` + // (see `PolicyPackageManifest`); mirror that naming here instead of + // reporting the policy's separate semantic digest under the same + // key. `packageDigest` is that semantic digest, the one `explain` + // also reports. + "policyDigest": manifest.policy_digest, + "packageDigest": policy.policy_digest(), "files": manifest.files, "runtimeConfigurationIncluded": false, "secretsIncluded": false, @@ -254,9 +259,23 @@ pub(super) fn package(project: &Path) -> Result { fn run_fixture(project: &Path, policy: &SchedulingPolicy, path: &Path) -> Result { let relative: PathBuf = path.strip_prefix(project).unwrap_or(path).to_owned(); let fixture = load_fixture(path)?; - let outcomes = fixture - .replay(policy) - .with_context(|| format!("replaying fixture {}", relative.display()))?; + // A calendar gap or fold is invisible to `check`: an opening carries no + // timezone, only a fixture's location record does, so authoring can + // never see this coming. Report it the way any other fixture failure is + // reported instead of refusing the whole command over a fixture whose + // policy `check` already called complete. + let outcomes = match fixture.replay(policy) { + Err(ReplayError::Calendar(error)) => { + return Ok(json!({ + "name": fixture.name, + "status": "failed", + "file": relative, + "cases": [], + "calendarRefusal": error.to_string(), + })); + } + other => other.with_context(|| format!("replaying fixture {}", relative.display()))?, + }; let cases = outcomes .iter() .map(|outcome| { @@ -536,6 +555,62 @@ mod tests { .all(|fixture| fixture["status"] == "passed")); } + #[test] + fn a_daylight_saving_gap_check_cannot_see_is_a_fixture_failure_not_a_command_refusal() { + // Openings carry no timezone; only a fixture's location record does. + // `check` never resolves a calendar, so it cannot see that an + // opening's local wall-clock hours fall inside a real spring-forward + // gap. Move `standalone-arrival-window`'s Saturday opening onto the + // Sunday of 2026-03-08, 02:00-03:00 America/New_York: a nonexistent + // local time, per registry-platform-calendar's own pinned case. + let (_root, project) = initialized("standalone-arrival-window"); + let policy_path = project.join(AUTHORED_POLICY_FILE); + let policy_text = std::fs::read_to_string(&policy_path) + .unwrap() + .replacen("weekdays: [sat]", "weekdays: [sun]", 1) + .replacen("startTime: \"08:00\"", "startTime: \"02:00\"", 1) + .replacen("endTime: \"12:00\"", "endTime: \"03:00\"", 1) + .replacen( + "effectiveFrom: \"2026-10-01\"", + "effectiveFrom: \"2026-03-08\"", + 1, + ) + .replacen( + "effectiveUntil: \"2026-12-31\"", + "effectiveUntil: \"2026-03-08\"", + 1, + ); + std::fs::write(&policy_path, policy_text).unwrap(); + let fixture_path = project.join("fixtures/household-morning.yaml"); + let fixture_text = std::fs::read_to_string(&fixture_path).unwrap().replacen( + "timezone: Asia/Bangkok", + "timezone: America/New_York", + 1, + ); + std::fs::write(&fixture_path, fixture_text).unwrap(); + + // check() has no calendar to resolve, so it reports clean. + let checked = check(&project).unwrap(); + assert_eq!(checked["status"], "complete"); + assert_eq!(checked["findings"], json!([])); + + // test() replays the calendar and must not let one fixture's + // calendar refusal abort the whole command: it reports that + // fixture as failed and names the calendar problem, the same way + // any other fixture failure is reported. + let tested = test(&project).unwrap(); + assert_eq!(tested["authoringStatus"], "complete"); + let fixtures = tested["fixtures"].as_array().unwrap(); + let fixture = fixtures + .iter() + .find(|fixture| fixture["name"] == "household-morning") + .unwrap(); + assert_eq!(fixture["status"], "failed"); + assert_eq!(fixture["cases"], json!([])); + let detail = fixture["calendarRefusal"].as_str().unwrap(); + assert!(detail.contains("does not exist"), "{detail}"); + } + #[test] fn explain_publishes_offerings_windows_hold_policy_and_digest() { let (_root, project) = initialized("standalone-arrival-window"); From 31d36e35c1236f616a67c8cca8ea2c6a914ab7e6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:51:37 +0700 Subject: [PATCH 058/115] fix(scheduling): bound the caller strings a commitment stores Nothing bounded the strings an admission or a cancellation carries, and all of them are stored verbatim: the ledger entry, the idempotency receipt and the audit journal each keep the caller's offering, channel, duplicate key, capability and prerequisite references, and its cancellation reason. Under the one megabyte body allowance any credentialed caller could write a megabyte into each of them, and a duplicate key of any length could be varied at will to slip the duplicate guard. Bound them at the edge, with the authoring grammar's own bounds rather than new numbers: an offering and a channel are policy identifiers, the references are scoped references, and a request may name no more of them than a policy collection may hold. A value outside those could never have matched anything the deployment published, so the edge answers request.invalid rather than passing it down. The cancellation reason is bounded to the column that stores it. Past that the column's CHECK refused the write, and a database refusal reaches the caller as service.unavailable: the service was reported down for a request only the caller could fix. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/http.rs | 178 ++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 4 deletions(-) diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs index 2da3401b20..1ea83dce9f 100644 --- a/crates/registry-scheduling/src/http.rs +++ b/crates/registry-scheduling/src/http.rs @@ -36,10 +36,11 @@ use registry_platform_httpsec::{ request_body_limit_default, security_headers, CspBuilder, ProblemBody, TraceContext, }; use registry_scheduling_core::{ - type_uri, AdmissionRequest, CancelAppointmentRequest, CreateAppointmentRequest, ProblemCode, - RescheduleAppointmentRequest, APPOINTMENTS_PATH, AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, - HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, LOCATIONS_PATH, MAXIMUM_IDEMPOTENCY_KEY_BYTES, - OFFERINGS_PATH, RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, + type_uri, valid_identifier, valid_reference, AdmissionRequest, CancelAppointmentRequest, + CreateAppointmentRequest, ProblemCode, RescheduleAppointmentRequest, APPOINTMENTS_PATH, + AVAILABILITY_EXPLAIN_PATH, AVAILABILITY_PATH, HOLDS_PATH, IDEMPOTENCY_KEY_HEADER, + LOCATIONS_PATH, MAXIMUM_COLLECTION_ENTRIES, MAXIMUM_IDEMPOTENCY_KEY_BYTES, OFFERINGS_PATH, + RESOURCES_PATH, SCHEDULING_PATH, SERVICES_PATH, }; use serde::Deserialize; use serde_json::Value; @@ -247,6 +248,7 @@ async fn availability( HttpError, > { authenticate_read(&state, &headers).await?; + bounded_identifier(&query.offering)?; Ok(Json( state .service @@ -268,6 +270,7 @@ async fn explain( Query(query): Query, ) -> Result, HttpError> { authenticate_explain(&state, &headers).await?; + bounded_identifier(&query.offering)?; Ok(Json( state .service @@ -283,6 +286,7 @@ async fn create_hold( ) -> Result { let caller = authenticate_mutate(&state, &headers).await?; let key = idempotency_key(&headers)?; + bounded_admission(&request)?; let answer = state .service .create_hold(&caller, key, &request, Utc::now()) @@ -322,6 +326,9 @@ async fn create_appointment( ) -> Result { let caller = authenticate_mutate(&state, &headers).await?; let key = idempotency_key(&headers)?; + if let Some(admission) = &request.admission { + bounded_admission(admission)?; + } let answer = state .service .create_appointment(&caller, key, &request, Utc::now()) @@ -359,6 +366,7 @@ async fn reschedule_appointment( ) -> Result { let caller = authenticate_mutate(&state, &headers).await?; let key = idempotency_key(&headers)?; + bounded_admission(&request.admission)?; let answer = state .service .reschedule_appointment(&caller, appointment_id, key, &request, Utc::now()) @@ -382,6 +390,7 @@ async fn cancel_appointment( ) -> Result { let caller = authenticate_mutate(&state, &headers).await?; let key = idempotency_key(&headers)?; + bounded_reason(request.reason.as_deref())?; let answer = state .service .cancel_appointment(&caller, appointment_id, key, &request, Utc::now()) @@ -501,6 +510,68 @@ fn authentication_problem(error: AuthenticationError) -> HttpError { } } +/// The most octets of a cancellation reason. +/// +/// The column the reason lands in refuses more, and a database CHECK that +/// fires during a commitment reaches the caller as service.unavailable: the +/// service would be reported down for a request only the caller can fix. The +/// edge refuses the same size first, and answers request.invalid. +const MAXIMUM_CANCELLATION_REASON_BYTES: usize = 4096; + +/// Bound every caller-supplied string an admission request carries. +/// +/// All of them are stored: the ledger entry, the idempotency receipt and the +/// audit journal keep them verbatim, so an unbounded string is an unbounded +/// write for anyone holding a credential. The bounds are the authoring +/// grammar's own, not new numbers: an offering and a channel are policy +/// identifiers, a capability, a prerequisite and a duplicate key are scoped +/// references, and a request may name no more references than a policy +/// collection may hold. A value outside them could never have matched +/// anything the deployment published. +fn bounded_admission(request: &AdmissionRequest) -> Result<(), HttpError> { + bounded_identifier(&request.offering)?; + if let Some(channel) = &request.channel { + bounded_identifier(channel)?; + } + if let Some(duplicate_key) = &request.duplicate_key { + bounded_reference(duplicate_key)?; + } + bounded_references(&request.capabilities)?; + bounded_references(&request.prerequisites) +} + +/// One policy identifier as the authored grammar admits it. +fn bounded_identifier(value: &str) -> Result<(), HttpError> { + valid_identifier(value) + .then_some(()) + .ok_or(HttpError(ProblemCode::RequestInvalid)) +} + +/// One scoped reference as the authored grammar admits it. +fn bounded_reference(value: &str) -> Result<(), HttpError> { + valid_reference(value) + .then_some(()) + .ok_or(HttpError(ProblemCode::RequestInvalid)) +} + +/// A list of scoped references, no longer than a policy collection may be. +fn bounded_references(values: &[String]) -> Result<(), HttpError> { + if values.len() > MAXIMUM_COLLECTION_ENTRIES { + return Err(HttpError(ProblemCode::RequestInvalid)); + } + values.iter().try_for_each(|value| bounded_reference(value)) +} + +/// The free text a cancellation may carry, bounded to what the column holds. +fn bounded_reason(reason: Option<&str>) -> Result<(), HttpError> { + match reason { + Some(reason) if reason.len() > MAXIMUM_CANCELLATION_REASON_BYTES => { + Err(HttpError(ProblemCode::RequestInvalid)) + } + _ => Ok(()), + } +} + /// The idempotency key every mutating command carries: present, bounded, and /// graphical, so it can never smuggle a header-breaking byte. fn idempotency_key(headers: &HeaderMap) -> Result<&str, HttpError> { @@ -628,6 +699,7 @@ mod tests { use axum::body::{to_bytes, Body}; use axum::http::header::IntoHeaderName; use axum::http::Request; + use registry_scheduling_core::PartyCounts; use tower::ServiceExt; const TRACEPARENT: &str = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"; @@ -924,6 +996,104 @@ mod tests { assert_eq!(body["code"], "service.unavailable"); } + fn admission() -> AdmissionRequest { + AdmissionRequest { + offering: "registry-update-30".to_owned(), + start: Utc::now(), + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: None, + policy_revision: 1, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + #[test] + fn an_admission_request_is_bounded_before_it_reaches_the_store() { + bounded_admission(&admission()).expect("an ordinary admission request passes the edge"); + + // Every one of these lands in the ledger, the idempotency receipt and + // the audit journal verbatim, so a caller with a body allowance of a + // megabyte could write a megabyte into each of them. + let oversized = [ + AdmissionRequest { + offering: "o".repeat(65), + ..admission() + }, + AdmissionRequest { + channel: Some("c".repeat(65)), + ..admission() + }, + AdmissionRequest { + duplicate_key: Some("k".repeat(257)), + ..admission() + }, + AdmissionRequest { + capabilities: vec!["c".repeat(257)], + ..admission() + }, + AdmissionRequest { + prerequisites: vec!["p".repeat(257)], + ..admission() + }, + AdmissionRequest { + capabilities: vec!["cap".to_owned(); MAXIMUM_COLLECTION_ENTRIES + 1], + ..admission() + }, + AdmissionRequest { + prerequisites: vec!["req".to_owned(); MAXIMUM_COLLECTION_ENTRIES + 1], + ..admission() + }, + // A reference is a stable scoped identifier, so an empty one and + // one carrying a control byte are both malformed. + AdmissionRequest { + duplicate_key: Some(String::new()), + ..admission() + }, + AdmissionRequest { + capabilities: vec!["cap\u{7}a".to_owned()], + ..admission() + }, + ]; + for request in oversized { + assert!( + matches!( + bounded_admission(&request), + Err(HttpError(ProblemCode::RequestInvalid)) + ), + "an unbounded caller string reached the store: {request:?}" + ); + } + } + + #[test] + fn a_cancellation_reason_is_bounded_below_the_column_that_stores_it() { + bounded_reason(None).expect("a cancellation needs no reason"); + bounded_reason(Some(&"r".repeat(MAXIMUM_CANCELLATION_REASON_BYTES))) + .expect("a reason the column accepts passes the edge"); + // Past this the column's own CHECK refuses the write, and a database + // refusal projects to service.unavailable: the caller would be told + // the service is down for a request only the caller can fix. + assert!(matches!( + bounded_reason(Some(&"r".repeat(MAXIMUM_CANCELLATION_REASON_BYTES + 1))), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + } + + #[test] + fn a_listing_offering_is_bounded_the_same_way_a_body_is() { + bounded_identifier("registry-update-30").expect("an authored offering reads"); + assert!(matches!( + bounded_identifier(&"o".repeat(65)), + Err(HttpError(ProblemCode::RequestInvalid)) + )); + } + fn headers_with(key: K, value: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert(key, value.parse().expect("a valid header value")); From 2ad5a6ff301d5638a6fdb5413835de7a0f843b92 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:53:12 +0700 Subject: [PATCH 059/115] docs(scheduling): state the cursor actor-binding contract where it is held A stored cursor carries no actor, which is sound only because every listing that mints one is either public to any reader or re-authorized on the request that continues it. That obligation lived nowhere a reader of the cursor module would find it, so a third listing could inherit the shape without the guarantee. The contract is now stated beside the cursor, and the database suite pins the half that holds it: a history cursor presented by a stranger is refused on ownership, not honoured on the token. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/cursors.rs | 21 +++ .../tests/postgres_commitments.rs | 132 +++++++++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/crates/registry-scheduling/src/cursors.rs b/crates/registry-scheduling/src/cursors.rs index 855b103303..7345bab092 100644 --- a/crates/registry-scheduling/src/cursors.rs +++ b/crates/registry-scheduling/src/cursors.rs @@ -12,6 +12,27 @@ //! replayed against a different listing, a different offering, or a different //! range is refused as invalid rather than silently returning rows from the //! wrong view. +//! +//! ## The actor-binding contract +//! +//! A stored cursor is deliberately not keyed by the caller: it holds its +//! identity, its context, its position and its expiry, and nothing else. That +//! is sound only because every listing that mints one is, at the moment the +//! cursor is honoured, one of two things. +//! +//! Either the listing is public to any caller holding the read scope, as the +//! catalogue and availability listings are, in which case a cursor discloses +//! nothing its holder could not ask for outright; or the listing re-applies +//! its own authorization on the continuing request before the cursor is +//! resolved, as appointment history does by re-running the ownership check +//! against the caller of the page, never against the caller who minted the +//! token. +//! +//! The obligation is therefore on the call site, and it is a real one: a +//! listing that is neither public nor re-authorized on every page must bind +//! the actor into the cursor rather than assume this module did it. Nothing +//! here can enforce that, which is why it is written where whoever adds the +//! third listing will read it. use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, TimeDelta, Utc}; diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 58107f8ed6..48df896d03 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -422,8 +422,14 @@ fn grant_claims() -> Value { } fn agent_token() -> String { + agent_token_for("principal-agent") +} + +/// A second booking agent, identical in scope and grant and different only in +/// subject, so what it may see is decided by ownership alone. +fn agent_token_for(subject: &str) -> String { let mut claims = json!({ - "sub": "principal-agent", + "sub": subject, "azp": CLIENT, "registry_scopes": "scheduling-read scheduling-explain", "registry_actor_kind": "service", @@ -1646,3 +1652,127 @@ async fn a_records_swap_waits_for_the_capacity_transaction_holding_the_pool() { .expect("the swap task runs to completion") .expect("the swap commits once the pool is free"); } + +#[tokio::test] +async fn a_history_cursor_is_re_authorized_against_the_caller_of_the_page() { + let fx = fixture().await; + let (appointment_id, revision) = booked(&fx, 90, 200, "cursor-actor-1").await; + let next = first_slot(&fx, OFFERING, 300, 440).await; + let (status, _) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "cursor-actor-2", + json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, next), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "the reschedule commits"); + + let (status, page) = fx + .get( + &format!("/v1/appointments/{appointment_id}/history?limit=1"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + let cursor = page["nextCursor"] + .as_str() + .expect("two history events page in two") + .to_owned(); + + // The cursor carries no actor, so the second page is safe only because + // the listing re-runs its ownership check against whoever presents it. + let stranger = agent_token_for("principal-stranger"); + let (status, problem) = fx + .get( + &format!("/v1/appointments/{appointment_id}/history?limit=1&cursor={cursor}"), + &stranger, + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "a stranger holding the token is still not the owner" + ); + assert_eq!(problem["code"], "operation.not-authorized"); + + // The owner continues the same listing normally. + let (status, page) = fx + .get( + &format!("/v1/appointments/{appointment_id}/history?limit=1&cursor={cursor}"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(page["items"].as_array().map(Vec::len), Some(1)); +} + +/// Write a runtime configuration whose migration database is a port nothing +/// listens on, so `scheduling migrate` fails at the connection and nowhere +/// else. +fn unreachable_deployment(root: &std::path::Path) -> std::path::PathBuf { + let package = root.join("package"); + std::fs::create_dir_all(&package).expect("a package directory"); + std::fs::write( + package.join(registry_scheduling_core::AUTHORED_POLICY_FILE), + POLICY, + ) + .expect("the authored policy"); + let secret_name = format!("SCHEDULING_CLOSED_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + std::env::set_var( + &secret_name, + "postgres://scheduling:scheduling@127.0.0.1:1/scheduling_runtime", + ); + let document = json!({ + "apiVersion": registry_scheduling_core::SCHEDULING_RUNTIME_API_VERSION, + "kind": registry_scheduling_core::SCHEDULING_RUNTIME_KIND, + "package": {"root": package}, + "listener": {"bind": "127.0.0.1:8199", "tlsTermination": "development-loopback"}, + "secretProviders": {"environment": {}}, + "database": { + "runtimeUrlRef": format!("secret:env/{secret_name}"), + "migrationUrlRef": format!("secret:env/{secret_name}"), + "testOnlyPlaintext": true, + }, + "authentication": {"oidc": { + "issuer": ISSUER, + "audience": AUDIENCE, + "allowedClients": [CLIENT], + }}, + "audit": {"path": root.join("audit.ndjson"), "hashKeyRef": "secret:env/UNUSED"}, + "destinations": {}, + "retention": {"attemptReceiptDays": 7}, + }); + let operator = root.join("runtime.yaml"); + std::fs::write( + &operator, + serde_norway::to_string(&document).expect("a serializable configuration"), + ) + .expect("the runtime configuration"); + operator +} + +#[tokio::test] +async fn a_database_that_refuses_at_startup_names_the_step_and_the_cause() { + let root = tempfile::tempdir().expect("a temporary deployment root"); + let operator = unreachable_deployment(root.path()); + let failure = registry_scheduling::runtime::migrate_from_path(&operator) + .await + .expect_err("an unreachable database fails the migration"); + let message = failure.to_string(); + assert!( + message.contains("schema migration"), + "{message} does not name the startup step that failed" + ); + assert!( + message.to_ascii_lowercase().contains("connect"), + "{message} does not name why the database was unusable" + ); + assert!( + !message.contains("scheduling:scheduling"), + "the startup failure repeats the database credentials" + ); +} From b4f7cdd4dc6c52238cc4ea5ff288269dd7444dd2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:53:12 +0700 Subject: [PATCH 060/115] fix(scheduling): name the step and the cause when the database fails at startup A database that refused during provisioning or boot said only that a pooled connection could not be built, naming neither what the runtime was doing nor what the driver reported. Store errors now carry the driver's own account, and every provisioning and startup step names itself, so an unreachable server, an unmigrated database, an unadopted identity and an unpublishable policy read as the four different operator tasks they are. Neither the pool nor the driver repeats the connection string, and the test pins that the credentials stay out of the message. The retention worker now states what the one configured period covers and what it deliberately does not, so the knob cannot be read as a promise to sweep committed scheduling data. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/runtime.rs | 56 +++++++++++++++++++---- crates/registry-scheduling/src/store.rs | 7 ++- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index 310b0a9355..0323b663ec 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -99,16 +99,28 @@ pub async fn run(matches: &clap::ArgMatches) -> Result<(), RuntimeError> { } } +/// Name the provisioning or startup act a store failure happened in. +fn database_step(stage: &'static str) -> impl Fn(StoreError) -> RuntimeError { + move |source| RuntimeError::Database { stage, source } +} + pub async fn migrate_from_path(path: impl AsRef) -> Result<(), RuntimeError> { let config = RuntimeConfig::load(path)?; let policy = config.load_policy()?; let secrets = secret_resolver(&config)?; - let store = PostgresStore::connect_migration(&config.database, &secrets)?; - store.migrate().await?; + let store = PostgresStore::connect_migration(&config.database, &secrets) + .map_err(database_step("migration database configuration"))?; + store + .migrate() + .await + .map_err(database_step("schema migration"))?; // Migrations leave the deployment identity unset; adopting it here is the // provisioning act that binds this database to the policy's scheduling // id, which every serve verifies before it writes anything. - store.adopt(&policy.scheduling.id).await?; + store + .adopt(&policy.scheduling.id) + .await + .map_err(database_step("deployment adoption"))?; Ok(()) } @@ -125,13 +137,20 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> let scheduling_id = policy.scheduling.id.clone(); let policy_digest = policy.policy_digest(); let secrets = secret_resolver(&config)?; - let store = PostgresStore::connect_runtime(&config.database, &secrets)?; - store.ready().await?; + let store = PostgresStore::connect_runtime(&config.database, &secrets) + .map_err(database_step("runtime database configuration"))?; + store + .ready() + .await + .map_err(database_step("schema readiness check"))?; // The deployment identity is read before anything is written: the store // refuses to apply a policy under a scheduling id the deployment does not // carry, and an empty one is a deployment that has not been adopted yet. - let (stored_id, _, _) = store.scheduling_meta().await?; + let (stored_id, _, _) = store + .scheduling_meta() + .await + .map_err(database_step("deployment identity read"))?; if stored_id.is_empty() { return Err(RuntimeError::Unbootstrapped); } @@ -190,7 +209,8 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> let window_ids = offering_window_ids(&policy); let policy_revision = store .apply_policy(&scheduling_id, &policy_digest, &pool_ids, &window_ids) - .await?; + .await + .map_err(database_step("policy publication"))?; let service = Arc::new(SchedulingService::new( store.clone(), @@ -254,6 +274,15 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> }, )); + // The whole of retention, and deliberately not all of retention. This + // sweep erases two things: idempotency attempt receipts, after the + // configured `retention.attemptReceiptDays`, and listing cursors, after + // the fifteen minutes the listing contract gives them. Appointments, + // their history, the delivery outbox and the audit journal are never + // swept, by decision and not by omission: committed scheduling data has + // no retention period in this milestone, and one knob must not read as a + // promise to sweep it. A future period for those is new configuration and + // new passes here, not a wider reading of this one. let retention_store = store.clone(); workers.push(supervise("retention", worker_stopped.clone(), async move { let mut interval = worker_timer(); @@ -320,8 +349,17 @@ pub enum RuntimeError { Audit, #[error("{0}")] AuditSecret(String), - #[error(transparent)] - Store(#[from] StoreError), + /// A database step of provisioning or startup failed. The step is named + /// because a bare store error says the database refused and not which + /// act it refused, and the acts have different remedies: an unreachable + /// server, a database nobody migrated, an identity nobody adopted, and a + /// policy that cannot be published are four different operator tasks. + #[error("the Scheduling {stage} failed: {source}")] + Database { + stage: &'static str, + #[source] + source: StoreError, + }, #[error("the Scheduling listener could not bind or serve")] Listen(#[from] std::io::Error), #[error("the Scheduling reminder destination is invalid: {0}")] diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 2dc6533237..0ce3ee3e6e 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -68,9 +68,12 @@ pub enum StoreError { /// either keeps the resource or closes what stands on it first. #[error("the environment records retire {0}, which live appointments or holds still occupy")] FactsInUse(String), - #[error("the Scheduling query failed")] + // Both carry the driver's own account of what went wrong. Neither the + // pool nor the driver repeats the connection string in its message, so + // naming the cause costs no credential. + #[error("the Scheduling query failed: {0}")] Query(#[from] tokio_postgres::Error), - #[error("the pooled Scheduling connection could not be built or leased")] + #[error("the Scheduling database connection could not be established: {0}")] Pool(#[from] deadpool_postgres::PoolError), } From 0f75022e9cc50ea3193b63a8aa0796bcdbe99ffc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:54:43 +0700 Subject: [PATCH 061/115] fix(schedulingctl): report ok:false when check --deny-findings or test refuses The exit code already told the caller this refused; the JSON body still said "ok": true underneath it. Mirror the exit-code decision into the report before rendering, for both a denied check and a failed test. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/lib.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index 69d21bbcb4..e30141ebaf 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -161,12 +161,19 @@ where let format = cli.format; let deny_findings = matches!(&cli.command, Command::Check(args) if args.deny_findings); match run(cli) { - Ok(report) => { + Ok(mut report) => { + // The exit code already tells the caller whether this refused; + // the JSON must agree instead of reporting "ok": true underneath + // a nonzero exit. + let refusal = report_is_refusal(&report, deny_findings); + if refusal { + report["ok"] = json!(false); + } let outcome = write_success(&report, format, stdout, stderr); if outcome != ExitCode::SUCCESS { return outcome; } - if report_is_refusal(&report, deny_findings) { + if refusal { ExitCode::from(DOMAIN_REFUSAL_EXIT) } else { ExitCode::SUCCESS @@ -564,6 +571,8 @@ mod tests { assert_eq!(exit, ExitCode::SUCCESS); assert!(stderr.is_empty()); assert_eq!(report["status"], "incomplete"); + // Findings alone, without --deny-findings, are not a refusal. + assert_eq!(report["ok"], true); assert!(report["findings"] .as_array() .unwrap() @@ -577,6 +586,8 @@ mod tests { assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); assert!(stderr.is_empty()); assert_eq!(report["status"], "incomplete"); + // The exit code already says this refused; the JSON must agree. + assert_eq!(report["ok"], false); let (_root, clean) = initialized("standalone-exact-time"); let (exit, report, stderr) = @@ -584,6 +595,7 @@ mod tests { assert_eq!(exit, ExitCode::SUCCESS); assert!(stderr.is_empty()); assert_eq!(report["status"], "complete"); + assert_eq!(report["ok"], true); } #[test] @@ -599,6 +611,8 @@ mod tests { let (exit, report, stderr) = run_json(&["test", project.to_str().unwrap()]); assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); assert!(stderr.is_empty()); + // The exit code already says this refused; the JSON must agree. + assert_eq!(report["ok"], false); let fixtures = report["fixtures"].as_array().unwrap(); let counter = fixtures .iter() From ad9ff09f2949797e051cad4999e55259dda66418 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:55:44 +0700 Subject: [PATCH 062/115] fix(scheduling-client): declare a workspace.dependencies entry registry-scheduling-client was the only client crate in the workspace with no [workspace.dependencies] entry. Add it in the same shape as its siblings, so a future dependent can pull it in with .workspace = true. Signed-off-by: Jeremi Joslin --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index ad807c2bf6..1c9bceba23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,6 +124,7 @@ registry-relayctl = { path = "crates/registry-relayctl", version = "0.32.0" } registry-scheduling = { path = "crates/registry-scheduling", version = "0.32.0" } registry-scheduling-core = { path = "crates/registry-scheduling-core", version = "0.32.0" } registry-schedulingctl = { path = "crates/registry-schedulingctl", version = "0.32.0" } +registry-scheduling-client = { path = "crates/registry-scheduling-client", version = "0.32.0" } registry-breg = { path = "crates/registry-breg", version = "0.32.0", default-features = false } registry-bregctl = { path = "crates/registry-bregctl", version = "0.32.0" } registry-stack-client = { path = "crates/registry-stack-client", version = "0.32.0" } From 0994669ead06de86825153c87e679b1565e2523c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:57:54 +0700 Subject: [PATCH 063/115] fix(schedulingctl): accept --format human|json, matching every sibling ctl schedulingctl was the only ctl binary whose --format took text|json; every other ctl takes human|json. Rename the value (and the enum variant behind it) to match. This changes public command content: --format text is no longer accepted, and --format human replaces it. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/lib.rs | 26 +++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index e30141ebaf..0d8cb1acee 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -104,7 +104,7 @@ struct RecordsApplyArgs { #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] enum OutputFormat { #[default] - Text, + Human, Json, } @@ -308,7 +308,7 @@ fn write_success( OutputFormat::Json => serde_json::to_writer_pretty(&mut *stdout, report) .map_err(io::Error::other) .and_then(|()| writeln!(stdout)), - OutputFormat::Text => render_human(report, stdout), + OutputFormat::Human => render_human(report, stdout), }; if result.is_ok() { ExitCode::SUCCESS @@ -439,13 +439,33 @@ mod tests { #[test] fn canonical_command_shapes_and_default_format_are_stable() { let cli = Cli::try_parse_from(["schedulingctl", "check", "/tmp/project"]).unwrap(); - assert_eq!(cli.format, OutputFormat::Text); + assert_eq!(cli.format, OutputFormat::Human); let Command::Check(args) = cli.command else { panic!("expected check") }; assert_eq!(args.project, PathBuf::from("/tmp/project")); assert!(!args.deny_findings); + // `--format` matches every sibling ctl's value names: `human` and + // `json`, never `text`. + let cli = Cli::try_parse_from([ + "schedulingctl", + "--format", + "human", + "check", + "/tmp/project", + ]) + .unwrap(); + assert_eq!(cli.format, OutputFormat::Human); + assert!(Cli::try_parse_from([ + "schedulingctl", + "--format", + "text", + "check", + "/tmp/project" + ]) + .is_err()); + let cli = Cli::try_parse_from(["schedulingctl", "check", "/tmp/project", "--deny-findings"]) .unwrap(); From f87788c7243654bbcaf042bda31720d3dd551f1d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 17:59:32 +0700 Subject: [PATCH 064/115] fix(scheduling): declare the refusal a malformed path segment already answers An operation whose caller names a hold or an appointment in the path refuses a segment that is not an identifier before the handler runs, so the answer is request.invalid at the edge. The OpenAPI document never declared it, leaving a client generated from the contract with no branch for the one refusal it can provoke without a body or a query. Name the path as a source of that refusal in the generator, require it of every operation carrying a path parameter, and document one answer per code however many sources carry it. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/http.rs | 43 ++++++++++++++++++- .../registry-scheduling.openapi.json | 36 ++++++++++++++++ .../scheduling/scripts/generate_openapi.py | 30 +++++++++---- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs index 1ea83dce9f..4161deefb5 100644 --- a/crates/registry-scheduling/src/http.rs +++ b/crates/registry-scheduling/src/http.rs @@ -732,11 +732,24 @@ mod tests { /// The edge under test, without a service behind it: the boundary layers /// and the fallbacks are the surface these tests pin. + /// + /// The identified-resource routes are carried under their own templates, + /// reading their segment with the extractor the real handlers use, so a + /// segment the extractor refuses is refused here exactly as it is in the + /// assembled router. fn edge_router() -> Router { async fn echo(Json(_): Json) -> StatusCode { StatusCode::OK } - http_edge(Router::new().route("/v1/echo", post(echo))) + async fn identified(Path(_): Path) -> StatusCode { + StatusCode::NO_CONTENT + } + http_edge( + Router::new() + .route("/v1/echo", post(echo)) + .route(HOLD_ROUTE, delete(identified)) + .route(APPOINTMENT_ROUTE, get(identified)), + ) } #[tokio::test] @@ -811,6 +824,34 @@ mod tests { assert_eq!(body["code"], "request.unsupported-media-type"); } + #[tokio::test] + async fn a_path_segment_that_is_not_an_identifier_is_a_request_problem() { + // The two operations whose only caller-controlled input is the path + // segment. The segment is read before the handler runs, so a segment + // that is not an identifier never reaches authentication and never + // reaches the store: the edge answers it, and the OpenAPI document + // declares that answer for every operation carrying a path parameter. + for (method, uri) in [ + ("DELETE", "/v1/holds/not-an-identifier"), + ("GET", "/v1/appointments/not-an-identifier"), + ] { + let response = send(edge_router(), traced_request(method, uri)).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/problem+json" + ); + let body = body_json(response).await; + assert_eq!(body["status"], 400); + assert_eq!(body["code"], "request.invalid"); + assert_eq!( + body["type"], + "https://id.registrystack.org/problems/registry-scheduling/request/invalid" + ); + assert_eq!(body["traceId"], TRACE_ID); + } + } + #[tokio::test] async fn answers_carry_no_store_and_the_callers_trace() { let response = send( diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json index ebb2eba35a..0956d4574e 100644 --- a/products/scheduling/generated/registry-scheduling.openapi.json +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -2396,6 +2396,24 @@ } } }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "401": { "content": { "application/problem+json": { @@ -4250,6 +4268,24 @@ } } }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestInvalid" + } + } + }, + "description": "Problem response: request.invalid", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "401": { "content": { "application/problem+json": { diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py index 2ce2b6b1a1..ed9a2d2802 100644 --- a/products/scheduling/scripts/generate_openapi.py +++ b/products/scheduling/scripts/generate_openapi.py @@ -186,6 +186,9 @@ "request.unsupported-media-type", ] QUERY = ["request.invalid"] +# A path segment naming a hold or an appointment is read into an identifier +# before the handler runs, so a segment that is not one is refused at the edge. +PATH = ["request.invalid"] CURSOR = ["cursor.expired", "cursor.invalid"] STORAGE = ["service.unavailable"] AUTHORITY = ["operation.not-authorized"] @@ -219,21 +222,22 @@ + ["precondition.failed", "service.unavailable"], # A release carries no caller-chosen key, so `idempotency.key-reused` is # not reachable: the key and the request hash are both the hold's own id. - ("DELETE", "/v1/holds/{hold_id}"): EDGE + AUTHENTICATION + AUTHORITY + ("DELETE", "/v1/holds/{hold_id}"): EDGE + AUTHENTICATION + PATH + AUTHORITY + ["hold.released", "idempotency.expired", "service.unavailable"], ("POST", "/v1/appointments"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY + ["hold.expired", "hold.released", "precondition.failed", "service.unavailable"], - ("GET", "/v1/appointments/{appointment_id}"): EDGE + AUTHENTICATION + AUTHORITY + STORAGE, + ("GET", "/v1/appointments/{appointment_id}"): EDGE + AUTHENTICATION + PATH + AUTHORITY + STORAGE, # A reschedule resolves its offering from the appointment, so an unknown # offering is operator state, not precondition.failed. - ("POST", "/v1/appointments/{appointment_id}/reschedule"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION - + IDEMPOTENCY + AUTHORITY + ["hold.released", "service.unavailable"], + ("POST", "/v1/appointments/{appointment_id}/reschedule"): EDGE + AUTHENTICATION + PATH + JSON_BODY + + ADMISSION + IDEMPOTENCY + AUTHORITY + ["hold.released", "service.unavailable"], # Cancellation is guarded by the observed revision and the cutoff, never by # the policy revision, so it answers no admission refusal at all. - ("POST", "/v1/appointments/{appointment_id}/cancel"): EDGE + AUTHENTICATION + JSON_BODY + IDEMPOTENCY - + AUTHORITY + ["cancellation.cutoff-passed", "hold.released", "revision.mismatch", "service.unavailable"], - ("GET", "/v1/appointments/{appointment_id}/history"): EDGE + AUTHENTICATION + AUTHORITY + QUERY + CURSOR - + STORAGE, + ("POST", "/v1/appointments/{appointment_id}/cancel"): EDGE + AUTHENTICATION + PATH + JSON_BODY + + IDEMPOTENCY + AUTHORITY + + ["cancellation.cutoff-passed", "hold.released", "revision.mismatch", "service.unavailable"], + ("GET", "/v1/appointments/{appointment_id}/history"): EDGE + AUTHENTICATION + PATH + AUTHORITY + QUERY + + CURSOR + STORAGE, } @@ -356,7 +360,10 @@ def problem_variant(entry: dict) -> dict: def problem_responses(codes: list[str], catalog: dict[str, dict]) -> dict: by_status: dict[int, list[dict]] = {} - for code in codes: + # An operation can reach one refusal down more than one path, and the + # groups above name every path it has. A code the operation answers is + # documented once however many groups carry it. + for code in dict.fromkeys(codes): entry = catalog[code] by_status.setdefault(entry["httpStatuses"][0], []).append(entry) result = {} @@ -1131,6 +1138,11 @@ def verify_operation_problems( required |= {"request.invalid", "request.unprocessable", "request.unsupported-media-type"} if any(parameter["in"] == "query" for parameter in operation["parameters"]): required |= {"request.invalid"} + # A path segment is read into its identifier before the handler runs, + # so a segment that is not one is refused at the edge, ahead of + # authentication and ahead of the store. + if any(parameter["in"] == "path" for parameter in operation["parameters"]): + required |= {"request.invalid"} if any(parameter["name"] == "Idempotency-Key" for parameter in operation["parameters"]): required |= {"request.invalid"} if not required <= set(codes): From 8fccdddb3343f3cf1b85d4fa8914e9cbd4ccb1c5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:02:48 +0700 Subject: [PATCH 065/115] fix(schedulingctl): name the authored policy path by its own constant, surface I/O failures The generic refusal path and the human-mode finding fallback each wrote the literal "scheduling.yaml" instead of reusing the crate's own AUTHORED_POLICY_FILE constant, already used everywhere else in this crate. Reuse it. Separately, a filesystem I/O failure (a missing project, an unreadable file) reported a canned "A required filesystem operation failed." message and discarded the actual error, which already names the path and the operating system's reason via its anyhow context chain. Surface that message instead of swallowing it. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/lib.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index 0d8cb1acee..474676f26c 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -15,6 +15,7 @@ use anyhow::Result; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use registry_scheduling::config::RuntimeConfigError; use registry_scheduling::store::StoreError; +use registry_scheduling_core::AUTHORED_POLICY_FILE; use serde_json::{json, Value}; use std::ffi::{OsStr, OsString}; use std::io; @@ -279,7 +280,7 @@ fn classify_failure(error: &anyhow::Error) -> (u8, Value) { "code": "schedulingctl.io-failure", "artifact": "filesystem", "path": "filesystem", - "message": "A required filesystem operation failed.", + "message": format!("{error:#}"), "suggestedAction": "Correct the path or permissions, then retry.", }), ) @@ -290,7 +291,7 @@ fn classify_failure(error: &anyhow::Error) -> (u8, Value) { "severity": "error", "code": "schedulingctl.refused", "artifact": "scheduling_project", - "path": "scheduling.yaml", + "path": AUTHORED_POLICY_FILE, "message": format!("{error:#}"), "suggestedAction": "Correct the authored input the message names, then retry.", }), @@ -396,7 +397,7 @@ fn render_human(report: &Value, stdout: &mut dyn io::Write) -> io::Result<()> { writeln!( stdout, "finding {}: {}", - finding["path"].as_str().unwrap_or("scheduling.yaml"), + finding["path"].as_str().unwrap_or(AUTHORED_POLICY_FILE), finding["reason"].as_str().unwrap_or("review required"), )?; } @@ -679,6 +680,9 @@ mod tests { assert!(diagnostic.get(field).is_some(), "missing {field}"); } assert_eq!(diagnostic["code"], "schedulingctl.refused"); + // The path names the authored policy file by the crate's own + // constant, not a literal that could drift from it. + assert_eq!(diagnostic["path"], AUTHORED_POLICY_FILE); assert!(diagnostic["message"] .as_str() .unwrap() @@ -725,6 +729,14 @@ mod tests { assert_eq!(exit, ExitCode::from(OPERATIONAL_FAILURE_EXIT)); assert!(stderr.is_empty()); assert_eq!(report["diagnostics"][0]["code"], "schedulingctl.io-failure"); + // The failure names the path and the operating system's reason + // instead of a generic placeholder that could be any I/O failure. + let message = report["diagnostics"][0]["message"].as_str().unwrap(); + assert!(message.contains(&missing), "{message}"); + assert!( + message.to_lowercase().contains("no such file or directory"), + "{message}" + ); let mut stdout = Vec::new(); let mut stderr = Vec::new(); From 53cbb3edd6ea9aa1929b2ab15703edf79e07358d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:03:02 +0700 Subject: [PATCH 066/115] fix(scheduling): audit a permission refused before the transaction opens A refusal decided in require_permission never opens the capacity transaction, so it left no journal trail at all: a caller probing which services and locations its grant reaches was invisible to the audit journal. Both refusals now write an authorization record. A refusal under a present grant records authorization.refused in the shape every commitment already publishes. A caller carrying no task grant records authorization.no-grant with no grant pseudonym, no approver, and no purpose, and its client field carries the credential's issuer under its own reference class so the digest cannot be mistaken for a client that actually presented a grant. The answer the caller receives is unchanged: the same closed code, naming neither the bound that failed nor whether a grant was carried. Security-sensitive: this adds audit coverage of an authorization decision. It contradicts the deferral SCHEDULING-DEF-02 recorded in the security-invariant matrix, SECURITY-REVIEW-NOTES.md, TASK_GRANTS.md and the published API reference, none of which this change owns; they need a follow-up edit. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/service.rs | 128 +++++++++++++++--- .../tests/postgres_commitments.rs | 119 ++++++++++++++++ 2 files changed, 225 insertions(+), 22 deletions(-) diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index 301a9a1682..d6cc143636 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -63,6 +63,13 @@ const MAXIMUM_AVAILABILITY_SPAN_DAYS: i64 = 62; /// key on this pseudonym, never on the raw identity. const PRINCIPAL_CLASS: &str = "scheduling-principal-v1"; +/// The audit reference class for the authority that minted a credential +/// carrying no task grant. It exists only so a refusal decided before any +/// grant was resolved still names an accountable second party; a different +/// class is a different keyed domain, so a digest written under it can never +/// be mistaken for the pseudonym of a client that actually presented a grant. +const ISSUER_CLASS: &str = "scheduling-issuer-v1"; + /// One verified caller, as the authenticator resolved them. pub struct Caller { /// The verified actor kind: `human`, `agent`, or `service`. @@ -502,7 +509,9 @@ impl SchedulingService { .policy .offering(&request.offering) .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; - let grant = self.require_permission(caller, offering, HOLD_CREATE_ACTION)?; + let grant = self + .require_permission(caller, offering, HOLD_CREATE_ACTION) + .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let supply = self.supply(offering).await?; let request_hash = admission_request_hash(request); @@ -564,7 +573,9 @@ impl SchedulingService { let offering = self.policy.offering(&hold.offering).ok_or_else(|| { ServiceError::internal("a committed hold names no offering in the policy") })?; - let grant = self.require_permission(caller, offering, HOLD_RELEASE_ACTION)?; + let grant = self + .require_permission(caller, offering, HOLD_RELEASE_ACTION) + .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let request_hash = canonical_hash(&json!({"hold": hold_id}))?; // The release carries no caller-chosen key, so the hold's own id is @@ -644,7 +655,9 @@ impl SchedulingService { let offering = self.policy.offering(&hold.offering).ok_or_else(|| { ServiceError::internal("a committed hold names no offering in the policy") })?; - let grant = self.require_permission(caller, offering, APPOINTMENT_CREATE_ACTION)?; + let grant = self + .require_permission(caller, offering, APPOINTMENT_CREATE_ACTION) + .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let supply = self.supply(offering).await?; let request_hash = canonical_hash(&json!({"hold": hold_id}))?; @@ -686,7 +699,9 @@ impl SchedulingService { .policy .offering(&request.offering) .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; - let grant = self.require_permission(caller, offering, APPOINTMENT_CREATE_ACTION)?; + let grant = self + .require_permission(caller, offering, APPOINTMENT_CREATE_ACTION) + .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let supply = self.supply(offering).await?; let request_hash = admission_request_hash(request); @@ -748,7 +763,9 @@ impl SchedulingService { let offering = self.policy.offering(&appointment.offering).ok_or_else(|| { ServiceError::internal("a committed appointment names no offering in the policy") })?; - let grant = self.require_permission(caller, offering, APPOINTMENT_RESCHEDULE_ACTION)?; + let grant = self + .require_permission(caller, offering, APPOINTMENT_RESCHEDULE_ACTION) + .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let supply = self.supply(offering).await?; let request_hash = canonical_hash(&json!({ @@ -808,7 +825,9 @@ impl SchedulingService { let offering = self.policy.offering(&appointment.offering).ok_or_else(|| { ServiceError::internal("a committed appointment names no offering in the policy") })?; - let grant = self.require_permission(caller, offering, APPOINTMENT_CANCEL_ACTION)?; + let grant = self + .require_permission(caller, offering, APPOINTMENT_CANCEL_ACTION) + .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let request_hash = canonical_hash(&json!({ "observedRevision": request.observed_revision, @@ -994,13 +1013,27 @@ impl SchedulingService { /// A grant that covers this offering's service, location, and action, or /// the refusal. Readable availability is not authority to book: the /// permission must name all three. - fn require_permission( + /// + /// Both refusals are audited. A refusal decided here never opens the + /// capacity transaction, so nothing further in the request would record + /// that it happened, and a caller probing which services and locations its + /// grant reaches would leave the journal empty. What the journal gains is + /// the attribution: the answer stays the same closed code, naming neither + /// the bound that failed nor whether a grant was carried at all. + async fn require_permission( &self, caller: &Caller, offering: &OfferingPolicy, action: &str, ) -> Result { let Some(grant) = &caller.grant else { + self.record_refusal(grantless_refusal_record( + &self.hasher, + &self.scheduling_id, + caller, + action, + )) + .await; return Err(ServiceError::Problem(ProblemCode::OperationNotAuthorized)); }; let allowed = grant @@ -1019,10 +1052,41 @@ impl SchedulingService { if allowed { Ok(grant.clone()) } else { + self.record_refusal(audit_record( + &self.hasher, + &self.scheduling_id, + caller, + grant, + action, + AuthorizationOutcome::Denied, + "authorization.refused", + )) + .await; Err(ServiceError::Problem(ProblemCode::OperationNotAuthorized)) } } + /// Write one authorization refusal to the journal. A journal that cannot + /// be written must not change the caller's answer: the decision is already + /// made and the caller is refused either way, so the failure is logged + /// loudly and the refusal stands. + async fn record_refusal(&self, record: Result) { + match record { + Ok(record) => { + if let Err(failure) = self + .store + .record_refusal_audit(Uuid::new_v4(), record) + .await + { + tracing::error!(%failure, "the refusal audit row could not be recorded"); + } + } + Err(refused) => { + tracing::error!(error = %refused, "the refusal audit row could not be built"); + } + } + } + /// The policy-resolved supply an offering runs against, read from the /// live facts the operator's records apply wrote. async fn supply(&self, offering: &OfferingPolicy) -> Result { @@ -1147,7 +1211,7 @@ impl SchedulingService { CommitError::Unauthorized => "authorization.refused", _ => "authorization.profile", }; - match audit_record( + self.record_refusal(audit_record( &self.hasher, &self.scheduling_id, caller, @@ -1155,20 +1219,8 @@ impl SchedulingService { operation, AuthorizationOutcome::Denied, reason, - ) { - Ok(record) => { - if let Err(failure) = self - .store - .record_refusal_audit(Uuid::new_v4(), record) - .await - { - tracing::error!(%failure, "the refusal audit row could not be recorded"); - } - } - Err(refused) => { - tracing::error!(error = %refused, "the refusal audit row could not be built"); - } - } + )) + .await; } Err(ServiceError::Problem(problem)) } @@ -1665,6 +1717,38 @@ fn hex(bytes: &[u8]) -> String { rendered } +/// The attributable record of a refusal decided before any grant was +/// resolved. The caller authenticated, so there is a principal to attribute +/// the attempt to, but no grant, approver, or purpose exists to record and +/// none is invented. +/// +/// The shape still demands a client. Without a grant the deployment verified +/// no client identity, so the field carries the credential's issuer under the +/// issuer reference class, and the absent grant pseudonym is what tells a +/// reader of the journal which of the two it is looking at. +fn grantless_refusal_record( + hasher: &AuditKeyHasher, + scope: &str, + caller: &Caller, + operation: &str, +) -> Result { + let issuer = hasher + .audit_reference_hash(ISSUER_CLASS, scope, &caller.issuer) + .map_err(|_| ServiceError::internal("an audit reference could not be pseudonymized"))?; + let event = AuthorizationAuditEvent::denied_without_purpose( + caller.actor_kind.clone(), + caller.actor_pseudonym(hasher, scope)?, + issuer, + None, + None, + operation, + "authorization.no-grant", + ) + .map_err(|_| ServiceError::internal("the authorization audit event is not publishable"))?; + serde_json::to_value(event) + .map_err(|_| ServiceError::internal("the authorization audit event is not publishable")) +} + /// The attributable authorization record of one commitment, with the /// principal, client, grant, and approver pseudonymized and the purpose /// recorded as presence only, the way the stack's other runtimes publish it. diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 48df896d03..d13bd2fe6b 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -1776,3 +1776,122 @@ async fn a_database_that_refuses_at_startup_names_the_step_and_the_cause() { "the startup failure repeats the database credentials" ); } + +/// A booking agent whose grant names another location, so its bounds cover no +/// action on the test policy's offering. +fn agent_token_outside_its_bounds() -> String { + let mut grant = grant_claims(); + grant["registry_grant_bounds"] = json!({ + "type": "scheduling", + "permissions": [{ + "service": "registry-update", + "location": "south-counter", + "actions": ["hold.create"], + }], + }); + let mut claims = json!({ + "sub": "principal-elsewhere", + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + }); + let object = claims.as_object_mut().unwrap(); + object.extend( + grant + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +/// The pending journal keyed by the reason each record carries. +async fn audit_by_reason(fx: &Fixture) -> std::collections::BTreeMap { + fx.store + .pending_audit(100) + .await + .expect("the pending audit journal") + .into_iter() + .map(|(_, record)| { + let reason = record["reason"] + .as_str() + .expect("an audit record names its reason") + .to_owned(); + (reason, record) + }) + .collect() +} + +#[tokio::test] +async fn a_permission_refused_before_the_transaction_writes_its_audit_row() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + + // A grant that covers another location is authority for nothing here. + let (status, problem) = fx + .post( + "/v1/holds", + &agent_token_outside_its_bounds(), + "outside-bounds", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(problem["code"], "operation.not-authorized"); + + // Readable availability is not authority to book, and the standing + // reader carries no task grant at all. + let (status, problem) = fx + .post( + "/v1/holds", + &fx.reader, + "no-grant", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(problem["code"], "operation.not-authorized"); + + let journal = audit_by_reason(&fx).await; + assert_eq!( + journal.keys().cloned().collect::>(), + vec![ + "authorization.no-grant".to_owned(), + "authorization.refused".to_owned() + ], + "a permission refused before the transaction leaves no journal trail" + ); + + let bounds_refusal = &journal["authorization.refused"]; + assert_eq!(bounds_refusal["outcome"], "denied"); + assert_eq!(bounds_refusal["actorKind"], "agent"); + assert_eq!(bounds_refusal["operation"], "hold.create"); + assert!( + bounds_refusal["grantPseudonym"].is_string(), + "a refusal under a present grant names the grant" + ); + let grantless_refusal = &journal["authorization.no-grant"]; + assert_eq!(grantless_refusal["outcome"], "denied"); + assert_eq!(grantless_refusal["actorKind"], "service"); + assert_eq!(grantless_refusal["operation"], "hold.create"); + assert!( + grantless_refusal["grantPseudonym"].is_null(), + "a refusal carrying no grant names none" + ); + assert!( + grantless_refusal["purpose"].is_null(), + "a refusal carrying no grant records no purpose" + ); + // The journal carries pseudonyms and the operation, never the caller's + // raw identity and never the bound that failed. + for record in journal.values() { + let rendered = record.to_string(); + for raw in ["principal-elsewhere", "principal-read", "north-counter"] { + assert!( + !rendered.contains(raw), + "the refusal audit record repeats {raw} in the clear" + ); + } + } +} From d2baf68cb0c179c6b4c14f6058a34120da9319ed Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:04:23 +0700 Subject: [PATCH 067/115] fix(scheduling): bound the caller-chosen duplicate key in the ledger The column took any string a 1 MiB body could carry. It now bounds at 256 octets, the ceiling the evaluator already gives every other caller-supplied string, so an unbounded value cannot be parked in the claim ledger. The comment records why the key's uniqueness is not a partial UNIQUE index: the evaluator's rule is scoped to the locked snapshot, the offering and the interval, while a UNIQUE over active claims would collide across offerings sharing a pool and would fire inside a hold confirmation, which writes the booking while the hold carrying the same key is still active in the same transaction. Migration 0001 is unreleased, so the constraint is amended in place. The edge must refuse the same size first or a caller who exceeds it is told the service is down; that bound belongs to the HTTP edge. Signed-off-by: Jeremi Joslin --- .../migrations/0001_scheduling.sql | 14 +++++++- .../tests/postgres_commitments.rs | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/registry-scheduling/migrations/0001_scheduling.sql b/crates/registry-scheduling/migrations/0001_scheduling.sql index 19c1a5e55f..441cc2e110 100644 --- a/crates/registry-scheduling/migrations/0001_scheduling.sql +++ b/crates/registry-scheduling/migrations/0001_scheduling.sql @@ -75,7 +75,19 @@ CREATE TABLE IF NOT EXISTS scheduling_claims ( occupied_start timestamptz NOT NULL, occupied_end timestamptz NOT NULL, units integer NOT NULL CHECK (units > 0), - duplicate_key text, + -- The caller chooses this value, so the column bounds it: 256 octets, + -- the same ceiling the evaluator gives every other caller-supplied + -- string. The edge refuses the same size first, so the bound is not + -- reached by an ordinary request. + -- + -- The uniqueness the key promises is not a UNIQUE index here. The + -- evaluator refuses a duplicate against the snapshot it locked, scoped to + -- the offering and to the interval under consideration, while a partial + -- UNIQUE over active claims would be a different and stronger rule: it + -- would collide across two offerings that share one pool, and it would + -- fire inside a hold confirmation, which writes the booking while the + -- hold carrying the same key is still active in the same transaction. + duplicate_key text CHECK (duplicate_key IS NULL OR octet_length(duplicate_key) <= 256), hold_expires_at timestamptz, revision bigint NOT NULL CHECK (revision > 0), policy_revision bigint NOT NULL CHECK (policy_revision > 0), diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index d13bd2fe6b..e6f68caf19 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -1895,3 +1895,37 @@ async fn a_permission_refused_before_the_transaction_writes_its_audit_row() { } } } + +/// The most octets the claim ledger stores for a caller-chosen duplicate key. +/// It mirrors the CHECK in migration 0001; the edge refuses the same size +/// first, so a caller who exceeds it is answered rather than told the service +/// is down. +const DUPLICATE_KEY_CEILING_BYTES: usize = 256; + +/// Write one claim carrying `duplicate_key` straight into the ledger, so the +/// column's own bound is what decides, not a caller-facing check above it. +async fn claim_keyed(fx: &Fixture, duplicate_key: &str) -> Result { + fx.admin + .execute( + "INSERT INTO scheduling_claims(claim_id, kind, state, offering, supply_id, \ + displayed_start, displayed_end, occupied_start, occupied_end, units, \ + duplicate_key, revision, policy_revision, actor) \ + VALUES($1,'booking','active','registry-update-30','station-1', \ + now(), now(), now(), now(), 1, $2, 1, 1, 'actor-pseudonym')", + &[&Uuid::new_v4(), &duplicate_key], + ) + .await +} + +#[tokio::test] +async fn the_claim_ledger_bounds_a_caller_chosen_duplicate_key() { + let fx = fixture().await; + claim_keyed(&fx, &"k".repeat(DUPLICATE_KEY_CEILING_BYTES)) + .await + .expect("a duplicate key at the ceiling is stored"); + let refused = claim_keyed(&fx, &"k".repeat(DUPLICATE_KEY_CEILING_BYTES + 1)).await; + assert!( + refused.is_err(), + "the ledger stores a duplicate key of any length the body ceiling allows" + ); +} From 1f98a118e92b9b9cb17ef76082c2311210e26217 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:06:32 +0700 Subject: [PATCH 068/115] fix(scheduling): let a client outlive the deployment it reads Every wire document refused a member it did not declare, and the client held a problem answer to this build's pinned title and detail. Both read a deployment one release ahead as broken: an added answer member, or a reworded title, became a failed exchange over a refusal the code had already named exactly. Strictness now follows the direction the document travels. A request the runtime reads stays closed, because an undeclared member there is a caller mistake or a reach for a field the store owns. An answer a client reads is open, and the OpenAPI schemas follow their wire types either way. The client validates the members that identify a problem, its status, code, type URI and trace, and reads the wording from its own pinned vocabulary. Signed-off-by: Jeremi Joslin --- .../registry-scheduling-client/src/client.rs | 19 +- .../registry-scheduling-client/src/error.rs | 43 +++- crates/registry-scheduling-client/src/lib.rs | 9 +- crates/registry-scheduling-core/src/wire.rs | 194 +++++++++++++++--- .../registry-scheduling.openapi.json | 38 ++-- .../scheduling/scripts/generate_openapi.py | 88 ++++++-- 6 files changed, 314 insertions(+), 77 deletions(-) diff --git a/crates/registry-scheduling-client/src/client.rs b/crates/registry-scheduling-client/src/client.rs index 7db96c896d..e439030141 100644 --- a/crates/registry-scheduling-client/src/client.rs +++ b/crates/registry-scheduling-client/src/client.rs @@ -449,11 +449,18 @@ impl SchedulingClient { /// A domain problem requires every one of these to hold: the document's /// status equals the response status, the code is in the closed vocabulary /// and pins that same status, the response trace header matches the -/// document's trace identifier, and the type URI, title, and detail equal -/// the pinned definition. Any mismatch, and any code outside the closed -/// vocabulary (a platform-owned transport problem carrying a different type -/// base, for example), is the edge talking: a protocol failure, never a -/// domain problem. +/// document's trace identifier, and the type URI equals the pinned +/// definition. Any mismatch, and any code outside the closed vocabulary (a +/// platform-owned transport problem carrying a different type base, for +/// example), is the edge talking: a protocol failure, never a domain +/// problem. +/// +/// The title and the remediation detail are deliberately not compared. They +/// are human-readable members of the answer, and the caller reads its own +/// pinned copies through `code.title()` and `code.detail()`. Holding the +/// deployment to this build's wording would make an editorial change to a +/// title, or a deployment one release ahead, an unrecoverable protocol +/// failure over a refusal the code already named exactly. pub(crate) fn domain_problem( status: StatusCode, header_trace: Option<&str>, @@ -467,8 +474,6 @@ pub(crate) fn domain_problem( || code.http_status() != status.as_u16() || header_trace != Some(document.trace_id.as_str()) || document.type_uri != type_uri(code.code()) - || document.title != code.title() - || document.detail != code.detail() { return protocol(status, SchedulingProtocolFailure::Problem, trace_id); } diff --git a/crates/registry-scheduling-client/src/error.rs b/crates/registry-scheduling-client/src/error.rs index 8c81d49973..c4c0fa8dbb 100644 --- a/crates/registry-scheduling-client/src/error.rs +++ b/crates/registry-scheduling-client/src/error.rs @@ -7,9 +7,11 @@ //! matchable without string inspection, and a product problem carries the //! core's typed `ProblemCode`, whose pinned title and remediation detail the //! caller reads with `code.title()` and `code.detail()`. The client -//! validates a problem against that pinned definition before surfacing it, -//! so a refused answer can never carry text this client has not already -//! pinned in the core. +//! validates the identifying members of a problem, its status, code, type +//! URI, and trace, against that pinned definition before surfacing it. The +//! human-readable title and detail are read from the core, never compared +//! against the answer, so a deployment that rewords one still answers a +//! refusal this caller can recover from. use registry_platform_httputil::client::TransportKind; use registry_scheduling_core::ProblemCode; @@ -134,22 +136,45 @@ mod tests { } } + /// The pinned title and remediation detail are this client's own copy of + /// the vocabulary, not members to hold a deployment to. A deployment that + /// rewords one has not changed which refusal it answered, and a client + /// that refused the answer over the wording would turn an editorial + /// change into an unrecoverable protocol failure. #[test] - fn every_problem_mismatch_degrades_to_a_protocol_failure() { + fn a_reworded_title_or_detail_is_still_the_problem_the_code_names() { let document = exhaustive_document(); let trace = Some(document.trace_id.as_str()); - - let mismatches: Vec = vec![ + let reworded = vec![ ProblemDocument { - status: 500, + title: "No capacity remains".to_owned(), ..document.clone() }, ProblemDocument { - title: "Wrong title".to_owned(), + detail: "Every opening at that start is taken.".to_owned(), ..document.clone() }, + ]; + for answer in reworded { + match domain_problem(StatusCode::CONFLICT, trace, &answer) { + SchedulingClientError::Problem { + status: 409, + code: ProblemCode::CapacityExhausted, + .. + } => {} + other => panic!("expected a typed domain problem, got {other:?}"), + } + } + } + + #[test] + fn every_problem_mismatch_degrades_to_a_protocol_failure() { + let document = exhaustive_document(); + let trace = Some(document.trace_id.as_str()); + + let mismatches: Vec = vec![ ProblemDocument { - detail: "Wrong detail.".to_owned(), + status: 500, ..document.clone() }, ProblemDocument { diff --git a/crates/registry-scheduling-client/src/lib.rs b/crates/registry-scheduling-client/src/lib.rs index 7b87eeb2b7..ca77d81a85 100644 --- a/crates/registry-scheduling-client/src/lib.rs +++ b/crates/registry-scheduling-client/src/lib.rs @@ -15,11 +15,12 @@ //! Scheduling accepts. The client never retains the token, follows //! redirects, or retries a mutation. Mutating calls carry a caller-supplied //! idempotency key, validated against the pinned bound before any network -//! input or output. Responses are read under a bounded byte ceiling, the -//! core documents themselves refuse unknown fields, and every failure lands +//! input or output. Responses are read under a bounded byte ceiling, the core +//! answer documents tolerate a member a later deployment added while the +//! request documents the runtime reads refuse one, and every failure lands //! in one of three named shapes: a caller-side request defect, a transport -//! failure, or a protocol failure, with exactly validated product problems -//! surfaced as their typed code. +//! failure, or a protocol failure, with validated product problems surfaced +//! as their typed code. //! //! Re-exports are flat, so a caller names a document, a route constant, or a //! vocabulary entry through this crate exactly as the core spells it. diff --git a/crates/registry-scheduling-core/src/wire.rs b/crates/registry-scheduling-core/src/wire.rs index b9b6b08bfd..11aba4e245 100644 --- a/crates/registry-scheduling-core/src/wire.rs +++ b/crates/registry-scheduling-core/src/wire.rs @@ -4,10 +4,16 @@ //! //! These types are the contract between the runtime and every client, so they //! live in the source-neutral core beside the model they project, the way the -//! Casework DTOs live in `registry-casework-core`. Every document is -//! camelCase on the wire and refuses unknown fields, so an undocumented -//! addition to a response is a compatibility event a caller notices, not a -//! field a lenient parser silently drops. +//! Casework DTOs live in `registry-casework-core`. Every document is camelCase +//! on the wire, and the direction it travels decides how strictly it is read. +//! +//! A request document is read by the runtime and refuses a member it does not +//! declare: an undeclared member is either a caller mistake or a reach for a +//! field the store owns, and either way it must be answered, not ignored. An +//! answer document is read by a client that may be older than the deployment +//! answering it, and carries the opposite rule: a member added to an answer is +//! ignored, so an additive server change is not an outage for every client +//! compiled before it. //! //! The projections are deliberate: `because` review reasons are authoring //! material and never published; occupied intervals (with buffers) are the @@ -23,7 +29,7 @@ use crate::model::AdmissionRequest; /// What `GET /v1/scheduling` answers: which deployment and which policy. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct SchedulingServiceDocument { pub scheduling_id: String, pub policy_revision: u64, @@ -32,7 +38,7 @@ pub struct SchedulingServiceDocument { /// One service in the catalogue. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct ServiceDocument { pub id: String, pub label: String, @@ -40,7 +46,7 @@ pub struct ServiceDocument { /// One offering in the catalogue, the public view of the authored policy. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct OfferingDocument { pub id: String, pub service: String, @@ -77,14 +83,14 @@ pub enum SchedulingModeDocument { /// One authored reminder offset, as the catalogue publishes it. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct ReminderDocument { pub minutes_before: u32, } /// A published arrival window in the catalogue. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct WindowDocument { pub id: String, pub revision: u64, @@ -96,7 +102,7 @@ pub struct WindowDocument { /// One backing resource in the resource listing: a concrete pool member, /// because a pool is its members, never an independent counter. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct ResourceDocument { pub resource_id: String, pub pool: String, @@ -107,7 +113,7 @@ pub struct ResourceDocument { /// One location in the location listing, with the IANA timezone identifier /// its published openings expand in. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct LocationDocument { pub location_id: String, pub timezone: String, @@ -119,8 +125,7 @@ pub struct LocationDocument { #[serde( tag = "kind", rename_all = "camelCase", - rename_all_fields = "camelCase", - deny_unknown_fields + rename_all_fields = "camelCase" )] pub enum AvailabilityEntry { /// A grid slot and how many pool members are still free across it. @@ -142,7 +147,7 @@ pub enum AvailabilityEntry { /// One page of a bounded listing, with the opaque cursor that continues it. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct PageDocument { pub items: Vec, pub next_cursor: Option, @@ -152,7 +157,7 @@ pub struct PageDocument { /// the same admission request shape a direct create carries: a hold is an /// admission ask that reserves instead of committing. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct HoldDocument { pub hold_id: String, pub offering: String, @@ -185,7 +190,7 @@ impl AppointmentStateDocument { /// A confirmed appointment. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct AppointmentDocument { pub appointment_id: String, pub offering: String, @@ -232,7 +237,7 @@ pub struct CancelAppointmentRequest { /// One attributable step in an appointment's history. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct AppointmentHistoryEntryDocument { pub event_id: String, pub kind: String, @@ -247,7 +252,7 @@ pub struct AppointmentHistoryEntryDocument { /// a refused start would have said, including the one detail the public path /// never names. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[serde(rename_all = "camelCase")] pub struct ExplainDocument { pub offering: String, pub start: DateTime, @@ -276,7 +281,7 @@ mod tests { } #[test] - fn catalogue_documents_round_trip_and_refuse_unknown_fields() { + fn catalogue_documents_round_trip() { let offering = OfferingDocument { id: "registry-update-30".to_owned(), service: "registry-update".to_owned(), @@ -303,10 +308,153 @@ mod tests { assert_eq!(json["leadTimeMinutes"], 120); let parsed: OfferingDocument = serde_json::from_value(json).unwrap(); assert_eq!(parsed, offering); - assert!(serde_json::from_str::( - &serde_json::to_string(&offering) - .unwrap() - .replace("\"reminders\"", "\"reminders\":{\"x\":1},\"stray\"",) + } + + /// One answer document as a deployment ahead of this build would send + /// it: the same members, plus one this build has never heard of. + /// + /// An answer document that refused it would turn an additive change on + /// the server into a failed exchange for every client compiled before + /// that change, so the member is ignored and the rest still parses. + fn tolerates_a_later_member(document: &T) + where + T: Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug, + { + let mut json = serde_json::to_value(document).expect("an answer document serializes"); + json.as_object_mut() + .expect("an answer document is a JSON object") + .insert("laterMember".to_owned(), Value::from(1)); + let parsed: T = serde_json::from_value(json) + .expect("an answer document tolerates a member a later deployment added"); + assert_eq!(&parsed, document); + } + + #[test] + fn answer_documents_tolerate_a_member_a_later_deployment_added() { + let window = WindowDocument { + id: "household-morning-window".to_owned(), + revision: 3, + start: utc(10, 8), + end: utc(10, 10), + units: 12, + }; + let service = ServiceDocument { + id: "registry-update".to_owned(), + label: "Registry record update".to_owned(), + }; + tolerates_a_later_member(&SchedulingServiceDocument { + scheduling_id: "counter-scheduling".to_owned(), + policy_revision: 4, + policy_digest: "sha256:0f".to_owned(), + }); + tolerates_a_later_member(&service); + tolerates_a_later_member(&ReminderDocument { + minutes_before: 1440, + }); + tolerates_a_later_member(&window); + tolerates_a_later_member(&OfferingDocument { + id: "registry-update-30".to_owned(), + service: "registry-update".to_owned(), + label: "30-minute counter update".to_owned(), + mode: SchedulingModeDocument::ArrivalWindow, + location: "north-counter".to_owned(), + lead_time_minutes: 120, + horizon_days: 60, + cancellation_cutoff_minutes: 240, + duration_minutes: None, + buffer_before_minutes: None, + buffer_after_minutes: None, + start_increment_minutes: None, + max_recipients: None, + window: Some(window.clone()), + reminders: vec![ReminderDocument { + minutes_before: 1440, + }], + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + }); + tolerates_a_later_member(&ResourceDocument { + resource_id: "station-1".to_owned(), + pool: "counters".to_owned(), + capabilities: vec!["sign-language".to_owned()], + available: true, + }); + tolerates_a_later_member(&LocationDocument { + location_id: "north-counter".to_owned(), + timezone: "Europe/Paris".to_owned(), + }); + tolerates_a_later_member(&AvailabilityEntry::Slot { + start: utc(5, 2), + end: utc(5, 3), + free: 2, + }); + tolerates_a_later_member(&AvailabilityEntry::Window { + window: "household-morning-window".to_owned(), + start: utc(10, 8), + end: utc(10, 10), + remaining: 1, + channel_remaining: Some(0), + }); + tolerates_a_later_member(&PageDocument { + items: vec![service], + next_cursor: None, + }); + tolerates_a_later_member(&HoldDocument { + hold_id: "0b2bb6b6-3f3a-4c66-9a5a-2fbd8c72d1ee".to_owned(), + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + end: utc(5, 3), + resource: Some("station-1".to_owned()), + units: 1, + expires_at: utc(5, 1), + policy_revision: 1, + }); + tolerates_a_later_member(&AppointmentDocument { + appointment_id: "6e97f6a3-8524-4a13-9db8-ad0c4eb4d64b".to_owned(), + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + end: utc(5, 3), + resource: Some("station-1".to_owned()), + units: 1, + channel: None, + revision: 2, + state: AppointmentStateDocument::Confirmed, + policy_revision: 1, + created_at: utc(4, 9), + cancelled_at: None, + }); + tolerates_a_later_member(&AppointmentHistoryEntryDocument { + event_id: "b31c0f4e-cc7f-4ba8-88f2-9c1a9102f0a1".to_owned(), + kind: "rescheduled".to_owned(), + revision: 2, + occurred_at: utc(4, 10), + actor: Some("hmac-sha256:v2:8f0a".to_owned()), + detail: serde_json::json!({"start": "2026-10-05T03:00:00Z"}), + }); + tolerates_a_later_member(&ExplainDocument { + offering: "registry-update-30".to_owned(), + start: utc(5, 2), + public_code: Some("capacity.exhausted".to_owned()), + detailed_code: Some("resource.unavailable".to_owned()), + explanation: Some("every capable member is unavailable".to_owned()), + }); + } + + #[test] + fn request_documents_refuse_a_member_they_do_not_declare() { + // The runtime reads these, and a member it does not declare is either + // a caller mistake or an attempt to reach a field the store owns. + // Tolerating one would let it pass unread and unanswered. + assert!(serde_json::from_str::( + r#"{"hold":"0b2bb6b6-3f3a-4c66-9a5a-2fbd8c72d1ee","laterMember":1}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"observedRevision":2,"admission":{},"laterMember":1}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"observedRevision":2,"laterMember":1}"# ) .is_err()); } diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json index 0956d4574e..e04d7b7958 100644 --- a/products/scheduling/generated/registry-scheduling.openapi.json +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -89,7 +89,7 @@ "type": "object" }, "AppointmentDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "appointmentId": { "type": "string" @@ -172,7 +172,7 @@ "type": "object" }, "AppointmentHistoryEntryDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "actor": { "anyOf": [ @@ -212,7 +212,7 @@ "type": "object" }, "AppointmentHistoryPage": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "items": { "items": { @@ -258,7 +258,7 @@ ] }, "AvailabilityPage": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "items": { "items": { @@ -284,7 +284,7 @@ "type": "object" }, "AvailabilitySlot": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "end": { "format": "date-time", @@ -311,7 +311,7 @@ "type": "object" }, "AvailabilityWindow": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "channelRemaining": { "anyOf": [ @@ -405,7 +405,7 @@ "type": "object" }, "ExplainDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "detailedCode": { "type": "string" @@ -431,7 +431,7 @@ "type": "object" }, "HoldDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "end": { "format": "date-time", @@ -483,7 +483,7 @@ "type": "object" }, "LocationDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "locationId": { "type": "string" @@ -499,7 +499,7 @@ "type": "object" }, "LocationPage": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "items": { "items": { @@ -525,7 +525,7 @@ "type": "object" }, "OfferingDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "bufferAfterMinutes": { "anyOf": [ @@ -658,7 +658,7 @@ "type": "object" }, "OfferingPage": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "items": { "items": { @@ -1633,7 +1633,7 @@ ] }, "ReminderDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "minutesBefore": { "minimum": 0, @@ -1664,7 +1664,7 @@ "type": "object" }, "ResourceDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "available": { "type": "boolean" @@ -1691,7 +1691,7 @@ "type": "object" }, "ResourcePage": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "items": { "items": { @@ -1717,7 +1717,7 @@ "type": "object" }, "SchedulingServiceDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "policyDigest": { "type": "string" @@ -1739,7 +1739,7 @@ "type": "object" }, "ServiceDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "id": { "type": "string" @@ -1755,7 +1755,7 @@ "type": "object" }, "ServicePage": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "items": { "items": { @@ -1781,7 +1781,7 @@ "type": "object" }, "WindowDocument": { - "additionalProperties": false, + "additionalProperties": true, "properties": { "end": { "format": "date-time", diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py index ed9a2d2802..3ba7dbc85c 100644 --- a/products/scheduling/scripts/generate_openapi.py +++ b/products/scheduling/scripts/generate_openapi.py @@ -250,12 +250,30 @@ def header_ref(name: str) -> dict: def obj(properties: dict, required: list[str] | None = None) -> dict: + """A document the caller sends: closed, as the Rust request type reads it. + + A member the contract does not declare is either a caller mistake or a + reach for a field the store owns, so the runtime answers it rather than + passing it over. + """ result = {"type": "object", "additionalProperties": False, "properties": properties} if required: result["required"] = required return result +def answer(properties: dict, required: list[str] | None = None) -> dict: + """A document the runtime sends: open, as the Rust answer type reads it. + + A client generated from this contract may be older than the deployment + answering it. Closing an answer would make every additive change on the + server a failed exchange for every client built before that change. + """ + result = obj(properties, required) + result["additionalProperties"] = True + return result + + def array(items: dict) -> dict: return {"type": "array", "items": items} @@ -440,7 +458,7 @@ def unauthenticated(summary: str, description: str, schema_name: str | None = No def page(item_schema_name: str) -> dict: """`PageDocument` with its item type named; the wire type is generic.""" - return obj( + return answer( {"items": array(ref(item_schema_name)), "nextCursor": nullable({"type": "string"})}, ["items", "nextCursor"], ) @@ -470,18 +488,18 @@ def schemas(problem_entries: list[dict]) -> dict: ["offering", "start", "party", "policyRevision", "capabilities", "prerequisites"], ) result = { - "SchedulingServiceDocument": obj( + "SchedulingServiceDocument": answer( {"schedulingId": text, "policyRevision": revision, "policyDigest": text}, ["schedulingId", "policyRevision", "policyDigest"], ), - "ServiceDocument": obj({"id": text, "label": text}, ["id", "label"]), + "ServiceDocument": answer({"id": text, "label": text}, ["id", "label"]), "ServicePage": page("ServiceDocument"), - "ReminderDocument": obj({"minutesBefore": count}, ["minutesBefore"]), - "WindowDocument": obj( + "ReminderDocument": answer({"minutesBefore": count}, ["minutesBefore"]), + "WindowDocument": answer( {"id": text, "revision": revision, "start": instant, "end": instant, "units": count}, ["id", "revision", "start", "end", "units"], ), - "OfferingDocument": obj( + "OfferingDocument": answer( { "id": text, "service": text, @@ -516,7 +534,7 @@ def schemas(problem_entries: list[dict]) -> dict: ], ), "OfferingPage": page("OfferingDocument"), - "ResourceDocument": obj( + "ResourceDocument": answer( { "resourceId": text, "pool": text, @@ -526,13 +544,13 @@ def schemas(problem_entries: list[dict]) -> dict: ["resourceId", "pool", "capabilities", "available"], ), "ResourcePage": page("ResourceDocument"), - "LocationDocument": obj({"locationId": text, "timezone": text}, ["locationId", "timezone"]), + "LocationDocument": answer({"locationId": text, "timezone": text}, ["locationId", "timezone"]), "LocationPage": page("LocationDocument"), - "AvailabilitySlot": obj( + "AvailabilitySlot": answer( {"kind": {"const": "slot"}, "start": instant, "end": instant, "free": count}, ["kind", "start", "end", "free"], ), - "AvailabilityWindow": obj( + "AvailabilityWindow": answer( { "kind": {"const": "window"}, "window": text, @@ -548,7 +566,7 @@ def schemas(problem_entries: list[dict]) -> dict: "discriminator": {"propertyName": "kind"}, }, "AvailabilityPage": page("AvailabilityEntry"), - "HoldDocument": obj( + "HoldDocument": answer( { "holdId": text, "offering": text, @@ -579,7 +597,7 @@ def schemas(problem_entries: list[dict]) -> dict: ["observedRevision"], ), "AppointmentState": appointment_state, - "AppointmentDocument": obj( + "AppointmentDocument": answer( { "appointmentId": text, "offering": text, @@ -606,7 +624,7 @@ def schemas(problem_entries: list[dict]) -> dict: "createdAt", ], ), - "AppointmentHistoryEntryDocument": obj( + "AppointmentHistoryEntryDocument": answer( { "eventId": text, "kind": { @@ -621,7 +639,7 @@ def schemas(problem_entries: list[dict]) -> dict: ["eventId", "kind", "revision", "occurredAt", "detail"], ), "AppointmentHistoryPage": page("AppointmentHistoryEntryDocument"), - "ExplainDocument": obj( + "ExplainDocument": answer( { "offering": text, "start": instant, @@ -1228,6 +1246,27 @@ def rust_struct_fields(source: str, name: str) -> set[str]: return set(re.findall(r"^\s*pub\s+([a-z_]+)\s*:", match.group("body"), re.M)) +def rust_serde_attributes(source: str, name: str, keyword: str = "struct") -> str: + """The serde attribute list immediately above one Rust DTO declaration.""" + declaration = re.search( + rf"^pub {keyword} {re.escape(name)}(?:<[^>]+>)?\s*[{{(]", source, re.M + ) + if not declaration: + raise ValueError(f"Rust DTO is missing: {name}") + attribute = re.search(r"#\[serde\(([^()]*)\)\]\s*\Z", source[: declaration.start()], re.S) + if not attribute: + raise ValueError(f"Rust DTO carries no serde attributes: {name}") + return attribute.group(1) + + +def rust_refuses_unknown_members(source: str, name: str, keyword: str = "struct") -> bool: + return "deny_unknown_fields" in rust_serde_attributes(source, name, keyword) + + +def schema_is_closed(schema: dict) -> bool: + return schema.get("additionalProperties") is False + + def rust_kebab_case_unit_enum_values(source: str, name: str) -> list[str]: match = re.search( rf'#\[serde\(rename_all = "kebab-case"\)\]\s*pub enum {re.escape(name)}\s*\{{(?P.*?)^\}}', @@ -1253,6 +1292,16 @@ def verify_dto_schemas(repository_root: Path, openapi: dict) -> None: f"documented_only={sorted(schema_fields - rust_fields)}, " f"rust_only={sorted(rust_fields - schema_fields)}" ) + # Openness follows the wire type, and the wire type follows the + # direction: a request the runtime reads is closed, an answer a + # client reads is open. + refuses = rust_refuses_unknown_members(source, rust_name) + if refuses != schema_is_closed(openapi_schemas[schema_name]): + raise ValueError( + f"OpenAPI openness drifted from {rust_name}; " + f"rust_refuses_unknown_members={refuses}, " + f"schema_is_closed={schema_is_closed(openapi_schemas[schema_name])}" + ) wire = production_source(repository_root, WIRE_SOURCE) page_fields = {camel_case(field) for field in rust_struct_fields(wire, "PageDocument")} for schema_name in ( @@ -1267,6 +1316,10 @@ def verify_dto_schemas(repository_root: Path, openapi: dict) -> None: raise ValueError(f"OpenAPI page shape drifted for {schema_name}") if set(openapi_schemas[schema_name]["required"]) != page_fields: raise ValueError(f"OpenAPI page required fields drifted for {schema_name}") + if rust_refuses_unknown_members(wire, "PageDocument") != schema_is_closed( + openapi_schemas[schema_name] + ): + raise ValueError(f"OpenAPI page openness drifted from PageDocument for {schema_name}") if set(openapi_schemas["OfferingDocument"]["properties"]["mode"]["enum"]) != set( rust_kebab_case_unit_enum_values(wire, "SchedulingModeDocument") ): @@ -1284,9 +1337,12 @@ def verify_dto_schemas(repository_root: Path, openapi: dict) -> None: raise ValueError("Rust availability entry is missing") attributes = entry_match.group("attributes") body = entry_match.group("body") - for text in ('tag = "kind"', 'rename_all_fields = "camelCase"', "deny_unknown_fields"): + for text in ('tag = "kind"', 'rename_all_fields = "camelCase"'): if text not in attributes: raise ValueError(f"OpenAPI availability entries drifted from Rust: {text}") + # An availability entry is an answer, so both variants stay open. + if "deny_unknown_fields" in attributes: + raise ValueError("Rust availability entries refuse a member a later deployment added") variants = re.findall(r"^\s*([A-Z][A-Za-z0-9]*)\s*\{", body, re.M) expected = {re.sub(r"(? None: ): if variant["properties"]["kind"]["const"] != kind: raise ValueError("OpenAPI availability discriminator drifted from Rust") + if schema_is_closed(variant): + raise ValueError(f"OpenAPI availability variant is closed against Rust: {kind}") explain = openapi_schemas["ExplainDocument"] for field in ("publicCode", "detailedCode", "explanation"): if field in explain["required"] or "anyOf" in explain["properties"][field]: From f832e305858064bfeecc2f3c2a606db99ca75b3a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:07:18 +0700 Subject: [PATCH 069/115] fix(scheduling): answer an unknown offering and a malformed body their own codes precondition.failed carried four unrelated refusals: an offering the deployed policy does not publish, on four routes, and two unreadable create bodies. It told every one of them to reload something and retry, which none of them can fix. An unknown offering is now request.not-found. It does not exist, the offering listing is where a caller learns what does, and that listing is readable by every caller holding the reads scope, so naming the absence discloses nothing the listing would not. The lookup is one named method now, so the reasoning sits in one place. A create naming both a hold and an admission, or neither, and a hold that is not an identifier, are now request.invalid: the request could not be read, and no precondition would make it readable. No problem code was added: both codes were already in the catalog. precondition.failed now has no producer in the Scheduling runtime. The OpenAPI generator and the published API reference still document the old mapping; neither is owned by this change. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/service.rs | 38 +++++------ .../tests/postgres_commitments.rs | 63 +++++++++++++++++++ 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index d6cc143636..e9b01715a7 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -316,10 +316,7 @@ impl SchedulingService { now: DateTime, ) -> Result, ServiceError> { let limit = page_limit(limit); - let offering = self - .policy - .offering(offering_id) - .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; + let offering = self.published_offering(offering_id)?; let from = start.unwrap_or(now); let span = TimeDelta::days(MAXIMUM_AVAILABILITY_SPAN_DAYS); let to = match end { @@ -402,10 +399,7 @@ impl SchedulingService { start: DateTime, now: DateTime, ) -> Result { - let offering = self - .policy - .offering(offering_id) - .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; + let offering = self.published_offering(offering_id)?; let probe = AdmissionRequest { offering: offering.id.clone(), start, @@ -505,10 +499,7 @@ impl SchedulingService { request: &AdmissionRequest, now: DateTime, ) -> Result, ServiceError> { - let offering = self - .policy - .offering(&request.offering) - .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; + let offering = self.published_offering(&request.offering)?; let grant = self .require_permission(caller, offering, HOLD_CREATE_ACTION) .await?; @@ -615,14 +606,14 @@ impl SchedulingService { ) -> Result, ServiceError> { match (&request.hold, &request.admission) { (Some(_), Some(_)) | (None, None) => { - return Err(ServiceError::Problem(ProblemCode::PreconditionFailed)); + return Err(ServiceError::Problem(ProblemCode::RequestInvalid)); } _ => {} } let answer = match &request.hold { Some(hold) => { let hold_id = Uuid::parse_str(hold) - .map_err(|_| ServiceError::Problem(ProblemCode::PreconditionFailed))?; + .map_err(|_| ServiceError::Problem(ProblemCode::RequestInvalid))?; self.confirm_appointment(caller, idempotency_key, hold_id, now) .await } @@ -695,10 +686,7 @@ impl SchedulingService { request: &AdmissionRequest, now: DateTime, ) -> Result, ServiceError> { - let offering = self - .policy - .offering(&request.offering) - .ok_or(ServiceError::Problem(ProblemCode::PreconditionFailed))?; + let offering = self.published_offering(&request.offering)?; let grant = self .require_permission(caller, offering, APPOINTMENT_CREATE_ACTION) .await?; @@ -1010,6 +998,20 @@ impl SchedulingService { } } + /// The offering the deployed policy publishes under `offering_id`. + /// + /// An offering the policy does not publish is not a precondition the + /// caller can satisfy by reloading and retrying, which is what + /// `precondition.failed` invites: it does not exist, and the offering + /// listing is where a caller learns what does. That listing is readable by + /// every caller holding the reads scope, so naming the absence discloses + /// nothing the listing would not. + fn published_offering(&self, offering_id: &str) -> Result<&OfferingPolicy, ServiceError> { + self.policy + .offering(offering_id) + .ok_or(ServiceError::Problem(ProblemCode::RequestNotFound)) + } + /// A grant that covers this offering's service, location, and action, or /// the refusal. Readable availability is not authority to book: the /// permission must name all three. diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index e6f68caf19..73553c8258 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -1929,3 +1929,66 @@ async fn the_claim_ledger_bounds_a_caller_chosen_duplicate_key() { "the ledger stores a duplicate key of any length the body ceiling allows" ); } + +#[tokio::test] +async fn an_unknown_offering_and_a_malformed_body_answer_their_own_codes() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let absent = "registry-update-90"; + + // An offering the deployed policy does not publish is not a precondition + // the caller can satisfy by reloading: it does not exist. + let (status, problem) = fx + .get(&availability_uri(absent, 300, 440), &fx.reader) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(problem["code"], "request.not-found"); + + let (status, problem) = fx + .get( + &format!( + "/v1/availability/explain?offering={absent}&start={}", + stamp(slot) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(problem["code"], "request.not-found"); + + let (status, problem) = fx + .post( + "/v1/holds", + &fx.agent, + "absent-hold", + admission(&fx, absent, slot), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(problem["code"], "request.not-found"); + + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "absent-appointment", + json!({"hold": null, "admission": admission(&fx, absent, slot)}), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(problem["code"], "request.not-found"); + + // A create names exactly one of a hold and an admission. Both, neither, + // or a hold that is not an identifier are all unreadable requests. + for body in [ + json!({"hold": Uuid::new_v4().to_string(), "admission": admission(&fx, OFFERING, slot)}), + json!({"hold": null, "admission": null}), + json!({"hold": "not-an-identifier", "admission": null}), + ] { + let (status, problem) = fx + .post("/v1/appointments", &fx.agent, "malformed", body) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(problem["code"], "request.invalid"); + } +} From 215538e7f3ed9d112f7690abcb129ddb74d50f8f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:09:34 +0700 Subject: [PATCH 070/115] feat(scheduling): read the delivery intents nothing will carry further A deployment that declares no reminders destination holds every intent locally, and a destination that refuses every attempt the ceiling allows leaves the intent failed. Both were recorded and then waited on by nobody: the ledger held them and no read path showed them, so an operator could not tell either had happened. undelivered_intents lists exactly those two states, oldest first. Delivered intents are left out, and so are pending ones, which the delivery sweep is still carrying. This is the store half. The operator verb that calls it belongs to schedulingctl, which is where the wave brief placed it. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/store.rs | 53 +++++++++++ .../tests/postgres_commitments.rs | 90 +++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 0ce3ee3e6e..587a818052 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -255,6 +255,22 @@ pub struct OutboxRow { pub payload: Value, } +/// One delivery intent no sweep will carry any further, as an operator reads +/// it. The delivery state is what separates the two ways that happens: `local` +/// is a deployment that declares no destination, and `failed` is a destination +/// that refused every attempt the ceiling allowed. +#[derive(Clone, Debug)] +pub struct UndeliveredIntent { + pub outbox_id: Uuid, + pub purpose: String, + pub claim_id: Uuid, + pub appointment_revision: i64, + pub due_at: DateTime, + pub delivery_state: String, + pub attempts: i32, + pub payload: Value, +} + impl PostgresStore { pub fn connect_runtime( config: &DatabaseConfig, @@ -1437,6 +1453,43 @@ impl PostgresStore { .collect()) } + /// The intents nothing will deliver without an operator, oldest first. + /// + /// A deployment that declares no reminders destination holds every intent + /// locally, and a destination that refused every attempt leaves the intent + /// failed. Both are recorded and then waited on by nobody, so without a + /// read path an operator cannot tell either has happened. Delivered + /// intents are not listed, and neither are pending ones: the sweep is + /// still carrying those. + pub async fn undelivered_intents( + &self, + limit: i64, + ) -> Result, StoreError> { + let client = self.client().await?; + let rows = client + .query( + "SELECT outbox_id, purpose, claim_id, appointment_revision, due_at, \ + delivery_state, attempts, payload FROM scheduling_outbox \ + WHERE delivery_state IN ('local','failed') \ + ORDER BY due_at, outbox_id LIMIT $1", + &[&limit], + ) + .await?; + Ok(rows + .iter() + .map(|row| UndeliveredIntent { + outbox_id: row.get(0), + purpose: row.get(1), + claim_id: row.get(2), + appointment_revision: row.get(3), + due_at: row.get(4), + delivery_state: row.get(5), + attempts: row.get(6), + payload: row.get(7), + }) + .collect()) + } + /// Mark one intent delivered. pub async fn mark_intent_delivered(&self, outbox_id: Uuid) -> Result<(), StoreError> { let client = self.client().await?; diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 73553c8258..80442c5f62 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -1992,3 +1992,93 @@ async fn an_unknown_offering_and_a_malformed_body_answer_their_own_codes() { assert_eq!(problem["code"], "request.invalid"); } } + +#[tokio::test] +async fn the_intents_nobody_will_deliver_are_readable_by_an_operator() { + let fx = fixture().await; + let (appointment_id, _) = booked(&fx, 90, 200, "held-1").await; + let due = Utc::now() + TimeDelta::days(1); + let claimed = fx + .store + .claim_due_intents(due, 100) + .await + .expect("the delivery sweep runs"); + let intent = |purpose: &str| { + claimed + .iter() + .find(|intent| intent.purpose == purpose) + .expect("the commitment minted this intent") + .outbox_id + }; + + // One deployment declares no destination, so its reminder stays recorded + // rather than pretending a delivery happened. One send exhausted its + // attempts. One was delivered, and one is still inside its back-off. + fx.store + .hold_intent_local(intent("reminder")) + .await + .expect("the reminder is held locally"); + fx.store + .retry_intent(intent("confirmation"), due, 0) + .await + .expect("the confirmation exhausts its attempts"); + + let held = fx + .store + .undelivered_intents(100) + .await + .expect("the operator reads the undelivered intents"); + let mut seen: Vec<(String, String)> = held + .iter() + .map(|intent| (intent.purpose.clone(), intent.delivery_state.clone())) + .collect(); + seen.sort(); + assert_eq!( + seen, + vec![ + ("confirmation".to_owned(), "failed".to_owned()), + ("reminder".to_owned(), "local".to_owned()), + ], + "an intent nothing will deliver is invisible to the operator" + ); + + let reminder = held + .iter() + .find(|intent| intent.purpose == "reminder") + .expect("the held reminder is listed"); + assert_eq!(reminder.claim_id.to_string(), appointment_id); + assert_eq!(reminder.attempts, 1); + assert_eq!(reminder.payload["appointmentId"], appointment_id); + + // A delivered intent needs no operator, and neither does one still + // waiting out its back-off. + let (second, _) = booked(&fx, 210, 320, "held-2").await; + let pending = fx + .store + .claim_due_intents(Utc::now(), 100) + .await + .expect("the delivery sweep runs"); + let delivered = pending + .iter() + .find(|intent| intent.claim_id.to_string() == second) + .expect("the second commitment confirms at once") + .outbox_id; + fx.store + .mark_intent_delivered(delivered) + .await + .expect("the confirmation is delivered"); + let held = fx + .store + .undelivered_intents(100) + .await + .expect("the operator reads the undelivered intents"); + assert!( + held.iter().all(|intent| intent.outbox_id != delivered), + "a delivered intent is listed as needing an operator" + ); + assert_eq!( + held.len(), + 2, + "an intent the sweep will still retry needs no operator" + ); +} From ed07650b4da2fed78d100921d77614f1376f302e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:16:11 +0700 Subject: [PATCH 071/115] fix(schedulingctl): print expected and actual codes for a failing fixture case `test` reported only what replay actually produced for a failing case, never what the fixture expected, so a mismatched problem code was legible only by opening the fixture file. Report both side by side. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/lib.rs | 13 ++++---- crates/registry-schedulingctl/src/project.rs | 33 +++++++++++++++++--- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index 474676f26c..d7df92afc6 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -647,12 +647,13 @@ mod tests { .find(|case| case["name"] == "same-key-retry-is-refused") .unwrap(); assert_eq!(case["status"], "fail"); - // The detail reports what actually happened: the duplicate-active - // refusal the broken expectation no longer matches. - assert!(case["detail"] - .as_str() - .unwrap() - .contains("booking.duplicate-active")); + // A failing case prints what the fixture expected and what actually + // happened side by side, so a mismatch is legible without opening + // the fixture file. + let detail = case["detail"].as_str().unwrap(); + assert!(detail.contains("expected"), "{detail}"); + assert!(detail.contains("capacity.exhausted"), "{detail}"); + assert!(detail.contains("booking.duplicate-active"), "{detail}"); } #[test] diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index 316f85c564..c7a50867f1 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -13,8 +13,9 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; use registry_scheduling::config::{verify_policy_package, PolicyPackageManifest}; use registry_scheduling_core::{ - parse_fixture_yaml, parse_policy_yaml, CaseStatus, ReplayError, SchedulingDiagnostic, - SchedulingFixture, SchedulingPolicy, AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, + parse_fixture_yaml, parse_policy_yaml, CaseStatus, FixtureExpectation, ReplayError, + SchedulingDiagnostic, SchedulingFixture, SchedulingPolicy, AUTHORED_POLICY_FILE, + SCHEDULING_PACKAGE_MANIFEST_FILE, }; use serde_json::{json, Value}; @@ -278,11 +279,23 @@ fn run_fixture(project: &Path, policy: &SchedulingPolicy, path: &Path) -> Result }; let cases = outcomes .iter() - .map(|outcome| { + .zip(fixture.cases.iter()) + .map(|(outcome, case)| { + // A failing case names what the fixture expected beside what + // replay actually produced, so a mismatched problem code is + // legible from the report alone, never only from opening the + // fixture file. + let detail = match (outcome.status, &outcome.detail) { + (CaseStatus::Fail, Some(actual)) => Some(format!( + "expected {}, actual {actual}", + expected_summary(&case.expect) + )), + (_, detail) => detail.clone(), + }; json!({ "name": outcome.name, "status": outcome.status.as_str(), - "detail": outcome.detail, + "detail": detail, }) }) .collect::>(); @@ -302,6 +315,18 @@ fn run_fixture(project: &Path, policy: &SchedulingPolicy, path: &Path) -> Result })) } +/// Render a fixture case's expectation the same way replay renders what +/// actually happened, so the two read side by side. +fn expected_summary(expect: &FixtureExpectation) -> String { + match expect { + FixtureExpectation::Admitted { units, resource } => format!( + "admitted onto {} for {units} unit(s)", + resource.as_deref().unwrap_or("the window") + ), + FixtureExpectation::Refused { code } => format!("refused {code}"), + } +} + fn load_policy(project: &Path) -> Result { let bytes = read_authoring_input(&project.join(AUTHORED_POLICY_FILE))?; parse_policy_yaml(&bytes).with_context(|| format!("parsing {AUTHORED_POLICY_FILE}")) From 58f5f801eddf6dac6209aafd5f8f86b9db6ffb59 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:17:49 +0700 Subject: [PATCH 072/115] fix(scheduling): say where and why a configuration was refused A malformed runtime document reported "not valid YAML at .", which names neither the member that failed nor the reason it failed. The operator got one sentence and no way to act on it. Two defects produced that. serde_path_to_error renders a refusal it never attributed to a member as ".", not as an empty string, so the fallback that was meant to turn the root into "/" never ran. And the reason lived only in the #[source] chain, which the binary never walks: it prints the top error and exits, so the cause never reached anyone. The authored policy had it worse, dropping the reason entirely. Both refusals now carry the reason serde gave, with the line and column the reader stopped on, and a document refused whole is reported at "/". The refused value itself is kept out of the message: a runtime configuration names secret references, database URLs and destinations, and a startup refusal is written to the operator's log. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/config.rs | 209 ++++++++++++++++++++--- 1 file changed, 184 insertions(+), 25 deletions(-) diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index d868dccce1..9be736907a 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -434,15 +434,8 @@ impl RuntimeConfig { let bytes = std::fs::read(path.as_ref()).map_err(RuntimeConfigError::Read)?; let deserializer = serde_norway::Deserializer::from_slice(&bytes); let config: Self = serde_path_to_error::deserialize(deserializer).map_err(|error| { - let path = error.path().to_string(); - RuntimeConfigError::Parse { - path: if path.is_empty() { - "/".to_owned() - } else { - path - }, - source: error.into_inner(), - } + let (path, cause) = refused_yaml(error); + RuntimeConfigError::Parse { path, cause } })?; config.check()?; Ok(config) @@ -458,14 +451,8 @@ impl RuntimeConfig { let policy_text = std::fs::read_to_string(self.policy_path()).map_err(RuntimeConfigError::PolicyRead)?; let policy = parse_policy_yaml(&policy_text).map_err(|error| { - let path = error.path().to_string(); - RuntimeConfigError::PolicyParse { - path: if path.is_empty() { - "/".to_owned() - } else { - path - }, - } + let (path, cause) = refused_yaml(error); + RuntimeConfigError::PolicyParse { path, cause } })?; if !policy.check().is_empty() { return Err(RuntimeConfigError::PolicyFindings); @@ -780,6 +767,88 @@ fn parse_static_jwks(bytes: &[u8]) -> Result { Ok(jwks) } +/// Name where a YAML document was refused and why, so an operator reading a +/// startup failure can open the document at the place that failed. +/// +/// `serde_path_to_error` renders a refusal it never attributed to a member as +/// `.`, which names nothing an operator can look up, so a document refused +/// whole is reported at its root. The reason is serde's own, and carries the +/// line and column the reader stopped on. +fn refused_yaml(error: serde_path_to_error::Error) -> (String, String) { + let path = error.path().to_string(); + let path = if path == "." { "/".to_owned() } else { path }; + (path, redact_refused_values(&error.into_inner().to_string())) +} + +/// Keep the parts of a refusal an operator acts on, the member, the reason +/// and the location, while the refused value stays out of the message. +/// +/// serde reports the offending value inside an `invalid type:` or an +/// `invalid value:` clause. Only the shape word that opens such a clause +/// survives, so the message still says a string arrived where a number was +/// required without repeating the string. A runtime configuration names +/// secret references, database URLs and destinations, and a startup refusal +/// is written to the operator's log. +fn redact_refused_values(message: &str) -> String { + const CLAUSES: [&str; 2] = ["invalid type: ", "invalid value: "]; + let mut redacted = String::with_capacity(message.len()); + let mut rest = message; + loop { + let Some((start, len)) = CLAUSES + .iter() + .filter_map(|clause| rest.find(clause).map(|start| (start, clause.len()))) + .min_by_key(|(start, _)| *start) + else { + redacted.push_str(rest); + return redacted; + }; + let opened = start + len; + redacted.push_str(&rest[..opened]); + let (shape, tail) = split_refused_value(&rest[opened..]); + redacted.push_str(shape); + rest = tail; + } +} + +/// Split the text after a clause marker into the shape word serde names and +/// the remainder that follows the refused value. +/// +/// serde renders the value with `Debug`, so it opens with a quote or a +/// backtick and may hold the comma that would otherwise end the clause. +fn split_refused_value(clause: &str) -> (&str, &str) { + let bytes = clause.as_bytes(); + let mut index = 0; + let mut shape_end = None; + while index < bytes.len() { + match bytes[index] { + delimiter @ (b'"' | b'`') => { + shape_end.get_or_insert(index); + index = skip_delimited(bytes, index, delimiter); + } + b',' => break, + _ => index += 1, + } + } + let shape_end = shape_end.unwrap_or(index); + (clause[..shape_end].trim_end(), &clause[index..]) +} + +/// Return the offset just past the delimited run that opens at `open`. +/// +/// A delimiter inside a `Debug` rendering arrives escaped, so it does not end +/// the run. +fn skip_delimited(bytes: &[u8], open: usize, delimiter: u8) -> usize { + let mut index = open + 1; + while index < bytes.len() { + match bytes[index] { + b'\\' => index += 2, + byte if byte == delimiter => return index + 1, + _ => index += 1, + } + } + bytes.len() +} + #[derive(Debug, Error)] pub enum PolicyPackageError { #[error("the Scheduling policy package could not be read")] @@ -792,12 +861,8 @@ pub enum PolicyPackageError { pub enum RuntimeConfigError { #[error("the Scheduling runtime configuration could not be read")] Read(#[source] std::io::Error), - #[error("the Scheduling runtime configuration is not valid YAML at {path}")] - Parse { - path: String, - #[source] - source: serde_norway::Error, - }, + #[error("the Scheduling runtime configuration is not valid YAML at {path}: {cause}")] + Parse { path: String, cause: String }, #[error( "unsupported Scheduling runtime apiVersion; expected registry.registrystack.org/scheduling-runtime/v1alpha1" )] @@ -816,8 +881,8 @@ pub enum RuntimeConfigError { SecretProviderRequired { path: String }, #[error("the authored scheduling policy could not be read")] PolicyRead(#[source] std::io::Error), - #[error("the authored scheduling policy is not valid YAML at {path}")] - PolicyParse { path: String }, + #[error("the authored scheduling policy is not valid YAML at {path}: {cause}")] + PolicyParse { path: String, cause: String }, #[error("the authored scheduling policy does not pass its checks")] PolicyFindings, #[error("the Scheduling policy package is invalid")] @@ -1093,6 +1158,100 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} } } + #[test] + fn a_runtime_document_refused_whole_is_reported_at_the_root() { + let root = tempfile::tempdir().unwrap(); + let operator = root.path().join("runtime.yaml"); + let canary = "DO_NOT_DISCLOSE_RUNTIME_VALUE"; + std::fs::write(&operator, format!("{canary}\n")).unwrap(); + + let error = RuntimeConfig::load(&operator).unwrap_err(); + let message = error.to_string(); + // A refusal the reader never attributed to a member belongs to the + // document, and "." names nothing an operator can look up. + assert_eq!(error.path(), "/"); + assert!(!message.contains(" at ."), "{message}"); + assert!(message.contains("invalid type: string"), "{message}"); + assert!(!message.contains(canary), "{message}"); + } + + #[test] + fn a_refused_value_never_survives_the_clause_that_names_it() { + // The value serde renders may hold the comma that ends the clause, + // so the shape word is where the rendering opens, not the comma. + assert_eq!( + redact_refused_values( + "invalid type: string \"secret:env/URL, and more\", expected u16 at line 3 column 5" + ), + "invalid type: string, expected u16 at line 3 column 5" + ); + // A refusal naming a member rather than a value is carried whole: the + // member name is what the operator has to correct. + assert_eq!( + redact_refused_values("unknown field `bnd`, expected `bind` at line 4 column 3"), + "unknown field `bnd`, expected `bind` at line 4 column 3" + ); + } + + #[test] + fn a_runtime_document_the_reader_stops_on_names_the_line_and_column() { + let root = tempfile::tempdir().unwrap(); + let operator = root.path().join("runtime.yaml"); + // A tab can never open an indented line, so the reader stops on it. + std::fs::write(&operator, "apiVersion: v1\n\tkind: x\n").unwrap(); + + let error = RuntimeConfig::load(&operator).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("line 2"), "{message}"); + assert!(message.contains("column 1"), "{message}"); + } + + #[test] + fn a_rejected_runtime_member_names_the_cause_without_the_value() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let canary = "DO_NOT_DISCLOSE_RUNTIME_VALUE"; + let mut document = operator_value(&package, "development-loopback"); + document["retention"]["attemptReceiptDays"] = serde_json::json!(canary); + let operator = write_operator(root.path(), document); + + let error = RuntimeConfig::load(&operator).unwrap_err(); + let message = error.to_string(); + assert_eq!(error.path(), "retention.attemptReceiptDays"); + assert!( + message.contains("retention.attemptReceiptDays"), + "{message}" + ); + assert!(message.contains("invalid type: string"), "{message}"); + assert!(message.contains("expected u16"), "{message}"); + assert!(!message.contains(canary), "{message}"); + } + + #[test] + fn a_refused_authored_policy_names_the_member_and_the_cause() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write( + package.join(AUTHORED_POLICY_FILE), + "scheduling: {id: standalone-exact-time, version: one}\n", + ) + .unwrap(); + let operator = write_operator( + root.path(), + operator_value(&package, "development-loopback"), + ); + + let error = RuntimeConfig::load(&operator).unwrap_err(); + let message = error.to_string(); + assert_eq!(error.path(), "package.root/scheduling.yaml"); + assert!(message.contains("scheduling.version"), "{message}"); + assert!(message.contains("invalid type: string"), "{message}"); + assert!(message.contains("line"), "{message}"); + assert!(message.contains("column"), "{message}"); + } + #[test] fn a_production_deployment_must_name_the_clients_it_admits() { let root = tempfile::tempdir().unwrap(); From 08a4534a9196f52078dd03e3550d8c6cb2c44da5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:18:09 +0700 Subject: [PATCH 073/115] test(scheduling): pin that two callers racing for one unit book it once AT-01 is specified as a parallel race and was only ever covered in sequence, so nothing proved the capacity transaction serializes two in-flight commitments against the same supply. Weakening the supply lock from FOR UPDATE to FOR SHARE makes this test report two 201s on one station at one start, which is the oversell the lock exists to prevent. Signed-off-by: Jeremi Joslin --- .../tests/postgres_commitments.rs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 80442c5f62..ac11018b41 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -2082,3 +2082,116 @@ async fn the_intents_nobody_will_deliver_are_readable_by_an_operator() { "an intent the sweep will still retry needs no operator" ); } + +/// AT-01 run in parallel: two callers reach for the same last unit at the same +/// moment. Every commitment locks its supply anchor before it reads the +/// ledger, so the two transactions serialize there and the one that arrives +/// second reads a snapshot already carrying the first booking. Exactly one +/// caller is created, the other is refused a conflict over capacity rather +/// than answered an infrastructure failure for having lost, and each key +/// stays spent on the answer it got: a replay repeats the verdict instead of +/// reaching for the unit a second time. +#[tokio::test] +async fn two_callers_reaching_for_one_unit_book_it_exactly_once() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let contender = agent_token_for("principal-contender"); + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + + let first = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + fx.agent.clone(), + Some("last-unit-first".to_owned()), + Some(body.clone()), + )); + let second = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + contender.clone(), + Some("last-unit-second".to_owned()), + Some(body.clone()), + )); + let answers = [ + first.await.expect("the first request answers"), + second.await.expect("the second request answers"), + ]; + + let created: Vec<&Value> = answers + .iter() + .filter(|(status, _)| *status == StatusCode::CREATED) + .map(|(_, answer)| answer) + .collect(); + let refused: Vec<&Value> = answers + .iter() + .filter(|(status, _)| *status == StatusCode::CONFLICT) + .map(|(_, answer)| answer) + .collect(); + assert_eq!( + created.len(), + 1, + "exactly one caller books the last unit: {answers:?}" + ); + assert_eq!( + refused.len(), + 1, + "the caller that lost is refused a conflict: {answers:?}" + ); + assert_eq!(refused[0]["code"], "capacity.exhausted"); + + // The ledger carries one claim on the contested slot, the winning + // appointment is that claim, and availability stops offering the start. + let active: i64 = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE state='active' AND displayed_start=$1", + &[&slot], + ) + .await + .expect("count the claims on the contested slot") + .get(0); + assert_eq!(active, 1, "the request that lost wrote no claim"); + let booked = Uuid::parse_str( + created[0]["appointmentId"] + .as_str() + .expect("an appointment id"), + ) + .expect("a parseable appointment identifier"); + assert!( + fx.store + .claim(booked) + .await + .expect("read the booked claim") + .is_some(), + "the appointment the winner was answered is in the ledger" + ); + assert!(!slot_offered(&fx, OFFERING, 90, 200, slot).await); + + // Each caller's key is spent on its own verdict, so neither replay moves + // the ledger. + let (replayed, _) = fx + .post( + "/v1/appointments", + &fx.agent, + "last-unit-first", + body.clone(), + ) + .await; + assert_eq!(replayed, answers[0].0, "the first key replays its answer"); + let (replayed, _) = fx + .post("/v1/appointments", &contender, "last-unit-second", body) + .await; + assert_eq!(replayed, answers[1].0, "the second key replays its answer"); + let active: i64 = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE state='active' AND displayed_start=$1", + &[&slot], + ) + .await + .expect("count the claims on the contested slot") + .get(0); + assert_eq!(active, 1, "replaying either key books nothing further"); +} From 49544e78f763361b919236a50a477cd4a65f5205 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:20:37 +0700 Subject: [PATCH 074/115] fix(scheduling): hold the caller, not just the pool, at the hold ceiling The hold ceiling bounds a caller across every pool, but the capacity transaction locks only the supply anchor the hold draws from. Two concurrent holds on two pools lock two different rows, so each reads the same under-ceiling count and both write: a caller admitted two holds ends up holding three, and the maxPerCaller the policy declares is advisory rather than enforced. The count now runs behind a transaction advisory lock keyed on the caller pseudonym, taken after the supply lock so every hold acquires the two in one order and no pair can deadlock. The lock and the count are one trait method, so neither can be taken without the other. Security-sensitive: this is a capacity bound the policy declares and the runtime did not hold. The added test reports two 201s and three open holds against a ceiling of two before the fix. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/store.rs | 31 ++++- .../tests/postgres_commitments.rs | 125 ++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 587a818052..e905b98380 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -55,6 +55,14 @@ const MIGRATIONS: [(i64, &str); 1] = [(1, SCHEDULING_MIGRATION)]; /// ASCII bytes of "sched". const MIGRATION_LOCK_KEY: i64 = 0x7363_6865_6475_6c65; +/// The advisory-lock namespace the hold ceiling serializes one caller in. The +/// second half of the key is the caller's pseudonym, which is already keyed to +/// this deployment, so two deployments sharing a database do not serialize each +/// other. The lock is a transaction lock: PostgreSQL releases it when the +/// capacity transaction ends, committed or rolled back. The namespace spells +/// the ASCII bytes of "SCHD". +const HOLD_CEILING_LOCK_NAMESPACE: i32 = 0x5343_4844; + #[derive(Debug, Error)] pub enum StoreError { #[error("the Scheduling database configuration is invalid")] @@ -798,8 +806,11 @@ impl PostgresStore { check_grant_current(&commitment)?; commitment.policy_revision = current_policy_revision(&transaction).await?; let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + // The caller lock is taken after the supply lock, never before: every + // hold transaction acquires the two in that one order, so no pair of + // them can hold what the other is waiting for. if transaction - .active_holds_by_caller(commitment.actor, commitment.now) + .lock_caller_and_count_active_holds(commitment.actor, commitment.now) .await? >= i64::from(max_per_caller) { @@ -2207,7 +2218,16 @@ trait CapacityStatements { ) -> Result<(), CommitError>; async fn insert_audit(&self, event_id: Uuid, record: &Value) -> Result<(), StoreError>; async fn claim_in_transaction(&self, claim_id: Uuid) -> Result, StoreError>; - async fn active_holds_by_caller( + /// Take the caller's hold-ceiling lock, then count the holds it has open. + /// + /// The lock is the reason the count means anything. A commitment locks the + /// supply anchor it is about to draw from, but the ceiling bounds the + /// caller across every pool, and two holds on two pools lock two different + /// rows. Without a lock scoped to the caller, two concurrent holds each + /// read the same under-ceiling count and both write, so a caller admitted + /// two holds can end up holding three. The count and the lock are one + /// method so neither can be taken without the other. + async fn lock_caller_and_count_active_holds( &self, actor: &str, now: DateTime, @@ -2435,11 +2455,16 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { row.map(map_claim_row).transpose() } - async fn active_holds_by_caller( + async fn lock_caller_and_count_active_holds( &self, actor: &str, now: DateTime, ) -> Result { + self.execute( + "SELECT pg_advisory_xact_lock($1, hashtext($2))", + &[&HOLD_CEILING_LOCK_NAMESPACE, &actor], + ) + .await?; let row = self .query_one( "SELECT count(*) FROM scheduling_claims \ diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index ac11018b41..74a6889c55 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -2195,3 +2195,128 @@ async fn two_callers_reaching_for_one_unit_book_it_exactly_once() { .get(0); assert_eq!(active, 1, "replaying either key books nothing further"); } + +/// A booking agent whose grant covers holds on both of the test policy's +/// services, so one caller can reach for two pools at once. The default agent +/// may only hold against `registry-update`, and the hold ceiling is a property +/// of the caller, not of the pool a hold happens to land on. +fn agent_token_holding_both_services() -> String { + let mut grant = grant_claims(); + grant["registry_grant_bounds"]["permissions"][1]["actions"] = + json!(["hold.create", "hold.release", "appointment.create"]); + let mut claims = json!({ + "sub": "principal-two-pools", + "azp": CLIENT, + "registry_scopes": "scheduling-read scheduling-explain", + "registry_actor_kind": "service", + }); + let object = claims.as_object_mut().unwrap(); + object.extend( + grant + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + +/// The count of holds one caller currently holds open. +async fn active_holds(fx: &Fixture) -> i64 { + fx.admin + .query_one( + "SELECT count(*) FROM scheduling_claims \ + WHERE kind='hold' AND state='active' AND hold_expires_at > now()", + &[], + ) + .await + .expect("count the caller's open holds") + .get(0) +} + +/// The hold ceiling bounds the caller, not the pool. A commitment locks the +/// supply anchor it is about to draw from, and two holds on two pools lock two +/// different rows, so the ceiling check alone is a count with nothing standing +/// behind it: two concurrent holds each read the same under-ceiling count and +/// both write. The caller-scoped lock is what makes the count mean something, +/// and this test is what says so. +#[tokio::test] +async fn one_caller_cannot_outrun_the_hold_ceiling_across_two_pools() { + let fx = fixture().await; + let caller = agent_token_holding_both_services(); + let north = first_slot(&fx, OFFERING, 90, 200).await; + let (status, _) = fx + .post( + "/v1/holds", + &caller, + "ceiling-first", + admission(&fx, OFFERING, north), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "one hold is under the ceiling"); + + // Two more at once, against two pools whose supply anchors are distinct + // rows. The test policy admits two holds per caller, so exactly one of + // these may commit. + let other_north = first_slot(&fx, OFFERING, 210, 400).await; + let review = first_slot(&fx, SECOND_OFFERING, 90, 200).await; + let north_hold = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/holds".to_owned(), + caller.clone(), + Some("ceiling-north".to_owned()), + Some(admission(&fx, OFFERING, other_north)), + )); + let review_hold = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/holds".to_owned(), + caller.clone(), + Some("ceiling-review".to_owned()), + Some(admission(&fx, SECOND_OFFERING, review)), + )); + let answers = [ + north_hold.await.expect("the first hold answers"), + review_hold.await.expect("the second hold answers"), + ]; + let created = answers + .iter() + .filter(|(status, _)| *status == StatusCode::CREATED) + .count(); + let refused: Vec<&Value> = answers + .iter() + .filter(|(status, _)| *status == StatusCode::CONFLICT) + .map(|(_, answer)| answer) + .collect(); + assert_eq!( + created, 1, + "one of the two concurrent holds reaches the ceiling: {answers:?}" + ); + assert_eq!( + refused.len(), + 1, + "the hold over the ceiling is refused: {answers:?}" + ); + assert_eq!(refused[0]["code"], "capacity.exhausted"); + assert_eq!( + active_holds(&fx).await, + 2, + "the caller never holds more than the policy admits" + ); + + // The same ceiling in sequence, so the concurrent refusal above is the + // ceiling and not a coincidence of the race. + let later = first_slot(&fx, OFFERING, 500, 700).await; + let (status, problem) = fx + .post( + "/v1/holds", + &caller, + "ceiling-third", + admission(&fx, OFFERING, later), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "capacity.exhausted"); + assert_eq!(active_holds(&fx).await, 2); +} From dced861222d8b634c4cbabe408e6a16dc5ea48f0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:22:31 +0700 Subject: [PATCH 075/115] test(scheduling): pin what the retention sweep erases and what it leaves The deployment advertises one retention period and the sweep enforces that one plus the fifteen minutes the listing contract gives a cursor. Nothing held the other half of that sentence: appointments, history, the delivery outbox and the audit journal have no retention period in this milestone and the sweep passes over them by decision, so the test counts those four tables before and after and requires them unchanged. It also pins the two shapes erasure takes, a cursor deleted outright and a receipt tombstoned into a spent key, and that both passes are idempotent. Signed-off-by: Jeremi Joslin --- .../tests/postgres_commitments.rs | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 74a6889c55..64e9735035 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -2320,3 +2320,148 @@ async fn one_caller_cannot_outrun_the_hold_ceiling_across_two_pools() { assert_eq!(problem["code"], "capacity.exhausted"); assert_eq!(active_holds(&fx).await, 2); } + +/// How many rows each table the retention sweep must not touch currently +/// holds, in one reading, so a sweep can be shown to have left every one of +/// them exactly as it found them. +async fn committed_row_counts(fx: &Fixture) -> Vec<(&'static str, i64)> { + let mut counts = Vec::new(); + for table in [ + "scheduling_claims", + "scheduling_history", + "scheduling_outbox", + "scheduling_audit_outbox", + ] { + let count: i64 = fx + .admin + .query_one(&format!("SELECT count(*) FROM {table}"), &[]) + .await + .unwrap_or_else(|_| panic!("count {table}")) + .get(0); + counts.push((table, count)); + } + counts +} + +/// What the retention sweep erases, and just as much what it does not. The +/// deployment advertises one retention period, for idempotency receipts, and +/// the sweep enforces that one plus the fifteen minutes the listing contract +/// gives a cursor. Appointments, their history, the delivery outbox and the +/// audit journal have no retention period in this milestone and the sweep +/// passes over them, by decision rather than omission; this test is what +/// stops the single configured knob from quietly growing into a promise to +/// erase committed scheduling data. +#[tokio::test] +async fn the_retention_sweep_erases_its_two_tables_and_leaves_the_rest_standing() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let booking = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "retention-1", + booking.clone(), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + let (status, page) = fx.get("/v1/services?limit=1", &fx.reader).await; + assert_eq!(status, StatusCode::OK); + let cursor = page["nextCursor"] + .as_str() + .expect("the first page mints a cursor") + .to_owned(); + let committed = committed_row_counts(&fx).await; + assert!( + committed.iter().all(|(_, count)| *count > 0), + "the booking wrote to every table the sweep must not touch: {committed:?}" + ); + + // Long past the cursor's fifteen minutes and the receipt period the + // fixture deploys. + let after = Utc::now() + TimeDelta::days(8); + assert_eq!( + fx.store + .erase_expired_cursors(after) + .await + .expect("the cursor retention pass runs"), + 1, + "the one expired cursor is erased" + ); + assert_eq!( + fx.store + .erase_expired_attempts(after) + .await + .expect("the receipt retention pass runs"), + 1, + "the one expired receipt is erased" + ); + + // A cursor is erased outright, so what the caller is told is that the + // token no longer names a listing. The row is gone, so nothing + // distinguishes a cursor swept past its expiry from one that never + // existed; a caller that waited is told to restart the listing either + // way. + let (status, problem) = fx + .get(&format!("/v1/services?limit=1&cursor={cursor}"), &fx.reader) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(problem["code"], "cursor.invalid"); + let cursors: i64 = fx + .admin + .query_one("SELECT count(*) FROM scheduling_cursors", &[]) + .await + .expect("count the cursors") + .get(0); + assert_eq!(cursors, 0, "an erased cursor leaves no row behind"); + + // A receipt is tombstoned, not deleted: the row keeps the key and the + // request it answered and drops only the answer, so the key stays spent. + let attempt = fx + .admin + .query_one( + "SELECT count(*), count(receipt), count(erased_at) FROM scheduling_attempts", + &[], + ) + .await + .expect("read the swept attempt row"); + assert_eq!(attempt.get::<_, i64>(0), 1, "the attempt row is kept"); + assert_eq!(attempt.get::<_, i64>(1), 0, "the answer is dropped"); + assert_eq!(attempt.get::<_, i64>(2), 1, "the row is stamped erased"); + let (status, problem) = fx + .post("/v1/appointments", &fx.agent, "retention-1", booking) + .await; + assert_eq!(status, StatusCode::GONE); + assert_eq!(problem["code"], "idempotency.expired"); + + // Everything the deployment committed is still there, and the + // appointment still reads back. + assert_eq!( + committed_row_counts(&fx).await, + committed, + "the sweep erased no committed scheduling data" + ); + let (status, fetched) = fx + .get(&format!("/v1/appointments/{appointment_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched["state"], "confirmed"); + + // Running the sweep again erases nothing: both passes are idempotent, so + // a tick that overlaps the previous one cannot double-count its work. + assert_eq!( + fx.store + .erase_expired_cursors(after) + .await + .expect("the cursor retention pass runs"), + 0 + ); + assert_eq!( + fx.store + .erase_expired_attempts(after) + .await + .expect("the receipt retention pass runs"), + 0 + ); +} From 4fa555edf2b83e6a42001648c3295fc800476f76 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:23:50 +0700 Subject: [PATCH 076/115] fix(scheduling): document the absence an unknown offering answers The runtime no longer answers an offering the deployed policy does not publish with precondition.failed: it answers request.not-found, because the offering does not exist and no precondition would make it exist. A create naming both a hold and an admission, or neither, answers request.invalid for the same reason. The document has to say so, on the four operations that resolve a caller-named offering and in the two descriptions that named the old code. precondition.failed is now produced nowhere, so it joins the vocabulary entries no operation answers. The reserved list is split by the reason an entry is on it, unproduced or detailed-only, which retires the positional slice that stood for "the ones produced nowhere". The generator's own tests had also fallen behind two earlier edge changes: every authenticated operation answers service.unavailable now that an unreachable issuer is an outage rather than a challenge, and an operation carrying a path parameter answers request.invalid down both its body path and its segment path, which the document states once. Signed-off-by: Jeremi Joslin --- .../registry-scheduling.openapi.json | 83 +++++++++++++------ .../scheduling/scripts/generate_openapi.py | 46 +++++----- .../scripts/test_generate_openapi.py | 40 ++++++--- 3 files changed, 111 insertions(+), 58 deletions(-) diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json index e04d7b7958..91518d8d9e 100644 --- a/products/scheduling/generated/registry-scheduling.openapi.json +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -378,7 +378,7 @@ }, "CreateAppointmentRequest": { "additionalProperties": false, - "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is precondition.failed.", + "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is request.invalid.", "minProperties": 1, "properties": { "admission": { @@ -2120,6 +2120,24 @@ } } }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestNotFound" + } + } + }, + "description": "Problem response: request.not-found", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "405": { "content": { "application/problem+json": { @@ -2207,15 +2225,12 @@ }, { "$ref": "#/components/schemas/ProblemRevisionMismatch" - }, - { - "$ref": "#/components/schemas/ProblemPreconditionFailed" } ] } } }, - "description": "Problem response: policy.changed, revision.mismatch, precondition.failed", + "description": "Problem response: policy.changed, revision.mismatch", "headers": { "cache-control": { "$ref": "#/components/headers/CacheControlHeader" @@ -3438,7 +3453,7 @@ } }, { - "description": "Required offering identifier. An offering the deployed policy does not publish is precondition.failed.", + "description": "Required offering identifier. An offering the deployed policy does not publish is request.not-found.", "in": "query", "name": "offering", "required": true, @@ -3576,15 +3591,15 @@ } } }, - "405": { + "404": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "$ref": "#/components/schemas/ProblemRequestNotFound" } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: request.not-found", "headers": { "cache-control": { "$ref": "#/components/headers/CacheControlHeader" @@ -3594,15 +3609,15 @@ } } }, - "410": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemCursorExpired" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: cursor.expired", + "description": "Problem response: request.method-not-allowed", "headers": { "cache-control": { "$ref": "#/components/headers/CacheControlHeader" @@ -3612,15 +3627,15 @@ } } }, - "412": { + "410": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemCursorExpired" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: cursor.expired", "headers": { "cache-control": { "$ref": "#/components/headers/CacheControlHeader" @@ -3698,7 +3713,7 @@ } }, { - "description": "Required offering identifier. An offering the deployed policy does not publish is precondition.failed.", + "description": "Required offering identifier. An offering the deployed policy does not publish is request.not-found.", "in": "query", "name": "offering", "required": true, @@ -3797,15 +3812,15 @@ } } }, - "405": { + "404": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" + "$ref": "#/components/schemas/ProblemRequestNotFound" } } }, - "description": "Problem response: request.method-not-allowed", + "description": "Problem response: request.not-found", "headers": { "cache-control": { "$ref": "#/components/headers/CacheControlHeader" @@ -3815,15 +3830,15 @@ } } }, - "412": { + "405": { "content": { "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ProblemPreconditionFailed" + "$ref": "#/components/schemas/ProblemRequestMethodNotAllowed" } } }, - "description": "Problem response: precondition.failed", + "description": "Problem response: request.method-not-allowed", "headers": { "cache-control": { "$ref": "#/components/headers/CacheControlHeader" @@ -4009,6 +4024,24 @@ } } }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemRequestNotFound" + } + } + }, + "description": "Problem response: request.not-found", + "headers": { + "cache-control": { + "$ref": "#/components/headers/CacheControlHeader" + }, + "traceparent": { + "$ref": "#/components/headers/TraceparentHeader" + } + } + }, "405": { "content": { "application/problem+json": { @@ -4086,15 +4119,12 @@ }, { "$ref": "#/components/schemas/ProblemRevisionMismatch" - }, - { - "$ref": "#/components/schemas/ProblemPreconditionFailed" } ] } } }, - "description": "Problem response: policy.changed, revision.mismatch, precondition.failed", + "description": "Problem response: policy.changed, revision.mismatch", "headers": { "cache-control": { "$ref": "#/components/headers/CacheControlHeader" @@ -5455,6 +5485,7 @@ "x-registry-scheduling-reserved-problems": [ "eligibility.unavailable", "hook.unavailable", + "precondition.failed", "resource.unavailable" ] } diff --git a/products/scheduling/scripts/generate_openapi.py b/products/scheduling/scripts/generate_openapi.py index 3ba7dbc85c..ec5bdc371b 100644 --- a/products/scheduling/scripts/generate_openapi.py +++ b/products/scheduling/scripts/generate_openapi.py @@ -91,15 +91,19 @@ "request.unsupported-media-type", ] -# Vocabulary entries no documented operation answers. `eligibility.unavailable` -# and `hook.unavailable` are produced nowhere in this milestone (there is no -# hook engine yet); `resource.unavailable` is the one detailed-only code, and -# it travels only inside the explain document, never as a public problem. -RESERVED_PROBLEMS = [ +# Vocabulary entries the runtime never reaches: there is no hook engine yet, +# no eligibility source yet, and the four refusals `precondition.failed` once +# carried are each answered by the code that names them. +UNPRODUCED_PROBLEMS = [ "eligibility.unavailable", "hook.unavailable", - "resource.unavailable", + "precondition.failed", ] +# `resource.unavailable` is produced, but only as the detailed code inside the +# explain document, so it is never a public problem either. +DETAILED_ONLY_PROBLEMS = ["resource.unavailable"] +# Vocabulary entries no documented operation answers. +RESERVED_PROBLEMS = UNPRODUCED_PROBLEMS + DETAILED_ONLY_PROBLEMS # The wire documents, verified field by field against the Rust structs. SCHEMA_STRUCTS = { @@ -193,6 +197,11 @@ STORAGE = ["service.unavailable"] AUTHORITY = ["operation.not-authorized"] IDEMPOTENCY = ["idempotency.expired", "idempotency.key-reused"] +# An operation that resolves a caller-named offering against the deployed +# policy answers for one the policy does not publish. The offering listing is +# where a caller learns what exists, so naming the absence discloses nothing +# that listing would not. +OFFERING = ["request.not-found"] # The refusal vocabulary the admission evaluator answers a booking ask with. ADMISSION = [ "booking.duplicate-active", @@ -216,19 +225,20 @@ ("GET", "/v1/offerings"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, ("GET", "/v1/resources"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, ("GET", "/v1/locations"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE, - ("GET", "/v1/availability"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE + ["precondition.failed"], - ("GET", "/v1/availability/explain"): EDGE + AUTHENTICATION + QUERY + STORAGE + ["precondition.failed"], + ("GET", "/v1/availability"): EDGE + AUTHENTICATION + QUERY + CURSOR + STORAGE + OFFERING, + ("GET", "/v1/availability/explain"): EDGE + AUTHENTICATION + QUERY + STORAGE + OFFERING, ("POST", "/v1/holds"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY - + ["precondition.failed", "service.unavailable"], + + OFFERING + ["service.unavailable"], # A release carries no caller-chosen key, so `idempotency.key-reused` is # not reachable: the key and the request hash are both the hold's own id. ("DELETE", "/v1/holds/{hold_id}"): EDGE + AUTHENTICATION + PATH + AUTHORITY + ["hold.released", "idempotency.expired", "service.unavailable"], ("POST", "/v1/appointments"): EDGE + AUTHENTICATION + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY - + ["hold.expired", "hold.released", "precondition.failed", "service.unavailable"], + + OFFERING + ["hold.expired", "hold.released", "service.unavailable"], ("GET", "/v1/appointments/{appointment_id}"): EDGE + AUTHENTICATION + PATH + AUTHORITY + STORAGE, - # A reschedule resolves its offering from the appointment, so an unknown - # offering is operator state, not precondition.failed. + # A reschedule resolves its offering from the appointment rather than from + # the caller, so an unknown offering is operator state, not a refusal the + # caller can read. ("POST", "/v1/appointments/{appointment_id}/reschedule"): EDGE + AUTHENTICATION + PATH + JSON_BODY + ADMISSION + IDEMPOTENCY + AUTHORITY + ["hold.released", "service.unavailable"], # Cancellation is guarded by the observed revision and the cutoff, never by @@ -331,7 +341,7 @@ def parameter(name: str, where: str, description: str, schema: dict | None = Non OFFERING_QUERY = parameter( "offering", "query", - "Required offering identifier. An offering the deployed policy does not publish is precondition.failed.", + "Required offering identifier. An offering the deployed policy does not publish is request.not-found.", {"type": "string", "minLength": 1}, ) @@ -586,7 +596,7 @@ def schemas(problem_entries: list[dict]) -> dict: "additionalProperties": False, "minProperties": 1, "properties": {"hold": nullable(text), "admission": nullable(ref("AdmissionRequest"))}, - "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is precondition.failed.", + "description": "Exactly one of hold or admission: confirming a held allocation, or a direct create. Both, or neither, is request.invalid.", }, "RescheduleAppointmentRequest": obj( {"observedRevision": revision, "admission": ref("AdmissionRequest")}, @@ -1113,10 +1123,8 @@ def verify_operation_problems( raise ValueError(f"declared problem is outside the Rust vocabulary: {code}") produced = produced_problem_codes(_root(), variants) reserved = set(RESERVED_PROBLEMS) - # The two reserved codes are produced nowhere; `resource.unavailable` is, - # but only as the detailed code the explain path discloses. - if set(RESERVED_PROBLEMS[:2]) & produced: - raise ValueError("a problem documented as reserved is produced by the runtime") + if set(UNPRODUCED_PROBLEMS) & produced: + raise ValueError("a problem documented as unproduced is produced by the runtime") for key, codes in OPERATION_PROBLEMS.items(): method, path = key operation = openapi["paths"][path][method.lower()] @@ -1169,7 +1177,7 @@ def verify_operation_problems( f"{sorted(required - set(codes))}" ) answered = {code for codes in OPERATION_PROBLEMS.values() for code in codes} - if answered != set(catalog) - reserved - {"request.not-found"}: + if answered != set(catalog) - reserved: raise ValueError( "documented problem coverage drifted from the closed vocabulary; " f"unanswered={sorted(set(catalog) - reserved - answered)}, " diff --git a/products/scheduling/scripts/test_generate_openapi.py b/products/scheduling/scripts/test_generate_openapi.py index 4faf92f0db..7f0ae8b411 100644 --- a/products/scheduling/scripts/test_generate_openapi.py +++ b/products/scheduling/scripts/test_generate_openapi.py @@ -81,7 +81,10 @@ def test_every_documented_problem_matches_its_pinned_rust_catalog_entry(self) -> def test_every_operation_answers_exactly_the_problems_it_was_mapped(self) -> None: for key, expected in GENERATOR.OPERATION_PROBLEMS.items(): by_status = codes_of(self.openapi, key) - self.assertEqual(sorted(expected), sorted(set().union(*by_status.values())), key) + # An operation can reach one refusal down more than one path, and + # the declaration names every path it has, so the document answers + # a code once however many groups carry it. + self.assertEqual(sorted(set(expected)), sorted(set().union(*by_status.values())), key) for status, codes in by_status.items(): for code in codes: self.assertEqual(status, self.catalog[code]["httpStatuses"][0], code) @@ -128,10 +131,7 @@ def test_documented_coverage_is_the_closed_vocabulary_minus_the_unreachable(self documented = { code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes } - self.assertEqual( - set(self.catalog) - set(GENERATOR.RESERVED_PROBLEMS) - {"request.not-found"}, - documented, - ) + self.assertEqual(set(self.catalog) - set(GENERATOR.RESERVED_PROBLEMS), documented) self.assertEqual( { "request.body-too-large", @@ -144,7 +144,12 @@ def test_documented_coverage_is_the_closed_vocabulary_minus_the_unreachable(self set(self.openapi["x-registry-scheduling-edge-problems"]), ) self.assertEqual( - {"eligibility.unavailable", "hook.unavailable", "resource.unavailable"}, + { + "eligibility.unavailable", + "hook.unavailable", + "precondition.failed", + "resource.unavailable", + }, set(self.openapi["x-registry-scheduling-reserved-problems"]), ) @@ -154,7 +159,7 @@ def test_only_a_reachable_code_is_documented_as_an_answer(self) -> None: code for codes in GENERATOR.OPERATION_PROBLEMS.values() for code in codes } self.assertEqual(documented, documented & produced) - self.assertEqual(set(), set(GENERATOR.RESERVED_PROBLEMS[:2]) & produced) + self.assertEqual(set(), set(GENERATOR.UNPRODUCED_PROBLEMS) & produced) self.assertIn("resource.unavailable", produced) def test_every_route_is_described_by_exactly_one_operation(self) -> None: @@ -279,7 +284,10 @@ def test_success_statuses_come_from_the_handlers(self) -> None: def test_a_fallible_service_method_always_documents_an_unavailable_dependency(self) -> None: for key in GENERATOR.ROUTE_HANDLERS: codes = documented_codes(self.openapi, key) - if key in {("GET", "/healthz"), ("GET", "/v1/scheduling")}: + # Liveness is the one route that reaches neither the store nor the + # issuer's key material. Every other route reaches at least one of + # them, and an outage in either is answered with a retry. + if key == ("GET", "/healthz"): self.assertNotIn("service.unavailable", codes, key) else: self.assertIn("service.unavailable", codes, key) @@ -342,12 +350,14 @@ def test_the_cancellation_guard_never_names_the_policy_revision(self) -> None: self.assertNotIn("policy.changed", codes) self.assertIn("never by the policy revision", operation(self.openapi, key)["description"]) - def test_a_reschedule_answers_a_moved_policy_but_never_a_failed_precondition(self) -> None: + def test_a_reschedule_answers_a_moved_policy_but_never_an_absent_offering(self) -> None: + # A reschedule resolves its offering from the appointment rather than + # from the caller, so there is no caller-named offering to be absent. key = ("POST", "/v1/appointments/{appointment_id}/reschedule") codes = documented_codes(self.openapi, key) self.assertIn("policy.changed", codes) self.assertIn("revision.mismatch", codes) - self.assertNotIn("precondition.failed", codes) + self.assertNotIn("request.not-found", codes) def test_only_a_confirming_create_answers_an_expired_hold(self) -> None: expected = {("POST", "/v1/appointments")} @@ -358,7 +368,11 @@ def test_only_a_confirming_create_answers_an_expired_hold(self) -> None: key, ) - def test_only_availability_answers_an_unknown_offering_as_a_failed_precondition(self) -> None: + def test_only_an_offering_the_caller_names_can_be_answered_as_absent(self) -> None: + # The four operations that resolve a caller-named offering against the + # deployed policy. Everywhere else request.not-found would conceal an + # owned record rather than name an absence the offering listing + # already discloses. expected = { ("GET", "/v1/availability"), ("GET", "/v1/availability/explain"), @@ -368,7 +382,7 @@ def test_only_availability_answers_an_unknown_offering_as_a_failed_precondition( for key in GENERATOR.ROUTE_HANDLERS: self.assertEqual( key in expected, - "precondition.failed" in documented_codes(self.openapi, key), + "request.not-found" in documented_codes(self.openapi, key), key, ) @@ -578,7 +592,7 @@ def test_a_create_carries_exactly_one_of_hold_or_admission(self) -> None: self.assertEqual({"hold", "admission"}, set(create["properties"])) self.assertEqual(1, create["minProperties"]) self.assertIn("Exactly one of hold or admission", create["description"]) - self.assertIn("Both, or neither, is precondition.failed", create["description"]) + self.assertIn("Both, or neither, is request.invalid", create["description"]) def test_the_documented_bounds_are_the_rust_ones(self) -> None: naming = GENERATOR.production_source(ROOT, GENERATOR.NAMING_SOURCE) From 42dd37d8ef4b049f0518188d7332e4fbebb216b9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:30:57 +0700 Subject: [PATCH 077/115] fix(scheduling): publish the audit journal in the order it was written The audit publisher appends every pending record to a keyed hash chain, and a chain is a record of order. The reader claimed "oldest first" while ordering by `event_id`, a random v4 UUID that carries no order at all, so the chain attested to a sequence the deployment never had: with six rows written under identifiers that sort against their write order, the reader handed back all six exactly reversed. `scheduling_audit_outbox` carried no ordering column, so add one (`recorded_seq`, an identity column) and order the reader by it, with a partial index covering the pending set the publisher reads. Migration 0001 is unreleased, so it is amended in place. DB-12 covers the property the chain exists to carry: a deployment stops and starts again over the same retained journal, and the first record of the second life links to the last record of the first, with exactly one record reaching genesis and none retained twice. It also reopens the append/mark gap by hand and proves the next life re-marks the retained tail instead of appending it a second time. Running a pass from a test needs the publisher, its state and the pass function visible, so they become `pub`; no logic moved with them. Security-sensitive (audit integrity): this changes the order the audit hash chain attests to and the visibility of the publication path. It does not change what is audited, what is retained, or who may read it. Signed-off-by: Jeremi Joslin --- .../migrations/0001_scheduling.sql | 9 + crates/registry-scheduling/src/runtime.rs | 26 ++- crates/registry-scheduling/src/store.rs | 6 +- .../tests/postgres_commitments.rs | 198 +++++++++++++++++- 4 files changed, 230 insertions(+), 9 deletions(-) diff --git a/crates/registry-scheduling/migrations/0001_scheduling.sql b/crates/registry-scheduling/migrations/0001_scheduling.sql index 441cc2e110..e47f71e629 100644 --- a/crates/registry-scheduling/migrations/0001_scheduling.sql +++ b/crates/registry-scheduling/migrations/0001_scheduling.sql @@ -141,9 +141,18 @@ CREATE INDEX IF NOT EXISTS scheduling_outbox_claim_idx CREATE TABLE IF NOT EXISTS scheduling_audit_outbox ( event_id uuid PRIMARY KEY, + -- The publisher appends every pending record to a hash chain, and a + -- chain is a record of order: publishing in an arbitrary one would have + -- the chain attest to a sequence the deployment never had. The event id + -- is a random UUID and carries no order at all, so the order the rows + -- were written is kept here and nowhere else. + recorded_seq bigint GENERATED ALWAYS AS IDENTITY, audit_record jsonb NOT NULL, published_at timestamptz ); +CREATE INDEX IF NOT EXISTS scheduling_audit_outbox_pending_idx + ON scheduling_audit_outbox(recorded_seq) + WHERE published_at IS NULL; CREATE TABLE IF NOT EXISTS scheduling_attempts ( attempt_id uuid PRIMARY KEY, diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index 0323b663ec..d65dc3e07d 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -743,19 +743,31 @@ async fn dispatch_due_intents( // Audit publication // --------------------------------------------------------------------------- -struct AuditPublisher { - store: PostgresStore, - chain: Arc, - sink: Arc, +/// What one publication pass writes through: the journal it drains, the keyed +/// chain it extends, and the sink that retains the envelopes. +/// +/// Public so the database suite can run a pass against a real deployment, +/// stop, and start again the way a restart does. Continuity across that +/// boundary is the property the chain exists to carry, and it cannot be +/// observed from inside one process that never puts the chain down. +pub struct AuditPublisher { + pub store: PostgresStore, + pub chain: Arc, + pub sink: Arc, } +/// The one record whose append may have landed without its mark, carried +/// across passes and across a restart. #[derive(Default)] -struct AuditPublicationState { +pub struct AuditPublicationState { unconfirmed: Option, } impl AuditPublicationState { - fn from_verified_tail(tail: Option<&AuditEnvelope>) -> Self { + /// Recover the append/mark gap from the retained tail. The caller + /// authenticates the chain first: this reads identity only. + #[must_use] + pub fn from_verified_tail(tail: Option<&AuditEnvelope>) -> Self { let unconfirmed = tail .and_then(|envelope| envelope.record.as_object()) .and_then(|record| record.get("eventId")) @@ -769,7 +781,7 @@ impl AuditPublicationState { /// it published. The one record whose append may have landed before its mark /// is remembered and re-marked first on the next pass, so a crash between the /// two writes never duplicates an envelope. -async fn publish_audit_pass( +pub async fn publish_audit_pass( publisher: &AuditPublisher, state: &mut AuditPublicationState, ) -> Result<(), &'static str> { diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index e905b98380..a813b9e599 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -1634,12 +1634,16 @@ impl PostgresStore { } /// The pending audit journal, oldest first. + /// + /// The order is the one the rows were written in, not the one their + /// identifiers sort in: the publisher appends what this returns to a hash + /// chain, so the order it reads in is the order the chain attests to. pub async fn pending_audit(&self, limit: i64) -> Result, StoreError> { let client = self.client().await?; Ok(client .query( "SELECT event_id, audit_record FROM scheduling_audit_outbox \ - WHERE published_at IS NULL ORDER BY event_id LIMIT $1", + WHERE published_at IS NULL ORDER BY recorded_seq LIMIT $1", &[&limit], ) .await? diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 64e9735035..7826fcc925 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -18,12 +18,15 @@ use axum::Router; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; use jsonwebtoken::{Algorithm, EncodingKey, Header}; -use registry_platform_audit::{AuditHashSecret, AuditKeyHasher}; +use registry_platform_audit::{ + AuditEnvelope, AuditHashSecret, AuditKeyHasher, AuditProfile, JsonlFileSink, +}; use registry_platform_config::{SecretProvider, SecretResolver}; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; use registry_scheduling::auth::SchedulingAuthenticator; use registry_scheduling::config::{DatabaseConfig, OidcConfig, OidcJwksSource}; use registry_scheduling::http::{router, HttpState}; +use registry_scheduling::runtime::{publish_audit_pass, AuditPublicationState, AuditPublisher}; use registry_scheduling::service::SchedulingService; use registry_scheduling::store::PostgresStore; use registry_scheduling_core::{ @@ -2465,3 +2468,196 @@ async fn the_retention_sweep_erases_its_two_tables_and_leaves_the_rest_standing( 0 ); } + +/// The environment variable the publication tests key their audit chain from, +/// and the master secret they put in it: a test constant like the token +/// signing secret above, never a deployment value. The platform profile +/// refuses anything shorter than 32 bytes. +const AUDIT_SECRET_ENV: &str = "SCHEDULING_TEST_AUDIT_CHAIN_SECRET"; +const AUDIT_SECRET: &str = "scheduling-test-audit-chain-secret"; + +/// Every envelope the journal retains, in the order it was written. +fn retained(path: &std::path::Path) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).expect("a retained audit envelope")) + .collect() +} + +/// One whole deployment life over the same journal: bootstrap the retained +/// chain under the deployment key, recover the append/mark gap from its tail, +/// drain what the store holds, and put the journal down again. Returns how +/// many envelopes the journal retains afterwards. +/// +/// The sink is a single-writer sink, so dropping it at the end is what makes +/// the next call a restart rather than a second writer forking the chain. +async fn publish_one_life(fx: &Fixture, path: &std::path::Path) -> usize { + let profile = AuditProfile::production_from_env(AUDIT_SECRET_ENV).expect("the audit profile"); + let sink = Arc::new(JsonlFileSink::new_single_writer(path).expect("the audit journal sink")); + let chain = Arc::new( + profile + .bootstrap_or_start_empty(sink.as_ref()) + .await + .expect("bootstrap the retained chain under the deployment key"), + ); + let mut state = AuditPublicationState::from_verified_tail( + sink.last_envelope() + .await + .expect("read the retained tail") + .as_ref(), + ); + let publisher = AuditPublisher { + store: fx.store.clone(), + chain, + sink, + }; + publish_audit_pass(&publisher, &mut state) + .await + .expect("the publication pass runs"); + drop(publisher); + retained(path).len() +} + +/// The audit journal is published in the order it was written. The publisher +/// appends what the store hands it to a hash chain, so the order it reads in +/// is the order the chain goes on to attest to. An event id is a random UUID +/// and sorts in no order at all; these rows are written under identifiers +/// that sort against the order they were written, so a reader ordering by +/// identifier hands them back reversed. +#[tokio::test] +async fn the_audit_journal_is_read_in_the_order_it_was_written() { + let fx = fixture().await; + let written: Vec = (0..6) + .map(|position| { + Uuid::parse_str(&format!("{:08x}-0000-4000-8000-000000000000", 5 - position)) + .expect("a well-formed test event identifier") + }) + .collect(); + for (position, event_id) in written.iter().enumerate() { + fx.store + .record_refusal_audit(*event_id, json!({"marker": position})) + .await + .expect("record one refusal in the journal"); + } + + let pending = fx + .store + .pending_audit(100) + .await + .expect("read the pending journal"); + let ours: Vec = pending + .iter() + .map(|(event_id, _)| *event_id) + .filter(|event_id| written.contains(event_id)) + .collect(); + assert_eq!( + ours, written, + "the journal reads back in the order it was written" + ); +} + +/// DB-12: the keyed audit chain survives the process that built it. A +/// deployment stops and starts again over the same retained journal, and the +/// first record of the second life links to the last record of the first: the +/// chain is one unbroken sequence from genesis, with exactly one record that +/// has no predecessor. A restart that forgot the retained chain would start +/// again at genesis and leave the earlier records unattested. +#[tokio::test] +async fn the_audit_chain_continues_across_a_restart() { + std::env::set_var(AUDIT_SECRET_ENV, AUDIT_SECRET); + let fx = fixture().await; + let journal = tempfile::tempdir().expect("a temporary audit journal"); + let path = journal.path().join("audit.jsonl"); + + booked(&fx, 90, 200, "chain-first").await; + let first_life = publish_one_life(&fx, &path).await; + assert!(first_life > 0, "the commitment wrote records to publish"); + assert!( + fx.store + .pending_audit(100) + .await + .expect("read the pending journal") + .is_empty(), + "a completed pass leaves nothing pending" + ); + + // The process stops. A second life over the same journal commits more and + // publishes it. + booked(&fx, 300, 440, "chain-second").await; + let second_life = publish_one_life(&fx, &path).await; + assert!( + second_life > first_life, + "the second life appended its own records" + ); + + let envelopes = retained(&path); + assert_eq!(envelopes.len(), second_life); + assert!( + envelopes[0].prev_hash.is_none(), + "the journal reaches genesis exactly once" + ); + for pair in envelopes.windows(2) { + assert_eq!( + pair[1].prev_hash, + Some(pair[0].record_hash), + "every envelope links to the one before it" + ); + } + assert_eq!( + envelopes[first_life].prev_hash, + Some(envelopes[first_life - 1].record_hash), + "the second life continues the chain the first life left" + ); + + // Every record is retained exactly once, and carries the identity the + // store knows it by. + let identities: Vec<&str> = envelopes + .iter() + .filter_map(|envelope| envelope.record["eventId"].as_str()) + .collect(); + assert_eq!( + identities.len(), + envelopes.len(), + "every retained envelope carries its event id" + ); + assert_eq!( + identities + .iter() + .copied() + .collect::>() + .len(), + identities.len(), + "no record is retained twice" + ); + + // A crash between the append and the mark: the tail is retained but the + // store still lists it pending. The next life re-marks it from the + // retained tail rather than appending it a second time. + let tail = Uuid::parse_str( + envelopes.last().expect("a retained tail").record["eventId"] + .as_str() + .expect("the tail carries its event id"), + ) + .expect("a parseable event identifier"); + fx.admin + .execute( + "UPDATE scheduling_audit_outbox SET published_at=NULL WHERE event_id=$1", + &[&tail], + ) + .await + .expect("reopen the append and mark gap"); + let reconciled = publish_one_life(&fx, &path).await; + assert_eq!( + reconciled, second_life, + "the record retained before the crash is not appended again" + ); + assert!( + fx.store + .pending_audit(100) + .await + .expect("read the pending journal") + .is_empty(), + "it is marked published instead" + ); +} From 1b085faa8821fb899ce821fd8f70a6c3c21ac626 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:32:09 +0700 Subject: [PATCH 078/115] fix(scheduling): name the package manifest apart from the policy One apiVersion and kind pair named both the authored `scheduling.yaml` and the `scheduling.package.json` manifest beside it, so neither reader could refuse the other's document on its declared names, and the package identity digest folded in a pair that did not describe it. The manifest now declares its own pair, which the identity digest binds, so a document carrying the authored policy's names never verifies as a package identity. The authored policy keeps the pair it has: renaming it would strand the authoring templates, the published example and the reference docs, which this change does not own. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/config.rs | 62 +++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index 9be736907a..b465d20df2 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -19,8 +19,7 @@ use registry_platform_oidc::{ }; use registry_scheduling_core::{ parse_policy_yaml, SchedulingPolicy, AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, - SCHEDULING_POLICY_API_VERSION, SCHEDULING_POLICY_KIND, SCHEDULING_RUNTIME_API_VERSION, - SCHEDULING_RUNTIME_KIND, + SCHEDULING_RUNTIME_API_VERSION, SCHEDULING_RUNTIME_KIND, }; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; @@ -69,6 +68,17 @@ pub(crate) fn describe_secret_failure( const MAXIMUM_POLICY_FILE_BYTES: usize = 1024 * 1024; const MAXIMUM_PACKAGE_MANIFEST_BYTES: usize = 1024 * 1024; +/// apiVersion of the package manifest written beside the authored policy. +/// +/// The manifest names a package identity; the authored policy names a policy. +/// They are two documents with two readers, so they carry two names and +/// neither reader accepts the other's document. +pub const SCHEDULING_PACKAGE_MANIFEST_API_VERSION: &str = + "registry.registrystack.org/scheduling-policy-package-manifest/v1alpha1"; + +/// Kind of the package manifest written beside the authored policy. +pub const SCHEDULING_PACKAGE_MANIFEST_KIND: &str = "SchedulingPolicyPackageManifest"; + /// The default number of days a stored idempotency receipt is replayable /// before the retention sweep erases it. The default is a floor, not a /// recommendation: a jurisdiction's retention schedule approves the deployed @@ -354,8 +364,8 @@ impl PolicyPackageManifest { bytes: u64::try_from(bytes.len()).map_err(|_| PolicyPackageError::Invalid)?, }]; Ok(Self { - api_version: SCHEDULING_POLICY_API_VERSION.to_owned(), - kind: SCHEDULING_POLICY_KIND.to_owned(), + api_version: SCHEDULING_PACKAGE_MANIFEST_API_VERSION.to_owned(), + kind: SCHEDULING_PACKAGE_MANIFEST_KIND.to_owned(), policy_digest: package_digest(&files)?, files, }) @@ -404,8 +414,8 @@ pub fn verify_policy_package( fn package_digest(files: &[PolicyPackageFile]) -> Result { let identity = serde_json::json!({ - "apiVersion": SCHEDULING_POLICY_API_VERSION, - "kind": SCHEDULING_POLICY_KIND, + "apiVersion": SCHEDULING_PACKAGE_MANIFEST_API_VERSION, + "kind": SCHEDULING_PACKAGE_MANIFEST_KIND, "files": files, }); let canonical = registry_platform_canonical_json::canonicalize_json(&identity) @@ -940,6 +950,7 @@ impl RuntimeConfigError { mod tests { use super::*; use registry_platform_oidc::is_access_token_typ_pair; + use registry_scheduling_core::{SCHEDULING_POLICY_API_VERSION, SCHEDULING_POLICY_KIND}; const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 kind: SchedulingPolicyPackage @@ -1252,6 +1263,45 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} assert!(message.contains("column"), "{message}"); } + // The package manifest and the policy it identifies are two documents + // with two readers: the manifest is verified by the runtime at startup, + // the policy is parsed by the authoring grammar. A reader that accepts + // one must be able to refuse the other on its declared names alone. + #[test] + fn a_package_manifest_is_a_different_document_from_the_policy_it_identifies() { + let manifest = PolicyPackageManifest::build(POLICY).expect("the policy is packaged"); + assert_ne!(manifest.api_version, SCHEDULING_POLICY_API_VERSION); + assert_ne!(manifest.kind, SCHEDULING_POLICY_KIND); + assert_eq!( + manifest.api_version, + SCHEDULING_PACKAGE_MANIFEST_API_VERSION + ); + assert_eq!(manifest.kind, SCHEDULING_PACKAGE_MANIFEST_KIND); + } + + // A deployment identity is only an identity if a document of another kind + // cannot stand in for it. The manifest is verified against its own names, + // so an authored policy's names never carry a package identity. + #[test] + fn a_manifest_wearing_the_authored_policy_names_is_refused() { + let root = tempfile::tempdir().unwrap(); + let package = root.path().join("package"); + write_policy(&package); + let manifest = PolicyPackageManifest::build(POLICY).expect("the policy is packaged"); + let mut document = serde_json::to_value(&manifest).unwrap(); + document["apiVersion"] = serde_json::json!(SCHEDULING_POLICY_API_VERSION); + document["kind"] = serde_json::json!(SCHEDULING_POLICY_KIND); + std::fs::write( + package.join(SCHEDULING_PACKAGE_MANIFEST_FILE), + serde_json::to_vec_pretty(&document).unwrap(), + ) + .unwrap(); + assert!(matches!( + verify_policy_package(&package.join(AUTHORED_POLICY_FILE), POLICY), + Err(PolicyPackageError::Invalid) + )); + } + #[test] fn a_production_deployment_must_name_the_clients_it_admits() { let root = tempfile::tempdir().unwrap(); From 2970193488383142863d54af89c8ba5f45b7a273 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:35:38 +0700 Subject: [PATCH 079/115] test(scheduling): pin what a dispatch pass sends and what it concludes Reminder dispatch is the one place the runtime speaks to another system, and nothing covered it: the suite minted intents and exercised the back-off through the store, but no test ever let the deployment send. The `wiremock` dev-dependency was declared and unused. A local destination this test controls answers three due intents with 202, 503 and 403 in one pass, matched on the identity each event carries. The pass is then held to what it put on the wire (one CloudEvents 1.0 event per intent, naming this deployment, carrying the committed payload, and nothing more) and to what it concluded from each answer: taken is delivered, a destination that could not answer proves nothing and waits out a back-off, and an outright refusal is held for an operator rather than retried against a destination that has already said no. A second pass sends nothing again. Non-vacuity: classifying 503 outside the transient statuses fails the test with "failed" where "pending" is required. Sending from a test needs the transport and the pass reachable, so `ReminderTransport` (fields still private), `reminder_transport` and `dispatch_due_intents` become `pub`; no logic moved with them. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/runtime.rs | 25 +++- .../tests/postgres_commitments.rs | 123 +++++++++++++++++- 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index d65dc3e07d..38511002ca 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -510,7 +510,12 @@ fn offering_window_ids(policy: ®istry_scheduling_core::SchedulingPolicy) -> V /// The frozen outbound transport for due reminder intents: one fixed /// destination, one reviewed request shape, and an operator-configured bearer /// credential that never reaches a log line. -struct ReminderTransport { +/// +/// Public, with its fields kept private, so the database suite can point a +/// dispatch pass at a local destination it controls. What the deployment +/// sends, and how it reads each answer back, is only observable against a +/// destination that answers. +pub struct ReminderTransport { policy: DataDestinationPolicy, template: DataDestinationRequestTemplate, bearer: Option, @@ -533,7 +538,14 @@ fn destination_origin_and_path(configured: &str) -> Result<(String, String), &'s Ok((origin.to_string(), path)) } -fn reminder_transport( +/// Compile one operator-configured destination into the frozen transport the +/// dispatch loop sends through. +/// +/// # Errors +/// +/// Returns the operator-facing sentence naming what about the configured +/// destination could not be frozen. +pub fn reminder_transport( destination: &ReminderDestinationConfig, secrets: &SecretResolver, ) -> Result { @@ -652,8 +664,13 @@ fn next_attempt(attempts: i32) -> DateTime { /// Claim every due intent and give each one dispatch attempt. A transport /// failure proves nothing and schedules a retry; a refusal outside the retry /// classes is held as failed for the operator; with no destination configured -/// the intent is marked local — recorded, never pretended delivered. -async fn dispatch_due_intents( +/// the intent is marked local: recorded, never pretended delivered. +/// +/// # Errors +/// +/// Returns the store failure that stopped the pass. A destination's answer, +/// whatever it is, is an outcome written back and not an error. +pub async fn dispatch_due_intents( store: &PostgresStore, scheduling_id: &str, transport: Option<&ReminderTransport>, diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 7826fcc925..1fa8fb61d9 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -24,9 +24,13 @@ use registry_platform_audit::{ use registry_platform_config::{SecretProvider, SecretResolver}; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; use registry_scheduling::auth::SchedulingAuthenticator; +use registry_scheduling::config::ReminderDestinationConfig; use registry_scheduling::config::{DatabaseConfig, OidcConfig, OidcJwksSource}; use registry_scheduling::http::{router, HttpState}; -use registry_scheduling::runtime::{publish_audit_pass, AuditPublicationState, AuditPublisher}; +use registry_scheduling::runtime::{ + dispatch_due_intents, publish_audit_pass, reminder_transport, AuditPublicationState, + AuditPublisher, +}; use registry_scheduling::service::SchedulingService; use registry_scheduling::store::PostgresStore; use registry_scheduling_core::{ @@ -36,6 +40,8 @@ use serde_json::{json, Value}; use std::sync::Arc; use tower::ServiceExt; use uuid::Uuid; +use wiremock::matchers::{body_string_contains, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; const ISSUER: &str = "https://task-token.test"; const AUDIENCE: &str = "urn:registry-scheduling:test"; @@ -2661,3 +2667,118 @@ async fn the_audit_chain_continues_across_a_restart() { "it is marked published instead" ); } + +/// How one intent stands after a dispatch pass: the state written back, how +/// many attempts it has consumed, and whether its next attempt is held behind +/// a back-off. +async fn intent_state(fx: &Fixture, claim_id: &str) -> (String, i32, bool) { + let claim = Uuid::parse_str(claim_id).expect("a claim identifier"); + let row = fx + .admin + .query_one( + "SELECT delivery_state, attempts, next_attempt_at > now() \ + FROM scheduling_outbox WHERE claim_id=$1 AND purpose='confirmation'", + &[&claim], + ) + .await + .expect("read one intent back"); + (row.get(0), row.get(1), row.get(2)) +} + +/// A dispatch pass sends every due intent to the configured destination and +/// reads each answer back the way the classes say: a taken event is delivered +/// and never sent twice, a destination that could not answer proves nothing +/// and waits out a back-off, and an outright refusal is held for an operator +/// rather than retried against a destination that has already said no. +/// +/// The destination is a local server this test controls, because what the +/// deployment puts on the wire, and what it concludes from an answer, cannot +/// be observed without one that answers. +#[tokio::test] +async fn a_dispatch_pass_delivers_retries_and_holds_by_what_the_destination_answers() { + let destination = MockServer::start().await; + let fx = fixture().await; + let (taken, _) = booked(&fx, 90, 200, "dispatch-taken").await; + let (unanswered, _) = booked(&fx, 300, 440, "dispatch-unanswered").await; + let (refused, _) = booked(&fx, 600, 800, "dispatch-refused").await; + + // One stub per appointment, matched on the identity the event carries, so + // a single pass produces all three outcomes against one destination. + for (appointment, status) in [(&taken, 202), (&unanswered, 503), (&refused, 403)] { + Mock::given(method("POST")) + .and(path("/events")) + .and(header("content-type", "application/cloudevents+json")) + .and(body_string_contains(appointment.as_str())) + .respond_with(ResponseTemplate::new(status)) + .mount(&destination) + .await; + } + + // The destination declares no bearer reference, so nothing is resolved; + // the resolver still has to exist, and refuses to be built with no + // provider enabled at all. + let secrets = SecretResolver::new([SecretProvider::Environment], std::path::Path::new("")) + .expect("a resolver this destination asks nothing of"); + let transport = reminder_transport( + &ReminderDestinationConfig { + url: format!("{}/events", destination.uri()), + bearer_token_ref: None, + }, + &secrets, + ) + .expect("the configured destination compiles into a frozen transport"); + + dispatch_due_intents(&fx.store, SCHEDULING_ID, Some(&transport)) + .await + .expect("the dispatch pass runs"); + + assert_eq!( + intent_state(&fx, &taken).await, + ("delivered".to_owned(), 1, false), + "a destination that took the event leaves nothing to retry" + ); + let (state, attempts, backed_off) = intent_state(&fx, &unanswered).await; + assert_eq!( + (state.as_str(), attempts), + ("pending", 1), + "a destination that could not answer proves nothing" + ); + assert!(backed_off, "so the next attempt waits out a back-off"); + assert_eq!( + intent_state(&fx, &refused).await, + ("failed".to_owned(), 1, false), + "a destination that refused outright is held for an operator" + ); + + // What went on the wire is one CloudEvents 1.0 event per intent, naming + // this deployment and carrying the committed payload, and nothing else. + let sent = destination.received_requests().await.expect("the requests"); + assert_eq!(sent.len(), 3, "one request per due intent, and no more"); + let event: Value = serde_json::from_slice( + &sent + .iter() + .find(|request| String::from_utf8_lossy(&request.body).contains(taken.as_str())) + .expect("the taken appointment's event") + .body, + ) + .expect("a JSON event body"); + assert_eq!(event["specversion"], "1.0"); + assert_eq!(event["source"], SCHEDULING_ID); + assert_eq!(event["type"], "org.registrystack.scheduling.confirmation"); + assert_eq!(event["data"]["appointmentId"], taken); + + // A second pass repeats nothing: the delivered intent is done and the + // other two are held or backed off, so the destination hears no more. + dispatch_due_intents(&fx.store, SCHEDULING_ID, Some(&transport)) + .await + .expect("the second dispatch pass runs"); + assert_eq!( + destination + .received_requests() + .await + .expect("the requests") + .len(), + 3, + "nothing a pass concluded is sent to the destination again" + ); +} From 69a9f7301831c01420390788b6929b004a173a56 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:35:42 +0700 Subject: [PATCH 080/115] chore(identifiers): regenerate the catalog for the scheduling runtime schema The Scheduling runtime configuration schema gained the declared assertion authority map, so the catalog's recorded source and artifact digests moved with it. Regenerated by `products/identifiers/scripts/generate.py`, never edited by hand. Signed-off-by: Jeremi Joslin --- products/identifiers/generated/catalog.v1.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index ba7f91646d..b3e603e133 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -2883,11 +2883,11 @@ "description": "Registry Scheduling runtime configuration JSON Schema.", "source": { "path": "crates/registry-scheduling/src/schema.rs", - "sha256": "f27ebebd2182e7aa05a844a1320ea7d8c24787ba259224cc1f9b8b880d18f709" + "sha256": "99d43221904847661cf4331d1d5b4fd7c936bbb60ead987509bcb6c3f83b61c5" }, "artifact": { "path": "products/scheduling/generated/runtime/runtime.schema.json", - "sha256": "775713d41483477dcb49e7d13237c7881a84a17aa70f8e307bfeff70d1633faa", + "sha256": "ad049c6b8d0fc70d989197131cd4c674cf728548415b265fd1f2147814f41247", "mediaType": "application/schema+json" } }, From 9c85a1501e55afb32153b04075134be78a9fbaf0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:39:56 +0700 Subject: [PATCH 081/115] feat(schedulingctl): read undelivered delivery intents An operator has no read path today for the reminder intents a deployment's sweep has stopped carrying: ones held locally because no reminders destination is declared, and ones a destination refused on every attempt. Add `schedulingctl intents ` to list them oldest due first, in the store's own terms, so an operator can see what needs attention without querying the database directly. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/Cargo.toml | 4 + crates/registry-schedulingctl/src/intents.rs | 55 +++ crates/registry-schedulingctl/src/lib.rs | 38 +- crates/registry-schedulingctl/src/records.rs | 2 +- .../tests/intents_postgres.rs | 338 ++++++++++++++++++ 5 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 crates/registry-schedulingctl/src/intents.rs create mode 100644 crates/registry-schedulingctl/tests/intents_postgres.rs diff --git a/crates/registry-schedulingctl/Cargo.toml b/crates/registry-schedulingctl/Cargo.toml index c58e2643bb..864e0ba9cb 100644 --- a/crates/registry-schedulingctl/Cargo.toml +++ b/crates/registry-schedulingctl/Cargo.toml @@ -41,3 +41,7 @@ tokio-postgres.workspace = true [[test]] name = "records_apply_postgres" required-features = ["postgres-test"] + +[[test]] +name = "intents_postgres" +required-features = ["postgres-test"] diff --git a/crates/registry-schedulingctl/src/intents.rs b/crates/registry-schedulingctl/src/intents.rs new file mode 100644 index 0000000000..7d6fac5751 --- /dev/null +++ b/crates/registry-schedulingctl/src/intents.rs @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The undelivered delivery-intents read command. +//! +//! A deployment that declares no reminders destination holds every delivery +//! intent locally, and a destination that refused every attempt leaves the +//! intent failed. Both are recorded in the outbox and then waited on by +//! nobody, so this is the read path an operator has without one: it lists +//! what the sweep has stopped carrying, oldest due first, in the store's own +//! terms. + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use registry_scheduling::config::RuntimeConfig; +use registry_scheduling::store::PostgresStore; +use serde_json::{json, Value}; + +pub fn undelivered(config_path: &Path, limit: i64) -> Result { + let config_path = + fs::canonicalize(config_path).context("resolving the Scheduling runtime configuration")?; + let config = RuntimeConfig::load(&config_path) + .with_context(|| format!("loading {}", config_path.display()))?; + let resolver = crate::records::secret_resolver(&config)?; + let store = PostgresStore::connect_runtime(&config.database, &resolver) + .context("the Scheduling runtime database configuration is invalid")?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("starting the Scheduling operator runtime")?; + let intents = runtime + .block_on(store.undelivered_intents(limit)) + .context("reading undelivered delivery intents")? + .into_iter() + .map(|intent| { + json!({ + "outboxId": intent.outbox_id.to_string(), + "purpose": intent.purpose, + "claimId": intent.claim_id.to_string(), + "appointmentRevision": intent.appointment_revision, + "dueAt": intent.due_at.to_rfc3339(), + "deliveryState": intent.delivery_state, + "attempts": intent.attempts, + "payload": intent.payload, + }) + }) + .collect::>(); + Ok(json!({ + "ok": true, + "command": "intents", + "config": config_path, + "intents": intents, + })) +} diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index d7df92afc6..0988ae0053 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -4,9 +4,12 @@ //! tooling: `init` writes a complete starter project, `check` validates the //! authored policy offline, `test` replays every fixture offline, `explain` //! publishes what the runtime would serve, `package` writes the deployment -//! identity the runtime verifies, and `records apply` performs the one -//! attributable operator write of a deployment's live environment records. +//! identity the runtime verifies, `records apply` performs the one +//! attributable operator write of a deployment's live environment records, +//! and `intents` reads the delivery intents a deployment's sweep has stopped +//! carrying. +pub mod intents; mod project; pub mod records; mod templates; @@ -51,6 +54,8 @@ enum Command { Package(ProjectArgs), /// Apply the live environment records of a deployment. Records(RecordsArgs), + /// List delivery intents a deployment's sweep has stopped carrying. + Intents(IntentsArgs), } #[derive(Debug, Args)] @@ -102,6 +107,16 @@ struct RecordsApplyArgs { records: PathBuf, } +#[derive(Debug, Args)] +struct IntentsArgs { + /// Runtime configuration document of the deployment to read. + #[arg(value_name = "RUNTIME_CONFIG")] + config: PathBuf, + /// Most intents to list. + #[arg(long, default_value_t = 50)] + limit: i64, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] enum OutputFormat { #[default] @@ -203,6 +218,7 @@ fn run(cli: Cli) -> Result { Command::Records(args) => match args.command { RecordsCommand::Apply(apply) => records::apply(&apply.config, &apply.records), }, + Command::Intents(args) => intents::undelivered(&args.config, args.limit), } } @@ -372,6 +388,7 @@ fn human_lead(report: &Value) -> String { "Policy package manifest written beside the authored policy.".to_owned() } ("records-apply", _, _) => "Environment records applied.".to_owned(), + ("intents", _, _) => "Undelivered delivery intents listed.".to_owned(), _ => format!("{command} succeeded."), } } @@ -524,6 +541,23 @@ mod tests { Cli::try_parse_from(["schedulingctl", "records", "apply", "/runtime.yaml"]).is_err() ); + let cli = Cli::try_parse_from(["schedulingctl", "intents", "/runtime.yaml"]).unwrap(); + let Command::Intents(args) = cli.command else { + panic!("expected intents") + }; + assert_eq!(args.config, PathBuf::from("/runtime.yaml")); + assert_eq!(args.limit, 50); + + let cli = + Cli::try_parse_from(["schedulingctl", "intents", "/runtime.yaml", "--limit", "10"]) + .unwrap(); + let Command::Intents(args) = cli.command else { + panic!("expected intents") + }; + assert_eq!(args.limit, 10); + + assert!(Cli::try_parse_from(["schedulingctl", "intents"]).is_err()); + assert!(Cli::try_parse_from([ "schedulingctl", "init", diff --git a/crates/registry-schedulingctl/src/records.rs b/crates/registry-schedulingctl/src/records.rs index 572fa5418a..c970e91bc3 100644 --- a/crates/registry-schedulingctl/src/records.rs +++ b/crates/registry-schedulingctl/src/records.rs @@ -188,7 +188,7 @@ fn counts(facts: &SchedulingFacts) -> Value { }) } -fn secret_resolver(config: &RuntimeConfig) -> Result { +pub(crate) fn secret_resolver(config: &RuntimeConfig) -> Result { let mut providers = Vec::new(); if config.secret_providers.file.is_some() { providers.push(SecretProvider::File); diff --git a/crates/registry-schedulingctl/tests/intents_postgres.rs b/crates/registry-schedulingctl/tests/intents_postgres.rs new file mode 100644 index 0000000000..d259ad1889 --- /dev/null +++ b/crates/registry-schedulingctl/tests/intents_postgres.rs @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `intents` against a real PostgreSQL, on a disposable schema. +//! +//! The test proves the read path end to end: a pending intent the sweep is +//! still carrying and a delivered one are never listed, a `local` intent (no +//! destination declared) and a `failed` one (every attempt refused) both are, +//! oldest due first, and a `--limit` narrower than the qualifying rows keeps +//! only the earliest of them. Every field the command reports is pinned +//! against what was seeded, so nothing beyond what the test asserts on is +//! carried through. + +use chrono::{DateTime, Duration, Utc}; +use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_scheduling::config::RuntimeConfig; +use registry_scheduling::store::PostgresStore; +use serde_json::{json, Value}; +use std::path::Path; +use uuid::Uuid; + +/// A minimal exact-time policy that passes its checks, in the vocabulary of +/// the standalone starter project. `RuntimeConfig::check` reads and +/// validates the authored policy at `package.root`, so a deployment +/// configuration this test can load needs one, even though `intents` itself +/// never reads it. +const POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: registry-updates + version: 1 +services: + - id: registry-update + label: Registry record update +offerings: + - id: registry-update-30 + service: registry-update + label: 30-minute counter update + mode: exact-time + location: bangkok-counter + because: A 30-minute registry update at an interchangeable counter station. + exactTime: + durationMinutes: 30 + bufferBeforeMinutes: 5 + bufferAfterMinutes: 5 + leadTimeMinutes: 120 + horizonDays: 60 + pool: update-stations + startIncrementMinutes: 30 + maxRecipients: 1 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry offices. + dates: [] +openings: + - id: bangkok-counter-hours + location: bangkok-counter + holidaySet: office-holidays + weekdays: [mon, tue, wed, thu, fri] + startTime: "09:00" + endTime: "12:30" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: Counter opening hours reviewed by the office manager. +windows: [] +holdPolicy: + ttlMinutes: 5 + maxPerCaller: 3 + because: Holds are short because counter capacity is scarce. +"#; + +fn scoped_url(base: &str, schema: &str) -> String { + let separator = if base.contains('?') { '&' } else { '?' }; + format!("{base}{separator}options=-csearch_path%3D{schema}") +} + +/// `intents` builds its own single-threaded runtime, so the call runs on a +/// plain thread: it cannot nest inside this test's runtime. +fn undelivered(config: &Path, limit: i64) -> Value { + let config = config.to_path_buf(); + std::thread::spawn(move || registry_schedulingctl::intents::undelivered(&config, limit)) + .join() + .expect("intents does not panic") + .expect("intents succeeds") +} + +async fn seed_claim(client: &tokio_postgres::Client, claim_id: Uuid) { + client + .execute( + "INSERT INTO scheduling_claims(claim_id, kind, state, offering, supply_id, \ + displayed_start, displayed_end, occupied_start, occupied_end, units, \ + revision, policy_revision, actor) \ + VALUES($1,'booking','active','registry-update-30','station-1', \ + now(), now(), now(), now(), 1, 1, 1, 'actor-pseudonym')", + &[&claim_id], + ) + .await + .expect("a claim seeds cleanly"); +} + +#[allow(clippy::too_many_arguments)] +async fn seed_outbox( + client: &tokio_postgres::Client, + outbox_id: Uuid, + purpose: &str, + claim_id: Uuid, + appointment_revision: i64, + due_at: DateTime, + delivery_state: &str, + attempts: i32, + payload: &Value, +) { + client + .execute( + "INSERT INTO scheduling_outbox(outbox_id, purpose, claim_id, \ + appointment_revision, due_at, delivery_state, attempts, next_attempt_at, \ + payload) VALUES($1,$2,$3,$4,$5,$6,$7,$5,$8)", + &[ + &outbox_id, + &purpose, + &claim_id, + &appointment_revision, + &due_at, + &delivery_state, + &attempts, + payload, + ], + ) + .await + .expect("an outbox row seeds cleanly"); +} + +#[tokio::test] +async fn intents_lists_local_and_failed_oldest_due_first_and_respects_limit() { + let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") + .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); + let schema = format!("intents_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .expect("the test server accepts an administrative connection"); + tokio::spawn(async move { + connection + .await + .expect("the administrative connection stays up") + }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .expect("a disposable schema is created"); + + let root = tempfile::tempdir().unwrap(); + let project = root.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("scheduling.yaml"), POLICY).unwrap(); + std::fs::write( + root.path().join("runtime.yaml"), + format!( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1\n\ + kind: SchedulingRuntimeConfig\n\ + package:\n root: {project}\n\ + listener:\n bind: 127.0.0.1:8106\n tlsTermination: development-loopback\n\ + secretProviders:\n environment: {{}}\n\ + authentication:\n oidc:\n issuer: https://identity.example.test\n\ + \x20 audience: urn:example:scheduling\n\ + database:\n runtimeUrlRef: secret:env/SCHEDULING_INTENTS_TEST_DATABASE\n\ + \x20 migrationUrlRef: secret:env/SCHEDULING_INTENTS_TEST_DATABASE\n\ + \x20 testOnlyPlaintext: true\n\ + audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_INTENTS_TEST_AUDIT\n\ + retention:\n attemptReceiptDays: 2\n", + project = project.display(), + audit = root.path().join("audit.ndjson").display(), + ), + ) + .unwrap(); + let config_path = root.path().join("runtime.yaml"); + std::env::set_var( + "SCHEDULING_INTENTS_TEST_DATABASE", + scoped_url(&base, &schema), + ); + std::env::set_var( + "SCHEDULING_INTENTS_TEST_AUDIT", + "0123456789abcdef0123456789abcdef", + ); + + let config = RuntimeConfig::load(&config_path).expect("the runtime configuration loads"); + let resolver = SecretResolver::new([SecretProvider::Environment], "") + .expect("the environment secret provider configures"); + let store = PostgresStore::connect_migration(&config.database, &resolver).unwrap(); + store.migrate().await.expect("the schema migrates"); + + let seed = tokio_postgres::connect(&scoped_url(&base, &schema), tokio_postgres::NoTls) + .await + .expect("a seeding connection scoped to the schema connects"); + tokio::spawn(async move { seed.1.await.expect("the seeding connection stays up") }); + let seed = seed.0; + + let claim_a = Uuid::new_v4(); + let claim_b = Uuid::new_v4(); + seed_claim(&seed, claim_a).await; + seed_claim(&seed, claim_b).await; + + let base_time = Utc::now(); + let pending_id = Uuid::new_v4(); + let local_first_id = Uuid::new_v4(); + let failed_id = Uuid::new_v4(); + let delivered_id = Uuid::new_v4(); + let local_second_id = Uuid::new_v4(); + + let local_first_payload = + json!({"appointmentId": claim_a, "revision": 1, "offering": "registry-update-30"}); + let failed_payload = + json!({"appointmentId": claim_b, "revision": 2, "offering": "registry-update-30"}); + let local_second_payload = + json!({"appointmentId": claim_a, "revision": 3, "offering": "registry-update-30"}); + + // Still carried by the sweep: never listed. + seed_outbox( + &seed, + pending_id, + "reminder", + claim_a, + 1, + base_time, + "pending", + 0, + &json!({"appointmentId": claim_a, "revision": 1, "offering": "registry-update-30"}), + ) + .await; + // No destination declared: listed. + seed_outbox( + &seed, + local_first_id, + "reminder", + claim_a, + 1, + base_time + Duration::seconds(60), + "local", + 0, + &local_first_payload, + ) + .await; + // Every attempt refused: listed. + seed_outbox( + &seed, + failed_id, + "confirmation", + claim_b, + 2, + base_time + Duration::seconds(120), + "failed", + 3, + &failed_payload, + ) + .await; + // Delivered: never listed. + seed_outbox( + &seed, + delivered_id, + "reminder", + claim_b, + 2, + base_time + Duration::seconds(180), + "delivered", + 1, + &json!({"appointmentId": claim_b, "revision": 2, "offering": "registry-update-30"}), + ) + .await; + // A second, later local intent: listed last. + seed_outbox( + &seed, + local_second_id, + "reminder", + claim_a, + 3, + base_time + Duration::seconds(240), + "local", + 0, + &local_second_payload, + ) + .await; + + let report = undelivered(&config_path, 10); + assert_eq!(report["command"], "intents"); + let intents = report["intents"].as_array().expect("intents is an array"); + assert_eq!(intents.len(), 3, "{intents:?}"); + + assert_eq!(intents[0]["outboxId"], local_first_id.to_string()); + assert_eq!(intents[0]["purpose"], "reminder"); + assert_eq!(intents[0]["claimId"], claim_a.to_string()); + assert_eq!(intents[0]["appointmentRevision"], 1); + assert_eq!( + intents[0]["dueAt"], + (base_time + Duration::seconds(60)).to_rfc3339() + ); + assert_eq!(intents[0]["deliveryState"], "local"); + assert_eq!(intents[0]["attempts"], 0); + assert_eq!(intents[0]["payload"], local_first_payload); + + assert_eq!(intents[1]["outboxId"], failed_id.to_string()); + assert_eq!(intents[1]["purpose"], "confirmation"); + assert_eq!(intents[1]["claimId"], claim_b.to_string()); + assert_eq!(intents[1]["appointmentRevision"], 2); + assert_eq!( + intents[1]["dueAt"], + (base_time + Duration::seconds(120)).to_rfc3339() + ); + assert_eq!(intents[1]["deliveryState"], "failed"); + assert_eq!(intents[1]["attempts"], 3); + assert_eq!(intents[1]["payload"], failed_payload); + + assert_eq!(intents[2]["outboxId"], local_second_id.to_string()); + assert_eq!(intents[2]["deliveryState"], "local"); + assert_eq!(intents[2]["payload"], local_second_payload); + + // A pending intent the sweep still carries and a delivered one are never + // listed, regardless of the limit. + let outbox_ids: Vec<&str> = intents + .iter() + .map(|intent| intent["outboxId"].as_str().unwrap()) + .collect(); + assert!(!outbox_ids.contains(&pending_id.to_string().as_str())); + assert!(!outbox_ids.contains(&delivered_id.to_string().as_str())); + + // A limit narrower than the qualifying rows keeps only the earliest due. + let limited = undelivered(&config_path, 2); + let limited_intents = limited["intents"].as_array().expect("intents is an array"); + assert_eq!(limited_intents.len(), 2); + assert_eq!(limited_intents[0]["outboxId"], local_first_id.to_string()); + assert_eq!(limited_intents[1]["outboxId"], failed_id.to_string()); + + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .expect("the disposable schema is dropped"); +} From bb9f98039bf383757379f90ec81a38f8a6c26df5 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:46:53 +0700 Subject: [PATCH 082/115] docs(scheduling): say which clients and which authorities a deployment admits The runtime now refuses to start when a deployment behind an operator-controlled terminator names no clients, and refuses an exchanged token whose assertion authority no entry declares. The configuration reference still told an operator that an empty client list admits every client the issuer verifies, which would now be a startup failure, and said nothing about the authority map at all. Signed-off-by: Jeremi Joslin --- products/scheduling/RUNTIME-CONFIG.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/products/scheduling/RUNTIME-CONFIG.md b/products/scheduling/RUNTIME-CONFIG.md index 2f0c1aae79..ac2d54780e 100644 --- a/products/scheduling/RUNTIME-CONFIG.md +++ b/products/scheduling/RUNTIME-CONFIG.md @@ -61,10 +61,24 @@ existing deployments; stock ThunderID emits `scope`, so the maintained example and `schedulingctl init` set that explicit override. `readsScope` defaults to `scheduling-read` and `explainScope` to `scheduling-explain`; the two must differ, because the explain path can name member-level causes the public -availability read never discloses. `allowedClients` is empty by default, which -admits every client the issuer verifies; list the exact client ids to narrow -that. Scopes authorize reads only. Every commitment takes its authority from a -task grant instead, which [TASK_GRANTS.md](TASK_GRANTS.md) documents. +availability read never discloses. `allowedClients` lists the exact client ids +the runtime admits. A deployment whose `listener.tlsTermination` is +`operator-controlled-upstream` must name them: an empty or absent list is a +startup refusal there, because a forgotten field would otherwise admit every +client the issuer verifies, including an application in the same realm that has +nothing to do with booking. Development loopback keeps the empty-means-any +convenience, since it is not a deployment an unrelated client can reach. + +`assertionIssuers` maps a client id to the assertion authorities that client may +exchange a subject token from. A deployment that performs no token exchange +leaves it empty, and an exchanged token whose authority no entry declares is +refused. Once a client is listed, a token it exchanged is accepted only for one +of that client's declared authorities, so an assertion minted by an unrelated +authority the issuer happens to federate cannot become a booking credential +here. + +Scopes authorize reads only. Every commitment takes its authority from a task +grant instead, which [TASK_GRANTS.md](TASK_GRANTS.md) documents. `audit.path` is the absolute JSONL journal path. `audit.hashKeyRef` supplies its keyed-chain secret. Every commitment and every authorization refusal is From bdeb0fc653c4c0800a9d111831e811eb9d20801f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:47:01 +0700 Subject: [PATCH 083/115] docs: answer an unknown offering as absent in the Scheduling API reference precondition.failed no longer has a producer in the Scheduling runtime: an offering the deployed policy does not publish answers request.not-found, and a stale revision answers policy.changed or revision.mismatch. The published reference still told a caller to reload and retry something that no reload produces. The site cannot be run end to end until the CLI publication record is reviewed, so this change is verified against the data file's own shape: 32 rows matching ProblemCode::ALL, sorted, every code present in the Rust vocabulary. Signed-off-by: Jeremi Joslin --- .../content/docs/reference/apis/registry-scheduling.mdx | 7 +++++-- docs/site/src/data/scheduling-api.yaml | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx index 29e51dcbfb..9c26253eea 100644 --- a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -46,8 +46,11 @@ caller holding anything back. Observed revisions travel in request bodies, not in an `If-Match` header: an admission names the `policyRevision` it resolved the offering under, and a reschedule or cancel names the appointment -revision it acted on. A stale revision is refused with `policy.changed`, `revision.mismatch`, or -`precondition.failed`, and the caller reloads the catalogue and tries again. +revision it acted on. A stale revision is refused with `policy.changed` or `revision.mismatch`, and +the caller reloads the catalogue and tries again. An offering the deployed policy does not publish +is a separate answer, `request.not-found`: it does not exist, so no reload produces it, and the +offering listing every caller holding the reads scope can read is where the published ones are +named. {/* Evidence: crates/registry-scheduling-core/src/model.rs, AdmissionRequest; crates/registry-scheduling-core/src/admission.rs, check_duplicate(); diff --git a/docs/site/src/data/scheduling-api.yaml b/docs/site/src/data/scheduling-api.yaml index 4a3ae00f61..74740f4dae 100644 --- a/docs/site/src/data/scheduling-api.yaml +++ b/docs/site/src/data/scheduling-api.yaml @@ -20,14 +20,14 @@ - ['`operation.not-authorized`', '403', 'The caller''s task grant does not name this operation, or the deployment''s client does not match the grant''s client.'] - ['`party.capacity-inadequate`', '422', 'The party is larger than the offering can ever serve, or its size falls outside the published units.'] - ['`policy.changed`', '412', 'The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.'] - - ['`precondition.failed`', '412', 'The appointment changed since the caller loaded it. Reload and try again.'] + - ['`precondition.failed`', '412', 'Reserved: no runtime path answers it. A stale revision answers `policy.changed` or `revision.mismatch` instead.'] - ['`precondition.required`', '428', 'The mutation requires the revision the caller loaded, or the duplicate key the offering keys on.'] - ['`prerequisite.missing`', '422', 'The party is missing a prerequisite the offering requires.'] - ['`profile.not-authorized`', '403', 'The caller holds no profile that authorizes this request, for example a read scope check on a mutation route.'] - ['`request.body-too-large`', '413', 'The request body exceeds the accepted size.'] - ['`request.invalid`', '400', 'The request could not be read as a Scheduling request.'] - ['`request.method-not-allowed`', '405', 'The route exists but not for this method.'] - - ['`request.not-found`', '404', 'The requested route does not exist.'] + - ['`request.not-found`', '404', 'The requested route does not exist, or the deployed policy publishes no offering by that id. The offering listing names the ones it does.'] - ['`request.unprocessable`', '422', 'The request body could not be processed.'] - ['`request.unsupported-media-type`', '415', 'The request body is not JSON.'] - ['`resource.unavailable`', '409', 'Every capable member is unavailable for this interval. The separately authorized explain path reports this code; the public path answers `capacity.exhausted`.'] From 86b771f0da46494153940dbeb3bc3f1a80119806 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:47:08 +0700 Subject: [PATCH 084/115] docs(scheduling): record the deferred schedulingctl dev verb DX-16 asked for a dev verb over the runtime with the demo's provisioning shape. The demo's shape depends on Docker container lifecycle, TLS certificate generation, and JWT signing infrastructure (products/scheduling/demo/support/demo.py), so a resident process supervisor over it is a project-sized effort of its own, comparable to crates/registry-evidencectl/src/dev.rs. Record the deferral and point at the demo script as the current local development path. Signed-off-by: Jeremi Joslin --- products/scheduling/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/products/scheduling/README.md b/products/scheduling/README.md index 5c404f223d..1fe049fd2e 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -82,6 +82,26 @@ token, and one of three authority profiles decides what it must carry; the published reference is [Registry Scheduling API](https://docs.registrystack.org/reference/apis/registry-scheduling/). +## Local development + +There is no `schedulingctl dev` verb that stands up a runtime instance +directly; this is a tracked exclusion, not an oversight. Local development +runs the demo instead: + +```sh +products/scheduling/demo/run.sh +``` + +which provisions a disposable TLS-enabled PostgreSQL database, a throwaway +signing key and JWKS, migrates and applies example environment records, and +serves the runtime on loopback (see `products/scheduling/demo/README.md`). A +verb over that same provisioning shape does not fit as a surgical addition: +the shape depends on Docker container lifecycle, TLS certificate generation, +and JWT signing infrastructure the demo carries in +`products/scheduling/demo/support/demo.py`, and a resident process supervisor +over a runtime instance is a project-sized effort on its own, comparable in +scope to `crates/registry-evidencectl/src/dev.rs`. + ## Task grants Reads take a scope. Every commitment takes a task grant whose scheduling From 6ddb7afcd266d54240f1eab8ad6f83341c909658 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:47:15 +0700 Subject: [PATCH 085/115] docs(schedulingctl): correct allowedClients and document assertionIssuers allowedClients being empty by default no longer admits every client unconditionally: behind listener.tlsTermination: operator-controlled-upstream an empty or absent list is a startup refusal (config.rs, a_production_deployment_must_name_the_clients_it_admits), and only development loopback keeps the old convenience. The runtime example still said otherwise. Also document the assertionIssuers key, added alongside that change, in the same commented style. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/templates.rs | 14 ++++++++++++-- .../standalone-exact-time/runtime.example.yaml | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs index 9a366b3893..61b0a8a1ce 100644 --- a/crates/registry-schedulingctl/src/templates.rs +++ b/crates/registry-schedulingctl/src/templates.rs @@ -454,9 +454,19 @@ authentication: scopeClaim: scope # The read scope defaults to `scheduling-read` and the explain scope to # `scheduling-explain`; the two must differ. `allowedClients` names the - # client ids the runtime admits and is empty by default, which admits - # every client the issuer verifies. + # client ids the runtime admits. Development loopback leaves it empty by + # default, which admits every client the issuer verifies; behind + # listener.tlsTermination: operator-controlled-upstream an empty or + # absent list is a startup refusal, so a production deployment must name + # the clients it admits. # allowedClients: [scheduling-booking-agent] + # `assertionIssuers` maps a client id to the assertion authorities it may + # exchange a subject token from. A deployment that performs no token + # exchange leaves it empty; once a client is listed, a token it + # exchanged is accepted only for one of that client's declared + # authorities. + # assertionIssuers: + # scheduling-booking-agent: [https://issuer.example.test/assertions] # Signing keys come from issuer discovery by default. Declare the static # alternative instead when the runtime cannot reach the issuer's discovery # document, when the deployment is air-gapped, or when a test issuer's diff --git a/products/scheduling/examples/standalone-exact-time/runtime.example.yaml b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml index 41c463c0bf..4c533b6ba5 100644 --- a/products/scheduling/examples/standalone-exact-time/runtime.example.yaml +++ b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml @@ -60,9 +60,19 @@ authentication: scopeClaim: scope # The read scope defaults to `scheduling-read` and the explain scope to # `scheduling-explain`; the two must differ. `allowedClients` names the - # client ids the runtime admits and is empty by default, which admits - # every client the issuer verifies. + # client ids the runtime admits. Development loopback leaves it empty by + # default, which admits every client the issuer verifies; behind + # listener.tlsTermination: operator-controlled-upstream an empty or + # absent list is a startup refusal, so a production deployment must name + # the clients it admits. # allowedClients: [scheduling-booking-agent] + # `assertionIssuers` maps a client id to the assertion authorities it may + # exchange a subject token from. A deployment that performs no token + # exchange leaves it empty; once a client is listed, a token it + # exchanged is accepted only for one of that client's declared + # authorities. + # assertionIssuers: + # scheduling-booking-agent: [https://issuer.example.test/assertions] # Signing keys come from issuer discovery by default. Declare the static # alternative instead when the runtime cannot reach the issuer's discovery # document, when the deployment is air-gapped, or when a test issuer's From 14ce50576295da3e3a89341bbf927b28c341d5fa Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:47:47 +0700 Subject: [PATCH 086/115] fix(scheduling): name a member only when the offering books one The claim ledger keeps a single supply column: an exact-time claim occupies the pool member it was assigned, a window claim occupies the window itself. The published documents decided which of the two they were holding from the ledger kind, which only ever says booking or hold, so the guard was always true and an arrival-window appointment reported the window id through the field documented as the pool member. Decide it from the offering's mode instead, and cover arrival windows end to end: unit allocation, the channel subquota ceiling, exhaustion, and a stale window revision. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/service.rs | 43 +++- .../tests/postgres_commitments.rs | 230 +++++++++++++++++- 2 files changed, 260 insertions(+), 13 deletions(-) diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index e9b01715a7..7680599920 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -540,7 +540,7 @@ impl SchedulingService { ) .await?; Ok(claim_or_replay(answer, |claim| { - hold_document(claim, self.revision()) + hold_document(claim, &self.policy, self.revision()) })) } @@ -624,7 +624,7 @@ impl SchedulingService { } }?; Ok(claim_or_replay(answer, |claim| { - appointment_document(claim, self.revision()) + appointment_document(claim, &self.policy, self.revision()) })) } @@ -730,7 +730,7 @@ impl SchedulingService { .owned_booking(caller, appointment_id) .await? .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; - Ok(appointment_document(&claim, self.revision())) + Ok(appointment_document(&claim, &self.policy, self.revision())) } pub async fn reschedule_appointment( @@ -794,7 +794,7 @@ impl SchedulingService { ) .await?; Ok(claim_or_replay(answer, |claim| { - appointment_document(claim, self.revision()) + appointment_document(claim, &self.policy, self.revision()) })) } @@ -854,7 +854,7 @@ impl SchedulingService { ) .await?; Ok(claim_or_replay(answer, |claim| { - appointment_document(claim, self.revision()) + appointment_document(claim, &self.policy, self.revision()) })) } @@ -1633,26 +1633,38 @@ fn history_entry(row: &Value) -> Result registry_scheduling_core::HoldDocument { +fn hold_document( + claim: &ClaimRow, + policy: &SchedulingPolicy, + policy_revision: u64, +) -> registry_scheduling_core::HoldDocument { registry_scheduling_core::HoldDocument { hold_id: claim.claim_id.to_string(), offering: claim.offering.clone(), start: claim.displayed_start, end: claim.displayed_end, - resource: claim.supply_id_is_member().then(|| claim.supply_id.clone()), + resource: claim + .supply_id_is_member(policy) + .then(|| claim.supply_id.clone()), units: u32::try_from(claim.units).unwrap_or(u32::MAX), expires_at: claim.hold_expires_at.unwrap_or(claim.created_at), policy_revision, } } -fn appointment_document(claim: &ClaimRow, policy_revision: u64) -> AppointmentDocument { +fn appointment_document( + claim: &ClaimRow, + policy: &SchedulingPolicy, + policy_revision: u64, +) -> AppointmentDocument { AppointmentDocument { appointment_id: claim.claim_id.to_string(), offering: claim.offering.clone(), start: claim.displayed_start, end: claim.displayed_end, - resource: claim.supply_id_is_member().then(|| claim.supply_id.clone()), + resource: claim + .supply_id_is_member(policy) + .then(|| claim.supply_id.clone()), units: u32::try_from(claim.units).unwrap_or(u32::MAX), channel: claim.channel.clone(), revision: u64::try_from(claim.revision).unwrap_or(u64::MAX), @@ -1670,8 +1682,17 @@ fn appointment_document(claim: &ClaimRow, policy_revision: u64) -> AppointmentDo impl ClaimRow { /// Whether the claim's supply id names a pool member (an exact-time /// booking) rather than a window. - fn supply_id_is_member(&self) -> bool { - self.kind == LedgerKind::Booking || self.kind == LedgerKind::Hold + /// + /// The ledger keeps one supply column for both: an exact-time claim + /// occupies the pool member it was assigned, and a window claim occupies + /// the window itself. Only the offering's mode says which of the two the + /// column holds, so an offering the policy no longer carries names no + /// member: a supply id that cannot be shown to be one is not published + /// through a field that promises one. + fn supply_id_is_member(&self, policy: &SchedulingPolicy) -> bool { + policy + .offering(&self.offering) + .is_some_and(|offering| offering.mode == SchedulingMode::ExactTime) } } diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 1fa8fb61d9..5893234261 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -245,6 +245,18 @@ async fn fixture() -> Fixture { /// A fresh deployment whose publication anchored exactly `pool_ids`, so a /// test can pin what happens when a policy names a pool no anchor covers. async fn fixture_anchoring(pool_ids: &[String]) -> Fixture { + fixture_publishing(POLICY, pool_ids, &[]).await +} + +/// A fresh deployment publishing `policy_yaml`, anchoring exactly `pool_ids` +/// and `window_ids`. A published window is an anchor of its own: its claims +/// occupy the window rather than a pool member, so the supply the capacity +/// transaction locks is the window itself. +async fn fixture_publishing( + policy_yaml: &str, + pool_ids: &[String], + window_ids: &[String], +) -> Fixture { let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); let schema = format!("scheduling_{}", Uuid::new_v4().simple()); @@ -309,10 +321,10 @@ async fn fixture_anchoring(pool_ids: &[String]) -> Fixture { .await .expect("adopt the scheduling deployment"); - let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let policy = parse_policy_yaml(policy_yaml).expect("the scheduling test policy"); let digest = policy.policy_digest(); let revision = store - .apply_policy(SCHEDULING_ID, &digest, pool_ids, &[]) + .apply_policy(SCHEDULING_ID, &digest, pool_ids, window_ids) .await .expect("publish the scheduling policy"); let keying = AuditHashSecret::new(vec![0x42; 32]).expect("the test audit hash secret"); @@ -2782,3 +2794,217 @@ async fn a_dispatch_pass_delivers_retries_and_holds_by_what_the_destination_answ "nothing a pass concluded is sent to the destination again" ); } + +/// The arrival-window offering and the window it serves, added to the test +/// policy. Both are only in the policies that ask for them: the exact-time +/// fixtures publish no window, so no test pays for supply it never reads. +const WINDOW_OFFERING: &str = "registry-arrivals"; +const WINDOW_ID: &str = "morning-arrivals"; +/// The window's own revision, deliberately not the policy revision, so a +/// request that sends one where the other belongs is caught rather than +/// accepted by coincidence. +const WINDOW_REVISION: u64 = 3; + +/// The test policy with one arrival window in it, opening at `start` and +/// running two hours, with two units in total and one of them reserved to the +/// assisted channel. +/// +/// A published window carries absolute instants, so its interval is built +/// against the clock the test runs on rather than frozen into the constant +/// policy beside it. +fn policy_with_window(start: DateTime) -> String { + let end = start + TimeDelta::hours(2); + POLICY.replace( + "windows: []", + &format!( + " - id: {WINDOW_OFFERING}\n\ + \x20 service: registry-update\n\ + \x20 label: Counter arrivals\n\ + \x20 mode: arrival-window\n\ + \x20 location: north-counter\n\ + \x20 because: test\n\ + \x20 cancellationCutoffMinutes: 240\n\ + \x20 arrival:\n\ + \x20 window: {WINDOW_ID}\n\ + \x20 leadTimeMinutes: 0\n\ + \x20 horizonDays: 60\n\ + \x20 requiresCapabilities: []\n\ + \x20 prerequisites: []\n\ + windows:\n\ + \x20 - id: {WINDOW_ID}\n\ + \x20 revision: {WINDOW_REVISION}\n\ + \x20 offering: {WINDOW_OFFERING}\n\ + \x20 location: north-counter\n\ + \x20 start: {}\n\ + \x20 end: {}\n\ + \x20 units: 2\n\ + \x20 unitsPolicy: {{kind: fixed, units: 1, because: test}}\n\ + \x20 subquotas: [{{id: assisted-quota, channel: assisted, units: 1, because: test}}]\n\ + \x20 because: test", + stamp(start), + stamp(end), + ), + ) +} + +/// One direct-create body admitting an arrival on the window, on `channel` +/// when it names one. +fn arrival(fx: &Fixture, start: DateTime, channel: Option<&str>) -> Value { + json!({"hold": null, "admission": { + "offering": WINDOW_OFFERING, + "start": stamp(start), + "party": {"recipients": 1, "attendees": 1}, + "channel": channel, + "duplicateKey": null, + "policyRevision": fx.revision, + "windowRevision": WINDOW_REVISION, + "capabilities": [], + "prerequisites": [], + }}) +} + +/// The window entry availability lists for the window offering, if any. +async fn window_entry(fx: &Fixture, from_minutes: i64, to_minutes: i64) -> Option { + let (status, page) = fx + .get( + &availability_uri(WINDOW_OFFERING, from_minutes, to_minutes), + &fx.reader, + ) + .await; + assert_eq!(status, StatusCode::OK, "availability answers over a window"); + page["items"] + .as_array() + .into_iter() + .flatten() + .find(|entry| entry["kind"] == "window") + .cloned() +} + +/// An arrival window is a second admission mode with a ledger of its own: its +/// claims occupy the window rather than a member of a pool, its capacity is +/// counted in units rather than in slots, and a channel subquota is a ceiling +/// inside the window's total. Nothing in the database suite had ever booked +/// one, so none of that was covered against a real ledger. +/// +/// The window here carries two units, one of them reserved to the assisted +/// channel. The assisted caller takes that one, the next assisted caller is +/// refused on the subquota while the window still has a unit left, a public +/// caller takes the last unit, and the window then offers nothing. +#[tokio::test] +async fn an_arrival_window_allocates_its_units_and_holds_its_channel_ceiling() { + // The published interval is stamped to the second, which is the precision + // every comparison below reads it back at. + let start = Utc::now() + TimeDelta::hours(3); + let fx = fixture_publishing( + &policy_with_window(start), + &["north-counter".to_owned(), "two-counter".to_owned()], + &[WINDOW_ID.to_owned()], + ) + .await; + + let offered = window_entry(&fx, 60, 300) + .await + .expect("the published window is offered"); + assert_eq!(offered["window"], WINDOW_ID); + assert_eq!(offered["start"], stamp(start)); + assert_eq!(offered["remaining"], 2, "the whole window is unallocated"); + + // The assisted channel holds one of the two units. + let (status, booked) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-assisted", + arrival(&fx, start, Some("assisted")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{booked}"); + assert_eq!(booked["start"], stamp(start)); + assert_eq!(booked["end"], stamp(start + TimeDelta::hours(2))); + // An arrival joins a window, so there is no member to name. The field is + // the pool member an exact-time booking occupies, and the ledger's one + // supply column must not be reported through it as if it were one. + assert!( + booked["resource"].is_null(), + "an arrival names no resource: {booked}" + ); + + // The window still has a unit, but the assisted subquota does not, so a + // second assisted arrival is refused on the ceiling inside the total. + let (status, refused) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-assisted-again", + arrival(&fx, start, Some("assisted")), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{refused}"); + assert_eq!(refused["code"], "capacity.exhausted"); + assert_eq!( + window_entry(&fx, 60, 300).await.expect("still offered")["remaining"], + 1, + "the window's own total is what availability reports" + ); + + // The same unit the assisted caller could not have is free to a public + // one, and it is the window's last. + let (status, last) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-public", + arrival(&fx, start, Some("public")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{last}"); + let (status, exhausted) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-too-late", + arrival(&fx, start, None), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{exhausted}"); + assert_eq!(exhausted["code"], "capacity.exhausted"); + assert!( + window_entry(&fx, 60, 300).await.is_none(), + "a window with no units left is not offered" + ); + + // A window claim occupies the window, not a member of a pool. The ledger + // holds both in one `supply_id` column, which carries the resource an + // exact-time claim books and the window an arrival claim joins, so the + // column means two things and only the claim's offering says which. + let occupied: Vec<(String, Option)> = fx + .admin + .query( + "SELECT supply_id, channel FROM scheduling_claims \ + WHERE state='active' AND offering=$1 ORDER BY created_at", + &[&WINDOW_OFFERING], + ) + .await + .expect("read the window's claims") + .iter() + .map(|row| (row.get(0), row.get(1))) + .collect(); + assert_eq!( + occupied, + vec![ + (WINDOW_ID.to_owned(), Some("assisted".to_owned())), + (WINDOW_ID.to_owned(), Some("public".to_owned())), + ], + "both arrivals occupy the window itself, each under its own channel" + ); + + // The window revision is the caller's agreement about the window, and a + // stale one is refused whatever the policy revision says. + let mut stale = arrival(&fx, start, None); + stale["admission"]["windowRevision"] = json!(WINDOW_REVISION - 1); + let (status, mismatch) = fx + .post("/v1/appointments", &fx.agent, "arrival-stale", stale) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{mismatch}"); + assert_eq!(mismatch["code"], "revision.mismatch"); +} From 6caf9b9bbf3d52a8fc2cbbcce31d10d46468e832 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:48:46 +0700 Subject: [PATCH 087/115] chore(scheduling): write the store and runtime prose without em dashes The branch's prose convention is commas, colons, semicolons, periods or parentheses. Comment text only, no behaviour change. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/runtime.rs | 6 +++--- crates/registry-scheduling/src/store.rs | 11 ++++++----- .../registry-scheduling/tests/postgres_commitments.rs | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index 38511002ca..ba233554b6 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -2,7 +2,7 @@ //! Service assembly and the supervised background loops: `scheduling serve` //! builds the store connection, the authenticator, the audit chain, and the -//! scheduling service, then runs the HTTP listener beside four workers — hold +//! scheduling service, then runs the HTTP listener beside four workers: hold //! expiry, reminder-intent dispatch, retention sweeps, and audit publication. //! A worker that dies stops the process rather than letting the runtime keep //! selling capacity its clocks no longer guard. @@ -67,8 +67,8 @@ const REMINDER_RETRY_MAX_SECONDS: i64 = 3600; const REMINDER_MAXIMUM_BODY_BYTES: usize = 16 * 1024; const REMINDER_MAXIMUM_REQUEST_BYTES: usize = 32 * 1024; const REMINDER_BEARER_MAXIMUM_BYTES: usize = 8192; -/// The whole dispatch of one intent — resolve, connect, send, and the status -/// line — shares one deadline. +/// The whole dispatch of one intent (resolve, connect, send, and the status +/// line) shares one deadline. const REMINDER_SEND_TIMEOUT: Duration = Duration::from_secs(5); #[must_use] diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index a813b9e599..29a4ddba1a 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -8,7 +8,7 @@ //! 1. `SELECT ... FOR UPDATE` on the supply row that anchors the decision: //! the resource pool for an exact-time offering, the published window for //! an arrival window. -//! 2. A ledger snapshot whose hold expiry is evaluated *in the query* — a +//! 2. A ledger snapshot whose hold expiry is evaluated *in the query*: a //! claim consumes capacity when it is an active booking, or an active //! hold whose `hold_expires_at` is still ahead of the observed now. A //! delayed cleanup worker therefore cannot keep an expired hold alive. @@ -997,8 +997,8 @@ impl PostgresStore { // The snapshot is read under the lock for serialization order, but // admission is not re-evaluated: the hold's own reservation transfers // to the booking in this same transaction, so capacity does not - // change. What must hold is identity — the policy revision the hold - // was admitted under is still current — checked below. + // change. What must hold is identity (the policy revision the hold + // was admitted under is still current), checked below. let _snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; let hold = transaction .claim_in_transaction(hold_id) @@ -1390,7 +1390,7 @@ impl PostgresStore { /// Expire holds whose TTL has passed. Each expiry is a state change with /// its own history event, so the ledger never depends on the sweeper - /// having run for capacity to be free — the snapshot query already + /// having run for capacity to be free: the snapshot query already /// discounts expired holds. pub async fn expire_due_holds( &self, @@ -1959,7 +1959,8 @@ async fn replay_stored_attempt( /// Mint reminder intents for a freshly committed appointment: one outbox /// row per authored offset whose due time is still ahead. An offset already -/// past mints nothing — a reminder about the past is noise, not a notice. +/// past mints nothing, because a reminder about the past is noise, not a +/// notice. async fn mint_reminders( transaction: &deadpool_postgres::Transaction<'_>, claim: &ClaimRow, diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 5893234261..fcea389a98 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -299,7 +299,7 @@ async fn fixture_publishing( let store = PostgresStore::connect_runtime(&database, &secrets).expect("the runtime store"); // The admin connection seeds the facts the policy resolves supply - // against — the path operator tooling owns — and the store adopts the + // against (the path operator tooling owns), and the store adopts the // deployment identity, the same provisioning verb `scheduling migrate` // runs on a fresh database. for statement in [ From 0efe1141fa16bdd810e74825989f1dcba3916572 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 18:56:38 +0700 Subject: [PATCH 088/115] feat(scheduling-core): say when an authored value's text is outside its grammar A clock that is not `HH:MM`, or a date that is not `YYYY-MM-DD`, was reported as an invalid bound. That reads as a number out of range, so adopter tooling treated it as unfinished authoring and let the check pass. Nothing an author adds turns `9:00 AM` into a clock: only a correction does, and the reason now says so. The check also names the field that actually carries the malformed text rather than always naming `startTime`, and leaves the range such a value belongs to unjudged, because comparing text that is not a clock answers a question the author never asked. `PolicyCheckReason::is_malformed_value` is what a caller asks to refuse the document rather than report it incomplete. Signed-off-by: Jeremi Joslin --- .../src/diagnostics.rs | 32 +++++++ crates/registry-scheduling-core/src/policy.rs | 93 ++++++++++++++++--- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/crates/registry-scheduling-core/src/diagnostics.rs b/crates/registry-scheduling-core/src/diagnostics.rs index 947707517d..141fda9ceb 100644 --- a/crates/registry-scheduling-core/src/diagnostics.rs +++ b/crates/registry-scheduling-core/src/diagnostics.rs @@ -38,6 +38,9 @@ pub enum PolicyCheckReason { MissingModeField, /// A field that belongs to the other scheduling mode is present. WrongModeField, + /// A value's text is outside the grammar its field requires: a clock that + /// is not `HH:MM`, a date that is not `YYYY-MM-DD`. + MalformedValue, /// A numeric bound is zero, negative, or inverted. InvalidBound, /// An opening's effective range spans more days than the weekly calendar @@ -77,6 +80,7 @@ impl PolicyCheckReason { Self::UnknownChannel => "unknown-channel", Self::MissingModeField => "missing-mode-field", Self::WrongModeField => "wrong-mode-field", + Self::MalformedValue => "malformed-value", Self::InvalidBound => "invalid-bound", Self::PatternSpanTooLarge => "pattern-span-too-large", Self::InvalidBands => "invalid-bands", @@ -87,6 +91,17 @@ impl PolicyCheckReason { Self::LeftoverUnsupported => "leftover-unsupported", } } + + /// Whether the reason describes a value whose text is outside the grammar + /// its field requires. + /// + /// Such a value is not an unfinished project: no addition makes it valid, + /// only a correction. Adopter tooling refuses a document carrying one + /// rather than reporting it as incomplete authoring. + #[must_use] + pub const fn is_malformed_value(self) -> bool { + matches!(self, Self::MalformedValue) + } } impl fmt::Display for PolicyCheckReason { @@ -133,4 +148,21 @@ mod tests { "windows[0].offering: unknown-offering" ); } + + #[test] + fn only_a_malformed_value_says_the_text_is_wrong() { + assert!(PolicyCheckReason::MalformedValue.is_malformed_value()); + assert_eq!( + PolicyCheckReason::MalformedValue.as_str(), + "malformed-value" + ); + for reason in [ + PolicyCheckReason::InvalidBound, + PolicyCheckReason::InvalidIdentifier, + PolicyCheckReason::InvalidBecause, + PolicyCheckReason::EmptyCollection, + ] { + assert!(!reason.is_malformed_value(), "{reason}"); + } + } } diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 0552567c8d..19a9a97a02 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -450,7 +450,7 @@ impl SchedulingPolicy { if !valid_iso_date(date) { findings.push(SchedulingDiagnostic::new( format!("{path}.dates[{date_index}]"), - PolicyCheckReason::InvalidBound, + PolicyCheckReason::MalformedValue, )); } } @@ -476,21 +476,43 @@ impl SchedulingPolicy { &format!("{path}.weekdays"), &mut findings, ); - if !valid_hh_mm(&opening.start_time) || !valid_hh_mm(&opening.end_time) { - findings.push(SchedulingDiagnostic::new( - format!("{path}.startTime"), - PolicyCheckReason::InvalidBound, - )); - } else if opening.start_time >= opening.end_time { + // A value outside its grammar is named where it stands, and the + // range it belongs to is left unjudged: comparing text that is not + // a clock, or not a date, would answer a question the author never + // asked. + for (field, value) in [ + ("startTime", &opening.start_time), + ("endTime", &opening.end_time), + ] { + if !valid_hh_mm(value) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.{field}"), + PolicyCheckReason::MalformedValue, + )); + } + } + let clocks_are_clocks = + valid_hh_mm(&opening.start_time) && valid_hh_mm(&opening.end_time); + if clocks_are_clocks && opening.start_time >= opening.end_time { findings.push(SchedulingDiagnostic::new( format!("{path}.endTime"), PolicyCheckReason::InvalidBound, )); } - if !valid_iso_date(&opening.effective_from) - || !valid_iso_date(&opening.effective_until) - || opening.effective_from > opening.effective_until - { + for (field, value) in [ + ("effectiveFrom", &opening.effective_from), + ("effectiveUntil", &opening.effective_until), + ] { + if !valid_iso_date(value) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.{field}"), + PolicyCheckReason::MalformedValue, + )); + } + } + let dates_are_dates = + valid_iso_date(&opening.effective_from) && valid_iso_date(&opening.effective_until); + if dates_are_dates && opening.effective_from > opening.effective_until { findings.push(SchedulingDiagnostic::new( format!("{path}.effectiveUntil"), PolicyCheckReason::InvalidBound, @@ -1932,6 +1954,55 @@ holdPolicy: assert!(!valid_iso_date("2026-02-30")); } + /// A value whose text is outside its grammar is not an unfinished + /// project: no addition turns `9:00 AM` into a clock, only a correction. + /// The reason says so, and it names the field that is actually malformed, + /// so adopter tooling can refuse the document instead of reporting it as + /// incomplete authoring. A well-formed value that breaks its range stays + /// an invalid bound: the text parses, the range does not hold. + #[test] + fn a_clock_or_date_outside_its_grammar_is_malformed_rather_than_unbounded() { + let rendered = |policy: &SchedulingPolicy| -> Vec { + policy.check().iter().map(ToString::to_string).collect() + }; + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].start_time = "9:00 AM".to_owned(); + assert_eq!( + rendered(&policy), + ["openings[0].startTime: malformed-value"] + ); + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].end_time = "12.30".to_owned(); + assert_eq!(rendered(&policy), ["openings[0].endTime: malformed-value"]); + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].effective_from = "01/10/2026".to_owned(); + assert_eq!( + rendered(&policy), + ["openings[0].effectiveFrom: malformed-value"] + ); + + let mut policy = minimal_exact_time_policy(); + policy.holiday_sets[0].dates[0] = "25 December 2026".to_owned(); + assert_eq!( + rendered(&policy), + ["holidaySets[0].dates[0]: malformed-value"] + ); + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].end_time = "08:00".to_owned(); + assert_eq!(rendered(&policy), ["openings[0].endTime: invalid-bound"]); + + let mut policy = minimal_exact_time_policy(); + policy.openings[0].effective_until = "2026-09-30".to_owned(); + assert_eq!( + rendered(&policy), + ["openings[0].effectiveUntil: invalid-bound"] + ); + } + #[test] fn weekdays_and_channels_serialize_in_their_published_spelling() { assert_eq!( From c9272a6f0fc75135765ff251906e3d9101b53afa Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 19:02:14 +0700 Subject: [PATCH 089/115] feat(schedulingctl): refuse a malformed authored value unconditionally registry-scheduling-core now raises PolicyCheckReason::MalformedValue, distinct from InvalidBound, for a clock or date whose text is outside its grammar. Nothing an author adds turns "9:00 AM" into a clock, only a correction does, so check and test must not treat it like ordinary incomplete authoring that --deny-findings alone gates. check now reports status: "invalid" for a malformed value and refuses regardless of --deny-findings; test reports authoringStatus: "invalid" the same way and skips fixture replay entirely, since a malformed value has no business being replayed against. The human-readable renderer names both new states in the product's voice. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/lib.rs | 87 ++++++++++++++++++-- crates/registry-schedulingctl/src/project.rs | 48 ++++++++--- 2 files changed, 118 insertions(+), 17 deletions(-) diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index 0988ae0053..7dc829d9ee 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -223,16 +223,20 @@ fn run(cli: Cli) -> Result { } /// A completed report may still describe a refusal: an authoring check that -/// found something when the caller passed `--deny-findings`, or a fixture run -/// with failing cases, which always fails the build. The report is the -/// evidence; the exit code is the signal. +/// found something when the caller passed `--deny-findings`, a value whose +/// text is outside its grammar (never gated behind `--deny-findings`, because +/// there is nothing to opt into), or a fixture run with failing cases, which +/// always fails the build. The report is the evidence; the exit code is the +/// signal. fn report_is_refusal(report: &Value, deny_findings: bool) -> bool { if report["command"] == "check" { - return deny_findings && report["status"] == "incomplete"; + return report["status"] == "invalid" + || (deny_findings && report["status"] == "incomplete"); } - report["fixtures"] - .as_array() - .is_some_and(|fixtures| fixtures.iter().any(|fixture| fixture["status"] == "failed")) + report["authoringStatus"] == "invalid" + || report["fixtures"] + .as_array() + .is_some_and(|fixtures| fixtures.iter().any(|fixture| fixture["status"] == "failed")) } fn usage_failure(message: String) -> Value { @@ -373,6 +377,10 @@ fn human_lead(report: &Value) -> String { report["status"].as_str(), report["authoringStatus"].as_str(), ) { + ("check", Some("invalid"), _) => { + "Authoring check refused: a value's text is outside the grammar its field requires." + .to_owned() + } ("check", Some("incomplete"), _) => { "Authoring check completed with incomplete inputs.".to_owned() } @@ -380,6 +388,10 @@ fn human_lead(report: &Value) -> String { ("test", _, _) if failed_fixtures => { "Offline synthetic fixtures reported failures.".to_owned() } + ("test", _, Some("invalid")) => { + "Offline synthetic fixtures were not run: a value's text is outside the grammar its field requires." + .to_owned() + } ("test", _, Some("incomplete")) => { "Offline synthetic fixtures passed with incomplete authored inputs.".to_owned() } @@ -653,6 +665,67 @@ mod tests { assert_eq!(report["ok"], true); } + /// A value whose text is outside its grammar (a clock that is not + /// `HH:MM`) is not unfinished authoring: no `--deny-findings` switch + /// governs it, because there is nothing to opt into. `check` refuses it + /// unconditionally, and the `version: 0` case above must keep exiting + /// zero without `--deny-findings` so the two families stay separated. + #[test] + fn a_malformed_value_refuses_check_regardless_of_deny_findings() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "startTime: \"09:00\"", + "startTime: \"9:00\"", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + + let (exit, report, stderr) = run_json(&["check", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "invalid"); + assert_eq!(report["ok"], false); + assert!(report["findings"] + .as_array() + .unwrap() + .iter() + .any(|finding| { + finding["path"] == "openings[0].startTime" && finding["reason"] == "malformed-value" + })); + + // --deny-findings changes nothing here: the refusal does not depend + // on it. + let (exit, report, stderr) = + run_json(&["check", "--deny-findings", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["status"], "invalid"); + assert_eq!(report["ok"], false); + } + + /// A malformed policy has no business running fixtures against it: + /// `test` refuses the same way `check` does, before it ever reads the + /// fixtures directory. + #[test] + fn a_malformed_value_refuses_test_without_running_fixtures() { + let (_root, project) = initialized("standalone-exact-time"); + let policy_path = project.join("scheduling.yaml"); + let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( + "startTime: \"09:00\"", + "startTime: \"9:00\"", + 1, + ); + std::fs::write(&policy_path, broken).unwrap(); + + let (exit, report, stderr) = run_json(&["test", project.to_str().unwrap()]); + assert_eq!(exit, ExitCode::from(DOMAIN_REFUSAL_EXIT)); + assert!(stderr.is_empty()); + assert_eq!(report["authoringStatus"], "invalid"); + assert_eq!(report["ok"], false); + assert_eq!(report["fixtures"].as_array().unwrap().len(), 0); + } + #[test] fn a_failing_fixture_case_is_reported_and_exits_nonzero() { let (_root, project) = initialized("standalone-exact-time"); diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index c7a50867f1..3a5487d550 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -71,11 +71,7 @@ pub(super) fn init(project: &Path, template: &str) -> Result { pub(super) fn check(project: &Path) -> Result { let policy = load_policy(project)?; let findings = policy.check(); - let status = if findings.is_empty() { - "complete" - } else { - "incomplete" - }; + let status = authoring_status(&findings); Ok(json!({ "ok": true, "command": "check", @@ -88,14 +84,46 @@ pub(super) fn check(project: &Path) -> Result { })) } -pub(super) fn test(project: &Path) -> Result { - let policy = load_policy(project)?; - let findings = policy.check(); - let authoring_status = if findings.is_empty() { +/// A value whose text is outside its grammar (a clock that is not `HH:MM`, a +/// date that is not `YYYY-MM-DD`) is not unfinished authoring: no addition +/// makes it valid, only a correction. `invalid` names that instead of +/// `incomplete`, so a caller can refuse it without opting into +/// `--deny-findings`. +fn authoring_status(findings: &[SchedulingDiagnostic]) -> &'static str { + if findings + .iter() + .any(|finding| finding.reason.is_malformed_value()) + { + "invalid" + } else if findings.is_empty() { "complete" } else { "incomplete" - }; + } +} + +pub(super) fn test(project: &Path) -> Result { + let policy = load_policy(project)?; + let findings = policy.check(); + let authoring_status = authoring_status(&findings); + if authoring_status == "invalid" { + // A malformed value has no business running fixtures against it: the + // authored text does not mean what the offering's evaluators would + // read it to mean, so replaying against it would answer a question + // the author never asked. + return Ok(json!({ + "ok": true, + "command": "test", + "project": project, + "authoringStatus": authoring_status, + "findings": findings_json(&findings), + "fixtures": [], + "proofBoundary": "offline_synthetic", + "productionClosure": false, + "networkAccess": false, + "databaseAccess": false, + })); + } let fixture_dir = project.join(FIXTURES_DIRECTORY); let mut paths = fs::read_dir(&fixture_dir) .context("reading fixtures directory")? From bccee97840d431c8bb7c9bfefb51b13c6ea406b0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 19:06:55 +0700 Subject: [PATCH 090/115] docs(scheduling): record the edge hardening as invariants rather than deferrals The runtime now audits a permission refused before the capacity transaction, publishes the journal in the order it was written, bounds caller strings at the edge, admits only the RFC 9068 access-token type, requires a production deployment to name its clients, binds an exchanged credential to a declared authority, and fails closed on an unreadable clock. Promote SCHEDULING-SEC-14 to enforced and retire SCHEDULING-DEF-02 and SCHEDULING-DEF-03, whose compensating controls describe behaviour the runtime no longer has. Add five invariant rows with the tests that earn them, the matching traceability entries, the review notes the changes are security-sensitive enough to require, and the recorded decision behind a records swap that refuses to strand a live claim. Signed-off-by: Jeremi Joslin --- products/scheduling/SECURITY-REVIEW-NOTES.md | 173 ++++++++++++++++-- products/scheduling/TASK_GRANTS.md | 3 +- .../contracts/recorded-decisions.yaml | 38 ++++ .../contracts/security-invariant-matrix.yaml | 123 +++++++++---- .../contracts/security-test-traceability.yaml | 23 +++ 5 files changed, 306 insertions(+), 54 deletions(-) diff --git a/products/scheduling/SECURITY-REVIEW-NOTES.md b/products/scheduling/SECURITY-REVIEW-NOTES.md index 70557dc970..25164698b2 100644 --- a/products/scheduling/SECURITY-REVIEW-NOTES.md +++ b/products/scheduling/SECURITY-REVIEW-NOTES.md @@ -53,11 +53,11 @@ audit journal. The threats this surface answers: 4. **A commitment leaves no accountable trace.** Every ledger-decided commitment and refusal writes an audit row with a pseudonymized principal, and the audit rows chain by hash. A permission mismatch - refused at the service, before the transaction opens, writes no audit - row today (SCHEDULING-DEF-02): the compensating control is that the - refusal answer is drawn from a closed vocabulary and says nothing about - which bound failed, so probing the edge yields nothing beyond allowed - or not allowed. + refused at the service, before the transaction opens, writes one too, + so probing grant scope against the edge is visible in the journal; the + answer itself stays the same closed refusal, naming neither the failing + bound nor whether a grant was carried. The two changes that made that + true are recorded below under "The audit journal". 5. **A refusal discloses supply or another caller's booking.** Admission refusals project through a public code; the member-level cause stays behind the separately authorized explain path. @@ -106,21 +106,158 @@ merge blocker. The threat, the wire change, and the test evidence are recorded in that change's own notes; the standing invariant is matrix row SCHEDULING-SEC-17. +## The audit journal: what it records, and in what order + +Two changes to `crates/registry-scheduling/src/service.rs` and +`crates/registry-scheduling/src/store.rs` moved the journal from partly +accountable to accountable. Both are security-sensitive in the sense +AGENTS.md names, so the record belongs here rather than only in a commit +message. + +**A permission refused before the transaction is audited.** *Threat:* the +permission check answered `operation.not-authorized` and returned without +writing anything, so a caller probing which services, locations, and +actions a grant carries could enumerate the edge and leave no trail. Every +refusal the ledger decided was recorded; every refusal decided above it +was not. *Default:* both refusal paths, the grantless case and the +scope-mismatch case, write an authorization record before they answer. The +answer is unchanged, so what changes is the record and not the disclosure. +*Test:* `a_permission_refused_before_the_transaction_writes_its_audit_row`, +`crates/registry-scheduling/tests/postgres_commitments.rs`. This retires +deferral SCHEDULING-DEF-02 and promotes matrix row SCHEDULING-SEC-14 to +`enforced`. + +**The journal is published in the order it was written.** *Threat:* the +audit outbox carried no ordering column, and the query feeding the +publisher ordered by a random version 4 event identifier while its own +documentation said oldest first. The publisher appends what that query +returns to a hash chain, so the chain was internally consistent and +attested to a sequence the deployment never had. A chain that certifies +the wrong order is worse than no chain, because it is believed. +*Default:* the outbox carries its own recorded sequence, the pending query +reads in it, and the chain continues across a restart from its verified +tail rather than from whatever the first query after startup returns. +*Tests:* `the_audit_journal_is_read_in_the_order_it_was_written` and +`the_audit_chain_continues_across_a_restart`, +`crates/registry-scheduling/tests/postgres_commitments.rs`. + +## The runtime edge hardening + +Nine changes to the authenticator, the configuration reader, and the HTTP +edge, in the file's threat / default / test shape. + +**A. Only the RFC 9068 access-token type is admitted.** *Threat:* the +accepted `typ` set included plain `JWT`, so an identity token, or any +other JWT the same issuer signs for another audience, satisfied the +access-token profile. *Default:* `at+jwt` and its case variants only; +there is no configuration key to widen it. *Test:* +`the_access_token_profile_admits_only_the_rfc_9068_pair`, +`crates/registry-scheduling/src/config.rs`. + +**B. A production deployment names the clients it admits.** *Threat:* an +absent `allowedClients` admitted every client the issuer verifies, so any +application in the issuer's realm could reach a Scheduling deployment. +*Default:* a non-loopback deployment refuses to start without a named +client list; development loopback stays permissive because it is not a +deployment an unrelated client can reach. *Test:* +`a_production_deployment_must_name_the_clients_it_admits`, +`crates/registry-scheduling/src/config.rs`. + +**C. Exchanged credentials are bound to declared assertion authorities.** +*Threat:* a token exchanged from a third-party assertion was accepted on +the strength of the issuer the token itself named, so any authority the +identity provider would exchange from could reach the deployment. +*Default:* an `assertionIssuers` map declares each accepted authority and +the client that may exchange from it; an undeclared authority is refused, +and the map is bounded in the runtime JSON Schema. *Tests:* +`an_exchanged_token_is_refused_until_its_authority_is_declared` +(`auth.rs`), `a_declared_assertion_authority_binds_the_client_that_may_exchange_from_it` +and `an_assertion_issuer_map_outside_its_bounds_is_refused` +(`config.rs`). + +**D. An unreadable clock refuses to judge an expiry.** *Threat:* the epoch +conversion was unwrapped, so a clock behind the epoch produced a timestamp +against which every grant compared as unexpired. *Default:* fail closed; a +clock the runtime cannot read is an authentication refusal, not a pass. +*Test:* +`an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it`, +`crates/registry-scheduling/src/auth.rs`. + +**E. A verifier that reached no verdict answers unavailable, not a +challenge.** *Threat:* a JWKS or issuer outage was rendered as 401, which +tells a caller their credential is bad when the deployment could not check +it, and invites a credential rotation that fixes nothing. *Default:* +infrastructure failure answers 503 with `Retry-After`; only a verdict of +refused answers a challenge. *Tests:* +`a_key_endpoint_outage_is_unavailable_not_a_refusal`, +`only_a_verifier_that_reached_no_verdict_is_unavailable` (`auth.rs`), +`a_verifier_that_cannot_answer_is_unavailable_not_a_challenge` with its +negative `a_refused_credential_still_answers_a_challenge` (`http.rs`). + +**F. Caller strings are bounded at the edge, at the authoring grammar's +own bounds.** *Threat:* every caller string an admission or cancellation +carries is stored verbatim in the ledger entry, the idempotency receipt, +and the audit journal. Under the 1 MiB body allowance a credentialed +caller could write a megabyte into each, and an unbounded duplicate key +could be varied at will to slip the duplicate guard. *Default:* the bounds +are the policy grammar's, not new numbers, because a value outside them +could never have matched anything the deployment published; the +cancellation reason is bounded to the column that stores it, so an +over-long reason is `request.invalid` rather than a database refusal +surfacing as `service.unavailable`. *Tests:* +`an_admission_request_is_bounded_before_it_reaches_the_store`, +`a_cancellation_reason_is_bounded_below_the_column_that_stores_it`, +`a_listing_offering_is_bounded_the_same_way_a_body_is`, all +`crates/registry-scheduling/src/http.rs`. The standing invariant is matrix +row SCHEDULING-SEC-19, which replaced deferral SCHEDULING-DEF-03. + +**G. A startup refusal says where and why without repeating the value.** +*Threat:* an operator could not find the member that failed, and the +obvious fix, putting serde's reason in the message, would echo +operator-authored text into the startup log, where a runtime configuration +names secret references, database URLs, and destinations. *Default:* the +message names the member path (a document refused whole is reported at +`/`), carries serde's reason with its line and column, and strips the +offending value out of any `invalid type:` or `invalid value:` clause, +keeping only the shape word. *Tests:* +`a_runtime_document_refused_whole_is_reported_at_the_root`, +`a_refused_value_never_survives_the_clause_that_names_it`, +`a_runtime_document_the_reader_stops_on_names_the_line_and_column`, +`a_rejected_runtime_member_names_the_cause_without_the_value`, +`a_refused_authored_policy_names_the_member_and_the_cause` (all +`config.rs`), plus the standing +`typed_parse_path_does_not_echo_the_rejected_value` +(SCHEDULING-SEC-13). + +**H. The package manifest is a different document from the policy it +identifies.** *Threat:* one `apiVersion` and `kind` pair named both +documents, so neither reader could refuse the other's document on its +declared names, and the package identity digest folded in a pair that did +not describe it. *Default:* the manifest declares its own pair, which the +identity digest binds; a document carrying the authored policy's names +never verifies as a package identity. *Tests:* +`a_manifest_wearing_the_authored_policy_names_is_refused` with +`a_package_manifest_is_a_different_document_from_the_policy_it_identifies`, +`crates/registry-scheduling/src/config.rs`. + +**I. Client tolerance is response-only.** *Threat:* relaxing client-side +parsing so a client outlives a deployment that adds a member would, if +applied to the wrong half, let a caller smuggle a field the server does +not declare. *Default:* response shapes tolerate unknown members and +problems are matched on the code alone, never on title or detail text; +request shapes stay strict. *Tests:* +`answer_documents_tolerate_a_member_a_later_deployment_added` and +`request_documents_refuse_a_member_they_do_not_declare` +(`crates/registry-scheduling-core/src/wire.rs`), +`a_reworded_title_or_detail_is_still_the_problem_the_code_names` +(`crates/registry-scheduling-client/src/error.rs`). + ## Known deferrals -The matrix records five deferrals with their compensating controls. They -are restated here only as the index the matrix's `recordedIn` points at: - -- **SCHEDULING-DEF-02, audit rows for pre-transaction permission - refusals.** Grant-scope probing against the edge is invisible to the - journal. Compensating control: closed-vocabulary refusals that name no - failing bound. -- **SCHEDULING-DEF-03, caller string ceilings below the body limit.** The - idempotency key and page limit are bounded; capabilities, prerequisites, - channel, duplicate key, and reason are bounded only by the 1 MiB body - ceiling and the database CHECK constraints, and an over-long reason - surfaces as a service problem on that caller's own request. Tier 2 work - bounds them at the edge with `request.invalid`. +The matrix records four deferrals with their compensating controls. +SCHEDULING-DEF-01 is stated in threat 2 above; the other three are +restated here as the index the matrix's `recordedIn` points at: + - **SCHEDULING-DEF-04, the channel is not bound to the verified caller.** A caller entitled to book may choose which subquota it draws from. The policy declares a closed channel set and every subquota is a ceiling diff --git a/products/scheduling/TASK_GRANTS.md b/products/scheduling/TASK_GRANTS.md index cdc531cf25..6a5d5699a5 100644 --- a/products/scheduling/TASK_GRANTS.md +++ b/products/scheduling/TASK_GRANTS.md @@ -99,7 +99,8 @@ this milestone keeps. ## What the audit journal records Every commitment and every refused commitment writes one authorization record -under `authorization.allowed` or `authorization.refused`. The principal, +under `authorization.allowed` or `authorization.refused`, and so does a +permission the service refuses before any commitment is reached. The principal, client, grant, and approver are keyed pseudonyms scoped to the request's reference class, the purpose is recorded as presence only and never as a value, and the record adds the grant's source issuer and its deadline. No diff --git a/products/scheduling/contracts/recorded-decisions.yaml b/products/scheduling/contracts/recorded-decisions.yaml index 03f54adc09..1d041c47c7 100644 --- a/products/scheduling/contracts/recorded-decisions.yaml +++ b/products/scheduling/contracts/recorded-decisions.yaml @@ -149,3 +149,41 @@ decisions: evidence: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_direct_create_books_without_a_hold_and_exhausts_capacity} - {path: crates/registry-scheduling-core/src/admission.rs, name: the_second_caller_against_the_last_unit_is_refused} + - id: SCHEDULING-DEC-08 + subject: An environment-records swap is refused whole when it retires occupied supply + decision: >- + `records apply` replaces the environment facts wholesale, but not + unconditionally. The swap takes every supply anchor in the anchor's own + order before it reads a claim, and refuses the whole apply when any + active booking, or any hold whose lifetime is still ahead, occupies a + pool member the incoming records do not carry. The operator either keeps + the member or closes what stands on it first; nothing partial is + written. + reasoning: >- + The ledger's claims name their supply by id and the facts are what give + that id a member, so deleting a member under a live claim leaves the + claim pointing at supply the deployment no longer has. Nothing else in + the runtime would notice: availability would stop offering the member + while the appointment kept its seat, and the caller would arrive to a + station that no longer exists. Taking the anchors first is what makes + the guard honest rather than advisory: a capacity transaction holds its + own anchor from its snapshot until it commits, so holding all of them + means no commitment is mid-evaluation against a member about to be + deleted, and a claim written a moment later is seen by the guard rather + than stranded behind it. A capacity transaction takes exactly one anchor + and the swap takes them in order, so the two cannot cycle. + alsoTrue: >- + A window supply anchor is excluded from the guard: a window claim + occupies the window, which the published policy carries, not the + environment records this verb replaces. An expired hold occupies + nothing, matching the live-predicate rule the rest of the ledger uses, + so it never blocks a swap. + residual: >- + The guard is keyed on a member disappearing, not on a member moving. + Moving a member between pools while it carries active claims still + applies, because the claim's supply id survives the swap. The claim then + sits in a pool its offering may not serve. Phase 2 work. + evidence: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_waits_for_the_capacity_transaction_holding_the_pool} + - {path: crates/registry-schedulingctl/tests/records_apply_postgres.rs, name: records_apply_replaces_facts_wholesale_and_audits_each_write} diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index ea3499a62d..5dad78cf36 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -225,22 +225,21 @@ invariants: path: crates/registry-scheduling/src/config.rs name: typed_parse_path_does_not_echo_the_rejected_value - id: SCHEDULING-SEC-14 - state: partial + state: enforced threat: >- A commitment decision leaves no accountable record, so a booking or a - refusal cannot be attributed afterwards. + refusal cannot be attributed afterwards, and grant-scope enumeration + against the edge passes unseen. enforcementPoint: >- - An authorization audit record built per commitment with a pseudonymized - principal, stamped with its identity exactly once, written for the - allowed case and for every refusal the ledger decides. + An authorization audit record built with a pseudonymized principal and + stamped with its identity exactly once, written for the allowed case, + for every refusal the ledger decides, and for every permission mismatch + the service refuses before the capacity transaction opens. The journal + is published in the order it was written, and the hash chain continues + across a restart from its verified tail. refusal: >- Record authorization.allowed or authorization.refused as an audit reason, never as a problem code, and never carry the caller's raw identifier. - partialNote: >- - A permission mismatch refused at the service, before the capacity - transaction opens, writes no audit row today, so grant-scope enumeration - against the edge is not visible in the journal. See deferred entry - SCHEDULING-DEF-02. negativeTest: path: crates/registry-scheduling/src/runtime.rs name: a_pending_audit_record_is_stamped_with_its_identity_exactly_once @@ -300,6 +299,85 @@ invariants: negativeTest: path: products/scheduling/scripts/test_dependency_direction.py name: test_product_dependency_on_scheduling_is_rejected + - id: SCHEDULING-SEC-19 + state: enforced + threat: >- + Caller strings stored verbatim in the ledger entry, the idempotency + receipt, and the audit journal are bounded only by the body ceiling, and + an unbounded duplicate key can be varied at will to slip the duplicate + guard. + enforcementPoint: >- + Edge bounds at the authoring grammar's own identifier, reference, + collection, and idempotency-key limits, plus a cancellation reason + bounded to the column that stores it. + refusal: >- + Answer request.invalid at the edge rather than passing the value to a + database CHECK that surfaces as a service problem on the caller's own + request. + negativeTest: + path: crates/registry-scheduling/src/http.rs + name: an_admission_request_is_bounded_before_it_reaches_the_store + - id: SCHEDULING-SEC-20 + state: enforced + threat: >- + The accepted token type set admits a plain JWT, so an identity token, or + any other JWT the same issuer signs, satisfies the access-token profile + and reaches a scheduling operation. + enforcementPoint: >- + The access-token profile built at configuration time, which admits the + RFC 9068 media type and its case variants only. No configuration key + widens it. + refusal: >- + Refuse the credential; a token that is not an access token never reaches + authorization. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: the_access_token_profile_admits_only_the_rfc_9068_pair + - id: SCHEDULING-SEC-21 + state: enforced + threat: >- + A deployment that names no clients admits every client the issuer + verifies, so any application in the issuer's realm reaches the + scheduling edge with a token the issuer minted for something else. + enforcementPoint: >- + Configuration validation at startup, which requires a named client list + for any listener that is not loopback. + refusal: >- + Refuse to start rather than serve a deployment that admits an unnamed + client. + negativeTest: + path: crates/registry-scheduling/src/config.rs + name: a_production_deployment_must_name_the_clients_it_admits + - id: SCHEDULING-SEC-22 + state: enforced + threat: >- + A credential exchanged from a third-party assertion is accepted on the + strength of the issuer the token itself names, so any authority the + identity provider will exchange from reaches the deployment. + enforcementPoint: >- + A declared assertion-issuer map binding each accepted authority to the + client that may exchange from it, matched when the exchanged credential + is authenticated, and bounded in the runtime schema. + refusal: >- + Refuse the credential until its authority is declared; an undeclared + authority is a credentials problem, never a silent acceptance. + negativeTest: + path: crates/registry-scheduling/src/auth.rs + name: an_exchanged_token_is_refused_until_its_authority_is_declared + - id: SCHEDULING-SEC-23 + state: enforced + threat: >- + A clock the runtime cannot read yields a timestamp against which every + grant compares as unexpired, so an expired credential commits. + enforcementPoint: >- + The expiry comparison in the authenticator, which propagates a clock + failure rather than substituting a default instant. + refusal: >- + Refuse the request as an authentication failure; an unreadable clock + never judges an expiry as passed. + negativeTest: + path: crates/registry-scheduling/src/auth.rs + name: an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it deferred: - id: SCHEDULING-DEF-01 subject: Full task-grant bounds re-checked inside the capacity transaction @@ -314,31 +392,6 @@ deferred: Short grant deadlines. The published reference states the limit plainly rather than claiming the bounds are re-read. recordedIn: docs/site/src/content/docs/reference/apis/registry-scheduling.mdx - - id: SCHEDULING-DEF-02 - subject: Audit rows for permission refusals decided before the transaction - state: deferred - reason: >- - Refusals the ledger decides are audited; a permission mismatch refused at - the service is not, so an attacker probing grant scope against the edge - leaves no journal trail. - compensatingControl: >- - The refusal is answered from a closed vocabulary and carries no - information about which bound failed, so probing yields nothing beyond - allowed or not allowed. - recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md - - id: SCHEDULING-DEF-03 - subject: Caller string ceilings above the body limit - state: deferred - reason: >- - The idempotency key and the page limit are bounded, and the whole body is - capped at 1 MiB, but capabilities, prerequisites, channel, duplicate key, - and reason are not individually bounded at the edge. A reason over the - database ceiling surfaces as a service-unavailable problem rather than a - request problem. - compensatingControl: >- - The 1 MiB body ceiling and the database CHECK constraints bound the - damage to a caller-triggered 5xx on that caller's own request. - recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md - id: SCHEDULING-DEF-04 subject: Channel bound to the verified caller state: deferred diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index ae57f9422d..9d208e3680 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -96,6 +96,9 @@ entries: tests: - {path: crates/registry-scheduling/src/runtime.rs, name: a_pending_audit_record_is_stamped_with_its_identity_exactly_once} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_hold_confirms_into_an_appointment_with_attributable_history} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_permission_refused_before_the_transaction_writes_its_audit_row} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_audit_journal_is_read_in_the_order_it_was_written} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_audit_chain_continues_across_a_restart} - id: SCHEDULING-SEC-15 tests: - {path: crates/registry-scheduling/src/config.rs, name: production_requires_a_verified_package_while_loopback_accepts_authoring} @@ -121,3 +124,23 @@ entries: - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_evidence_dependency_from_client_is_rejected} - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_core_depending_on_the_runtime_is_rejected} - {path: products/scheduling/scripts/test_dependency_direction.py, name: test_client_depending_on_the_runtime_is_rejected} + - id: SCHEDULING-SEC-19 + tests: + - {path: crates/registry-scheduling/src/http.rs, name: an_admission_request_is_bounded_before_it_reaches_the_store} + - {path: crates/registry-scheduling/src/http.rs, name: a_cancellation_reason_is_bounded_below_the_column_that_stores_it} + - {path: crates/registry-scheduling/src/http.rs, name: a_listing_offering_is_bounded_the_same_way_a_body_is} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_claim_ledger_bounds_a_caller_chosen_duplicate_key} + - id: SCHEDULING-SEC-20 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: the_access_token_profile_admits_only_the_rfc_9068_pair} + - id: SCHEDULING-SEC-21 + tests: + - {path: crates/registry-scheduling/src/config.rs, name: a_production_deployment_must_name_the_clients_it_admits} + - id: SCHEDULING-SEC-22 + tests: + - {path: crates/registry-scheduling/src/auth.rs, name: an_exchanged_token_is_refused_until_its_authority_is_declared} + - {path: crates/registry-scheduling/src/config.rs, name: a_declared_assertion_authority_binds_the_client_that_may_exchange_from_it} + - {path: crates/registry-scheduling/src/config.rs, name: an_assertion_issuer_map_outside_its_bounds_is_refused} + - id: SCHEDULING-SEC-23 + tests: + - {path: crates/registry-scheduling/src/auth.rs, name: an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it} From c1e476f41746c4499a83ebadd14f34e383d2a5c2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 19:07:16 +0700 Subject: [PATCH 091/115] docs: say the journal records a permission refused before a commitment The runtime now writes an authorization record when the service refuses a permission before the capacity transaction opens, so the sentence that named only the commitment was true but incomplete. Signed-off-by: Jeremi Joslin --- .../src/content/docs/reference/apis/registry-scheduling.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx index 9c26253eea..919496979c 100644 --- a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -130,8 +130,9 @@ a grant narrowed or withdrawn at the issuer after its token was minted keeps the carries until the token or the grant deadline passes. Keep grant deadlines short. Re-checking the full bounds inside the capacity transaction is a recorded deferral, not a promise this milestone keeps. -The audit journal records `authorization.allowed` or `authorization.refused` for the commitment as an -audit reason, never as a problem code. +The audit journal records `authorization.allowed` or `authorization.refused` as an audit reason, never +as a problem code, for the commitment and for a permission the service refuses before any commitment is +reached. {/* Evidence: crates/registry-scheduling/src/store.rs, check_grant_current() checks grant_exp_unix and nothing else; crates/registry-scheduling/src/service.rs, require_permission() and problem_of(); From 8e06e1f07e0318743c6da013e01c1818726ca7ca Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 19:09:17 +0700 Subject: [PATCH 092/115] chore(scheduling): write the edge and core prose without em dashes Finishes the pass that cleaned the store and runtime prose. The two occurrences in registry-schedulingctl/src/records.rs stay for the lane that owns that crate. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling-core/src/admission.rs | 2 +- crates/registry-scheduling/src/http.rs | 6 +++--- crates/registry-scheduling/src/lib.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index d0e03b2ae5..27271d63fc 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -402,7 +402,7 @@ pub fn evaluate_window_admission( /// /// An active hold confirms. A hold that expired at or before `now` is /// expired, whatever the cleanup worker has or has not done. A claim that is -/// not a hold at all — a booking, or a hold already released or consumed — is +/// not a hold at all (a booking, or a hold already released or consumed) is /// not confirmable; release and consumption are store states the runtime /// reports with `hold.released` when it resolves the claim, so seeing one /// here means the caller passed the wrong claim. diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs index 4161deefb5..a0ac48883d 100644 --- a/crates/registry-scheduling/src/http.rs +++ b/crates/registry-scheduling/src/http.rs @@ -4,10 +4,10 @@ //! problem rendering every refusal flows through. //! //! Every refusal leaves this edge as one problem shape: a code from the closed -//! vocabulary — the product refusals (authentication, authorization, +//! vocabulary, the product refusals (authentication, authorization, //! admission, idempotency, cursors, availability) and the `request.*` family //! carrying the request-edge rejections (a route that does not exist, a -//! method the route refuses, a body too large or not JSON) — under the +//! method the route refuses, a body too large or not JSON), under the //! Scheduling problem base, with the answering request's trace id. No shared //! platform problem prefix exists in the Registry Stack catalog, so the edge //! codes live in this product's own vocabulary exactly as Casework's do. @@ -660,7 +660,7 @@ fn finish_problem(status: u16, body: ProblemBody, trace: &TraceContext) -> Respo /// Answer a replayed idempotency key exactly as the stored attempt first /// answered. A release's empty receipt answers as an empty 204 again. A /// refusal receipt carries its problem without a trace id, so it re-renders -/// from its pinned code under the answering request's trace — the first +/// from its pinned code under the answering request's trace: the first /// answer's words, this answer's correlation. A receipt in no known shape is /// corrupt stored state: the caller learns nothing from it. fn replay_response(status_code: u16, receipt: Value) -> Response { diff --git a/crates/registry-scheduling/src/lib.rs b/crates/registry-scheduling/src/lib.rs index 34e7b575e3..52c615bb28 100644 --- a/crates/registry-scheduling/src/lib.rs +++ b/crates/registry-scheduling/src/lib.rs @@ -4,7 +4,7 @@ //! the supervised workers. //! //! The runtime owns commitment: every booking, hold, release, and cancellation -//! lands through one capacity-transaction shape — lock the supply row, read a +//! lands through one capacity-transaction shape: lock the supply row, read a //! ledger snapshot whose hold expiries are evaluated in the query, run the //! pure evaluators from `registry-scheduling-core` in memory, and write the //! claim, the idempotency attempt, the history event, and the outbox rows in From 4975f163094c041a1ad160a341338a2ed0296045 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 19:16:04 +0700 Subject: [PATCH 093/115] chore(schedulingctl): write the records module doc without em dashes The last em-dash holdout in the scheduling family, inside schedulingctl's own ownership boundary. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/records.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/registry-schedulingctl/src/records.rs b/crates/registry-schedulingctl/src/records.rs index c970e91bc3..293e682705 100644 --- a/crates/registry-schedulingctl/src/records.rs +++ b/crates/registry-schedulingctl/src/records.rs @@ -4,8 +4,8 @@ //! //! `records apply` is the one attributable operator write that swaps a //! deployment's locations, pools, members, and exceptions wholesale. The -//! records document is parsed and validated offline first — nothing reaches -//! the database until the whole document holds together — and the swap itself +//! records document is parsed and validated offline first, nothing reaches +//! the database until the whole document holds together, and the swap itself //! lands through the store's single replace transaction, which writes its own //! audit row beside the new facts. From 16fefdfd18210421d23191e667f6f3bf7994a9e8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 19:16:11 +0700 Subject: [PATCH 094/115] feat(schedulingctl): give the arrival-window template a banded units table Add a second standalone-arrival-window offering, opening, and window: a Saturday afternoon household block sized by a banded units table instead of a flat per-recipient rate. Its aboveHighestBand policy refuses a party larger than the table's highest band rather than costing it by guess, so an adopter starting from the template has a working example of both units policies the grammar supports. The accompanying fixture proves a household in the first band, a household above the highest band refused with party.capacity-inadequate, and a household in the second band, all replaying clean offline. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/src/project.rs | 4 +- .../registry-schedulingctl/src/templates.rs | 169 +++++++++++++++++- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index 3a5487d550..66547adb97 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -429,12 +429,14 @@ mod tests { json!([ "scheduling.yaml", "runtime.example.yaml", - "fixtures/household-morning.yaml" + "fixtures/household-morning.yaml", + "fixtures/household-afternoon.yaml" ]) ); assert!(project.join(AUTHORED_POLICY_FILE).is_file()); assert!(project.join("runtime.example.yaml").is_file()); assert!(project.join("fixtures/household-morning.yaml").is_file()); + assert!(project.join("fixtures/household-afternoon.yaml").is_file()); let error = init(&project, "standalone-arrival-window").unwrap_err(); assert!(error.to_string().contains("never overwrites")); let error = init(&root.path().join("other"), "no-such-template").unwrap_err(); diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs index 61b0a8a1ce..80199a5cef 100644 --- a/crates/registry-schedulingctl/src/templates.rs +++ b/crates/registry-schedulingctl/src/templates.rs @@ -262,8 +262,10 @@ cases: code: schedule.unpublished "#; -/// The `standalone-arrival-window` policy: a Saturday household block with a -/// public and an assisted subquota over a per-recipient units table. +/// The `standalone-arrival-window` policy: a Saturday morning household block +/// with a public and an assisted subquota over a per-recipient units table, +/// and a Saturday afternoon block over a banded table that refuses a party +/// above its highest band instead of guessing what it costs. const ARRIVAL_WINDOW_POLICY: &str = r#"apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 kind: SchedulingPolicyPackage scheduling: @@ -286,6 +288,19 @@ offerings: cancellationCutoffMinutes: 1440 requiresCapabilities: [] prerequisites: [] + - id: household-afternoon + service: household-day + label: Afternoon household block + mode: arrival-window + location: civic-hall + because: Larger households arrive in an afternoon block sized by party, not a flat headcount. + arrival: + window: household-afternoon-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] holidaySets: - id: office-holidays revision: 1 @@ -301,6 +316,15 @@ openings: effectiveFrom: "2026-10-01" effectiveUntil: "2026-12-31" because: The hall opens on Saturday mornings. + - id: hall-afternoon-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "13:00" + endTime: "17:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall reopens on Saturday afternoons for banded household visits. windows: - id: household-morning-window revision: 2 @@ -323,6 +347,28 @@ windows: units: 1 because: Assisted bookings hold a protected unit. because: The Saturday morning household block, sized for two officers. + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. holdPolicy: ttlMinutes: 10 maxPerCaller: 2 @@ -388,6 +434,63 @@ cases: units: 1 "#; +/// The `standalone-arrival-window` fixture: two households fit the banded +/// table's first two bands, and a household above the highest band is +/// refused instead of costed by a guess. +const HOUSEHOLD_AFTERNOON_FIXTURE: &str = r#"apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-afternoon +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok +initial: [] +cases: + - name: a-small-household-fits-the-first-band + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 3 + attendees: 3 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + - name: a-household-above-the-highest-band-is-refused + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 9 + attendees: 9 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: party.capacity-inadequate + - name: a-mid-size-household-fits-the-second-band + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 6 + attendees: 6 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 +"#; + /// The operator runtime configuration example every template writes beside /// the authored policy. It names no project path, so one document serves every /// template and the committed example can never drift from what an adopter @@ -511,6 +614,10 @@ pub(super) fn template_files(template: &str) -> Option None, } @@ -520,9 +627,9 @@ pub(super) fn template_files(template: &str) -> Option Date: Tue, 15 Sep 2026 21:03:55 +0700 Subject: [PATCH 095/115] fix(schedulingctl): keep the runtime database suite out of database-free builds Cargo unifies the features a dependency entry names across one invocation, so asking for `registry-scheduling/postgres-test` in this crate's dev-dependencies turned the feature on for every build that holds both crates. That satisfied the runtime's own `required-features` gate, and the shared test shard, which has no PostgreSQL server, compiled and ran `postgres_commitments`. The database targets here already declare `required-features = ["postgres-test"]` against this crate's own feature, which chains to the runtime's, so the gates that mean to run them still get the plaintext test connection and nothing else does. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/Cargo.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/registry-schedulingctl/Cargo.toml b/crates/registry-schedulingctl/Cargo.toml index 864e0ba9cb..782556e3be 100644 --- a/crates/registry-schedulingctl/Cargo.toml +++ b/crates/registry-schedulingctl/Cargo.toml @@ -35,7 +35,11 @@ tokio = { workspace = true, features = ["rt"] } uuid.workspace = true [dev-dependencies] -registry-scheduling = { workspace = true, features = ["postgres-test"] } +# The database suites reach the runtime's plaintext test connection through +# this crate's own `postgres-test` feature, which their `required-features` +# already demand. Asking for it here instead would turn it on for every build +# holding both crates, and the runtime's own database suite would then be +# compiled and run by shards that have no server to offer it. tokio-postgres.workspace = true [[test]] From 3c33fa559b90edc3f79a707c49c6d8cd28c8d511 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 21:04:02 +0700 Subject: [PATCH 096/115] test(scheduling): gate that the database suites stay opt-in A dependency entry naming a sibling's feature is the same as passing that feature on the command line for every build that selects both crates, so one crate can silently select another's database-only test targets and hand them to a job with no server. The checker reads `cargo metadata`, maps each Scheduling test target to the feature that gates it, and reports any Scheduling dependency entry that turns such a gate on. The checkpoint runs it beside the dependency-direction guard. Signed-off-by: Jeremi Joslin --- .../scheduling/scripts/check-checkpoint.sh | 1 + .../scripts/check_database_test_isolation.py | 98 ++++++++++++++ .../scripts/test_database_test_isolation.py | 125 ++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100755 products/scheduling/scripts/check_database_test_isolation.py create mode 100644 products/scheduling/scripts/test_database_test_isolation.py diff --git a/products/scheduling/scripts/check-checkpoint.sh b/products/scheduling/scripts/check-checkpoint.sh index 6be9374d20..a8fee2ea3c 100755 --- a/products/scheduling/scripts/check-checkpoint.sh +++ b/products/scheduling/scripts/check-checkpoint.sh @@ -8,6 +8,7 @@ schedulingctl_bin=${SCHEDULINGCTL_BIN:-"$repo_root/target/debug/schedulingctl"} cd "$repo_root" python3 products/scheduling/scripts/generate_openapi.py --check python3 products/scheduling/scripts/check_dependency_direction.py +python3 products/scheduling/scripts/check_database_test_isolation.py python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py' python3 products/scheduling/scripts/validate_contracts.py diff --git a/products/scheduling/scripts/check_database_test_isolation.py b/products/scheduling/scripts/check_database_test_isolation.py new file mode 100755 index 0000000000..afca9b8b0d --- /dev/null +++ b/products/scheduling/scripts/check_database_test_isolation.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Check that no Scheduling crate selects a sibling's database-only tests.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +# The product this gate speaks for. A dependency on another product's crate is +# that product's business, and its own gates are where it belongs. +SCHEDULING_PREFIX = "registry-scheduling" + + +def gated_test_targets(metadata: dict) -> dict[str, dict[str, list[str]]]: + """Map each package to the feature that gates each of its test targets.""" + gated: dict[str, dict[str, list[str]]] = {} + for package in metadata["packages"]: + for target in package.get("targets", []): + if "test" not in target.get("kind", []): + continue + for feature in target.get("required-features") or []: + gated.setdefault(package["name"], {}).setdefault(feature, []).append( + target["name"] + ) + return gated + + +def violations(metadata: dict) -> list[str]: + """Report every dependency entry that turns a sibling's gate on. + + Cargo unifies features across one invocation, so a dependency entry naming + a feature is the same as passing it on the command line for every build + that selects both crates. A feature gating a test target that needs a + disposable PostgreSQL server therefore stops being opt-in: the database-free + shard compiles and runs the suite, and it fails for want of a server the + caller never offered. The feature the package declares for itself is the + opt-in the suites are meant to have; this is about what a sibling asks for. + """ + gated = gated_test_targets(metadata) + failures: list[str] = [] + for package in metadata["packages"]: + if not package["name"].startswith(SCHEDULING_PREFIX): + continue + for dependency in package.get("dependencies", []): + if not dependency["name"].startswith(SCHEDULING_PREFIX): + continue + requested = gated.get(dependency["name"], {}) + for feature in dependency.get("features") or []: + targets = requested.get(feature) + if not targets: + continue + kind = dependency.get("kind") + table = f"{kind}-dependencies" if kind else "dependencies" + failures.append( + f"{package['name']} enables {dependency['name']}/{feature} in its " + f"[{table}] entry, which selects that crate's database-only test " + f"target(s) {', '.join(sorted(targets))} in every build holding " + f"both crates, including the ones with no database" + ) + return failures + + +def load_metadata(repository_root: Path, fixture: Path | None) -> dict: + if fixture: + return json.loads(fixture.read_text(encoding="utf-8")) + completed = subprocess.run( + ["cargo", "metadata", "--locked", "--format-version", "1", "--no-deps"], + cwd=repository_root, + check=True, + capture_output=True, + text=True, + ) + return json.loads(completed.stdout) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--metadata", type=Path, help="read a Cargo metadata fixture") + args = parser.parse_args() + repository_root = Path(__file__).resolve().parents[3] + try: + failures = violations(load_metadata(repository_root, args.metadata)) + except (OSError, ValueError, KeyError, json.JSONDecodeError, subprocess.CalledProcessError) as error: + print(f"database-test isolation check could not inspect Cargo metadata: {error}", file=sys.stderr) + return 2 + if failures: + for failure in failures: + print(f"database-test isolation violation: {failure}", file=sys.stderr) + return 1 + print("Scheduling database suites stay opt-in.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/products/scheduling/scripts/test_database_test_isolation.py b/products/scheduling/scripts/test_database_test_isolation.py new file mode 100644 index 0000000000..c6f51ca15a --- /dev/null +++ b/products/scheduling/scripts/test_database_test_isolation.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +import importlib.util +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("check_database_test_isolation.py") +SPEC = importlib.util.spec_from_file_location("scheduling_database_test_isolation", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def package(name: str, gated: dict[str, list[str]], dependencies: list[dict]) -> dict: + return { + "name": name, + "targets": [ + {"name": target, "kind": ["test"], "required-features": features} + for target, features in gated.items() + ], + "dependencies": dependencies, + } + + +def dependency(name: str, features: list[str], kind: str | None = None) -> dict: + return {"name": name, "kind": kind, "features": features} + + +def metadata(packages: list[dict]) -> dict: + return {"packages": packages} + + +class DatabaseTestIsolationTests(unittest.TestCase): + def test_an_opt_in_feature_chain_is_accepted(self): + graph = metadata( + [ + package("registry-scheduling", {"postgres_commitments": ["postgres-test"]}, []), + package( + "registry-schedulingctl", + {"records_apply_postgres": ["postgres-test"]}, + [dependency("registry-scheduling", [])], + ), + ] + ) + self.assertEqual(MODULE.violations(graph), []) + + def test_a_dev_dependency_selecting_a_siblings_database_tests_is_rejected(self): + graph = metadata( + [ + package("registry-scheduling", {"postgres_commitments": ["postgres-test"]}, []), + package( + "registry-schedulingctl", + {"records_apply_postgres": ["postgres-test"]}, + [dependency("registry-scheduling", ["postgres-test"], kind="dev")], + ), + ] + ) + failures = MODULE.violations(graph) + self.assertEqual(len(failures), 1) + self.assertIn("registry-schedulingctl", failures[0]) + self.assertIn("registry-scheduling/postgres-test", failures[0]) + self.assertIn("postgres_commitments", failures[0]) + + def test_an_ordinary_dependency_selecting_them_is_rejected_too(self): + graph = metadata( + [ + package("registry-scheduling", {"postgres_commitments": ["postgres-test"]}, []), + package( + "registry-schedulingctl", + {}, + [dependency("registry-scheduling", ["postgres-test"])], + ), + ] + ) + self.assertEqual(len(MODULE.violations(graph)), 1) + + def test_a_feature_that_gates_no_test_target_is_not_the_subject(self): + graph = metadata( + [ + package("registry-scheduling", {"postgres_commitments": ["postgres-test"]}, []), + package( + "registry-schedulingctl", + {}, + [dependency("registry-scheduling", ["schema"], kind="dev")], + ), + ] + ) + self.assertEqual(MODULE.violations(graph), []) + + def test_a_non_scheduling_dependency_is_left_to_its_own_product(self): + graph = metadata( + [ + package("registry-breg", {"postgres_kernel": ["postgres-test"]}, []), + package( + "registry-casework", + {}, + [dependency("registry-breg", ["postgres-test"], kind="dev")], + ), + ] + ) + self.assertEqual(MODULE.violations(graph), []) + + def test_every_gated_target_is_named_once(self): + graph = metadata( + [ + package( + "registry-scheduling", + {"postgres_commitments": ["postgres-test"], "postgres_replay": ["postgres-test"]}, + [], + ), + package( + "registry-schedulingctl", + {}, + [dependency("registry-scheduling", ["postgres-test"], kind="dev")], + ), + ] + ) + failures = MODULE.violations(graph) + self.assertEqual(len(failures), 1) + self.assertIn("postgres_commitments", failures[0]) + self.assertIn("postgres_replay", failures[0]) + + +if __name__ == "__main__": + unittest.main() From e914d6515c6611b5a12149c56f30aa0842f6c507 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 21:04:08 +0700 Subject: [PATCH 097/115] ci: run the Scheduling delivery intent suite `intents_postgres` needs a PostgreSQL server and was executed by no job, so the undelivered delivery intent read had no proof in CI. It runs in the Scheduling PostgreSQL job on its own database, the way the authoring record application suite does, because both are destructive. Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 640b11005c..2027963572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -786,6 +786,14 @@ jobs: env: SCHEDULING_TEST_DATABASE_URL: postgresql://scheduling:scheduling_test@localhost:${{ job.services.postgres.ports['5432'] }}/scheduling_records_apply run: cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test records_apply_postgres + - name: Create isolated delivery-intent test database + env: + POSTGRES_CONTAINER_ID: ${{ job.services.postgres.id }} + run: docker exec "$POSTGRES_CONTAINER_ID" createdb -U scheduling scheduling_intents + - name: Verify undelivered delivery intent reads + env: + SCHEDULING_TEST_DATABASE_URL: postgresql://scheduling:scheduling_test@localhost:${{ job.services.postgres.ports['5432'] }}/scheduling_intents + run: cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test intents_postgres breg-contracts: name: Base Registry Engine product contracts (${{ matrix.lane }}) From f9442ece46739097ad195b2492ebe82201ac0e51 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 21:04:15 +0700 Subject: [PATCH 098/115] test(release): bind each PostgreSQL image pin to the job that runs it The Casework image-pin gate named a bare digest, and the Scheduling PostgreSQL job pins the same image, so removing Casework's pin left the gate satisfied by Scheduling's copy and its own probe stopped failing. Each pin now names the job it belongs to, Scheduling's pin is declared beside Casework's, and the Scheduling workflow and checkpoint gates get the removal probes the Casework ones already have, including the delivery intent suite and the database test isolation guard. Signed-off-by: Jeremi Joslin --- release/scripts/check-gates-inventory.py | 21 +++- release/scripts/test_check_gates_inventory.py | 102 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 6c7cf10574..051c662e8a 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -213,8 +213,13 @@ "casework-postgres:\n name: Casework PostgreSQL transactions", ), ( + # Casework and Scheduling run the same pinned PostgreSQL image, so each + # pin names the job it belongs to. A bare digest would be satisfied by + # the other product's copy and would stop reporting its own removal. "Casework PostgreSQL 17 image pin", - "postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675", + "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + " env:\n" + " POSTGRES_DB: casework", ), ( "Casework product checkpoint wrapper", @@ -296,6 +301,12 @@ "Scheduling PostgreSQL gate", "scheduling-postgres:\n name: Scheduling PostgreSQL transactions", ), + ( + "Scheduling PostgreSQL 17 image pin", + "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + " env:\n" + " POSTGRES_DB: scheduling", + ), ( "Scheduling product checkpoint wrapper", "run: products/scheduling/scripts/check-checkpoint.sh", @@ -308,6 +319,10 @@ "Scheduling dependency-direction guard", "python3 products/scheduling/scripts/check_dependency_direction.py", ), + ( + "Scheduling database test isolation guard", + "python3 products/scheduling/scripts/check_database_test_isolation.py", + ), ( "Scheduling product script tests", "python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py'", @@ -324,6 +339,10 @@ "Scheduling authoring record application suite", "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test records_apply_postgres", ), + ( + "Scheduling delivery intent suite", + "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test intents_postgres", + ), ( "Release Linux Node client path filter", "release_linux_node_clients: ${{ steps.filter.outputs.release_linux_node_clients }}", diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index b3338f285f..20d655ff22 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -53,6 +53,9 @@ def setUp(self) -> None: self.casework_checkpoint_runner = ( ROOT / "products" / "casework" / "scripts" / "check-checkpoint.sh" ).read_text(encoding="utf-8") + self.scheduling_checkpoint_runner = ( + ROOT / "products" / "scheduling" / "scripts" / "check-checkpoint.sh" + ).read_text(encoding="utf-8") self.nightly_security = ( ROOT / ".github" / "workflows" / "nightly-security.yml" ).read_text(encoding="utf-8") @@ -926,6 +929,105 @@ def test_missing_casework_checkpoint_wrapper_steps_are_reported(self) -> None: ), ) + def test_missing_scheduling_workflow_gates_are_reported(self) -> None: + for snippet, replacement, gate in ( + ( + "scheduling_contracts: ${{ steps.filter.outputs.scheduling_contracts }}", + "scheduling_contracts: 'false'", + "Scheduling contract path filter", + ), + ( + "scheduling-contracts:\n name: Scheduling product contracts", + "scheduling-contracts:\n name: Scheduling disabled", + "Scheduling contract gate", + ), + ( + "run: products/scheduling/scripts/check-contracts.sh", + "run: true # Scheduling contract reproduction disabled", + "Scheduling contract reproduction", + ), + ( + "scheduling_postgres: ${{ steps.filter.outputs.scheduling_postgres }}", + "scheduling_postgres: 'false'", + "Scheduling PostgreSQL path filter", + ), + ( + "scheduling-postgres:\n name: Scheduling PostgreSQL transactions", + "scheduling-postgres:\n name: Scheduling disabled", + "Scheduling PostgreSQL gate", + ), + ( + "image: postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675\n" + " env:\n" + " POSTGRES_DB: scheduling", + "image: postgres:17.11\n env:\n POSTGRES_DB: scheduling", + "Scheduling PostgreSQL 17 image pin", + ), + ( + "run: products/scheduling/scripts/check-checkpoint.sh", + "run: true # Scheduling checkpoint wrapper disabled", + "Scheduling product checkpoint wrapper", + ), + ( + "cargo test --locked --profile ci -p registry-scheduling --features postgres-test --test postgres_commitments", + "true # Scheduling commitment ledger disabled", + "Scheduling commitment ledger suite", + ), + ( + "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test records_apply_postgres", + "true # Scheduling record application disabled", + "Scheduling authoring record application suite", + ), + ( + "cargo test --locked --profile ci -p registry-schedulingctl --features postgres-test --test intents_postgres", + "true # Scheduling delivery intent reads disabled", + "Scheduling delivery intent suite", + ), + ): + with self.subTest(gate=gate): + text = self.workflow.replace(snippet, replacement, 1) + self.assertIn(gate, self.module.missing_gates(text)) + + def test_missing_scheduling_checkpoint_wrapper_steps_are_reported(self) -> None: + for snippet, replacement, gate in ( + ( + "python3 products/scheduling/scripts/generate_openapi.py --check", + "true # Scheduling OpenAPI drift check disabled", + "Scheduling HTTP contract drift check", + ), + ( + "python3 products/scheduling/scripts/check_dependency_direction.py", + "true # Scheduling dependency guard disabled", + "Scheduling dependency-direction guard", + ), + ( + "python3 products/scheduling/scripts/check_database_test_isolation.py", + "true # Scheduling database test isolation guard disabled", + "Scheduling database test isolation guard", + ), + ( + "python3 -m unittest discover -s products/scheduling/scripts -p 'test_*.py'", + "true # Scheduling product script tests disabled", + "Scheduling product script tests", + ), + ( + '"$schedulingctl_bin" test "$work/$template"', + "true # Scheduling authoring journey disabled", + "Scheduling offline authoring journeys", + ), + ): + with self.subTest(gate=gate): + runner = self.scheduling_checkpoint_runner.replace( + snippet, replacement, 1 + ) + self.assertIn( + gate, + self.module.missing_gates( + self.workflow, + scheduling_checkpoint_runner_text=runner, + ), + ) + def test_linux_node_release_proof_is_two_runner_read_only_and_aggregated( self, ) -> None: From a126183b1cb158bce4a9dc5136b82e609ff1572a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 15 Sep 2026 22:54:53 +0700 Subject: [PATCH 099/115] test(schedulingctl): compare intent due times at the resolution PostgreSQL keeps The seeded base time came straight from the clock, so it carried a nanosecond tail that `timestamptz` drops on write. The assertion then compared the microsecond value the command read back against the nanosecond value still held in memory, and the two never matched on a platform whose realtime clock resolves finer than a microsecond. Truncating the base time to microseconds makes the seeded instant and the reported `dueAt` the same value on any clock. Signed-off-by: Jeremi Joslin --- crates/registry-schedulingctl/tests/intents_postgres.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/registry-schedulingctl/tests/intents_postgres.rs b/crates/registry-schedulingctl/tests/intents_postgres.rs index d259ad1889..0e2bccb7ff 100644 --- a/crates/registry-schedulingctl/tests/intents_postgres.rs +++ b/crates/registry-schedulingctl/tests/intents_postgres.rs @@ -10,7 +10,7 @@ //! against what was seeded, so nothing beyond what the test asserts on is //! carried through. -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Duration, SubsecRound, Utc}; use registry_platform_config::{SecretProvider, SecretResolver}; use registry_scheduling::config::RuntimeConfig; use registry_scheduling::store::PostgresStore; @@ -202,7 +202,11 @@ async fn intents_lists_local_and_failed_oldest_due_first_and_respects_limit() { seed_claim(&seed, claim_a).await; seed_claim(&seed, claim_b).await; - let base_time = Utc::now(); + // PostgreSQL `timestamptz` holds microseconds, so the nanosecond tail of a + // clock reading does not survive the round trip through `due_at`. Truncating + // at the source keeps the seeded instant and the `dueAt` the command reports + // comparable as strings on any clock. + let base_time = Utc::now().trunc_subsecs(6); let pending_id = Uuid::new_v4(); let local_first_id = Uuid::new_v4(); let failed_id = Uuid::new_v4(); From 190d2c4f5eb9dba439e32f8a8038b5489f285dba Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 17 Sep 2026 17:07:24 +0700 Subject: [PATCH 100/115] fix(scheduling): guard lifecycle revisions, dispatch ownership, and availability truth Close the review findings that admitted concurrent lifecycle writers, stamped cached policy evaluations, resolved stale supply facts, leased no dispatch, suppressed nothing already loaded, and disagreed between availability and admission about the published schedule. Co-Authored-By: Claude Code Signed-off-by: Jeremi Joslin --- .../registry-scheduling-core/src/admission.rs | 53 +- crates/registry-scheduling-core/src/model.rs | 12 + .../migrations/0001_scheduling.sql | 7 +- .../0002_facts_revision_and_suppressed.sql | 14 + crates/registry-scheduling/src/cursors.rs | 46 +- crates/registry-scheduling/src/runtime.rs | 91 ++- crates/registry-scheduling/src/service.rs | 240 ++++-- crates/registry-scheduling/src/store.rs | 516 +++++++++---- .../tests/postgres_commitments.rs | 727 +++++++++++++++++- crates/registry-schedulingctl/src/intents.rs | 4 +- crates/registry-schedulingctl/src/project.rs | 11 +- .../tests/records_apply_postgres.rs | 4 +- products/scheduling/README.md | 6 + products/scheduling/SECURITY-REVIEW-NOTES.md | 15 +- .../contracts/security-invariant-matrix.yaml | 8 +- .../contracts/security-test-traceability.yaml | 4 + .../scripts/check_database_test_isolation.py | 2 +- .../scripts/check_dependency_direction.py | 4 +- .../scheduling/scripts/validate_contracts.py | 5 +- 19 files changed, 1522 insertions(+), 247 deletions(-) create mode 100644 crates/registry-scheduling/migrations/0002_facts_revision_and_suppressed.sql diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index 27271d63fc..a1bd1cfb34 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -229,14 +229,15 @@ pub fn evaluate_exact_time_admission( // starts at fixed increments from the opening's start. A zero increment // publishes no grid at all, so every start is off it; the check refuses // zero, and the evaluator still refuses to divide by it rather than trust - // its caller. - let offset = request - .start - .signed_duration_since(covering.start) - .num_minutes(); - let grid = u64::from(exact.start_increment_minutes); - let on_grid = - offset >= 0 && grid != 0 && u64::try_from(offset).is_ok_and(|minutes| minutes % grid == 0); + // its caller. Alignment is exact: a start inside the minute after a grid + // point, whatever seconds it carries, is off the grid availability + // publishes. + let offset = request.start.signed_duration_since(covering.start); + let grid_seconds = i64::from(exact.start_increment_minutes) * 60; + let on_grid = offset >= Duration::zero() + && grid_seconds != 0 + && offset.subsec_nanos() == 0 + && offset.num_seconds() % grid_seconds == 0; if !on_grid { return Err(AdmissionRefusal::ScheduleUnpublished); } @@ -258,12 +259,7 @@ pub fn evaluate_exact_time_admission( let capable: Vec<&PoolMember> = members .iter() - .filter(|member| { - offering - .requires_capabilities - .iter() - .all(|wanted| member.capabilities.iter().any(|held| held == wanted)) - }) + .filter(|member| member.serves(&offering.requires_capabilities)) .collect(); if capable.is_empty() { return Err(AdmissionRefusal::CapabilityUnmatched); @@ -834,6 +830,35 @@ mod tests { ); } + /// Grid alignment is exact, not minute-truncated: a start inside the + /// minute after a grid point is off the grid availability publishes, + /// whatever seconds it carries. + #[test] + fn a_start_inside_the_minute_after_a_grid_point_is_off_the_grid() { + let (offering, exact) = exact_offering(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 4, 0); + let context = exact_context(&offering, &exact, members(), &snapshot, now); + let grid_point = utc(5, 2, 30); + assert!( + evaluate_exact_time_admission(&context, &exact_request(grid_point), None).is_ok(), + "the grid point itself admits" + ); + for off_grid in [ + grid_point + Duration::seconds(45), + grid_point + Duration::milliseconds(500), + grid_point + Duration::minutes(29) + Duration::seconds(59), + ] { + assert_eq!( + evaluate_exact_time_admission(&context, &exact_request(off_grid), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished), + "a start off the grid by less than a minute is still off it: {off_grid}" + ); + } + } + /// Self-overlap: a reschedule onto its own current slot succeeds, because /// its own allocation is excluded, while another caller at that slot is /// refused. diff --git a/crates/registry-scheduling-core/src/model.rs b/crates/registry-scheduling-core/src/model.rs index cd8c45bc63..48d0c743a3 100644 --- a/crates/registry-scheduling-core/src/model.rs +++ b/crates/registry-scheduling-core/src/model.rs @@ -202,6 +202,18 @@ pub struct PoolMember { pub available: bool, } +impl PoolMember { + /// Whether this member carries every capability an offering requires. + /// Availability counting and admission share this one predicate, so a + /// free count never advertises a member admission would refuse. + #[must_use] + pub fn serves(&self, requires_capabilities: &[String]) -> bool { + requires_capabilities + .iter() + .all(|wanted| self.capabilities.iter().any(|held| held == wanted)) + } +} + /// A pool of interchangeable resources backing exact-time offerings. A pool is /// its concrete members, never an independent counter. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] diff --git a/crates/registry-scheduling/migrations/0001_scheduling.sql b/crates/registry-scheduling/migrations/0001_scheduling.sql index e47f71e629..4fc10a6d14 100644 --- a/crates/registry-scheduling/migrations/0001_scheduling.sql +++ b/crates/registry-scheduling/migrations/0001_scheduling.sql @@ -144,8 +144,11 @@ CREATE TABLE IF NOT EXISTS scheduling_audit_outbox ( -- The publisher appends every pending record to a hash chain, and a -- chain is a record of order: publishing in an arbitrary one would have -- the chain attest to a sequence the deployment never had. The event id - -- is a random UUID and carries no order at all, so the order the rows - -- were written is kept here and nowhere else. + -- is a random UUID and carries no order at all. The sequence orders the + -- rows a publication pass reads; it is allocated at write time, so + -- between two concurrent transactions it can differ from commit + -- visibility order. The chain attests to publication order, not to + -- insertion-sequence order. recorded_seq bigint GENERATED ALWAYS AS IDENTITY, audit_record jsonb NOT NULL, published_at timestamptz diff --git a/crates/registry-scheduling/migrations/0002_facts_revision_and_suppressed.sql b/crates/registry-scheduling/migrations/0002_facts_revision_and_suppressed.sql new file mode 100644 index 0000000000..20574901d2 --- /dev/null +++ b/crates/registry-scheduling/migrations/0002_facts_revision_and_suppressed.sql @@ -0,0 +1,14 @@ +-- The records revision. Every wholesale records replacement increments it, +-- and a capacity transaction reads it under the supply anchor it holds: a +-- commitment whose facts were resolved before a replacement is refused +-- rather than allowed to name a resource the deployment no longer carries. +ALTER TABLE scheduling_meta + ADD COLUMN facts_revision bigint NOT NULL DEFAULT 0; + +-- A suppressed reminder is retained rather than deleted. The row keeps +-- accounting for an intent whose appointment moved on, whether or not a +-- dispatch already in flight reached its destination. +ALTER TABLE scheduling_outbox DROP CONSTRAINT scheduling_outbox_delivery_state_check; +ALTER TABLE scheduling_outbox + ADD CONSTRAINT scheduling_outbox_delivery_state_check + CHECK (delivery_state IN ('pending','delivered','failed','local','suppressed')); diff --git a/crates/registry-scheduling/src/cursors.rs b/crates/registry-scheduling/src/cursors.rs index 7345bab092..e02a040c2c 100644 --- a/crates/registry-scheduling/src/cursors.rs +++ b/crates/registry-scheduling/src/cursors.rs @@ -111,8 +111,16 @@ pub fn cursor_expiry(now: DateTime) -> DateTime { pub enum ListingPosition { /// Resume strictly after this catalogue id. AfterId { last_id: String }, - /// Resume strictly after this UTC instant. - FromInstant { after: DateTime }, + /// Resume an availability walk after this instant, carrying the + /// effective interval the walk was minted for. The interval rides with + /// the cursor because a request that omitted its bounds has none of its + /// own to repeat: the continuation restores them from here rather than + /// deriving a different interval from its own request time. + AvailabilityAfter { + from: DateTime, + to: DateTime, + after: DateTime, + }, /// Resume strictly before this instant and id, walking newest first: the /// position one history page leaves behind. BeforeInstantAndId { @@ -128,9 +136,11 @@ impl ListingPosition { pub fn to_json(&self) -> Value { match self { Self::AfterId { last_id } => serde_json::json!({"afterId": last_id}), - Self::FromInstant { after } => { - serde_json::json!({"after": after.to_rfc3339()}) - } + Self::AvailabilityAfter { from, to, after } => serde_json::json!({ + "from": from.to_rfc3339(), + "to": to.to_rfc3339(), + "after": after.to_rfc3339(), + }), Self::BeforeInstantAndId { before, last_id } => { serde_json::json!({"before": before.to_rfc3339(), "lastId": last_id}) } @@ -158,13 +168,19 @@ impl ListingPosition { } }); } - value - .get("after") - .and_then(Value::as_str) - .and_then(|after| DateTime::parse_from_rfc3339(after).ok()) - .map(|after| Self::FromInstant { - after: after.with_timezone(&Utc), - }) + let instant = |key: &str| { + value + .get(key) + .and_then(Value::as_str) + .and_then(|text| DateTime::parse_from_rfc3339(text).ok()) + .map(|instant| instant.with_timezone(&Utc)) + }; + match (instant("from"), instant("to"), instant("after")) { + (Some(from), Some(to), Some(after)) => { + Some(Self::AvailabilityAfter { from, to, after }) + } + _ => None, + } } } @@ -238,7 +254,11 @@ mod tests { ListingPosition::AfterId { last_id: "w1".to_owned(), }, - ListingPosition::FromInstant { after: Utc::now() }, + ListingPosition::AvailabilityAfter { + from: Utc::now(), + to: Utc::now(), + after: Utc::now(), + }, ListingPosition::BeforeInstantAndId { before: Utc::now(), last_id: "event-1".to_owned(), diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index ba233554b6..183dd7216e 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -63,6 +63,13 @@ const REMINDER_ATTEMPTS_CEILING: i32 = 8; /// hour. const REMINDER_RETRY_BASE_SECONDS: i64 = 30; const REMINDER_RETRY_MAX_SECONDS: i64 = 3600; +/// The lease one dispatch pass holds its claimed intents for. The worst +/// case of one whole claimed batch is every intent sending to its full +/// timeout, 100 x 5 s = 500 s, plus the database round trips around each +/// send; 600 s covers both, so a second dispatcher never re-sends what a +/// slow first one still owns, and a dispatcher that dies delays its +/// intents by exactly one lease. +const INTENT_DISPATCH_LEASE_SECONDS: i64 = 600; /// One rendered reminder event is bounded, as is the whole request. const REMINDER_MAXIMUM_BODY_BYTES: usize = 16 * 1024; const REMINDER_MAXIMUM_REQUEST_BYTES: usize = 32 * 1024; @@ -663,8 +670,18 @@ fn next_attempt(attempts: i32) -> DateTime { /// Claim every due intent and give each one dispatch attempt. A transport /// failure proves nothing and schedules a retry; a refusal outside the retry -/// classes is held as failed for the operator; with no destination configured -/// the intent is marked local: recorded, never pretended delivered. +/// classes is held as failed for the operator; with no destination +/// configured the intent is marked local: recorded, never pretended +/// delivered. +/// +/// A claimed intent is leased for the pass that claimed it and fenced by +/// its attempt number, so a concurrent or restarted dispatcher cannot +/// re-send it or overwrite a newer outcome. Reminders whose appointment has +/// been cancelled or moved to a newer revision are skipped before the send +/// rather than delivered against a decision the caller already changed; the +/// residual race, a suppression landing while the send itself is in flight, +/// is reported as a lost outcome rather than erased, and the destination +/// deduplicates by the event's stable identifier. /// /// # Errors /// @@ -676,11 +693,34 @@ pub async fn dispatch_due_intents( transport: Option<&ReminderTransport>, ) -> Result<(), StoreError> { let now = Utc::now(); - for row in store.claim_due_intents(now, REMINDER_BATCH).await? { + let lease = TimeDelta::seconds(INTENT_DISPATCH_LEASE_SECONDS); + for row in store.claim_due_intents(now, REMINDER_BATCH, lease).await? { let Some(transport) = transport else { - store.hold_intent_local(row.outbox_id).await?; + let owned = store.hold_intent_local(row.outbox_id, row.attempts).await?; + if !owned { + // Nothing was sent: no destination exists. The intent was + // suppressed or taken over between the claim and this write, + // and its owner holds the record. + tracing::warn!( + outbox_id = %row.outbox_id, + attempts = row.attempts, + "a local hold arrived after its attempt lost the intent; nothing was sent" + ); + } continue; }; + // The claim's own liveness: a suppressed reminder, or one whose + // appointment moved to a newer revision, is not sent. + if !store + .intent_still_dispatchable(row.outbox_id, row.attempts) + .await? + { + tracing::info!( + outbox_id = %row.outbox_id, + "a claimed intent was suppressed or superseded before its dispatch" + ); + continue; + } let body = match reminder_event(scheduling_id, &row) { Ok(body) => body, Err(error) => { @@ -692,7 +732,12 @@ pub async fn dispatch_due_intents( // An unrenderable intent is held, not retried: no amount of // retrying fixes a payload that cannot become an event. store - .retry_intent(row.outbox_id, next_attempt(row.attempts), row.attempts) + .retry_intent( + row.outbox_id, + next_attempt(row.attempts), + row.attempts, + row.attempts, + ) .await?; continue; } @@ -728,13 +773,27 @@ pub async fn dispatch_due_intents( } }; match outcome.unwrap_or(Delivery::Transient) { - Delivery::Delivered => store.mark_intent_delivered(row.outbox_id).await?, + Delivery::Delivered => { + let owned = store + .mark_intent_delivered(row.outbox_id, row.attempts) + .await?; + if !owned { + // The send completed, but suppression or a superseding + // attempt owns the row now. The row records its own + // state; this line is the delivery's accounting. + report_lost_outcome(&row); + } + } Delivery::Transient => { + // A retry write that loses its claim needs no report: the + // attempt that took the intent over writes the outcome that + // counts, and a back-off nobody owes is nobody's news. store .retry_intent( row.outbox_id, next_attempt(row.attempts), REMINDER_ATTEMPTS_CEILING, + row.attempts, ) .await?; } @@ -748,7 +807,12 @@ pub async fn dispatch_due_intents( "a reminder destination refused an intent outright" ); store - .retry_intent(row.outbox_id, next_attempt(row.attempts), row.attempts) + .retry_intent( + row.outbox_id, + next_attempt(row.attempts), + row.attempts, + row.attempts, + ) .await?; } } @@ -756,6 +820,19 @@ pub async fn dispatch_due_intents( Ok(()) } +/// Report an outcome write that landed on nobody: the intent was +/// suppressed, or a superseding attempt took it over, while this attempt +/// was in flight. The row keeps the state its owner left it in; what this +/// records is that a send this attempt made may have reached a destination +/// that no outbox state will name. +fn report_lost_outcome(row: &OutboxRow) { + tracing::warn!( + outbox_id = %row.outbox_id, + attempts = row.attempts, + "a dispatch outcome arrived after its attempt lost the intent; a send may have completed" + ); +} + // --------------------------------------------------------------------------- // Audit publication // --------------------------------------------------------------------------- diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index 7680599920..ac5df039ec 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -317,25 +317,48 @@ impl SchedulingService { ) -> Result, ServiceError> { let limit = page_limit(limit); let offering = self.published_offering(offering_id)?; - let from = start.unwrap_or(now); let span = TimeDelta::days(MAXIMUM_AVAILABILITY_SPAN_DAYS); + // A continuation restores the effective interval of the request that + // minted its cursor. A default availability request, with neither + // start nor end, has no bounds of its own to repeat, so it reads + // them from the cursor rather than deriving a different interval + // from this request's own now; bounds a caller does supply must + // repeat the minted interval exactly, and anything else is a + // different listing the cursor refuses. + let stored = match cursor { + Some(encoded) => Some(self.stored_cursor(encoded).await?), + None => None, + }; + let minted_interval = match &stored { + Some(row) => match ListingPosition::from_json(&row.position) { + Some(ListingPosition::AvailabilityAfter { from, to, .. }) => Some((from, to)), + _ => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), + }, + None => None, + }; + let from = start + .or(minted_interval.map(|(from, _)| from)) + .unwrap_or(now); let to = match end { Some(end) => end.max(from + TimeDelta::minutes(1)).min(from + span), - None => from + span, + None => minted_interval.map(|(_, to)| to).unwrap_or(from + span), }; let context = format!("availability:{offering_id}:{}:{to}", from.to_rfc3339()); - let position = self.resolve_position(cursor, &context, now).await?; + let position = match stored { + Some(ref row) => Some(bind_stored(row, &context, now)?), + None => None, + }; let after = match position { - Some(ListingPosition::FromInstant { after }) => Some(after), + Some(ListingPosition::AvailabilityAfter { after, .. }) => Some(after), Some(_) => return Err(ServiceError::Problem(ProblemCode::CursorInvalid)), None => None, }; - let entries = match self.supply(offering).await? { + let entries = match self.supply(offering).await?.0 { ResolvedSupply::ExactTime { exact, members, open, - closures, + .. } => { let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); let buffer = TimeDelta::minutes(i64::from( @@ -349,7 +372,16 @@ impl SchedulingService { .store .member_snapshot(&ids, from - buffer, to + duration + buffer, now) .await?; - exact_time_slots(&exact, &members, &open, &closures, &snapshot, from, to, now) + exact_time_slots( + &exact, + &members, + &offering.requires_capabilities, + &open, + &snapshot, + from, + to, + now, + ) } ResolvedSupply::Window { window, @@ -380,7 +412,11 @@ impl SchedulingService { let items: Vec<_> = selected.into_iter().take(limit).collect(); let next_cursor = if more { let last = entry_instant(items.last().expect("a limited page is not empty")); - let position = ListingPosition::FromInstant { after: last }; + let position = ListingPosition::AvailabilityAfter { + from, + to, + after: last, + }; Some(self.mint_cursor(&context, &position, now).await?) } else { None @@ -414,7 +450,7 @@ impl SchedulingService { capabilities: Vec::new(), prerequisites: Vec::new(), }; - let refusal = match self.supply(offering).await? { + let refusal = match self.supply(offering).await?.0 { ResolvedSupply::ExactTime { exact, members, @@ -459,6 +495,13 @@ impl SchedulingService { horizon_days, channels, } => { + // The probe carries the window's current revision, the way a + // booking request would: window admission checks capacity + // only under a revision the caller actually observed, and an + // explain that never names one answers every window with a + // revision mismatch instead of explaining the start. + let mut probe = probe.clone(); + probe.window_revision = Some(window.revision); let snapshot = self.store.window_snapshot(&window.id, now).await?; evaluate_window_admission( &WindowContext { @@ -504,7 +547,7 @@ impl SchedulingService { .require_permission(caller, offering, HOLD_CREATE_ACTION) .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; - let supply = self.supply(offering).await?; + let (supply, facts_revision) = self.supply(offering).await?; let request_hash = admission_request_hash(request); let commitment = self.commitment( caller, @@ -514,6 +557,7 @@ impl SchedulingService { &request_hash, HOLD_CREATE_ACTION, now, + facts_revision, )?; let outcome = self .store @@ -537,6 +581,7 @@ impl SchedulingService { "hold:create", HOLD_CREATE_ACTION, now, + facts_revision, ) .await?; Ok(claim_or_replay(answer, |claim| { @@ -572,6 +617,8 @@ impl SchedulingService { // The release carries no caller-chosen key, so the hold's own id is // the idempotency key: a retried release replays the first answer. let release_key = hold_id.to_string(); + // A release evaluates no supply, so no records revision guards it: + // closing a hold cannot name a resource. let commitment = self.commitment( caller, &actor, @@ -580,6 +627,7 @@ impl SchedulingService { &request_hash, HOLD_RELEASE_ACTION, now, + 0, )?; let outcome = self.store.release_hold(hold_id, commitment).await; self.commitment_outcome( @@ -592,6 +640,7 @@ impl SchedulingService { &format!("hold:{hold_id}:release"), HOLD_RELEASE_ACTION, now, + 0, ) .await } @@ -650,7 +699,7 @@ impl SchedulingService { .require_permission(caller, offering, APPOINTMENT_CREATE_ACTION) .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; - let supply = self.supply(offering).await?; + let (supply, facts_revision) = self.supply(offering).await?; let request_hash = canonical_hash(&json!({"hold": hold_id}))?; let commitment = self.commitment( caller, @@ -660,6 +709,7 @@ impl SchedulingService { &request_hash, APPOINTMENT_CREATE_ACTION, now, + facts_revision, )?; let outcome = self .store @@ -675,6 +725,7 @@ impl SchedulingService { &format!("hold:{hold_id}:confirm"), APPOINTMENT_CREATE_ACTION, now, + facts_revision, ) .await } @@ -691,7 +742,7 @@ impl SchedulingService { .require_permission(caller, offering, APPOINTMENT_CREATE_ACTION) .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; - let supply = self.supply(offering).await?; + let (supply, facts_revision) = self.supply(offering).await?; let request_hash = admission_request_hash(request); let commitment = self.commitment( caller, @@ -701,6 +752,7 @@ impl SchedulingService { &request_hash, APPOINTMENT_CREATE_ACTION, now, + facts_revision, )?; let outcome = self .store @@ -716,6 +768,7 @@ impl SchedulingService { "appointment:create", APPOINTMENT_CREATE_ACTION, now, + facts_revision, ) .await } @@ -755,7 +808,7 @@ impl SchedulingService { .require_permission(caller, offering, APPOINTMENT_RESCHEDULE_ACTION) .await?; let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; - let supply = self.supply(offering).await?; + let (supply, facts_revision) = self.supply(offering).await?; let request_hash = canonical_hash(&json!({ "observedRevision": request.observed_revision, "admissionHash": admission_request_hash(&request.admission), @@ -768,6 +821,7 @@ impl SchedulingService { &request_hash, APPOINTMENT_RESCHEDULE_ACTION, now, + facts_revision, )?; let outcome = self .store @@ -791,6 +845,7 @@ impl SchedulingService { &format!("appointment:{appointment_id}:reschedule"), APPOINTMENT_RESCHEDULE_ACTION, now, + facts_revision, ) .await?; Ok(claim_or_replay(answer, |claim| { @@ -821,6 +876,8 @@ impl SchedulingService { "observedRevision": request.observed_revision, "reason": request.reason, }))?; + // A cancellation evaluates no supply, so no records revision guards + // it: closing an appointment cannot name a resource. let commitment = self.commitment( caller, &actor, @@ -829,6 +886,7 @@ impl SchedulingService { &request_hash, APPOINTMENT_CANCEL_ACTION, now, + 0, )?; let outcome = self .store @@ -851,6 +909,7 @@ impl SchedulingService { &format!("appointment:{appointment_id}:cancel"), APPOINTMENT_CANCEL_ACTION, now, + 0, ) .await?; Ok(claim_or_replay(answer, |claim| { @@ -1090,10 +1149,19 @@ impl SchedulingService { } /// The policy-resolved supply an offering runs against, read from the - /// live facts the operator's records apply wrote. - async fn supply(&self, offering: &OfferingPolicy) -> Result { - let facts = self.store.facts().await?; - ResolvedSupply::resolve(&self.policy, &facts, offering) + /// live facts the operator's records apply wrote, with the records + /// revision the read observed. The revision travels into the capacity + /// transaction, which refuses to commit against records a replacement + /// has since superseded. + async fn supply( + &self, + offering: &OfferingPolicy, + ) -> Result<(ResolvedSupply, i64), ServiceError> { + let (facts, revision) = self.store.facts().await?; + Ok(( + ResolvedSupply::resolve(&self.policy, &facts, offering)?, + revision, + )) } #[allow(clippy::too_many_arguments)] @@ -1106,6 +1174,7 @@ impl SchedulingService { request_hash: &'a str, operation: &str, now: DateTime, + facts_revision: i64, ) -> Result, ServiceError> { let attempt_expires_at = now .checked_add_signed(TimeDelta::days(self.attempt_receipt_days)) @@ -1124,6 +1193,7 @@ impl SchedulingService { Ok(Commitment { now, policy_revision: i64::try_from(self.policy_revision).unwrap_or(i64::MAX), + facts_revision, actor, actor_issuer: &caller.issuer, actor_subject: &caller.subject, @@ -1152,6 +1222,7 @@ impl SchedulingService { scope: &str, operation: &str, now: DateTime, + facts_revision: i64, ) -> Result, ServiceError> where T: FromMinted, @@ -1172,6 +1243,17 @@ impl SchedulingService { tracing::error!(%error, "the Scheduling store failed mid-commitment"); return Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)); } + if matches!(error, CommitError::FactsStale) { + // A records replacement moved under this request. Nothing + // was decided and the caller retries; the swap is an + // expected operator act, so this is a warning, not a + // failure. + tracing::warn!( + %error, + "the environment records were replaced while a commitment was in flight" + ); + return Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)); + } let problem = problem_of(&error); // Key misuse keeps the stored attempt as the answer and // records nothing new. @@ -1184,6 +1266,7 @@ impl SchedulingService { request_hash, operation, now, + facts_revision, ); match receipt { Ok(commitment) => { @@ -1229,28 +1312,35 @@ impl SchedulingService { } } - async fn resolve_position( - &self, - cursor: Option<&str>, - context: &str, - now: DateTime, - ) -> Result, ServiceError> { - let Some(encoded) = cursor else { - return Ok(None); - }; + /// Decode and load one stored cursor row. Binding to a listing is the + /// caller's act: the availability walk reads the stored interval before + /// it can compute the context it binds against. + async fn stored_cursor(&self, encoded: &str) -> Result { let cursor_id = decode_cursor(encoded)?; let stored = self .store .cursor(cursor_id) .await? .ok_or(CursorError::Invalid)?; - let stored = StoredCursor { + Ok(StoredCursor { cursor_id, context: stored["context"].as_str().unwrap_or_default().to_owned(), position: stored["position"].clone(), expires_at: serde_json::from_value(stored["expiresAt"].clone()) .map_err(|_| CursorError::Invalid)?, + }) + } + + async fn resolve_position( + &self, + cursor: Option<&str>, + context: &str, + now: DateTime, + ) -> Result, ServiceError> { + let Some(encoded) = cursor else { + return Ok(None); }; + let stored = self.stored_cursor(encoded).await?; Ok(Some(bind_stored(&stored, context, now)?)) } @@ -1431,14 +1521,20 @@ impl ResolvedSupply { } /// The exact-time availability walk: one entry per grid slot that lies inside -/// the published openings, clear of closures, inside the booking horizon, and -/// that at least one available member can still serve. +/// the effective openings, inside the booking horizon, and that at least one +/// available, capable member can still serve. +/// +/// `open` is the effective-open truth: closures already removed from it, and +/// an authorized reopening already restored into it. Filtering by raw +/// closures again here would strike out the time a reopening put back, so +/// discovery and admission would disagree; they read the same intervals +/// instead. #[allow(clippy::too_many_arguments)] fn exact_time_slots( exact: &ExactTimeOffering, members: &[PoolMember], + requires_capabilities: &[String], open: &[CalendarInterval], - closures: &[CalendarInterval], snapshot: &LedgerSnapshot, from: DateTime, to: DateTime, @@ -1451,8 +1547,7 @@ fn exact_time_slots( let mut entries = Vec::new(); for interval in open { // Slots anchor on each published opening's own start and step the - // authored grid; a start that leaves the opening or lands in a - // closure is not offered. + // authored grid; a start that leaves the opening is not offered. let mut slot_start = interval.start; while slot_start + duration <= interval.end { let slot_end = slot_start + duration; @@ -1461,13 +1556,11 @@ fn exact_time_slots( let occupied_end = slot_end + TimeDelta::minutes(i64::from(exact.buffer_after_minutes)); let in_range = slot_start >= from && slot_start < to; let in_horizon = slot_start >= earliest && slot_start <= latest; - let clear_of_closures = closures - .iter() - .all(|closure| closure.end <= slot_start || slot_end <= closure.start); - if in_range && in_horizon && clear_of_closures { + if in_range && in_horizon { let free = members .iter() .filter(|member| member.available) + .filter(|member| member.serves(requires_capabilities)) .filter(|member| { snapshot.claims.iter().all(|claim| { claim.supply_id != member.resource_id @@ -1706,6 +1799,9 @@ fn problem_of(error: &CommitError) -> ProblemCode { CommitError::Unauthorized => ProblemCode::OperationNotAuthorized, CommitError::RevisionMismatch => ProblemCode::RevisionMismatch, CommitError::CutoffPassed => ProblemCode::CancellationCutoffPassed, + // Never reached: the outcome handler intercepts a stale-facts + // refusal before it projects, because nothing was decided. + CommitError::FactsStale => ProblemCode::ServiceUnavailable, } } @@ -1881,8 +1977,8 @@ mod tests { let entries = exact_time_slots( &exact(), &members(2), - &[interval(2, 4)], &[], + &[interval(2, 4)], &LedgerSnapshot { claims: Vec::new() }, at(1, 0), at(5, 0), @@ -1902,8 +1998,8 @@ mod tests { let entries = exact_time_slots( &exact(), &members(1), - &[interval(2, 4)], &[], + &[interval(2, 4)], &LedgerSnapshot { claims: Vec::new() }, at(1, 0), at(5, 0), @@ -1924,8 +2020,8 @@ mod tests { let entries = exact_time_slots( &exact(), &members(2), - &[interval(2, 4)], &[], + &[interval(2, 4)], &snapshot, at(1, 0), at(5, 0), @@ -1948,16 +2044,22 @@ mod tests { } #[test] - fn a_closure_removes_the_slots_it_covers() { - let closures = vec![CalendarInterval { - start: at(2, 30), - end: at(3, 30), - }]; + fn slots_follow_the_effective_openings_a_reopening_restored() { + // The openings are the effective-open truth: a closure that an + // authorized reopening partly restores has already been subtracted + // and added back here, so the walk does not filter by raw closures + // again and strike out the restored time. let entries = exact_time_slots( &exact(), &members(1), - &[interval(2, 4)], - &closures, + &[], + &[ + interval(2, 3), + CalendarInterval { + start: at(3, 30), + end: at(4, 0), + }, + ], &LedgerSnapshot { claims: Vec::new() }, at(1, 0), at(5, 0), @@ -1965,8 +2067,48 @@ mod tests { ); assert_eq!( entries.iter().map(entry_instant).collect::>(), - vec![at(2, 0), at(3, 30)] + vec![at(2, 0), at(2, 30), at(3, 30)] + ); + } + + #[test] + fn a_slot_without_a_free_capable_member_is_not_availability() { + let capable = [PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec!["interpreter".to_owned()], + available: true, + }]; + let requires = vec!["interpreter".to_owned()]; + // Only an incapable member is free: the slot is not listed, because + // admission would refuse every start it advertised. + let entries = exact_time_slots( + &exact(), + &members(1), + &requires, + &[interval(2, 4)], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + at(0, 0), ); + assert!(entries.is_empty()); + // One capable member among two: the free count names only it. + let mut mixed = members(2); + mixed.extend_from_slice(&capable); + let entries = exact_time_slots( + &exact(), + &mixed, + &requires, + &[interval(2, 4)], + &LedgerSnapshot { claims: Vec::new() }, + at(1, 0), + at(5, 0), + at(0, 0), + ); + assert!(entries.iter().all(|entry| match entry { + AvailabilityEntry::Slot { free, .. } => *free == 1, + _ => false, + })); } #[test] @@ -1980,8 +2122,8 @@ mod tests { let entries = exact_time_slots( &exact(), &members(2), - &[interval(2, 4)], &[], + &[interval(2, 4)], &snapshot, at(1, 0), at(5, 0), @@ -1997,6 +2139,7 @@ mod tests { let entries = exact_time_slots( &exact(), &members(1), + &[], &[ interval(2, 3), CalendarInterval { @@ -2004,7 +2147,6 @@ mod tests { end: at(4, 15), }, ], - &[], &LedgerSnapshot { claims: Vec::new() }, at(1, 0), at(5, 0), diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 29a4ddba1a..702d458724 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -12,18 +12,25 @@ //! claim consumes capacity when it is an active booking, or an active //! hold whose `hold_expires_at` is still ahead of the observed now. A //! delayed cleanup worker therefore cannot keep an expired hold alive. -//! 3. The revision and policy-revision guards, with `policy.changed` -//! winning. +//! 3. The revision guards, with `policy.changed` winning: the revision of +//! the policy this process actually evaluated must still be the stored +//! one, and the facts the request resolved must not have been replaced +//! by a records swap. Both are checked under the anchor, after every +//! lock wait, so a commitment never carries a stale evaluation forward. //! 4. The pure evaluators from `registry-scheduling-core`, in memory, inside //! the lock but never doing I/O. The task grant's expiry is re-checked -//! here too, against the same now, so a grant that lapses before the -//! commit never books. +//! here too, against a fresh observation of the clock taken after the +//! waits, so a grant that lapses before the commit never books. //! 5. The claim, the idempotency attempt, the history event, and the outbox -//! rows, written in that same transaction. +//! rows, written in that same transaction. The lifecycle writes carry +//! the observed revision and the active state in their own predicates, +//! so a second writer that accepted the same observed state changes +//! nothing. //! 6. Commit. //! -//! Workers claim due work with `FOR UPDATE SKIP LOCKED` and a limit, the -//! idiom the stack already uses for due clocks and retention sweeps. +//! Workers claim due work with `FOR UPDATE SKIP LOCKED`, a limit, and a +//! dispatch lease: a claimed intent is not claimable again until its lease +//! passes, and an outcome write only lands for the attempt that owns it. use std::str::FromStr; use std::time::Duration; @@ -46,9 +53,11 @@ use uuid::Uuid; use crate::config::{describe_secret_failure, DatabaseConfig}; const SCHEDULING_MIGRATION: &str = include_str!("../migrations/0001_scheduling.sql"); +const FACTS_REVISION_MIGRATION: &str = + include_str!("../migrations/0002_facts_revision_and_suppressed.sql"); /// Every schema version in ledger order. -const MIGRATIONS: [(i64, &str); 1] = [(1, SCHEDULING_MIGRATION)]; +const MIGRATIONS: [(i64, &str); 2] = [(1, SCHEDULING_MIGRATION), (2, FACTS_REVISION_MIGRATION)]; /// Serializes operator-run migrations on one session lock. A second migrator /// waits here instead of racing the ledger primary key. The key spells the @@ -63,6 +72,18 @@ const MIGRATION_LOCK_KEY: i64 = 0x7363_6865_6475_6c65; /// the ASCII bytes of "SCHD". const HOLD_CEILING_LOCK_NAMESPACE: i32 = 0x5343_4844; +/// The clock the store reads when it re-checks authorization currency. +/// Production observes the system clock; the database suite pins one to +/// cross an expiry boundary deterministically inside a transaction. The +/// clock sits behind a shared lock so every handle cloned from one store +/// reads the same time. +pub type Clock = std::sync::Arc DateTime + Send + Sync>; +type SharedClock = std::sync::Arc>; + +fn system_clock() -> SharedClock { + std::sync::Arc::new(std::sync::RwLock::new(std::sync::Arc::new(Utc::now))) +} + #[derive(Debug, Error)] pub enum StoreError { #[error("the Scheduling database configuration is invalid")] @@ -88,6 +109,40 @@ pub enum StoreError { #[derive(Clone)] pub struct PostgresStore { pool: Pool, + clock: SharedClock, +} + +impl PostgresStore { + /// One observation of the store's clock: the time an authorization + /// re-check inside a transaction is decided under. + fn observed_now(&self) -> DateTime { + let clock = self + .clock + .read() + .expect("the store clock is never held across a panic"); + clock() + } + + /// The task-grant re-check at decision time: one fresh observation of + /// the store's clock, taken after every wait and immediately before the + /// final writes, so a grant that lapsed while this transaction waited + /// for a lock never commits. The rest of the transaction keeps the + /// request's own single `now`; this is the one authorization-currency + /// exception to it. + fn recheck_grant(&self, commitment: &Commitment<'_>) -> Result<(), CommitError> { + check_grant_current_at(commitment, self.observed_now()) + } + + /// Pin the clock the store's authorization re-checks read. Test-only: + /// production always observes the system clock. Every handle cloned + /// from this store reads the pinned time. + #[cfg(feature = "postgres-test")] + pub fn pin_clock(&self, clock: Clock) { + *self + .clock + .write() + .expect("the store clock is never held across a panic") = clock; + } } impl std::fmt::Debug for PostgresStore { @@ -166,11 +221,22 @@ impl ClaimState { } /// The caller-side facts every commitment carries. `now` is observed once -/// per request and used for every decision inside it, including the grant -/// re-check, so a transaction never mixes two observations of time. +/// per request and used for every decision the caller reads inside it, so +/// a transaction never mixes two observations of the time it answers with. +/// Authorization currency is the one deliberate exception: the grant's +/// expiry is re-checked against a fresh observation taken inside the +/// transaction, after every wait and immediately before the final write. pub struct Commitment<'c> { pub now: DateTime, + /// The revision of the policy this process evaluated, not the stored + /// current one: the capacity transaction verifies the two still agree + /// before it commits, so a claim never carries a revision that names + /// rules it was not evaluated under. pub policy_revision: i64, + /// The records revision the request resolved its supply under. A + /// wholesale records replacement increments it; a transaction whose + /// resolved facts are older than the stored revision is refused. + pub facts_revision: i64, /// The pseudonymized actor reference history and audit carry. pub actor: &'c str, pub actor_issuer: &'c str, @@ -249,6 +315,11 @@ pub enum CommitError { RevisionMismatch, #[error("the cancellation cutoff has passed")] CutoffPassed, + /// The environment records were replaced after this request resolved + /// its supply. Nothing was decided; the caller retries against the + /// current records. + #[error("the environment records were replaced while the request was in flight")] + FactsStale, } /// One due or delivered outbox intent. @@ -348,7 +419,10 @@ impl PostgresStore { .runtime(Runtime::Tokio1) .build() .map_err(|_| StoreError::Configuration)?; - Ok(Self { pool }) + Ok(Self { + pool, + clock: system_clock(), + }) } async fn client(&self) -> Result { @@ -477,6 +551,16 @@ impl PostgresStore { /// Publish a policy: bump the revision when the digest changed, and make /// sure every supply anchor the policy needs exists. Re-applying the /// same policy is a no-op that still verifies the anchors. + /// + /// Lock order, load-bearing: this method takes `scheduling_meta` + /// exclusively and then only inserts `scheduling_supply` rows with + /// `ON CONFLICT DO NOTHING`, which never waits on a row lock a capacity + /// transaction holds on that table. The capacity transactions take a + /// supply anchor first and `scheduling_meta` for share after, and the + /// records swap takes every anchor before `scheduling_meta`. Those three + /// orders cannot cycle only while this method keeps to inserts here: + /// an update or a locking read of `scheduling_supply` under the meta + /// lock would close a cycle. pub async fn apply_policy( &self, scheduling_id: &str, @@ -532,33 +616,49 @@ impl PostgresStore { Ok(revision) } - /// The environment records an admission runs against. - pub async fn facts(&self) -> Result { - let client = self.client().await?; - let locations = client + /// The environment records an admission runs against, with the records + /// revision they were read under. The whole read shares one repeatable + /// snapshot, so no records replacement can land between the members and + /// the exceptions and hand a caller a torn combination; the revision + /// travels with the facts into the capacity transaction, which refuses + /// to commit against records a replacement has since superseded. + pub async fn facts(&self) -> Result<(SchedulingFacts, i64), StoreError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + transaction + .batch_execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ") + .await?; + let revision: i64 = transaction + .query_one( + "SELECT facts_revision FROM scheduling_meta WHERE singleton", + &[], + ) + .await? + .get(0); + let locations = transaction .query( "SELECT location_id, timezone FROM scheduling_locations", &[], ) .await?; - let members = client + let members = transaction .query( "SELECT resource_id, pool_id, capabilities, available \ FROM scheduling_pool_members ORDER BY pool_id, resource_id", &[], ) .await?; - let pools = client + let pools = transaction .query("SELECT pool_id FROM scheduling_pools ORDER BY pool_id", &[]) .await?; - let exceptions = client + let exceptions = transaction .query( "SELECT exception_id, location, kind, date::text, start_time, end_time, \ reopens, authority FROM scheduling_exceptions", &[], ) .await?; - Ok(SchedulingFacts { + let facts = SchedulingFacts { locations: locations .iter() .map(|row| registry_scheduling_core::LocationRecord { @@ -597,7 +697,8 @@ impl PostgresStore { authority: row.get(7), }) .collect(), - }) + }; + Ok((facts, revision)) } /// Replace the environment records wholesale. This is the operator @@ -785,7 +886,7 @@ impl PostgresStore { request: ®istry_scheduling_core::AdmissionRequest, ttl_minutes: u32, max_per_caller: u32, - mut commitment: Commitment<'_>, + commitment: Commitment<'_>, ) -> Result { let mut client = self.client().await?; let transaction = client.transaction().await?; @@ -804,7 +905,6 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; - commitment.policy_revision = current_policy_revision(&transaction).await?; let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; // The caller lock is taken after the supply lock, never before: every // hold transaction acquires the two in that one order, so no pair of @@ -816,7 +916,12 @@ impl PostgresStore { { return Err(CommitError::HoldCeiling); } + guard_revisions(&transaction, &commitment).await?; let admission = evaluate(offering, supply, request, &snapshot, &commitment, None)?; + // The last authorization fact before the writes: the grant must + // still stand at the time this transaction decides, observed after + // every wait rather than at the door. + self.recheck_grant(&commitment)?; let expires_at = commitment .now .checked_add_signed(TimeDelta::minutes(i64::from(ttl_minutes))) @@ -880,7 +985,7 @@ impl PostgresStore { offering: ®istry_scheduling_core::OfferingPolicy, supply: &SupplyContext<'_>, request: ®istry_scheduling_core::AdmissionRequest, - mut commitment: Commitment<'_>, + commitment: Commitment<'_>, ) -> Result { let mut client = self.client().await?; let transaction = client.transaction().await?; @@ -899,9 +1004,10 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; - commitment.policy_revision = current_policy_revision(&transaction).await?; let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + guard_revisions(&transaction, &commitment).await?; let admission = evaluate(offering, supply, request, &snapshot, &commitment, None)?; + self.recheck_grant(&commitment)?; let appointment_id = Uuid::new_v4(); let claim = transaction .insert_claim(&NewClaim { @@ -973,7 +1079,7 @@ impl PostgresStore { hold_id: Uuid, offering: ®istry_scheduling_core::OfferingPolicy, supply: &SupplyContext<'_>, - mut commitment: Commitment<'_>, + commitment: Commitment<'_>, ) -> Result { let scope = format!("hold:{hold_id}:confirm"); let mut client = self.client().await?; @@ -993,12 +1099,12 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; - commitment.policy_revision = current_policy_revision(&transaction).await?; // The snapshot is read under the lock for serialization order, but // admission is not re-evaluated: the hold's own reservation transfers // to the booking in this same transaction, so capacity does not // change. What must hold is identity (the policy revision the hold - // was admitted under is still current), checked below. + // was admitted under is still current), checked with the other + // revision guards below, under the anchor. let _snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; let hold = transaction .claim_in_transaction(hold_id) @@ -1007,6 +1113,7 @@ impl PostgresStore { if hold.kind != LedgerKind::Hold || hold.state != ClaimState::Active { return Err(AdmissionRefusal::HoldReleased.into()); } + guard_revisions(&transaction, &commitment).await?; // The expiry check stays here so a hold whose TTL lapsed inside this // very transaction is refused by the same clock the snapshot used. evaluate_hold_state(&hold_ledger_claim(&hold), commitment.now)?; @@ -1018,6 +1125,7 @@ impl PostgresStore { // an authorization refusal the audit journal records. return Err(CommitError::Unauthorized); } + self.recheck_grant(&commitment)?; let appointment_id = Uuid::new_v4(); let claim = transaction .insert_claim(&NewClaim { @@ -1040,9 +1148,15 @@ impl PostgresStore { reason: None, }) .await?; - transaction - .close_claim(hold_id, ClaimState::Consumed, None, hold.revision + 1) - .await?; + if !transaction + .close_claim(hold_id, ClaimState::Consumed, None, hold.revision) + .await? + { + // The hold changed state between the read and this write; the + // whole transaction, booking included, rolls back behind the + // refusal. + return Err(AdmissionRefusal::HoldReleased.into()); + } transaction .insert_history( appointment_id, @@ -1124,9 +1238,13 @@ impl PostgresStore { if hold.actor != commitment.actor { return Err(CommitError::Unauthorized); } - transaction - .close_claim(hold_id, ClaimState::Released, None, hold.revision + 1) - .await?; + self.recheck_grant(&commitment)?; + if !transaction + .close_claim(hold_id, ClaimState::Released, None, hold.revision) + .await? + { + return Err(AdmissionRefusal::HoldReleased.into()); + } transaction .insert_history( hold_id, @@ -1163,7 +1281,7 @@ impl PostgresStore { supply: &SupplyContext<'_>, request: ®istry_scheduling_core::AdmissionRequest, observed_revision: u64, - mut commitment: Commitment<'_>, + commitment: Commitment<'_>, ) -> Result { let scope = format!("appointment:{appointment_id}:reschedule"); let mut client = self.client().await?; @@ -1183,7 +1301,6 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; - commitment.policy_revision = current_policy_revision(&transaction).await?; let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; let appointment = transaction .claim_in_transaction(appointment_id) @@ -1192,6 +1309,7 @@ impl PostgresStore { if appointment.kind != LedgerKind::Booking || appointment.state != ClaimState::Active { return Err(AdmissionRefusal::HoldReleased.into()); } + guard_revisions(&transaction, &commitment).await?; // The policy guard wins over the revision guard: a caller whose // observed revision is also stale learns the policy moved first. if request.policy_revision != u64::try_from(commitment.policy_revision).unwrap_or(u64::MAX) @@ -1216,22 +1334,32 @@ impl PostgresStore { &commitment, Some(&own_claim_id), )?; + self.recheck_grant(&commitment)?; let next_revision = appointment.revision + 1; - transaction - .move_claim(&ClaimMove { - claim_id: appointment_id, - supply_id: admission - .resource - .as_deref() - .unwrap_or(&appointment.supply_id), - displayed_start: admission.start, - displayed_end: admission.end, - occupied_start: admission.occupied_start, - occupied_end: admission.occupied_end, - policy_revision: commitment.policy_revision, - next_revision, - }) - .await?; + if !transaction + .move_claim( + &ClaimMove { + claim_id: appointment_id, + supply_id: admission + .resource + .as_deref() + .unwrap_or(&appointment.supply_id), + displayed_start: admission.start, + displayed_end: admission.end, + occupied_start: admission.occupied_start, + occupied_end: admission.occupied_end, + policy_revision: commitment.policy_revision, + next_revision, + }, + appointment.revision, + ) + .await? + { + // A concurrent lifecycle writer moved the appointment first; + // this transaction's whole decision rolls back behind the + // refusal. + return Err(CommitError::RevisionMismatch); + } transaction .insert_history(appointment_id, next_revision, "rescheduled", commitment.now, commitment.actor, @@ -1334,10 +1462,21 @@ impl PostgresStore { return Err(CommitError::CutoffPassed); } } + self.recheck_grant(&commitment)?; let next_revision = appointment.revision + 1; - transaction - .close_claim(appointment_id, ClaimState::Cancelled, reason, next_revision) - .await?; + if !transaction + .close_claim( + appointment_id, + ClaimState::Cancelled, + reason, + appointment.revision, + ) + .await? + { + // A concurrent lifecycle writer closed or moved the appointment + // first; nothing this transaction decided stands. + return Err(CommitError::RevisionMismatch); + } transaction .insert_history( appointment_id, @@ -1425,16 +1564,29 @@ impl PostgresStore { Ok(rows.len() as u64) } - /// Claim due outbox intents for dispatch, marking their attempt. An - /// intent is due when its own time has come and the back-off a failed - /// attempt wrote has passed: without the second half a failing intent - /// would be re-claimed on every tick and burn its attempts ceiling in - /// seconds. + /// Claim due outbox intents for dispatch, marking the attempt and + /// holding each intent for the lease a dispatch pass needs. An intent + /// is due when its own time has come and the back-off a failed attempt + /// wrote has passed: without the second half a failing intent would be + /// re-claimed on every tick and burn its attempts ceiling in seconds. + /// + /// The claim is a durable reservation. `next_attempt_at` moves to the + /// end of the lease, so a second dispatcher, or the same one after a + /// crash, cannot claim the intent again until the lease passes; the + /// outcome writes that follow are fenced by the attempt number this + /// claim stamped, so only the attempt that owns the intent can settle + /// it. The lease must therefore cover a whole claimed batch's worst + /// case, and a dispatcher that dies delays its intents by exactly one + /// lease. pub async fn claim_due_intents( &self, now: DateTime, limit: i64, + lease: TimeDelta, ) -> Result, StoreError> { + let leased_until = now + .checked_add_signed(lease) + .ok_or(StoreError::Configuration)?; let mut client = self.client().await?; let transaction = client.transaction().await?; let rows = transaction @@ -1445,8 +1597,8 @@ impl PostgresStore { WHERE delivery_state='pending' AND due_at <= $1 \ AND next_attempt_at <= $1 \ ORDER BY due_at LIMIT $2 FOR UPDATE SKIP LOCKED) \ - RETURNING outbox_id, purpose, claim_id, appointment_revision, due_at, attempts, payload", - &[&now, &limit, &now], + RETURNING outbox_id, purpose, claim_id, appointment_revision, due_at, attempts, payload", + &[&now, &limit, &leased_until], ) .await?; transaction.commit().await?; @@ -1464,6 +1616,31 @@ impl PostgresStore { .collect()) } + /// Whether the attempt that claimed an intent still owns a dispatch it + /// should carry out: the row must still be pending at that attempt, and + /// a reminder must still describe its appointment's current revision. + /// A cancellation or reschedule suppresses the row, and a superseding + /// attempt means this one no longer speaks for the intent; either way + /// the send is skipped. + pub async fn intent_still_dispatchable( + &self, + outbox_id: Uuid, + attempts: i32, + ) -> Result { + let client = self.client().await?; + let row = client + .query_opt( + "SELECT 1 FROM scheduling_outbox AS o \ + JOIN scheduling_claims AS c ON c.claim_id = o.claim_id \ + WHERE o.outbox_id=$1 AND o.attempts=$2 AND o.delivery_state='pending' \ + AND (o.purpose <> 'reminder' \ + OR (c.state='active' AND c.revision = o.appointment_revision))", + &[&outbox_id, &attempts], + ) + .await?; + Ok(row.is_some()) + } + /// The intents nothing will deliver without an operator, oldest first. /// /// A deployment that declares no reminders destination holds every intent @@ -1501,53 +1678,70 @@ impl PostgresStore { .collect()) } - /// Mark one intent delivered. - pub async fn mark_intent_delivered(&self, outbox_id: Uuid) -> Result<(), StoreError> { + /// Mark one intent delivered, for the attempt that delivered it. + /// + /// `false` means the write changed nothing: the intent was suppressed + /// or a superseding attempt took it over while this one was in flight. + /// The caller reports that outcome explicitly, because a send whose + /// acknowledgement is lost is a delivery the accounting must still talk + /// about, not a silence. + pub async fn mark_intent_delivered( + &self, + outbox_id: Uuid, + attempts: i32, + ) -> Result { let client = self.client().await?; - client + let delivered = client .execute( - "UPDATE scheduling_outbox SET delivery_state='delivered', delivered_at=now() \ - WHERE outbox_id=$1", - &[&outbox_id], + "UPDATE scheduling_outbox SET delivery_state='delivered', delivered_at=now(), \ + next_attempt_at=now() \ + WHERE outbox_id=$1 AND attempts=$2 AND delivery_state='pending'", + &[&outbox_id, &attempts], ) .await?; - Ok(()) + Ok(delivered == 1) } /// Schedule one intent's retry, or fail it once its attempts reach the - /// ceiling. + /// ceiling, for the attempt that owns it. pub async fn retry_intent( &self, outbox_id: Uuid, next_attempt_at: DateTime, attempts_ceiling: i32, - ) -> Result<(), StoreError> { + attempts: i32, + ) -> Result { let client = self.client().await?; - client + let written = client .execute( "UPDATE scheduling_outbox \ SET delivery_state = CASE WHEN attempts >= $3 THEN 'failed' ELSE 'pending' END, \ next_attempt_at = CASE WHEN attempts >= $3 THEN now() ELSE $2 END \ - WHERE outbox_id=$1", - &[&outbox_id, &next_attempt_at, &attempts_ceiling], + WHERE outbox_id=$1 AND attempts=$4 AND delivery_state='pending'", + &[&outbox_id, &next_attempt_at, &attempts_ceiling, &attempts], ) .await?; - Ok(()) + Ok(written == 1) } - /// Hold one intent locally: the deployment has no destination - /// configured, so the intent stays recorded instead of pretending a - /// delivery happened. Nothing is delivered, so no delivery instant is - /// stamped either. - pub async fn hold_intent_local(&self, outbox_id: Uuid) -> Result<(), StoreError> { + /// Hold one intent locally, for the attempt that claimed it: the + /// deployment has no destination configured, so the intent stays + /// recorded instead of pretending a delivery happened. Nothing is + /// delivered, so no delivery instant is stamped either. + pub async fn hold_intent_local( + &self, + outbox_id: Uuid, + attempts: i32, + ) -> Result { let client = self.client().await?; - client + let held = client .execute( - "UPDATE scheduling_outbox SET delivery_state='local' WHERE outbox_id=$1", - &[&outbox_id], + "UPDATE scheduling_outbox SET delivery_state='local', next_attempt_at=now() \ + WHERE outbox_id=$1 AND attempts=$2 AND delivery_state='pending'", + &[&outbox_id, &attempts], ) .await?; - Ok(()) + Ok(held == 1) } /// Erase cursors whose fifteen minutes have passed. @@ -1635,9 +1829,14 @@ impl PostgresStore { /// The pending audit journal, oldest first. /// - /// The order is the one the rows were written in, not the one their - /// identifiers sort in: the publisher appends what this returns to a hash - /// chain, so the order it reads in is the order the chain attests to. + /// The chain the publisher extends attests to *publication order*: the + /// order in which rows became visible and were appended, with each + /// batch internally ordered by the recorded sequence. The sequence is + /// allocated when a row is written, not when its transaction commits, so + /// two concurrent transactions can become visible in the opposite order + /// from their sequence numbers; the chain does not claim + /// insertion-sequence order, and a verifier of the chain verifies what + /// the deployment published, in the order it published it. pub async fn pending_audit(&self, limit: i64) -> Result, StoreError> { let client = self.client().await?; Ok(client @@ -1808,12 +2007,20 @@ async fn lock_and_snapshot( } } -/// The task-grant re-check, inside the transaction: the grant's own expiry -/// is the one fact that can change between the service's authorization -/// decision and the commit, so it is checked against the commitment's now. +/// The task-grant re-check at the door of the transaction: a request whose +/// grant already lapsed fails before any lock is taken. fn check_grant_current(commitment: &Commitment<'_>) -> Result<(), CommitError> { + check_grant_current_at(commitment, commitment.now) +} + +/// The same expiry rule against an explicitly supplied observation of now, +/// which is how the store re-checks it at decision time. +fn check_grant_current_at( + commitment: &Commitment<'_>, + now: DateTime, +) -> Result<(), CommitError> { match commitment.grant_exp_unix { - Some(exp) if commitment.now.timestamp() < i64::try_from(exp).unwrap_or(i64::MAX) => Ok(()), + Some(exp) if now.timestamp() < i64::try_from(exp).unwrap_or(i64::MAX) => Ok(()), Some(_) => Err(CommitError::Unauthorized), // A mutating commitment without a grant never reaches the store: the // service refuses it before the transaction opens. @@ -1891,26 +2098,36 @@ fn hold_ledger_claim(hold: &ClaimRow) -> LedgerClaim { } } -/// The policy revision the deployment is published under, read inside the -/// commitment's own transaction. A commitment is admitted against the stored -/// revision, never against one cached when the process started: operator -/// tooling and a rolling deploy both move it under a running process, and a -/// claim written under the cached number would name a policy that no longer -/// stands. +/// The revision guards, read under the transaction after the supply anchor +/// it holds is locked. /// -/// The share lock holds it still for the life of the transaction, so a -/// policy published concurrently waits behind the commitments already in -/// flight instead of moving under them. -async fn current_policy_revision( +/// A commitment may only land under the policy revision this process +/// actually evaluated and the records revision the request actually +/// resolved. The stored revisions are the deployment's current truth: a +/// policy published by another process, or a records swap another operator +/// wrote, makes the caller's whole evaluation stale, and the commitment is +/// refused rather than stamped with a revision whose rules it never saw. +/// The share lock holds both revisions still for the life of the +/// transaction, so they cannot move again between the guard and the commit. +async fn guard_revisions( transaction: &deadpool_postgres::Transaction<'_>, -) -> Result { + commitment: &Commitment<'_>, +) -> Result<(), CommitError> { let row = transaction .query_one( - "SELECT policy_revision FROM scheduling_meta WHERE singleton FOR SHARE", + "SELECT policy_revision, facts_revision FROM scheduling_meta WHERE singleton FOR SHARE", &[], ) .await?; - Ok(row.get(0)) + let current_policy: i64 = row.get(0); + let current_facts: i64 = row.get(1); + if current_policy != commitment.policy_revision { + return Err(AdmissionRefusal::PolicyChanged.into()); + } + if current_facts != commitment.facts_revision { + return Err(CommitError::FactsStale); + } + Ok(()) } /// Replay a stored attempt: the same key with a different payload is @@ -2101,6 +2318,17 @@ pub(crate) async fn replace_facts_in_transaction( ) .await?; } + // The records revision moves last, inside the same transaction that + // holds every supply anchor: a capacity transaction that acquires its + // anchor after this commit reads the new revision and refuses to + // evaluate against the resolved-before facts it carried in. + transaction + .execute( + "UPDATE scheduling_meta SET facts_revision = facts_revision + 1, \ + updated_at=now() WHERE singleton", + &[], + ) + .await?; transaction .execute( "INSERT INTO scheduling_audit_outbox(event_id, audit_record) VALUES($1,$2)", @@ -2188,9 +2416,16 @@ trait CapacityStatements { claim_id: Uuid, state: ClaimState, reason: Option<&str>, - next_revision: i64, - ) -> Result<(), StoreError>; - async fn move_claim(&self, movement: &ClaimMove<'_>) -> Result<(), StoreError>; + observed_revision: i64, + ) -> Result; + /// Move an active booking to a new time or resource under the revision + /// the mover observed. `false` means a concurrent writer changed the + /// claim first and this transaction's whole decision is superseded. + async fn move_claim( + &self, + movement: &ClaimMove<'_>, + observed_revision: i64, + ) -> Result; async fn insert_history( &self, claim_id: Uuid, @@ -2310,40 +2545,52 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { }) } + /// Close the claim under the revision its caller observed. The + /// predicate, not an earlier read, decides: a concurrent lifecycle + /// writer that already moved the claim makes this affect zero rows, and + /// the caller rolls its whole transaction back behind the conflict. async fn close_claim( &self, claim_id: Uuid, state: ClaimState, reason: Option<&str>, - next_revision: i64, - ) -> Result<(), StoreError> { - self.execute( - "UPDATE scheduling_claims SET state=$2, reason=$3, revision=$4, \ - closed_at=now(), changed_at=now() WHERE claim_id=$1", - &[&claim_id, &state.as_str(), &reason, &next_revision], - ) - .await?; - Ok(()) + observed_revision: i64, + ) -> Result { + let closed = self + .execute( + "UPDATE scheduling_claims SET state=$2, reason=$3, \ + revision=$4 + 1, closed_at=now(), changed_at=now() \ + WHERE claim_id=$1 AND state='active' AND revision=$4", + &[&claim_id, &state.as_str(), &reason, &observed_revision], + ) + .await?; + Ok(closed == 1) } - async fn move_claim(&self, movement: &ClaimMove<'_>) -> Result<(), StoreError> { - self.execute( - "UPDATE scheduling_claims SET supply_id=$2, displayed_start=$3, displayed_end=$4, \ - occupied_start=$5, occupied_end=$6, policy_revision=$7, revision=$8, \ - changed_at=now() WHERE claim_id=$1", - &[ - &movement.claim_id, - &movement.supply_id, - &movement.displayed_start, - &movement.displayed_end, - &movement.occupied_start, - &movement.occupied_end, - &movement.policy_revision, - &movement.next_revision, - ], - ) - .await?; - Ok(()) + async fn move_claim( + &self, + movement: &ClaimMove<'_>, + observed_revision: i64, + ) -> Result { + let moved = self + .execute( + "UPDATE scheduling_claims SET supply_id=$2, displayed_start=$3, displayed_end=$4, \ + occupied_start=$5, occupied_end=$6, policy_revision=$7, revision=$8, \ + changed_at=now() WHERE claim_id=$1 AND state='active' AND revision=$9", + &[ + &movement.claim_id, + &movement.supply_id, + &movement.displayed_start, + &movement.displayed_end, + &movement.occupied_start, + &movement.occupied_end, + &movement.policy_revision, + &movement.next_revision, + &observed_revision, + ], + ) + .await?; + Ok(moved == 1) } async fn insert_history( @@ -2398,8 +2645,11 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { } async fn suppress_pending_reminders(&self, claim_id: Uuid) -> Result<(), StoreError> { + // Suppression marks rather than deletes: the row keeps accounting + // for the intent, and a dispatch already in flight reports its + // outcome as lost to suppression instead of vanishing. self.execute( - "DELETE FROM scheduling_outbox \ + "UPDATE scheduling_outbox SET delivery_state='suppressed' \ WHERE claim_id=$1 AND purpose='reminder' AND delivery_state='pending'", &[&claim_id], ) diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index fcea389a98..fd80c0fcdd 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -32,9 +32,10 @@ use registry_scheduling::runtime::{ AuditPublisher, }; use registry_scheduling::service::SchedulingService; -use registry_scheduling::store::PostgresStore; +use registry_scheduling::store::{CommitError, Commitment, PostgresStore, SupplyContext}; use registry_scheduling_core::{ - parse_policy_yaml, LocationRecord, PoolMember, ResourcePool, SchedulingFacts, + location_closure_intervals, location_open_intervals, parse_policy_yaml, AdmissionRequest, + LocationRecord, PartyCounts, PoolMember, ResourcePool, SchedulingFacts, }; use serde_json::{json, Value}; use std::sync::Arc; @@ -1470,7 +1471,7 @@ async fn a_failing_reminder_intent_waits_out_its_back_off() { // reminder is not due with it. let now_due = fx .store - .claim_due_intents(Utc::now(), 100) + .claim_due_intents(Utc::now(), 100, TimeDelta::minutes(10)) .await .expect("the delivery sweep runs"); assert_eq!( @@ -1485,7 +1486,7 @@ async fn a_failing_reminder_intent_waits_out_its_back_off() { let due = Utc::now() + TimeDelta::days(1); let claimed: Vec<_> = fx .store - .claim_due_intents(due, 100) + .claim_due_intents(due, 100, TimeDelta::minutes(10)) .await .expect("the delivery sweep runs") .into_iter() @@ -1502,13 +1503,18 @@ async fn a_failing_reminder_intent_waits_out_its_back_off() { // The send failed, so the intent goes back to pending behind a back-off. fx.store - .retry_intent(claimed[0].outbox_id, due + TimeDelta::minutes(5), 8) + .retry_intent( + claimed[0].outbox_id, + due + TimeDelta::minutes(5), + 8, + claimed[0].attempts, + ) .await .expect("the failed delivery is scheduled to retry"); let early: Vec<_> = fx .store - .claim_due_intents(due + TimeDelta::seconds(2), 100) + .claim_due_intents(due + TimeDelta::seconds(2), 100, TimeDelta::minutes(10)) .await .expect("the delivery sweep runs") .into_iter() @@ -1521,7 +1527,7 @@ async fn a_failing_reminder_intent_waits_out_its_back_off() { let later: Vec<_> = fx .store - .claim_due_intents(due + TimeDelta::minutes(6), 100) + .claim_due_intents(due + TimeDelta::minutes(6), 100, TimeDelta::minutes(10)) .await .expect("the delivery sweep runs") .into_iter() @@ -1607,7 +1613,7 @@ async fn a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies() // The refusal rolled the whole swap back, so the records stand as they // were and the booking still occupies its slot. - let standing = fx.store.facts().await.expect("the records survive"); + let (standing, _) = fx.store.facts().await.expect("the records survive"); assert_eq!( standing .pool("north-counter") @@ -1629,7 +1635,7 @@ async fn a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies() ) .await .expect("retiring an unoccupied resource commits"); - let standing = fx.store.facts().await.expect("the records are readable"); + let (standing, _) = fx.store.facts().await.expect("the records are readable"); assert_eq!( standing.pool("two-counter").map(|pool| pool.members.len()), Some(0), @@ -2021,7 +2027,7 @@ async fn the_intents_nobody_will_deliver_are_readable_by_an_operator() { let due = Utc::now() + TimeDelta::days(1); let claimed = fx .store - .claim_due_intents(due, 100) + .claim_due_intents(due, 100, TimeDelta::minutes(10)) .await .expect("the delivery sweep runs"); let intent = |purpose: &str| { @@ -2036,11 +2042,11 @@ async fn the_intents_nobody_will_deliver_are_readable_by_an_operator() { // rather than pretending a delivery happened. One send exhausted its // attempts. One was delivered, and one is still inside its back-off. fx.store - .hold_intent_local(intent("reminder")) + .hold_intent_local(intent("reminder"), 1) .await .expect("the reminder is held locally"); fx.store - .retry_intent(intent("confirmation"), due, 0) + .retry_intent(intent("confirmation"), due, 0, 1) .await .expect("the confirmation exhausts its attempts"); @@ -2076,7 +2082,7 @@ async fn the_intents_nobody_will_deliver_are_readable_by_an_operator() { let (second, _) = booked(&fx, 210, 320, "held-2").await; let pending = fx .store - .claim_due_intents(Utc::now(), 100) + .claim_due_intents(Utc::now(), 100, TimeDelta::minutes(10)) .await .expect("the delivery sweep runs"); let delivered = pending @@ -2085,7 +2091,7 @@ async fn the_intents_nobody_will_deliver_are_readable_by_an_operator() { .expect("the second commitment confirms at once") .outbox_id; fx.store - .mark_intent_delivered(delivered) + .mark_intent_delivered(delivered, 1) .await .expect("the confirmation is delivered"); let held = fx @@ -3008,3 +3014,696 @@ async fn an_arrival_window_allocates_its_units_and_holds_its_channel_ceiling() { assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{mismatch}"); assert_eq!(mismatch["code"], "revision.mismatch"); } + +// --------------------------------------------------------------------------- +// Concurrency and ownership regressions +// --------------------------------------------------------------------------- + +/// COR-3. Two lifecycle writers that accepted the same observed revision +/// must not both commit. The observed revision and the active state guard +/// the UPDATE itself, so one transition lands and the losing writer's whole +/// transaction rolls back behind a revision mismatch, whatever order the +/// two arrive in. +#[tokio::test] +async fn competing_cancellations_commit_exactly_one_transition() { + let fx = fixture().await; + let (appointment, revision) = booked(&fx, 300, 440, "race-cancel").await; + let cancel = |key: &'static str| { + let http = fx.http.clone(); + let token = fx.agent.clone(); + let uri = format!("/v1/appointments/{appointment}/cancel"); + let body = json!({"observedRevision": revision, "reason": null}); + tokio::spawn(async move { + send( + http, + "POST".into(), + uri, + token, + Some(key.into()), + Some(body), + ) + .await + }) + }; + let (first, second) = tokio::join!(cancel("race-cancel-a"), cancel("race-cancel-b")); + let mut answers = [ + first.expect("the first cancel answered"), + second.expect("the second cancel answered"), + ]; + answers.sort_by_key(|(status, _)| *status); + assert_eq!( + answers[0].0, + StatusCode::OK, + "exactly one writer wins: {}", + answers[0].1 + ); + // The loser is refused whichever way it met the moved claim: a + // revision.mismatch when its fenced write lost the race, or a + // hold.released when its read landed after the winner committed. Both + // are coherent refusals of a superseded observation. + assert!( + answers[1].0 == StatusCode::PRECONDITION_FAILED || answers[1].0 == StatusCode::CONFLICT, + "exactly one writer loses: {}", + answers[1].1 + ); + assert!(answers[1].0.is_client_error()); + // The ledger carries one coherent transition: one lifecycle event, one + // cancellation intent, and the revision the winner wrote. + let claim_id = Uuid::parse_str(&appointment).expect("a claim identifier"); + let counts = fx + .admin + .query_one( + "SELECT \ + (SELECT count(*) FROM scheduling_history WHERE claim_id=$1 AND revision=2), \ + (SELECT count(*) FROM scheduling_outbox WHERE claim_id=$1 AND purpose='cancellation'), \ + (SELECT revision FROM scheduling_claims WHERE claim_id=$1)", + &[&claim_id], + ) + .await + .expect("read the committed transition"); + assert_eq!( + counts.get::<_, i64>(0), + 1, + "one history event at the next revision" + ); + assert_eq!(counts.get::<_, i64>(1), 1, "one cancellation intent"); + assert_eq!( + counts.get::<_, i64>(2), + 2, + "the revision moved exactly once" + ); +} + +/// The same guard coordinates a reschedule against a cancellation: the two +/// writers take different locks, but the claim row's own predicate decides, +/// and the response the caller keeps is the one that actually landed. +#[tokio::test] +async fn a_reschedule_and_a_cancellation_commit_exactly_one_transition() { + let fx = fixture().await; + let (appointment, revision) = booked(&fx, 300, 440, "race-mixed").await; + let other = first_slot(&fx, OFFERING, 480, 620).await; + let reschedule = { + let http = fx.http.clone(); + let token = fx.agent.clone(); + let uri = format!("/v1/appointments/{appointment}/reschedule"); + let body = json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, other), + }); + tokio::spawn(async move { + send( + http, + "POST".into(), + uri, + token, + Some("race-mixed-move".into()), + Some(body), + ) + .await + }) + }; + let cancel = { + let http = fx.http.clone(); + let token = fx.agent.clone(); + let uri = format!("/v1/appointments/{appointment}/cancel"); + let body = json!({"observedRevision": revision, "reason": null}); + tokio::spawn(async move { + send( + http, + "POST".into(), + uri, + token, + Some("race-mixed-cancel".into()), + Some(body), + ) + .await + }) + }; + let (moved, cancelled) = tokio::join!(reschedule, cancel); + let moved = moved.expect("the reschedule answered"); + let cancelled = cancelled.expect("the cancel answered"); + let successes = usize::from(moved.0.is_success()) + usize::from(cancelled.0.is_success()); + assert_eq!( + successes, 1, + "exactly one transition commits: {moved:?} {cancelled:?}" + ); + // Whichever won, the appointment's stored state and its history agree, + // and no two history events share a revision. + let claim_id = Uuid::parse_str(&appointment).expect("a claim identifier"); + let duplicates = fx + .admin + .query_one( + "SELECT count(*) FROM (SELECT revision FROM scheduling_history \ + WHERE claim_id=$1 GROUP BY revision HAVING count(*) > 1) AS doubled", + &[&claim_id], + ) + .await + .expect("read the history revisions"); + assert_eq!( + duplicates.get::<_, i64>(0), + 0, + "no two history events share a revision" + ); +} + +/// SEC-03. The task grant is re-checked against a fresh observation of the +/// clock immediately before the final write: a grant that still stands at +/// the door and lapses before the commit books nothing. +#[tokio::test] +async fn a_grant_that_lapses_before_the_commit_never_books() { + let fx = fixture().await; + // The pinned clock sits past the test token's grant expiry, while the + // request's own now, observed at the door, is still inside it. + let pinned = Utc::now() + TimeDelta::minutes(20); + fx.store.pin_clock(Arc::new(move || pinned)); + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "lapsed-grant", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{problem}"); + assert_eq!(problem["code"], "operation.not-authorized"); + let committed = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE kind='booking'", + &[], + ) + .await + .expect("read the claim ledger"); + assert_eq!( + committed.get::<_, i64>(0), + 0, + "a lapsed grant commits nothing" + ); +} + +/// A running process refuses a commitment even when the caller names the +/// stored revision another process just published: the rules this process +/// evaluates are not the rules that revision names, and the claim is +/// refused rather than stamped with a policy it never saw. +#[tokio::test] +async fn a_stale_process_refuses_even_a_request_under_the_current_revision() { + let fx = fixture().await; + let moved = fx + .store + .apply_policy( + SCHEDULING_ID, + "a-policy-this-process-never-loaded", + &["north-counter".to_owned(), "two-counter".to_owned()], + &[], + ) + .await + .expect("publish a second policy revision"); + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let mut admission_body = admission(&fx, OFFERING, slot); + admission_body["policyRevision"] = json!(u64::try_from(moved).expect("a bounded revision")); + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "stale-process-current-revision", + json!({"hold": null, "admission": admission_body}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{problem}"); + assert_eq!(problem["code"], "policy.changed"); +} + +/// A records replacement moves the records revision under the supply +/// anchor: a commitment whose facts were resolved before the swap is +/// refused whole, rather than naming a resource the deployment retired. +#[tokio::test] +async fn a_records_swap_refuses_a_commitment_resolving_the_old_facts() { + let fx = fixture().await; + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let offering = policy.offering(OFFERING).expect("the test offering"); + // The slot resolves while the station still stands: the swap retires it. + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (facts, resolved_under) = fx.store.facts().await.expect("resolve the standing facts"); + let exceptions: Vec<_> = facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let timezone = facts + .location(&offering.location) + .expect("the seeded location") + .timezone + .clone(); + let members: Vec = facts + .pool( + &offering + .exact_time + .as_ref() + .expect("an exact-time offering") + .pool, + ) + .expect("the seeded pool") + .members + .clone(); + let open = location_open_intervals(&policy, &offering.location, &timezone, &exceptions) + .expect("resolve the openings"); + let closures = location_closure_intervals(&facts, &offering.location, &timezone) + .expect("resolve the closures"); + // The operator retires the station this offering books; nothing occupies + // it, so the swap commits. + let swapped = records_without(&["station-1"]); + fx.store + .replace_facts(&swapped, Uuid::new_v4(), operator_audit()) + .await + .expect("the records swap commits"); + let supply = SupplyContext::ExactTime { + exact: offering + .exact_time + .as_ref() + .expect("an exact-time offering"), + members: &members, + open: &open, + closures: &closures, + }; + let request = AdmissionRequest { + offering: OFFERING.to_owned(), + start: slot, + party: PartyCounts { + recipients: 1, + attendees: 1, + }, + channel: None, + duplicate_key: None, + policy_revision: fx.revision, + window_revision: None, + capabilities: Vec::new(), + prerequisites: Vec::new(), + }; + let commitment = Commitment { + now: Utc::now(), + policy_revision: i64::try_from(fx.revision).expect("a bounded revision"), + facts_revision: resolved_under, + actor: "actor-pseudonym", + actor_issuer: ISSUER, + actor_subject: "principal-agent", + idempotency_key: "stale-facts", + request_hash: "sha256:stale-facts", + attempt_expires_at: Utc::now() + TimeDelta::days(7), + grant_exp_unix: None, + audit_event: Uuid::new_v4(), + audit_record: operator_audit(), + }; + let outcome = fx + .store + .create_appointment(offering, &supply, &request, commitment) + .await; + assert!( + matches!(outcome, Err(CommitError::FactsStale)), + "a commitment resolving the pre-swap facts is refused: {outcome:?}" + ); + let committed = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE kind='booking'", + &[], + ) + .await + .expect("read the claim ledger"); + assert_eq!( + committed.get::<_, i64>(0), + 0, + "a stale facts resolution commits nothing" + ); +} + +/// The dispatch lease: a claimed intent is not claimable again until its +/// lease passes, and an outcome write only lands for the attempt that owns +/// the intent. +#[tokio::test] +async fn a_claimed_intent_is_leased_and_its_outcome_is_fenced_by_the_attempt() { + let fx = fixture().await; + let _ = booked(&fx, 90, 200, "lease-1").await; + let due = Utc::now() + TimeDelta::days(1); + let claimed: Vec<_> = fx + .store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.purpose == "reminder") + .collect(); + assert_eq!( + claimed.len(), + 1, + "the commitment minted one reminder intent" + ); + let row = &claimed[0]; + assert_eq!(row.attempts, 1); + + // Inside the lease a second dispatcher claims nothing of this intent. + let leased = fx + .store + .claim_due_intents(due + TimeDelta::minutes(9), 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs"); + assert!( + leased + .iter() + .all(|intent| intent.outbox_id != row.outbox_id), + "a leased intent is not claimable again" + ); + + // Past the lease the intent is recoverable, as a new attempt. + let reclaimed: Vec<_> = fx + .store + .claim_due_intents(due + TimeDelta::minutes(11), 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.outbox_id == row.outbox_id) + .collect(); + assert_eq!(reclaimed.len(), 1, "the lease passed, so the intent is due"); + assert_eq!( + reclaimed[0].attempts, 2, + "the second attempt is counted once" + ); + + // The superseded attempt cannot settle the intent; the owning one can. + assert!(!fx + .store + .mark_intent_delivered(row.outbox_id, 1) + .await + .expect("the stale outcome write runs")); + let state: String = fx + .admin + .query_one( + "SELECT delivery_state FROM scheduling_outbox WHERE outbox_id=$1", + &[&row.outbox_id], + ) + .await + .expect("read the intent") + .get(0); + assert_eq!(state, "pending", "an obsolete attempt changes nothing"); + assert!(fx + .store + .mark_intent_delivered(row.outbox_id, 2) + .await + .expect("the owning outcome write runs")); +} + +/// Suppression wins up to the send: a reminder claimed for dispatch whose +/// appointment is then cancelled is skipped, its row is retained as +/// accounting, and the in-flight attempt's outcome cannot land. +#[tokio::test] +async fn a_suppressed_reminder_is_skipped_and_keeps_its_accounting() { + let fx = fixture().await; + let (appointment, revision) = booked(&fx, 300, 440, "suppress-1").await; + let due = Utc::now() + TimeDelta::days(1); + let claimed: Vec<_> = fx + .store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.purpose == "reminder") + .collect(); + assert_eq!(claimed.len(), 1); + let reminder = &claimed[0]; + + // The caller cancels while the claimed reminder is in flight. + let (status, _) = fx + .post( + &format!("/v1/appointments/{appointment}/cancel"), + &fx.agent, + "suppress-cancel", + json!({"observedRevision": revision, "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // The pre-send check skips the suppressed reminder. + assert!( + !fx.store + .intent_still_dispatchable(reminder.outbox_id, reminder.attempts) + .await + .expect("the liveness read runs"), + "a suppressed reminder is not sent" + ); + // The in-flight attempt's outcome write cannot land either. + assert!(!fx + .store + .mark_intent_delivered(reminder.outbox_id, reminder.attempts) + .await + .expect("the stale outcome write runs")); + // The row survives as accounting, suppressed rather than deleted. + let state: String = fx + .admin + .query_one( + "SELECT delivery_state FROM scheduling_outbox WHERE outbox_id=$1", + &[&reminder.outbox_id], + ) + .await + .expect("read the intent") + .get(0); + assert_eq!(state, "suppressed"); + + // The cancellation the caller made is still live for its own dispatch: + // suppression takes reminders, not the audit trail of the change. + let cancellation: Uuid = fx + .admin + .query_one( + "SELECT outbox_id FROM scheduling_outbox WHERE claim_id=$1 AND purpose='cancellation'", + &[&Uuid::parse_str(&appointment).expect("a claim identifier")], + ) + .await + .expect("the cancellation intent is minted") + .get(0); + assert!( + fx.store + .intent_still_dispatchable(cancellation, 0) + .await + .expect("the liveness read runs"), + "the cancellation intent stays dispatchable" + ); +} + +/// An arrival-window offering explains its own start: the probe carries the +/// window's current revision, so a free start explains as admitting and a +/// full one as the public capacity refusal, never as a revision mismatch. +#[tokio::test] +async fn explain_answers_a_window_offering_rather_than_a_revision_mismatch() { + let start = Utc::now() + TimeDelta::hours(3); + let fx = fixture_publishing( + &policy_with_window(start), + &["north-counter".to_owned(), "two-counter".to_owned()], + &[WINDOW_ID.to_owned()], + ) + .await; + let uri = format!( + "/v1/availability/explain?offering={WINDOW_OFFERING}&start={}", + stamp(start) + ); + let (status, free) = fx.get(&uri, &fx.agent).await; + assert_eq!(status, StatusCode::OK, "{free}"); + assert!( + free["publicCode"].is_null(), + "a free window start explains as admitting: {free}" + ); + + // Fill both published units; the same probe now explains the refusal a + // booking would receive. + for (key, channel) in [ + ("explain-a", Some("assisted")), + ("explain-b", Some("public")), + ] { + let (status, booked_body) = fx + .post( + "/v1/appointments", + &fx.agent, + key, + arrival(&fx, start, channel), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{booked_body}"); + } + let (status, full) = fx.get(&uri, &fx.agent).await; + assert_eq!(status, StatusCode::OK, "{full}"); + assert_eq!(full["publicCode"], "capacity.exhausted", "{full}"); +} + +/// A default availability request, with neither start nor end, mints a +/// cursor its own continuation can use: the effective interval rides with +/// the cursor instead of being re-derived from the next request's now. +#[tokio::test] +async fn a_default_availability_request_continues_its_own_cursor() { + let fx = fixture().await; + let (status, first) = fx + .get( + &format!("/v1/availability?offering={OFFERING}&limit=2"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{first}"); + let cursor = first["nextCursor"] + .as_str() + .expect("a limited availability page carries a cursor") + .to_owned(); + let first_last = moment(&first["items"][0], "start"); + + // The continuation omits start and end exactly as the first page did. + let (status, second) = fx + .get( + &format!("/v1/availability?offering={OFFERING}&limit=2&cursor={cursor}"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{second}"); + assert!(!second["items"] + .as_array() + .expect("a page of items") + .is_empty()); + let second_first = moment(&second["items"][0], "start"); + assert!( + second_first > first_last, + "the continuation resumes after the first page" + ); + + // A caller that narrows the range on continuation is asking a different + // listing, and the cursor refuses it. + let (status, _) = fx + .get( + &format!( + "/v1/availability?offering={OFFERING}&limit=2&start={}&cursor={cursor}", + stamp(Utc::now() + TimeDelta::minutes(90)) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Explicit bounds continue as they always did: the same interval twice + // is the same listing. + let (status, bounded) = fx + .get( + &format!("{}&limit=2", availability_uri(OFFERING, 90, 620)), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{bounded}"); + let bounded_cursor = bounded["nextCursor"] + .as_str() + .expect("a bounded window under a page limit pages"); + let (status, _) = fx + .get( + &format!("/v1/availability?offering={OFFERING}&limit=2&cursor={bounded_cursor}"), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK); +} + +/// A reschedule suppresses the reminder its obsolete revision minted and +/// mints the replacement at the new revision: the obsolete row is retained +/// as accounting, the replacement stays live for dispatch, and a reminder +/// that names anything but the appointment's current revision is never +/// sent, whether a swap marked it or not. +#[tokio::test] +async fn a_reschedule_suppresses_the_obsolete_reminder_and_mints_its_replacement() { + let fx = fixture().await; + let (appointment, revision) = booked(&fx, 300, 440, "reschedule-remind").await; + let due = Utc::now() + TimeDelta::days(1); + let claimed: Vec<_> = fx + .store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("the delivery sweep runs") + .into_iter() + .filter(|intent| intent.purpose == "reminder") + .collect(); + assert_eq!( + claimed.len(), + 1, + "the booking minted one reminder at revision 1" + ); + let obsolete = &claimed[0]; + + // The caller moves the appointment clear of the old slot. + let later = first_slot(&fx, OFFERING, 480, 620).await; + let (status, moved) = fx + .post( + &format!("/v1/appointments/{appointment}/reschedule"), + &fx.agent, + "reschedule-remind-move", + json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, later), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{moved}"); + assert_eq!(moved["revision"], 2, "the move wrote the next revision"); + + let claim_id = Uuid::parse_str(&appointment).expect("a claim identifier"); + let rows = fx + .admin + .query( + "SELECT outbox_id, appointment_revision, delivery_state FROM scheduling_outbox \ + WHERE claim_id=$1 AND purpose='reminder' ORDER BY created_at", + &[&claim_id], + ) + .await + .expect("read the appointment's reminders"); + assert_eq!( + rows.len(), + 2, + "the obsolete reminder is retained beside its replacement" + ); + let obsolete_state: String = rows[0].get(2); + assert_eq!( + obsolete_state, "suppressed", + "the claimed reminder is suppressed, not deleted" + ); + let replacement: Uuid = rows[1].get(0); + assert_eq!( + rows[1].get::<_, i64>(1), + 2, + "the replacement names the new revision" + ); + assert_eq!( + rows[1].get::<_, String>(2), + "pending", + "the replacement is live for dispatch" + ); + + // The obsolete reminder is skipped, and the replacement is live. + assert!(!fx + .store + .intent_still_dispatchable(obsolete.outbox_id, obsolete.attempts) + .await + .expect("the liveness read runs")); + assert!(fx + .store + .intent_still_dispatchable(replacement, 0) + .await + .expect("the liveness read runs")); + + // The revision clause decides on its own: a pending reminder that names + // an appointment revision other than the current one is not sent, even + // when nothing marked the row. + fx.admin + .execute( + "UPDATE scheduling_outbox SET appointment_revision=1 WHERE outbox_id=$1", + &[&replacement], + ) + .await + .expect("age the replacement's revision"); + assert!( + !fx.store + .intent_still_dispatchable(replacement, 0) + .await + .expect("the liveness read runs"), + "a reminder naming an obsolete revision is not sent" + ); +} diff --git a/crates/registry-schedulingctl/src/intents.rs b/crates/registry-schedulingctl/src/intents.rs index 7d6fac5751..2a6ce172f0 100644 --- a/crates/registry-schedulingctl/src/intents.rs +++ b/crates/registry-schedulingctl/src/intents.rs @@ -7,7 +7,9 @@ //! intent failed. Both are recorded in the outbox and then waited on by //! nobody, so this is the read path an operator has without one: it lists //! what the sweep has stopped carrying, oldest due first, in the store's own -//! terms. +//! terms. A reminder suppressed by a cancellation or a reschedule is not +//! listed: nobody was ever meant to deliver it, and the row is retained as +//! accounting rather than as work. use std::fs; use std::path::Path; diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index 66547adb97..fcb6096b6b 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -125,9 +125,18 @@ pub(super) fn test(project: &Path) -> Result { })); } let fixture_dir = project.join(FIXTURES_DIRECTORY); + // An entry the directory cannot hand over is a filesystem failure, not a + // fixture that does not exist: swallowing it here would report a partial + // replay as a complete, passing one. let mut paths = fs::read_dir(&fixture_dir) .context("reading fixtures directory")? - .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .map(|entry| { + entry + .context("reading a fixtures directory entry") + .map(|entry| entry.path()) + }) + .collect::, _>>()? + .into_iter() .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("yaml")) .collect::>(); paths.sort(); diff --git a/crates/registry-schedulingctl/tests/records_apply_postgres.rs b/crates/registry-schedulingctl/tests/records_apply_postgres.rs index ca533bfcfd..f71dfde534 100644 --- a/crates/registry-schedulingctl/tests/records_apply_postgres.rs +++ b/crates/registry-schedulingctl/tests/records_apply_postgres.rs @@ -183,7 +183,7 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { .expect("the environment secret provider configures"); let store = PostgresStore::connect_runtime(&config.database, &resolver).unwrap(); - let facts = store.facts().await.unwrap(); + let (facts, _) = store.facts().await.unwrap(); assert_eq!(facts.locations.len(), 1); assert_eq!(facts.locations[0].id, "bangkok-counter"); assert_eq!(facts.locations[0].timezone, "Asia/Bangkok"); @@ -216,7 +216,7 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { report["applied"], json!({"locations": 2, "pools": 1, "members": 1, "exceptions": 0}) ); - let facts = store.facts().await.unwrap(); + let (facts, _) = store.facts().await.unwrap(); assert_eq!(facts.locations.len(), 2); let retired_station = facts .pools diff --git a/products/scheduling/README.md b/products/scheduling/README.md index 1fe049fd2e..c3cac60bad 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -67,6 +67,12 @@ scheduling --runtime-config "$PWD/runtime.yaml" serve `migrate` applies the schema and binds the database to the policy's scheduling id, which every later start verifies before it writes anything. +A `serve` that finds a schema older than the one its binary carries refuses +at the readiness check rather than serving against it, so an upgrade runs +`migrate` before it restarts the new runtime; the few minutes in between, +an old runtime may still be serving, and its in-flight availability +continuation cursors answer `cursor.invalid` once the new binary holds +them, so a caller mid-pagination restarts that listing from its first page. `records apply` is the one attributable operator write of a deployment's environment records: the locations with their time zones, the resource pools and their members, and the dated exceptions such as closures. The policy diff --git a/products/scheduling/SECURITY-REVIEW-NOTES.md b/products/scheduling/SECURITY-REVIEW-NOTES.md index 25164698b2..b6f49d6f6f 100644 --- a/products/scheduling/SECURITY-REVIEW-NOTES.md +++ b/products/scheduling/SECURITY-REVIEW-NOTES.md @@ -41,8 +41,10 @@ audit journal. The threats this surface answers: is refused as a credentials problem, never honoured partially. 2. **A token that was valid at the door commits after its grant lapsed.** The grant's expiry is re-checked inside the capacity transaction, - immediately before the claim commits, against the same observed now the - rest of the transaction decided under. The full bounds are matched once + immediately before the claim commits, against a fresh observation of + the store's clock taken after every lock wait, not against the + request's own entry time: a transaction delayed by contention cannot + carry a lapsed grant to a commit. The full bounds are matched once at the service before the transaction opens; re-reading them inside would only defend against narrowing at the issuer, which needs a revocation path this milestone does not have (matrix deferral @@ -127,7 +129,7 @@ answer is unchanged, so what changes is the record and not the disclosure. deferral SCHEDULING-DEF-02 and promotes matrix row SCHEDULING-SEC-14 to `enforced`. -**The journal is published in the order it was written.** *Threat:* the +**The journal is published in the order it became visible.** *Threat:* the audit outbox carried no ordering column, and the query feeding the publisher ordered by a random version 4 event identifier while its own documentation said oldest first. The publisher appends what that query @@ -136,7 +138,12 @@ attested to a sequence the deployment never had. A chain that certifies the wrong order is worse than no chain, because it is believed. *Default:* the outbox carries its own recorded sequence, the pending query reads in it, and the chain continues across a restart from its verified -tail rather than from whatever the first query after startup returns. +tail rather than from whatever the first query after startup returns. The +sequence is allocated when a row is written, not when its transaction +commits, so two concurrent transactions can publish in the opposite order +from their sequence numbers; the chain attests to publication order, and +no claim is made about insertion-sequence order between transactions that +committed in the other order. *Tests:* `the_audit_journal_is_read_in_the_order_it_was_written` and `the_audit_chain_continues_across_a_restart`, `crates/registry-scheduling/tests/postgres_commitments.rs`. diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index 5dad78cf36..f9539fe719 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -49,8 +49,10 @@ invariants: A token that passed the door commits capacity after the task grant behind it has expired. enforcementPoint: >- - check_grant_current inside the capacity transaction, immediately before - the claim commits, against the clock that commitment is decided under. + The grant's expiry re-checked inside the capacity transaction, after + every lock wait and immediately before the claim commits, against a + fresh observation of the store's clock rather than the request's own + entry time. refusal: >- Refuse with operation.not-authorized and write the authorization.refused audit row, leaving no claim behind. @@ -63,7 +65,7 @@ invariants: deadline passes. See deferred entry SCHEDULING-DEF-01. negativeTest: path: crates/registry-scheduling/tests/postgres_commitments.rs - name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations + name: a_grant_that_lapses_before_the_commit_never_books - id: SCHEDULING-SEC-04 state: enforced threat: >- diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index 9d208e3680..fdb4ea6370 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -20,6 +20,7 @@ entries: - {path: crates/registry-scheduling/src/runtime.rs, name: the_offering_anchors_are_collected_without_duplicates} - id: SCHEDULING-SEC-03 tests: + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_grant_that_lapses_before_the_commit_never_books} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations} - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: expiry_uses_earlier_token_or_grant_deadline_and_refuses_exact_deadline} - id: SCHEDULING-SEC-04 @@ -68,6 +69,9 @@ entries: - {path: crates/registry-scheduling-core/src/admission.rs, name: policy_change_outranks_a_window_revision_mismatch} - {path: crates/registry-scheduling-core/src/admission.rs, name: window_revisions_must_match_and_policy_change_wins} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_stale_process_refuses_even_a_request_under_the_current_revision} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: competing_cancellations_commit_exactly_one_transition} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_and_a_cancellation_commit_exactly_one_transition} - id: SCHEDULING-SEC-11 tests: - {path: crates/registry-scheduling-core/src/admission.rs, name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed} diff --git a/products/scheduling/scripts/check_database_test_isolation.py b/products/scheduling/scripts/check_database_test_isolation.py index afca9b8b0d..c05d6e26a1 100755 --- a/products/scheduling/scripts/check_database_test_isolation.py +++ b/products/scheduling/scripts/check_database_test_isolation.py @@ -90,7 +90,7 @@ def main() -> int: for failure in failures: print(f"database-test isolation violation: {failure}", file=sys.stderr) return 1 - print("Scheduling database suites stay opt-in.") + print("No Scheduling crate enables a sibling's database-only test feature.") return 0 diff --git a/products/scheduling/scripts/check_dependency_direction.py b/products/scheduling/scripts/check_dependency_direction.py index ee0f4d3001..d8c41f0a82 100644 --- a/products/scheduling/scripts/check_dependency_direction.py +++ b/products/scheduling/scripts/check_dependency_direction.py @@ -126,8 +126,10 @@ def violations(metadata: dict) -> list[str]: def load_metadata(repository_root: Path, fixture: Path | None) -> dict: if fixture: return json.loads(fixture.read_text(encoding="utf-8")) + # All-features resolution, so an optional dependency no supported build + # enables by default still cannot escape the forward-closure check. completed = subprocess.run( - ["cargo", "metadata", "--locked", "--format-version", "1"], + ["cargo", "metadata", "--locked", "--all-features", "--format-version", "1"], cwd=repository_root, check=True, capture_output=True, diff --git a/products/scheduling/scripts/validate_contracts.py b/products/scheduling/scripts/validate_contracts.py index 9aab5b19b8..0bf4df3f3a 100644 --- a/products/scheduling/scripts/validate_contracts.py +++ b/products/scheduling/scripts/validate_contracts.py @@ -8,8 +8,9 @@ - every security invariant names a threat, an enforcement point, a refusal, and a negative test; - every cited test exists in the cited file, as a Rust test function or a - Python test method, which is what makes it selectable by its own runner - (cargo test , unittest ); + Python test method. That is source-reference consistency: it proves the + citation points at maintained source, not that the citation's runner + selects or compiles it. - the traceability document covers exactly the matrix's invariants; - every recorded decision cites evidence that exists; - every deferral names the tracked file that records it, and that file From ca44484830a525aedd38c03bf2e6845a42383780 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 17 Sep 2026 19:07:44 +0700 Subject: [PATCH 101/115] fix(scheduling): recheck the grant at commit and stop sends past the lease The authorization clock a mutation answers under is observed once more after its tentative writes, immediately before the commit that lands them, so a grant that lapses while a write waits for a lock commits nothing; the six mutation paths and the regressive test that crosses the boundary per operation pin it. Cancellation now guards the policy its cutoff is evaluated under, the same guard admission answers to. The dispatch liveness read refuses an intent whose remaining lease cannot cover one more send deadline, so an overrunning pass hands the send over instead of racing the reclaiming one. Co-Authored-By: Claude Code Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/runtime.rs | 5 +- crates/registry-scheduling/src/store.rs | 45 ++++- .../tests/postgres_commitments.rs | 187 +++++++++++++++++- 3 files changed, 222 insertions(+), 15 deletions(-) diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index 183dd7216e..8779e94da3 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -41,7 +41,7 @@ use crate::auth::SchedulingAuthenticator; use crate::config::{ReminderDestinationConfig, RuntimeConfig, RuntimeConfigError}; use crate::http::{router, HttpState}; use crate::service::SchedulingService; -use crate::store::{OutboxRow, PostgresStore, StoreError}; +use crate::store::{OutboxRow, PostgresStore, StoreError, REMINDER_SEND_TIMEOUT}; /// How often each background loop wakes. The intervals are fixed constants, /// not configuration: they are internal mechanics of one deployment, not an @@ -74,9 +74,6 @@ const INTENT_DISPATCH_LEASE_SECONDS: i64 = 600; const REMINDER_MAXIMUM_BODY_BYTES: usize = 16 * 1024; const REMINDER_MAXIMUM_REQUEST_BYTES: usize = 32 * 1024; const REMINDER_BEARER_MAXIMUM_BYTES: usize = 8192; -/// The whole dispatch of one intent (resolve, connect, send, and the status -/// line) shares one deadline. -const REMINDER_SEND_TIMEOUT: Duration = Duration::from_secs(5); #[must_use] pub fn command() -> Command { diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 702d458724..21136eb144 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -72,6 +72,9 @@ const MIGRATION_LOCK_KEY: i64 = 0x7363_6865_6475_6c65; /// the ASCII bytes of "SCHD". const HOLD_CEILING_LOCK_NAMESPACE: i32 = 0x5343_4844; +/// The send deadline must fit inside the remaining dispatch lease. +pub(crate) const REMINDER_SEND_TIMEOUT: Duration = Duration::from_secs(5); + /// The clock the store reads when it re-checks authorization currency. /// Production observes the system clock; the database suite pins one to /// cross an expiry boundary deterministically inside a transaction. The @@ -974,6 +977,7 @@ impl PostgresStore { json!({"kind": "hold", "claim": claim}), ) .await?; + self.recheck_grant(&commitment)?; transaction.commit().await?; Ok(CommitOutcome::Hold(claim)) } @@ -1066,6 +1070,7 @@ impl PostgresStore { json!({"kind": "booking", "claim": claim}), ) .await?; + self.recheck_grant(&commitment)?; transaction.commit().await?; Ok(CommitOutcome::Booking(claim)) } @@ -1200,6 +1205,7 @@ impl PostgresStore { json!({"kind": "booking", "claim": claim}), ) .await?; + self.recheck_grant(&commitment)?; transaction.commit().await?; Ok(CommitOutcome::Booking(claim)) } @@ -1269,6 +1275,7 @@ impl PostgresStore { Value::Null, ) .await?; + self.recheck_grant(&commitment)?; transaction.commit().await?; Ok(CommitOutcome::Released) } @@ -1406,6 +1413,7 @@ impl PostgresStore { json!({"kind": "booking", "claim": moved}), ) .await?; + self.recheck_grant(&commitment)?; transaction.commit().await?; Ok(CommitOutcome::Booking(moved)) } @@ -1453,6 +1461,16 @@ impl PostgresStore { if appointment.actor != commitment.actor { return Err(CommitError::Unauthorized); } + let current_policy: i64 = transaction + .query_one( + "SELECT policy_revision FROM scheduling_meta WHERE singleton FOR SHARE", + &[], + ) + .await? + .get(0); + if current_policy != commitment.policy_revision { + return Err(AdmissionRefusal::PolicyChanged.into()); + } if let Some(cutoff) = cancellation_cutoff_minutes { let earliest_cancel_end = appointment .displayed_start @@ -1523,6 +1541,7 @@ impl PostgresStore { json!({"kind": "booking", "claim": cancelled.clone()}), ) .await?; + self.recheck_grant(&commitment)?; transaction.commit().await?; Ok(CommitOutcome::Cancelled(cancelled)) } @@ -1617,11 +1636,13 @@ impl PostgresStore { } /// Whether the attempt that claimed an intent still owns a dispatch it - /// should carry out: the row must still be pending at that attempt, and - /// a reminder must still describe its appointment's current revision. - /// A cancellation or reschedule suppresses the row, and a superseding - /// attempt means this one no longer speaks for the intent; either way - /// the send is skipped. + /// should carry out: the row must still be pending at that attempt, the + /// remaining lease must cover one more send deadline, and a reminder + /// must still describe its appointment's current revision. A + /// cancellation or reschedule suppresses the row, a superseding attempt + /// means this one no longer speaks for the intent, and a lease that + /// expired means another pass is entitled to reclaim the intent; any of + /// the three skips the send. pub async fn intent_still_dispatchable( &self, outbox_id: Uuid, @@ -1630,15 +1651,23 @@ impl PostgresStore { let client = self.client().await?; let row = client .query_opt( - "SELECT 1 FROM scheduling_outbox AS o \ + "SELECT o.next_attempt_at FROM scheduling_outbox AS o \ JOIN scheduling_claims AS c ON c.claim_id = o.claim_id \ - WHERE o.outbox_id=$1 AND o.attempts=$2 AND o.delivery_state='pending' \ + WHERE o.outbox_id=$1 AND o.attempts=$2 AND o.attempts > 0 \ + AND o.delivery_state='pending' \ AND (o.purpose <> 'reminder' \ OR (c.state='active' AND c.revision = o.appointment_revision))", &[&outbox_id, &attempts], ) .await?; - Ok(row.is_some()) + let send_deadline = self + .observed_now() + .checked_add_signed( + TimeDelta::from_std(REMINDER_SEND_TIMEOUT) + .map_err(|_| StoreError::Configuration)?, + ) + .ok_or(StoreError::Configuration)?; + Ok(row.is_some_and(|row| row.get::<_, DateTime>(0) > send_deadline)) } /// The intents nothing will deliver without an operator, oldest first. diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index fd80c0fcdd..b0efdad49a 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -3202,6 +3202,119 @@ async fn a_grant_that_lapses_before_the_commit_never_books() { ); } +/// Every mutation must roll back its tentative writes when authorization +/// expires after its admission-time check but before the final commit check. +#[tokio::test] +async fn every_mutation_rechecks_expiry_after_its_writes() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + for operation in [ + "hold", + "create", + "confirm", + "release", + "reschedule", + "cancel", + ] { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 480, 620).await; + let (method, uri, body) = match operation { + "hold" => ( + "POST", + "/v1/holds".to_owned(), + Some(admission(&fx, OFFERING, slot)), + ), + "create" => ( + "POST", + "/v1/appointments".to_owned(), + Some(json!({"hold": null, "admission": admission(&fx, OFFERING, slot)})), + ), + "confirm" | "release" => { + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "setup", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{hold}"); + let id = hold["holdId"].as_str().expect("a hold identifier"); + if operation == "confirm" { + ( + "POST", + "/v1/appointments".to_owned(), + Some(json!({"hold": id, "admission": null})), + ) + } else { + ("DELETE", format!("/v1/holds/{id}"), None) + } + } + "reschedule" | "cancel" => { + let (id, revision) = booked(&fx, 300, 440, "setup").await; + if operation == "reschedule" { + ( + "POST", + format!("/v1/appointments/{id}/reschedule"), + Some(json!({ + "observedRevision": revision, + "admission": admission(&fx, OFFERING, slot), + })), + ) + } else { + ( + "POST", + format!("/v1/appointments/{id}/cancel"), + Some(json!({ + "observedRevision": revision, "reason": null, + })), + ) + } + } + _ => unreachable!(), + }; + // Compare all transactional business effects, not just the status. + // A refusal may add its own refused receipt and denied audit event. + let state = "SELECT jsonb_build_array( \ + (SELECT jsonb_agg(to_jsonb(c) ORDER BY claim_id) FROM scheduling_claims c), \ + (SELECT jsonb_agg(to_jsonb(h) ORDER BY event_id) FROM scheduling_history h), \ + (SELECT jsonb_agg(to_jsonb(o) ORDER BY outbox_id) FROM scheduling_outbox o), \ + (SELECT count(*) FROM scheduling_attempts WHERE status_code BETWEEN 200 AND 299))"; + let before: Value = fx + .admin + .query_one(state, &[]) + .await + .expect("snapshot before mutation") + .get(0); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let valid = Utc::now(); + fx.store.pin_clock(Arc::new(move || { + if observed.fetch_add(1, Ordering::SeqCst) == 0 { + valid + } else { + valid + TimeDelta::minutes(20) + } + })); + let (status, problem) = fx + .request(method, &uri, &fx.agent, Some("expires-after-writes"), body) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{operation}: {problem}"); + assert_eq!(problem["code"], "operation.not-authorized", "{operation}"); + assert!( + calls.load(Ordering::SeqCst) >= 2, + "{operation} observes the final clock" + ); + let after: Value = fx + .admin + .query_one(state, &[]) + .await + .expect("snapshot after refusal") + .get(0); + assert_eq!(after, before, "{operation} rolls back all business writes"); + } +} + /// A running process refuses a commitment even when the caller names the /// stored revision another process just published: the rules this process /// evaluates are not the rules that revision names, and the claim is @@ -3234,6 +3347,41 @@ async fn a_stale_process_refuses_even_a_request_under_the_current_revision() { assert_eq!(problem["code"], "policy.changed"); } +#[tokio::test] +async fn a_stale_process_cannot_cancel_under_its_cached_cutoff() { + let fx = fixture().await; + let (id, revision) = booked(&fx, 480, 620, "before-policy-change").await; + fx.store + .apply_policy( + SCHEDULING_ID, + "policy-with-a-different-cancellation-cutoff", + &["north-counter".to_owned(), "two-counter".to_owned()], + &[], + ) + .await + .expect("publish the replacement policy"); + let (status, problem) = fx + .post( + &format!("/v1/appointments/{id}/cancel"), + &fx.agent, + "stale-policy-cancellation", + json!({"observedRevision": revision, "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{problem}"); + assert_eq!(problem["code"], "policy.changed"); + let row = fx + .admin + .query_one( + "SELECT state, revision FROM scheduling_claims WHERE claim_id=$1", + &[&Uuid::parse_str(&id).expect("appointment id")], + ) + .await + .expect("read unchanged appointment"); + assert_eq!(row.get::<_, String>(0), "active"); + assert_eq!(row.get::<_, i64>(1), i64::try_from(revision).unwrap()); +} + /// A records replacement moves the records revision under the supply /// anchor: a commitment whose facts were resolved before the swap is /// refused whole, rather than naming a resource the deployment retired. @@ -3413,6 +3561,31 @@ async fn a_claimed_intent_is_leased_and_its_outcome_is_fenced_by_the_attempt() { .expect("the owning outcome write runs")); } +#[tokio::test] +async fn an_expired_dispatch_lease_cannot_start_a_send() { + let fx = fixture().await; + let _ = booked(&fx, 90, 200, "expired-dispatch").await; + let due = Utc::now() + TimeDelta::days(1); + let rows = fx + .store + .claim_due_intents(due, 1, TimeDelta::minutes(10)) + .await + .expect("claim one intent"); + let row = rows.first().expect("a due intent"); + for (remaining, dispatchable) in [(6, true), (5, false), (0, false), (-1, false)] { + let now = due + TimeDelta::minutes(10) - TimeDelta::seconds(remaining); + fx.store.pin_clock(Arc::new(move || now)); + assert_eq!( + fx.store + .intent_still_dispatchable(row.outbox_id, row.attempts) + .await + .expect("check dispatch eligibility"), + dispatchable, + "remaining lease seconds: {remaining}" + ); + } +} + /// Suppression wins up to the send: a reminder claimed for dispatch whose /// appointment is then cancelled is skipped, its row is retained as /// accounting, and the in-flight attempt's outcome cannot land. @@ -3480,9 +3653,13 @@ async fn a_suppressed_reminder_is_skipped_and_keeps_its_accounting() { .await .expect("the cancellation intent is minted") .get(0); + fx.store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("lease the cancellation before dispatch"); assert!( fx.store - .intent_still_dispatchable(cancellation, 0) + .intent_still_dispatchable(cancellation, 1) .await .expect("the liveness read runs"), "the cancellation intent stays dispatchable" @@ -3677,6 +3854,10 @@ async fn a_reschedule_suppresses_the_obsolete_reminder_and_mints_its_replacement "the replacement is live for dispatch" ); + fx.store + .claim_due_intents(due, 100, TimeDelta::minutes(10)) + .await + .expect("lease the replacement before dispatch"); // The obsolete reminder is skipped, and the replacement is live. assert!(!fx .store @@ -3685,7 +3866,7 @@ async fn a_reschedule_suppresses_the_obsolete_reminder_and_mints_its_replacement .expect("the liveness read runs")); assert!(fx .store - .intent_still_dispatchable(replacement, 0) + .intent_still_dispatchable(replacement, 1) .await .expect("the liveness read runs")); @@ -3701,7 +3882,7 @@ async fn a_reschedule_suppresses_the_obsolete_reminder_and_mints_its_replacement .expect("age the replacement's revision"); assert!( !fx.store - .intent_still_dispatchable(replacement, 0) + .intent_still_dispatchable(replacement, 1) .await .expect("the liveness read runs"), "a reminder naming an obsolete revision is not sent" From 57c64d76575536e08e02801dd6b3c64a1a6354df Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 17 Sep 2026 20:00:56 +0700 Subject: [PATCH 102/115] fix(scheduling): hold the database suites' opt-in gate under forwarding The database-test isolation gate now reports a maintained database suite whose required-features gate disappeared, and a dependency entry that asks for a feature whose expansion enables a sibling's gate, not only the gate's own name. The known suites are named so losing a gate fails the checkpoint rather than passing silently. Co-Authored-By: Claude Code Signed-off-by: Jeremi Joslin --- .../scripts/check_database_test_isolation.py | 84 ++++++++++++++++++- .../scripts/test_database_test_isolation.py | 42 +++++++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/products/scheduling/scripts/check_database_test_isolation.py b/products/scheduling/scripts/check_database_test_isolation.py index c05d6e26a1..09e41b6c58 100755 --- a/products/scheduling/scripts/check_database_test_isolation.py +++ b/products/scheduling/scripts/check_database_test_isolation.py @@ -28,6 +28,58 @@ def gated_test_targets(metadata: dict) -> dict[str, dict[str, list[str]]]: return gated +# The database suites the product maintains. Each must keep its +# required-features gate: a suite that loses it runs in every workspace test +# invocation and fails for want of a database the caller never offered. +DATABASE_SUITES = { + "registry-scheduling": {"postgres_commitments"}, + "registry-schedulingctl": {"records_apply_postgres", "intents_postgres"}, +} + + +def ungated_database_targets(metadata: dict) -> dict[str, list[str]]: + """Map each package to its declared database suites missing the gate. + + A suite the metadata does not declare at all is absent, not ungated: a + fixture that does not model it, or a suite a later change removed, is + not running anywhere uninvited. Only a declared target with an empty or + absent `required-features` list runs in every test invocation. + """ + ungated: dict[str, list[str]] = {} + for package in metadata["packages"]: + expected = DATABASE_SUITES.get(package["name"]) + if expected is None: + continue + missing_gate = sorted( + target["name"] + for target in package.get("targets", []) + if "test" in target.get("kind", []) + and target["name"] in expected + and not target.get("required-features") + ) + if missing_gate: + ungated[package["name"]] = missing_gate + return ungated + + +def features_enabling(metadata: dict, sibling: str, wanted: str) -> list[str]: + """The sibling's features whose expansion names `wanted`. + + One level of expansion is enough here: a feature whose own expansion + names a gating feature selects the gated targets exactly as naming the + gate itself would, and the gate's subject stays the dependency entry + that asks for it. + """ + for package in metadata["packages"]: + if package["name"] == sibling: + return [ + name + for name, enables in (package.get("features") or {}).items() + if wanted in enables + ] + return [] + + def violations(metadata: dict) -> list[str]: """Report every dependency entry that turns a sibling's gate on. @@ -40,22 +92,46 @@ def violations(metadata: dict) -> list[str]: opt-in the suites are meant to have; this is about what a sibling asks for. """ gated = gated_test_targets(metadata) + ungated = ungated_database_targets(metadata) failures: list[str] = [] for package in metadata["packages"]: if not package["name"].startswith(SCHEDULING_PREFIX): continue + # A database-only suite must stay opt-in. A test target the package + # does not gate runs in every workspace test invocation, including + # the shards with no database to offer. + for target in ungated.get(package["name"], []): + failures.append( + f"{package['name']}/{target} has no required-features gate; " + f"the database suites must stay opt-in" + ) for dependency in package.get("dependencies", []): if not dependency["name"].startswith(SCHEDULING_PREFIX): continue - requested = gated.get(dependency["name"], {}) - for feature in dependency.get("features") or []: - targets = requested.get(feature) + sibling = dependency["name"] + requested = gated.get(sibling, {}) + # Both spellings of the same accident: asking for the gating + # feature itself, or for a feature whose expansion enables it. + # The alias map is keyed by the gate; a forwarded feature lands + # there under the gate it selects. + aliases = { + forwarder: gate + for gate in requested + for forwarder in features_enabling(metadata, sibling, gate) + } + direct = list(dependency.get("features") or []) + asked: list[tuple[str, str]] = [(feature, feature) for feature in direct] + asked.extend( + (feature, aliases[feature]) for feature in direct if feature in aliases + ) + for feature, gate in sorted(set(asked)): + targets = requested.get(gate) if not targets: continue kind = dependency.get("kind") table = f"{kind}-dependencies" if kind else "dependencies" failures.append( - f"{package['name']} enables {dependency['name']}/{feature} in its " + f"{package['name']} enables {sibling}/{feature} in its " f"[{table}] entry, which selects that crate's database-only test " f"target(s) {', '.join(sorted(targets))} in every build holding " f"both crates, including the ones with no database" diff --git a/products/scheduling/scripts/test_database_test_isolation.py b/products/scheduling/scripts/test_database_test_isolation.py index c6f51ca15a..d33cac5950 100644 --- a/products/scheduling/scripts/test_database_test_isolation.py +++ b/products/scheduling/scripts/test_database_test_isolation.py @@ -120,6 +120,46 @@ def test_every_gated_target_is_named_once(self): self.assertIn("postgres_commitments", failures[0]) self.assertIn("postgres_replay", failures[0]) + def test_a_feature_forwarded_through_a_default_features_definition_is_rejected(self): + graph = metadata( + [ + { + "name": "registry-scheduling", + "targets": [ + {"name": "postgres_commitments", "kind": ["test"], "required-features": ["postgres-test"]}, + ], + "features": {"full": ["postgres-test"]}, + }, + package( + "registry-schedulingctl", + {}, + [dependency("registry-scheduling", ["full"], kind="dev")], + ), + ] + ) + failures = MODULE.violations(graph) + self.assertEqual(len(failures), 1) + self.assertIn("registry-scheduling/full", failures[0]) + + def test_an_ungated_database_suite_is_named_for_its_package(self): + graph = metadata( + [ + package("registry-scheduling", {"postgres_commitments": []}, []), + { + "name": "registry-schedulingctl", + "targets": [ + {"name": "records_apply_postgres", "kind": ["test"], "required-features": []}, + ], + "dependencies": [], + }, + ] + ) + failures = MODULE.violations(graph) + self.assertEqual(len(failures), 2) + self.assertIn("registry-scheduling/postgres_commitments", failures[0]) + self.assertIn("registry-schedulingctl/records_apply_postgres", failures[1]) + self.assertIn("required-features", failures[0]) + if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file From 2aeaeacc6d3962557eb2f32bbb1a85dc24da0949 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Thu, 17 Sep 2026 21:12:48 +0700 Subject: [PATCH 103/115] fix(scheduling): prove contract citations selectable and suites opt-in The contracts validator now asks each cited test's own runner for its inventory instead of matching names in source text: cargo lists the selected target's tests under the citation's features, unittest discovers the Python module, and neither executes a test body. The one citation living behind a feature gate names the feature in the matrix and the traceability file. The database-suite gate reads Cargo's resolved default features, so aliasing or forwarding postgres-test into a default build fails the checkpoint, and an ordinary ungated integration test is no longer mistaken for a database suite. Co-Authored-By: Claude Code Signed-off-by: Jeremi Joslin --- .../contracts/security-invariant-matrix.yaml | 1 + .../contracts/security-test-traceability.yaml | 2 +- .../scripts/check_database_test_isolation.py | 141 ++---------- .../scripts/test_database_test_isolation.py | 207 ++++++------------ .../scripts/test_validate_contracts.py | 66 ++++++ .../scheduling/scripts/validate_contracts.py | 120 +++++++++- 6 files changed, 268 insertions(+), 269 deletions(-) diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index f9539fe719..16c0b232a0 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -112,6 +112,7 @@ invariants: negativeTest: path: crates/registry-breg/src/task_grant/tests.rs name: a_scheduling_grant_is_refused_by_the_breg_binding + features: [runtime] - id: SCHEDULING-SEC-07 state: enforced threat: >- diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index fdb4ea6370..9b91220ed9 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -38,7 +38,7 @@ entries: - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_permission_cardinality_bounds_are_enforced} - id: SCHEDULING-SEC-06 tests: - - {path: crates/registry-breg/src/task_grant/tests.rs, name: a_scheduling_grant_is_refused_by_the_breg_binding} + - {path: crates/registry-breg/src/task_grant/tests.rs, name: a_scheduling_grant_is_refused_by_the_breg_binding, features: [runtime]} - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: grant_bounds_variants_are_distinct_and_strict} - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_value_boundaries_and_unknown_fields_are_enforced} - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: the_scheduling_tag_round_trips_through_serde} diff --git a/products/scheduling/scripts/check_database_test_isolation.py b/products/scheduling/scripts/check_database_test_isolation.py index 09e41b6c58..3cb733e445 100755 --- a/products/scheduling/scripts/check_database_test_isolation.py +++ b/products/scheduling/scripts/check_database_test_isolation.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check that no Scheduling crate selects a sibling's database-only tests.""" +"""Keep Scheduling's maintained PostgreSQL suites out of default test runs.""" from __future__ import annotations @@ -9,133 +9,36 @@ import sys from pathlib import Path -# The product this gate speaks for. A dependency on another product's crate is -# that product's business, and its own gates are where it belongs. -SCHEDULING_PREFIX = "registry-scheduling" - - -def gated_test_targets(metadata: dict) -> dict[str, dict[str, list[str]]]: - """Map each package to the feature that gates each of its test targets.""" - gated: dict[str, dict[str, list[str]]] = {} - for package in metadata["packages"]: - for target in package.get("targets", []): - if "test" not in target.get("kind", []): - continue - for feature in target.get("required-features") or []: - gated.setdefault(package["name"], {}).setdefault(feature, []).append( - target["name"] - ) - return gated - - -# The database suites the product maintains. Each must keep its -# required-features gate: a suite that loses it runs in every workspace test -# invocation and fails for want of a database the caller never offered. DATABASE_SUITES = { "registry-scheduling": {"postgres_commitments"}, "registry-schedulingctl": {"records_apply_postgres", "intents_postgres"}, } -def ungated_database_targets(metadata: dict) -> dict[str, list[str]]: - """Map each package to its declared database suites missing the gate. - - A suite the metadata does not declare at all is absent, not ungated: a - fixture that does not model it, or a suite a later change removed, is - not running anywhere uninvited. Only a declared target with an empty or - absent `required-features` list runs in every test invocation. - """ - ungated: dict[str, list[str]] = {} - for package in metadata["packages"]: - expected = DATABASE_SUITES.get(package["name"]) - if expected is None: - continue - missing_gate = sorted( - target["name"] - for target in package.get("targets", []) - if "test" in target.get("kind", []) - and target["name"] in expected - and not target.get("required-features") - ) - if missing_gate: - ungated[package["name"]] = missing_gate - return ungated - - -def features_enabling(metadata: dict, sibling: str, wanted: str) -> list[str]: - """The sibling's features whose expansion names `wanted`. - - One level of expansion is enough here: a feature whose own expansion - names a gating feature selects the gated targets exactly as naming the - gate itself would, and the gate's subject stays the dependency entry - that asks for it. - """ - for package in metadata["packages"]: - if package["name"] == sibling: - return [ - name - for name, enables in (package.get("features") or {}).items() - if wanted in enables - ] - return [] - - def violations(metadata: dict) -> list[str]: - """Report every dependency entry that turns a sibling's gate on. + """Use Cargo's default feature resolution, including aliases and forwarding. - Cargo unifies features across one invocation, so a dependency entry naming - a feature is the same as passing it on the command line for every build - that selects both crates. A feature gating a test target that needs a - disposable PostgreSQL server therefore stops being opt-in: the database-free - shard compiles and runs the suite, and it fails for want of a server the - caller never offered. The feature the package declares for itself is the - opt-in the suites are meant to have; this is about what a sibling asks for. + Only maintained database targets need a gate. Ordinary integration tests + remain database-free and may run without required-features. """ - gated = gated_test_targets(metadata) - ungated = ungated_database_targets(metadata) + resolved = { + node["id"]: set(node["features"]) + for node in metadata["resolve"]["nodes"] + } failures: list[str] = [] for package in metadata["packages"]: - if not package["name"].startswith(SCHEDULING_PREFIX): - continue - # A database-only suite must stay opt-in. A test target the package - # does not gate runs in every workspace test invocation, including - # the shards with no database to offer. - for target in ungated.get(package["name"], []): - failures.append( - f"{package['name']}/{target} has no required-features gate; " - f"the database suites must stay opt-in" - ) - for dependency in package.get("dependencies", []): - if not dependency["name"].startswith(SCHEDULING_PREFIX): + expected = DATABASE_SUITES.get(package["name"], set()) + for target in package.get("targets", []): + if "test" not in target.get("kind", []) or target["name"] not in expected: continue - sibling = dependency["name"] - requested = gated.get(sibling, {}) - # Both spellings of the same accident: asking for the gating - # feature itself, or for a feature whose expansion enables it. - # The alias map is keyed by the gate; a forwarded feature lands - # there under the gate it selects. - aliases = { - forwarder: gate - for gate in requested - for forwarder in features_enabling(metadata, sibling, gate) - } - direct = list(dependency.get("features") or []) - asked: list[tuple[str, str]] = [(feature, feature) for feature in direct] - asked.extend( - (feature, aliases[feature]) for feature in direct if feature in aliases - ) - for feature, gate in sorted(set(asked)): - targets = requested.get(gate) - if not targets: - continue - kind = dependency.get("kind") - table = f"{kind}-dependencies" if kind else "dependencies" - failures.append( - f"{package['name']} enables {sibling}/{feature} in its " - f"[{table}] entry, which selects that crate's database-only test " - f"target(s) {', '.join(sorted(targets))} in every build holding " - f"both crates, including the ones with no database" - ) + label = f"{package['name']}/{target['name']}" + required = set(target.get("required-features") or []) + if "postgres-test" not in required: + failures.append(f"{label} must require the postgres-test feature") + elif package["id"] not in resolved: + failures.append(f"{label} has no resolved feature inventory") + elif required <= resolved[package["id"]]: + failures.append(f"{label} is enabled by default resolved features") return failures @@ -143,7 +46,7 @@ def load_metadata(repository_root: Path, fixture: Path | None) -> dict: if fixture: return json.loads(fixture.read_text(encoding="utf-8")) completed = subprocess.run( - ["cargo", "metadata", "--locked", "--format-version", "1", "--no-deps"], + ["cargo", "metadata", "--locked", "--format-version", "1"], cwd=repository_root, check=True, capture_output=True, @@ -159,14 +62,14 @@ def main() -> int: repository_root = Path(__file__).resolve().parents[3] try: failures = violations(load_metadata(repository_root, args.metadata)) - except (OSError, ValueError, KeyError, json.JSONDecodeError, subprocess.CalledProcessError) as error: + except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error: print(f"database-test isolation check could not inspect Cargo metadata: {error}", file=sys.stderr) return 2 if failures: for failure in failures: print(f"database-test isolation violation: {failure}", file=sys.stderr) return 1 - print("No Scheduling crate enables a sibling's database-only test feature.") + print("Scheduling database suites remain opt-in under default Cargo resolution.") return 0 diff --git a/products/scheduling/scripts/test_database_test_isolation.py b/products/scheduling/scripts/test_database_test_isolation.py index d33cac5950..ba7dd98559 100644 --- a/products/scheduling/scripts/test_database_test_isolation.py +++ b/products/scheduling/scripts/test_database_test_isolation.py @@ -1,8 +1,11 @@ #!/usr/bin/env python3 import importlib.util +import json +import subprocess +import tempfile import unittest from pathlib import Path - +from unittest.mock import patch SCRIPT = Path(__file__).with_name("check_database_test_isolation.py") SPEC = importlib.util.spec_from_file_location("scheduling_database_test_isolation", SCRIPT) @@ -11,155 +14,69 @@ SPEC.loader.exec_module(MODULE) -def package(name: str, gated: dict[str, list[str]], dependencies: list[dict]) -> dict: +def metadata(features=(), required=("postgres-test",), target="postgres_commitments"): return { - "name": name, - "targets": [ - {"name": target, "kind": ["test"], "required-features": features} - for target, features in gated.items() - ], - "dependencies": dependencies, + "packages": [{"id": "runtime", "name": "registry-scheduling", "targets": [ + {"name": target, "kind": ["test"], "required-features": list(required)}, + ]}], + "resolve": {"nodes": [{"id": "runtime", "features": list(features)}]}, } -def dependency(name: str, features: list[str], kind: str | None = None) -> dict: - return {"name": name, "kind": kind, "features": features} - - -def metadata(packages: list[dict]) -> dict: - return {"packages": packages} - - class DatabaseTestIsolationTests(unittest.TestCase): - def test_an_opt_in_feature_chain_is_accepted(self): - graph = metadata( - [ - package("registry-scheduling", {"postgres_commitments": ["postgres-test"]}, []), - package( - "registry-schedulingctl", - {"records_apply_postgres": ["postgres-test"]}, - [dependency("registry-scheduling", [])], - ), - ] - ) - self.assertEqual(MODULE.violations(graph), []) - - def test_a_dev_dependency_selecting_a_siblings_database_tests_is_rejected(self): - graph = metadata( - [ - package("registry-scheduling", {"postgres_commitments": ["postgres-test"]}, []), - package( - "registry-schedulingctl", - {"records_apply_postgres": ["postgres-test"]}, - [dependency("registry-scheduling", ["postgres-test"], kind="dev")], - ), - ] - ) - failures = MODULE.violations(graph) - self.assertEqual(len(failures), 1) - self.assertIn("registry-schedulingctl", failures[0]) - self.assertIn("registry-scheduling/postgres-test", failures[0]) - self.assertIn("postgres_commitments", failures[0]) - - def test_an_ordinary_dependency_selecting_them_is_rejected_too(self): - graph = metadata( - [ - package("registry-scheduling", {"postgres_commitments": ["postgres-test"]}, []), - package( - "registry-schedulingctl", - {}, - [dependency("registry-scheduling", ["postgres-test"])], - ), - ] - ) - self.assertEqual(len(MODULE.violations(graph)), 1) - - def test_a_feature_that_gates_no_test_target_is_not_the_subject(self): - graph = metadata( - [ - package("registry-scheduling", {"postgres_commitments": ["postgres-test"]}, []), - package( - "registry-schedulingctl", - {}, - [dependency("registry-scheduling", ["schema"], kind="dev")], - ), - ] - ) - self.assertEqual(MODULE.violations(graph), []) - - def test_a_non_scheduling_dependency_is_left_to_its_own_product(self): - graph = metadata( - [ - package("registry-breg", {"postgres_kernel": ["postgres-test"]}, []), - package( - "registry-casework", - {}, - [dependency("registry-breg", ["postgres-test"], kind="dev")], - ), - ] - ) - self.assertEqual(MODULE.violations(graph), []) - - def test_every_gated_target_is_named_once(self): - graph = metadata( - [ - package( - "registry-scheduling", - {"postgres_commitments": ["postgres-test"], "postgres_replay": ["postgres-test"]}, - [], - ), - package( - "registry-schedulingctl", - {}, - [dependency("registry-scheduling", ["postgres-test"], kind="dev")], - ), - ] - ) - failures = MODULE.violations(graph) - self.assertEqual(len(failures), 1) - self.assertIn("postgres_commitments", failures[0]) - self.assertIn("postgres_replay", failures[0]) - - def test_a_feature_forwarded_through_a_default_features_definition_is_rejected(self): - graph = metadata( - [ - { - "name": "registry-scheduling", - "targets": [ - {"name": "postgres_commitments", "kind": ["test"], "required-features": ["postgres-test"]}, - ], - "features": {"full": ["postgres-test"]}, - }, - package( - "registry-schedulingctl", - {}, - [dependency("registry-scheduling", ["full"], kind="dev")], - ), - ] - ) - failures = MODULE.violations(graph) - self.assertEqual(len(failures), 1) - self.assertIn("registry-scheduling/full", failures[0]) - - def test_an_ungated_database_suite_is_named_for_its_package(self): - graph = metadata( - [ - package("registry-scheduling", {"postgres_commitments": []}, []), - { - "name": "registry-schedulingctl", - "targets": [ - {"name": "records_apply_postgres", "kind": ["test"], "required-features": []}, - ], - "dependencies": [], - }, - ] - ) - failures = MODULE.violations(graph) - self.assertEqual(len(failures), 2) - self.assertIn("registry-scheduling/postgres_commitments", failures[0]) - self.assertIn("registry-schedulingctl/records_apply_postgres", failures[1]) - self.assertIn("required-features", failures[0]) + def test_explicit_opt_in_is_not_enabled_by_default(self): + self.assertEqual(MODULE.violations(metadata()), []) + + def test_resolved_default_features_cannot_enable_a_database_suite(self): + self.assertTrue(MODULE.violations(metadata(features=["default", "postgres-test"]))) + + def test_a_database_suite_cannot_lose_its_gate(self): + self.assertTrue(MODULE.violations(metadata(required=[]))) + + def test_an_unrelated_feature_cannot_replace_the_database_gate(self): + self.assertTrue(MODULE.violations(metadata(required=["schema"]))) + + def test_an_ordinary_integration_test_does_not_need_a_gate(self): + self.assertEqual(MODULE.violations(metadata(required=[], target="http_boundary")), []) + + def test_all_required_features_must_be_enabled(self): + self.assertEqual(MODULE.violations(metadata( + features=["postgres-test"], required=["postgres-test", "tooling"], + )), []) + + def test_missing_resolution_fails_visibly(self): + graph = metadata() + graph["resolve"]["nodes"] = [] + self.assertIn("no resolved feature inventory", MODULE.violations(graph)[0]) + + def test_metadata_loader_keeps_the_default_resolved_graph(self): + with patch.object(MODULE.subprocess, "run") as run: + run.return_value.stdout = json.dumps(metadata()) + MODULE.load_metadata(Path("."), None) + command = run.call_args.args[0] + self.assertNotIn("--no-deps", command) + self.assertNotIn("--all-features", command) + self.assertNotIn("--no-default-features", command) + + def test_cargo_resolves_defaults_and_transitive_forwarding(self): + with tempfile.TemporaryDirectory(prefix="scheduling-feature-test.") as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "tests").mkdir() + (root / "src/lib.rs").write_text("", encoding="utf-8") + (root / "tests/postgres_commitments.rs").write_text("", encoding="utf-8") + for default, expected in [('[]', False), ('["full"]', True)]: + (root / "Cargo.toml").write_text( + '[package]\nname="registry-scheduling"\nversion="0.1.0"\nedition="2021"\n' + '[workspace]\n[features]\n' + f'default={default}\nfull=["alias"]\nalias=["postgres-test"]\npostgres-test=[]\n' + '[[test]]\nname="postgres_commitments"\nrequired-features=["postgres-test"]\n', + encoding="utf-8", + ) + subprocess.run(["cargo", "generate-lockfile", "--offline"], cwd=root, check=True, + capture_output=True, text=True) + self.assertEqual(bool(MODULE.violations(MODULE.load_metadata(root, None))), expected) if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/products/scheduling/scripts/test_validate_contracts.py b/products/scheduling/scripts/test_validate_contracts.py index 5ddb1b222a..b59602f600 100644 --- a/products/scheduling/scripts/test_validate_contracts.py +++ b/products/scheduling/scripts/test_validate_contracts.py @@ -149,6 +149,72 @@ def validate_tree(tree: dict[str, dict], files: dict[str, str]) -> list[str]: } +class RunnerValidation(unittest.TestCase): + def test_python_discovery_rejects_an_ordinary_method(self) -> None: + with tempfile.TemporaryDirectory(prefix="scheduling-python-inventory.") as directory: + root = Path(directory) + source = root / "test_fixture.py" + source.write_text( + 'import unittest\nclass Tests(unittest.TestCase):\n' + ' def test_selectable(self): raise AssertionError("must not execute")\n' + ' def ordinary_method(self): pass\n', encoding="utf-8", + ) + self.assertEqual(validator.runner_violations(root, [ + {"path": source.name, "name": "test_selectable"}, + ]), []) + failures = validator.runner_violations(root, [ + {"path": source.name, "name": "ordinary_method"}, + ]) + self.assertEqual(len(failures), 1, failures) + self.assertIn("not selectable", failures[0]) + + def test_a_feature_gated_library_citation_names_its_feature(self) -> None: + with tempfile.TemporaryDirectory(prefix="scheduling-feature-inventory.") as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "Cargo.toml").write_text( + '[package]\nname = "citation-fixture"\nversion = "0.1.0"\nedition = "2021"\n' + '[workspace]\n[features]\nruntime = []\n', encoding="utf-8", + ) + (root / "Cargo.lock").write_text( + 'version = 3\n[[package]]\nname = "citation-fixture"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + (root / "src/lib.rs").write_text( + '#[cfg(feature = "runtime")]\n#[test]\n' + 'fn selectable() { panic!("must not execute"); }\n', encoding="utf-8", + ) + citation = {"path": "src/lib.rs", "name": "selectable"} + self.assertTrue(validator.runner_violations(root, [citation])) + citation["features"] = ["runtime"] + self.assertEqual(validator.runner_violations(root, [citation]), []) + + def test_rust_citation_must_be_listed_by_its_runner(self) -> None: + with tempfile.TemporaryDirectory(prefix="scheduling-runner-test.") as directory: + root = Path(directory) + (root / "src").mkdir() + (root / "Cargo.toml").write_text( + '[package]\nname = "citation-fixture"\nversion = "0.1.0"\nedition = "2021"\n' + '[workspace]\n', encoding="utf-8", + ) + (root / "Cargo.lock").write_text( + 'version = 3\n[[package]]\nname = "citation-fixture"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + (root / "src/lib.rs").write_text( + '#[test]\nfn selectable() { panic!("inventory must not execute tests"); }\n' + 'pub fn ordinary_function() {}\n', encoding="utf-8", + ) + self.assertEqual(validator.runner_violations(root, [ + {"path": "src/lib.rs", "name": "selectable"}, + ]), []) + failures = validator.runner_violations(root, [ + {"path": "src/lib.rs", "name": "ordinary_function"}, + ]) + self.assertEqual(len(failures), 1, failures) + self.assertIn("not selectable", failures[0]) + + class MatrixValidation(unittest.TestCase): def test_a_consistent_tree_has_no_violations(self) -> None: self.assertEqual(validate_tree(happy_tree(), DEFAULT_FILES), []) diff --git a/products/scheduling/scripts/validate_contracts.py b/products/scheduling/scripts/validate_contracts.py index 0bf4df3f3a..b049184008 100644 --- a/products/scheduling/scripts/validate_contracts.py +++ b/products/scheduling/scripts/validate_contracts.py @@ -7,10 +7,10 @@ - every security invariant names a threat, an enforcement point, a refusal, and a negative test; -- every cited test exists in the cited file, as a Rust test function or a - Python test method. That is source-reference consistency: it proves the - citation points at maintained source, not that the citation's runner - selects or compiles it. +- every cited test exists in the cited file and is selectable by its runner. + Rust inventories compile the selected target with its required features; + citations may add features for gated library modules. Python inventories + use unittest discovery. Neither inventory executes test bodies. - the traceability document covers exactly the matrix's invariants; - every recorded decision cites evidence that exists; - every deferral names the tracked file that records it, and that file @@ -23,8 +23,11 @@ from __future__ import annotations +import json import re +import subprocess import sys +import tomllib from pathlib import Path from typing import Callable @@ -214,6 +217,112 @@ def validate( ] +def runner_violations(root: Path, citations: list[dict]) -> list[str]: + """List tests, never execute them, caching each runner within this check.""" + inventories: dict[tuple[str, ...], set[str]] = {} + failures: list[str] = [] + for path, name, features in sorted({ + (test["path"], test["name"], tuple(test.get("features", []))) for test in citations + }): + source = (root / path).resolve() + if not source.is_relative_to(root.resolve()) or not source.is_file(): + failures.append(f"{path}: cited test source is missing or outside the repository") + continue + try: + if source.suffix == ".rs": + manifest = next( + (parent / "Cargo.toml" for parent in source.parents + if parent.is_relative_to(root.resolve()) + and (parent / "Cargo.toml").is_file()), + None, + ) + if manifest is None: + raise ValueError("no Cargo manifest for cited test") + package = tomllib.loads(manifest.read_text(encoding="utf-8")) + command = ["cargo", "test", "--locked", "--manifest-path", str(manifest)] + relative = source.relative_to(manifest.parent) + targets = package.get("test", []) + target = next((target for target in targets if + target.get("path", f"tests/{target['name']}.rs") == relative.as_posix()), None) + if target is not None or relative.parts[0] == "tests": + if target is None and len(relative.parts) != 2: + raise ValueError("cite the integration test target source") + target = target or {"name": source.stem} + command.extend(["--test", target["name"]]) + if target.get("required-features"): + command.extend(["--features", ",".join(target["required-features"])]) + else: + command.append("--lib") + if features: + command.extend(["--features", ",".join(features)]) + command.extend(["--", "--list", "--format", "terse"]) + key = tuple(command) + if key not in inventories: + result = subprocess.run(command, cwd=root, check=True, + capture_output=True, text=True) + inventories[key] = { + line.removesuffix(": test") for line in result.stdout.splitlines() + if line.endswith(": test") + } + matches = [test for test in inventories[key] + if test == name or test.endswith(f"::{name}")] + elif source.suffix == ".py": + # A separate interpreter keeps discovery imports and load_tests + # hooks out of the validator's own module namespace. + command = [sys.executable, "-c", PYTHON_TEST_INVENTORY, str(source)] + key = tuple(command) + if key not in inventories: + result = subprocess.run(command, cwd=root, check=True, + capture_output=True, text=True) + inventories[key] = set(json.loads(result.stdout)) + matches = [test for test in inventories[key] + if test == name or test.endswith(f".{name}")] + else: + raise ValueError("cited file is neither Rust nor Python") + if len(matches) != 1: + failures.append(f"{path}: {name} is not selectable as one test by its runner") + except (OSError, ValueError, subprocess.CalledProcessError) as error: + # Do not turn a failed build/import into an empty passing inventory. + failures.append(f"{path}: test inventory failed ({type(error).__name__})") + return failures + + +PYTHON_TEST_INVENTORY = """\ +import inspect +import json +from pathlib import Path +import sys +import unittest + +source = Path(sys.argv[1]).resolve() +loader = unittest.TestLoader() +suite = loader.discover(str(source.parent), pattern=source.name) +if loader.errors: + raise RuntimeError("unittest discovery failed") + +def names(suite): + for test in suite: + if isinstance(test, unittest.TestSuite): + yield from names(test) + else: + method = getattr(test, test._testMethodName) + if Path(inspect.getfile(method)).resolve() == source: + yield test.id() + +print(json.dumps(list(names(suite)))) +""" + + +def cited_tests(value: object) -> list[dict]: + if isinstance(value, dict): + if "path" in value and "name" in value: + return [value] + return [test for child in value.values() for test in cited_tests(child)] + if isinstance(value, list): + return [test for child in value for test in cited_tests(child)] + return [] + + def main() -> int: root = Path(__file__).resolve().parents[3] contracts = root / "products" / "scheduling" / "contracts" @@ -247,6 +356,9 @@ def resolve(path: str) -> str | None: resolve, ) + if not failures: + failures = runner_violations(root, cited_tests(loaded)) + if failures: for failure in failures: print(f"contracts: {failure}", file=sys.stderr) From 60b7c062dfae9beb493ad26350c440aa979a5ce7 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 01:16:39 +0700 Subject: [PATCH 104/115] feat(scheduling): deliver appointment observer hooks Signed-off-by: Jeremi Joslin --- AGENTS.md | 12 +- Cargo.lock | 1 + .../src/delivery_schema.rs | 22 +- .../src/diagnostics.rs | 26 +- crates/registry-scheduling-core/src/policy.rs | 280 +++- crates/registry-scheduling/Cargo.toml | 1 + crates/registry-scheduling/src/config.rs | 117 +- crates/registry-scheduling/src/hooks.rs | 1204 +++++++++++++++++ crates/registry-scheduling/src/lib.rs | 1 + crates/registry-scheduling/src/runtime.rs | 117 +- crates/registry-scheduling/src/service.rs | 21 +- crates/registry-scheduling/src/store.rs | 97 +- .../tests/postgres_commitments.rs | 576 +++++++- .../registry-schedulingctl/src/templates.rs | 17 +- .../reference/apis/registry-scheduling.mdx | 14 +- products/scheduling/CHANGELOG.md | 12 +- products/scheduling/README.md | 35 +- products/scheduling/RUNTIME-CONFIG.md | 31 +- .../contracts/security-invariant-matrix.yaml | 19 + .../contracts/security-test-traceability.yaml | 19 +- products/scheduling/demo/support/demo.py | 1 + .../runtime.example.yaml | 17 +- .../generated/runtime/runtime.schema.json | 44 + 23 files changed, 2560 insertions(+), 124 deletions(-) create mode 100644 crates/registry-scheduling/src/hooks.rs diff --git a/AGENTS.md b/AGENTS.md index 8ee885b45c..51b0b800a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,11 +150,13 @@ identifier, a hold or appointment is a claim against that supply inside one capacity transaction, and every mutation carries a task grant matched against the offering's service, location, and action before that transaction opens, with the grant's expiry re-checked inside it immediately before the claim -commits. Reminder delivery and retention are outbox work, never inline side -effects, and the runtime has no scripting engine: the reserved hook ABI -`registry.scheduling-hook/v1` exists on the authored form only. The -source-neutral core and the client must not depend on the runtime or another -product's protocol types. +commits. Reminder delivery, observer-hook delivery, and retention are outbox +work, never inline side effects. Scheduling accepts only `after` URL observers +for `appointment.confirmed`, `appointment.rescheduled`, and +`appointment.cancelled`, with the product-owned closed projections documented +under `products/scheduling`. It refuses conditions, principals, local handlers, +and every proposal returned by an observer. The source-neutral core and the +client must not depend on the runtime or another product's protocol types. For the MVP, no scheduling crate may reach a BReg, Casework, or Evidence crate in either direction, and the dependency-direction gate enforces that on the diff --git a/Cargo.lock b/Cargo.lock index f8ab7eaa94..13b62f8e12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6110,6 +6110,7 @@ dependencies = [ "registry-platform-canonical-json", "registry-platform-config", "registry-platform-crypto", + "registry-platform-hooks", "registry-platform-httpsec", "registry-platform-httputil", "registry-platform-oidc", diff --git a/crates/registry-platform-hooks/src/delivery_schema.rs b/crates/registry-platform-hooks/src/delivery_schema.rs index d0807409c5..ec3bcbf25d 100644 --- a/crates/registry-platform-hooks/src/delivery_schema.rs +++ b/crates/registry-platform-hooks/src/delivery_schema.rs @@ -31,7 +31,10 @@ const DELIVERY_STATEMENTS: &[&str] = &[ outbox_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_id uuid NOT NULL UNIQUE, event_type text NOT NULL CHECK (event_type <> ''), - trigger text NOT NULL CHECK (trigger IN ('created', 'patched', 'tombstoned', 'request_lifecycle')), + trigger text NOT NULL + CONSTRAINT registry_outbox_trigger_check CHECK ( + trigger <> '' AND octet_length(trigger) <= 128 + ), entity_id text NOT NULL CHECK (entity_id <> ''), record_reference text NOT NULL CHECK (record_reference <> ''), record_revision bigint NOT NULL CHECK (record_revision > 0), @@ -245,8 +248,9 @@ const DELIVERY_STATEMENTS: &[&str] = &[ " ALTER TABLE {schema}.registry_outbox DROP CONSTRAINT IF EXISTS registry_outbox_trigger_check;", " ALTER TABLE {schema}.registry_outbox - ADD CONSTRAINT registry_outbox_trigger_check - CHECK (trigger IN ('created', 'patched', 'tombstoned', 'request_lifecycle'));", + ADD CONSTRAINT registry_outbox_trigger_check CHECK ( + trigger <> '' AND octet_length(trigger) <= 128 + );", " UPDATE {schema}.registry_outbox SET payload_expires_at = created_at + interval '7 days' WHERE payload_expires_at IS NULL;", @@ -742,6 +746,18 @@ mod tests { assert_eq!(bounds, 2); } + #[test] + fn the_shared_outbox_bounds_but_does_not_own_product_triggers() { + let statements = rendered(KERNEL_SCHEMA).join("\n"); + assert!(statements.contains("trigger <> '' AND octet_length(trigger) <= 128")); + for product_trigger in ["created", "patched", "tombstoned", "request_lifecycle"] { + assert!( + !statements.contains(&format!("trigger IN ('{product_trigger}'")), + "the shared schema must not freeze {product_trigger} as platform vocabulary" + ); + } + } + #[test] fn a_local_handler_row_carries_no_destination() { let statements = rendered(KERNEL_SCHEMA); diff --git a/crates/registry-scheduling-core/src/diagnostics.rs b/crates/registry-scheduling-core/src/diagnostics.rs index 141fda9ceb..7acded8730 100644 --- a/crates/registry-scheduling-core/src/diagnostics.rs +++ b/crates/registry-scheduling-core/src/diagnostics.rs @@ -54,10 +54,22 @@ pub enum PolicyCheckReason { /// Two declarations draw on the same supply without an attributable /// partition of it. SharedSupplyUnpartitioned, - /// A hook declares an ABI other than the one reserved scheduling ABI. + /// A local hook omits or changes the shared handler ABI. UnsupportedHookAbi, - /// This version has no hook engine, so a declared hook can never run. - HooksUnsupported, + /// A hook phase and handler kind cannot run together. + UnsupportedHookPhase, + /// A hook trigger is outside Scheduling's closed lifecycle vocabulary. + UnsupportedHookTrigger, + /// Scheduling's observer slice does not evaluate hook conditions. + UnsupportedHookCondition, + /// Scheduling observer hooks cannot carry proposal authority. + UnsupportedHookPrincipal, + /// A projected field is unavailable for the selected Scheduling trigger. + UnsupportedHookProjection, + /// A URL hook names no valid deployment-owned logical destination. + InvalidHookDestination, + /// A shared hook declaration violates a rule unknown to this product. + InvalidHookDeclaration, /// This version reads no leftover policy, so a declared one never /// applies. LeftoverUnsupported, @@ -87,7 +99,13 @@ impl PolicyCheckReason { Self::SubquotaOverdrawn => "subquota-overdrawn", Self::SharedSupplyUnpartitioned => "shared-supply-unpartitioned", Self::UnsupportedHookAbi => "unsupported-hook-abi", - Self::HooksUnsupported => "hooks-unsupported", + Self::UnsupportedHookPhase => "unsupported-hook-phase", + Self::UnsupportedHookTrigger => "unsupported-hook-trigger", + Self::UnsupportedHookCondition => "unsupported-hook-condition", + Self::UnsupportedHookPrincipal => "unsupported-hook-principal", + Self::UnsupportedHookProjection => "unsupported-hook-projection", + Self::InvalidHookDestination => "invalid-hook-destination", + Self::InvalidHookDeclaration => "invalid-hook-declaration", Self::LeftoverUnsupported => "leftover-unsupported", } } diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 19a9a97a02..6cd4223f5c 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -11,6 +11,7 @@ //! resolves those identifiers. use chrono::{DateTime, Utc}; +use registry_platform_hooks::{validate_hooks, HookHandlerSource, HookPhase, HookValidationError}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -292,17 +293,25 @@ pub struct HoldPolicy { pub because: String, } -/// A declared lifecycle hook. This version has no hook engine, so a declared -/// hook always fails the policy check: refusing what cannot run beats -/// accepting what would silently not run. -#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct HookPolicy { - pub id: String, - /// Must be the one reserved scheduling hook ABI. - pub abi: String, - pub because: String, -} +/// The shared Registry Stack hook declaration shape. Scheduling adds a closed +/// observer contract: after-commit URL handlers over three appointment +/// lifecycle triggers, with no condition or proposal principal. +pub type HookPolicy = registry_platform_hooks::HookDeclaration; + +pub const APPOINTMENT_CONFIRMED_TRIGGER: &str = "appointment.confirmed"; +pub const APPOINTMENT_RESCHEDULED_TRIGGER: &str = "appointment.rescheduled"; +pub const APPOINTMENT_CANCELLED_TRIGGER: &str = "appointment.cancelled"; + +const APPOINTMENT_OBSERVER_FIELDS: &[&str] = &[ + "appointmentId", + "end", + "offering", + "policyRevision", + "revision", + "start", + "state", +]; +const CANCELLATION_OBSERVER_FIELDS: &[&str] = &["appointmentId", "revision", "state"]; /// The authored scheduling policy package. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] @@ -557,22 +566,13 @@ impl SchedulingPolicy { )); } - if !self.hooks.is_empty() { - findings.push(SchedulingDiagnostic::new( - "hooks", - PolicyCheckReason::HooksUnsupported, - )); - } + check_collection_bound(&self.hooks, "hooks", &mut findings); for (index, hook) in self.hooks.iter().enumerate() { - let path = format!("hooks[{index}]"); - if hook.abi != crate::naming::SCHEDULING_HOOK_ABI { - findings.push(SchedulingDiagnostic::new( - format!("{path}.abi"), - PolicyCheckReason::UnsupportedHookAbi, - )); - } - check_identifier(&hook.id, &format!("{path}.id"), &mut findings); - check_because(&hook.because, &format!("{path}.because"), &mut findings); + check_identifier(&hook.id, &format!("hooks[{index}].id"), &mut findings); + check_scheduling_hook(hook, index, &mut findings); + } + if let Err(error) = validate_hooks(&self.hooks) { + findings.push(hook_validation_diagnostic(&error)); } findings @@ -905,6 +905,75 @@ impl SchedulingPolicy { } } +fn check_scheduling_hook( + hook: &HookPolicy, + index: usize, + findings: &mut Vec, +) { + let path = format!("hooks[{index}]"); + if hook.phase != HookPhase::After || !matches!(hook.handler, HookHandlerSource::Url { .. }) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.phase"), + PolicyCheckReason::UnsupportedHookPhase, + )); + } + if let HookHandlerSource::Url { destination_id } = &hook.handler { + if !valid_hook_destination_id(destination_id) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.handler.destinationId"), + PolicyCheckReason::InvalidHookDestination, + )); + } + } + let allowed_projection = match hook.trigger.as_str() { + APPOINTMENT_CONFIRMED_TRIGGER | APPOINTMENT_RESCHEDULED_TRIGGER => { + Some(APPOINTMENT_OBSERVER_FIELDS) + } + APPOINTMENT_CANCELLED_TRIGGER => Some(CANCELLATION_OBSERVER_FIELDS), + _ => None, + }; + let Some(allowed_projection) = allowed_projection else { + findings.push(SchedulingDiagnostic::new( + format!("{path}.trigger"), + PolicyCheckReason::UnsupportedHookTrigger, + )); + return; + }; + if hook.when.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.when"), + PolicyCheckReason::UnsupportedHookCondition, + )); + } + if hook.principal.is_some() { + findings.push(SchedulingDiagnostic::new( + format!("{path}.principal"), + PolicyCheckReason::UnsupportedHookPrincipal, + )); + } + for field in &hook.projection { + if !allowed_projection.contains(&field.as_str()) { + findings.push(SchedulingDiagnostic::new( + format!("{path}.projection"), + PolicyCheckReason::UnsupportedHookProjection, + )); + break; + } + } +} + +fn valid_hook_destination_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + /// One window, or one channel slice of a window, whose proposed capacity fell /// below what is already committed. #[derive(Clone, Debug, Eq, PartialEq)] @@ -1098,6 +1167,29 @@ fn service_ids_contains(policy: &SchedulingPolicy, id: &str) -> bool { policy.services.iter().any(|service| service.id == id) } +fn hook_validation_diagnostic(error: &HookValidationError) -> SchedulingDiagnostic { + match error { + HookValidationError::EmptyHookId { index } => SchedulingDiagnostic::new( + format!("hooks[{index}].id"), + PolicyCheckReason::InvalidIdentifier, + ), + HookValidationError::DuplicateHookId { index, .. } => SchedulingDiagnostic::new( + format!("hooks[{index}].id"), + PolicyCheckReason::DuplicateIdentifier, + ), + HookValidationError::BeforePhaseRemoteHandler { index, .. } => SchedulingDiagnostic::new( + format!("hooks[{index}].phase"), + PolicyCheckReason::UnsupportedHookPhase, + ), + HookValidationError::AbiMissing { index, .. } + | HookValidationError::AbiUnknown { index, .. } => SchedulingDiagnostic::new( + format!("hooks[{index}].handler.abi"), + PolicyCheckReason::UnsupportedHookAbi, + ), + _ => SchedulingDiagnostic::new("hooks", PolicyCheckReason::InvalidHookDeclaration), + } +} + fn push_unique( seen: &mut Vec, id: &str, @@ -1911,21 +2003,135 @@ holdPolicy: } #[test] - fn declared_hooks_are_refused_because_nothing_can_run_them() { + fn scheduling_observer_hooks_accept_only_the_closed_runtime_contract() { let mut policy = minimal_exact_time_policy(); - policy.hooks = vec![HookPolicy { - id: "on-hold".to_owned(), - abi: crate::naming::SCHEDULING_HOOK_ABI.to_owned(), - because: "A future notification hook.".to_owned(), - }]; - let findings = policy.check(); - let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); - assert!(rendered.contains(&"hooks: hooks-unsupported".to_owned())); + policy.hooks = vec![serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + })) + .expect("the shared declaration shape parses")]; + assert!(policy.check().is_empty()); - policy.hooks[0].abi = "vendor.hook/v9".to_owned(); + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "wasm", "module": "observer.wasm", "abi": "vendor.hook/v9"}, + })) + .expect("the declaration shape parses before semantic validation"); let findings = policy.check(); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); - assert!(rendered.contains(&"hooks[0].abi: unsupported-hook-abi".to_owned())); + assert!(rendered.contains(&"hooks[0].handler.abi: unsupported-hook-abi".to_owned())); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "before", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + })) + .expect("the declaration shape parses before semantic validation"); + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"hooks[0].phase: unsupported-hook-phase".to_owned())); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "rhai", "script": "observer.rhai"}, + })) + .expect("the declaration shape parses before semantic validation"); + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"hooks[0].handler.abi: unsupported-hook-abi".to_owned())); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "https://receiver.example"}, + })) + .expect("the declaration shape parses before product validation"); + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered + .contains(&"hooks[0].handler.destinationId: invalid-hook-destination".to_owned())); + + policy.hooks[0] = serde_json::from_value(serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + })) + .expect("the shared declaration shape parses"); + policy.hooks.push(policy.hooks[0].clone()); + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"hooks[1].id: duplicate-identifier".to_owned())); + + for (member, value, expected) in [ + ( + "trigger", + serde_json::json!("hold.expired"), + "hooks[0].trigger: unsupported-hook-trigger", + ), + ( + "when", + serde_json::json!({"state": "active"}), + "hooks[0].when: unsupported-hook-condition", + ), + ( + "principal", + serde_json::json!("scheduling-hook"), + "hooks[0].principal: unsupported-hook-principal", + ), + ( + "projection", + serde_json::json!(["actor"]), + "hooks[0].projection: unsupported-hook-projection", + ), + ] { + let mut declaration = serde_json::json!({ + "id": "appointment-observer", + "phase": "after", + "trigger": "appointment.confirmed", + "projection": [], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + }); + declaration[member] = value; + policy.hooks = vec![serde_json::from_value(declaration).expect("the shape parses")]; + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&expected.to_owned()), "{rendered:?}"); + } + + policy.hooks = vec![serde_json::from_value(serde_json::json!({ + "id": "cancellation-observer", + "phase": "after", + "trigger": "appointment.cancelled", + "projection": ["reason"], + "handler": {"kind": "url", "destinationId": "appointment-events"}, + })) + .expect("the shared declaration shape parses")]; + let rendered: Vec = policy.check().iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"hooks[0].projection: unsupported-hook-projection".to_owned())); + } + + #[test] + fn legacy_duplicate_and_mismatched_hook_members_are_refused_by_the_shared_shape() { + let base = + serde_norway::to_string(&minimal_exact_time_policy()).expect("the fixture serializes"); + for hooks in [ + "hooks:\n- id: observer\n abi: registry.scheduling-hook/v1\n because: legacy", + "hooks:\n- id: observer\n phase: after\n phase: before\n trigger: appointment.confirmed\n projection: []\n handler:\n kind: url\n destinationId: appointment-events", + "hooks:\n- id: observer\n phase: after\n trigger: appointment.confirmed\n projection: []\n handler:\n kind: wasm\n script: observer.rhai\n abi: registry.hook-handler/v1", + ] { + let yaml = base.replace("hooks: []", hooks); + assert!(parse_policy_yaml(&yaml).is_err(), "accepted:\n{hooks}"); + } } #[test] diff --git a/crates/registry-scheduling/Cargo.toml b/crates/registry-scheduling/Cargo.toml index 2d80e3d984..de33cc435c 100644 --- a/crates/registry-scheduling/Cargo.toml +++ b/crates/registry-scheduling/Cargo.toml @@ -45,6 +45,7 @@ registry-platform-config.workspace = true registry-platform-crypto.workspace = true registry-platform-httpsec = { workspace = true, features = ["server"] } registry-platform-httputil.workspace = true +registry-platform-hooks = { workspace = true, features = ["postgres"] } registry-platform-oidc.workspace = true rustls.workspace = true serde.workspace = true diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index b465d20df2..dd0d1324ea 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -295,6 +295,10 @@ pub struct AuditConfig { pub struct DestinationsConfig { #[serde(default)] pub reminders: Option, + /// Logical URL hook destinations. The governed policy names only these + /// ids; URL, signing secret, and retry ceilings stay deployment-owned. + #[serde(default)] + pub hooks: BTreeMap, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -306,6 +310,26 @@ pub struct ReminderDestinationConfig { pub bearer_token_ref: Option, } +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HookDestinationConfig { + pub url: String, + pub hmac_sha256_key_ref: String, + #[serde(default = "default_hook_attempt_timeout_milliseconds")] + pub attempt_timeout_milliseconds: u32, + #[serde(default = "default_hook_maximum_attempts")] + pub maximum_attempts: u8, +} + +const fn default_hook_attempt_timeout_milliseconds() -> u32 { + 5_000 +} + +const fn default_hook_maximum_attempts() -> u8 { + 8 +} + /// Retention periods the retention sweep enforces. Every value is deployment /// configuration, never policy: a jurisdiction's retention schedule approves /// the deployed numbers, and changing one is an operational event. @@ -316,12 +340,17 @@ pub struct RetentionConfig { /// How long a stored idempotency receipt stays replayable. #[serde(default = "default_attempt_receipt_days")] pub attempt_receipt_days: u16, + /// Lifetime of canonical hook envelopes retained for retry and + /// dead-letter inspection. + #[serde(default = "default_hook_payload_days")] + pub hook_payload_days: u16, } impl Default for RetentionConfig { fn default() -> Self { Self { attempt_receipt_days: DEFAULT_ATTEMPT_RECEIPT_DAYS, + hook_payload_days: default_hook_payload_days(), } } } @@ -330,6 +359,10 @@ fn default_attempt_receipt_days() -> u16 { DEFAULT_ATTEMPT_RECEIPT_DAYS } +const fn default_hook_payload_days() -> u16 { + 7 +} + /// The immutable identity of a packaged policy: the digest of the package /// envelope over its exact authored files. A scheduling package is one /// authored policy file, so the manifest is small, but it is still a @@ -542,7 +575,9 @@ impl RuntimeConfig { if self.audit.hash_key_ref.is_empty() { return Err(RuntimeConfigError::InvalidAuditReference); } - if self.retention.attempt_receipt_days == 0 { + if self.retention.attempt_receipt_days == 0 + || !(1..=30).contains(&self.retention.hook_payload_days) + { return Err(RuntimeConfigError::InvalidRetention); } if let Some(reminders) = &self.destinations.reminders { @@ -550,9 +585,36 @@ impl RuntimeConfig { return Err(RuntimeConfigError::InvalidDestination); } } + if self.destinations.hooks.len() > 128 { + return Err(RuntimeConfigError::InvalidHookDestination); + } + for (id, destination) in &self.destinations.hooks { + if !valid_logical_destination_id(id) + || !valid_destination_url(&destination.url) + || !(100..=10_000).contains(&destination.attempt_timeout_milliseconds) + || !(1..=20).contains(&destination.maximum_attempts) + { + return Err(RuntimeConfigError::InvalidHookDestination); + } + } self.validate_secret_references()?; - self.load_policy()?; + let policy = self.load_policy()?; + let declared_hook_destinations = policy + .hooks + .iter() + .filter_map(|hook| match &hook.handler { + registry_platform_hooks::HookHandlerSource::Url { destination_id } => { + Some(destination_id.as_str()) + } + _ => None, + }) + .collect::>(); + let configured_hook_destinations = + self.destinations.hooks.keys().map(String::as_str).collect(); + if !declared_hook_destinations.is_subset(&configured_hook_destinations) { + return Err(RuntimeConfigError::HookDestinationInventoryMismatch); + } if self.listener.tls_termination == TlsTermination::OperatorControlledUpstream && self.policy_package_digest()?.is_none() { @@ -624,6 +686,12 @@ impl RuntimeConfig { )); } } + for (id, destination) in &self.destinations.hooks { + references.push(( + format!("destinations.hooks.{id}.hmacSha256KeyRef"), + &destination.hmac_sha256_key_ref, + )); + } for (path, raw) in references { let reference = SecretReference::parse(raw.clone()) .map_err(|_| RuntimeConfigError::InvalidSecretReference { path: path.clone() })?; @@ -726,6 +794,18 @@ fn valid_destination_url(value: &str) -> bool { } } +fn valid_logical_destination_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + fn valid_listener( address: IpAddr, exposure: ListenerNetworkExposure, @@ -913,6 +993,10 @@ pub enum RuntimeConfigError { InvalidRetention, #[error("destinations.reminders.url is not a valid destination URL")] InvalidDestination, + #[error("a destinations.hooks binding is invalid")] + InvalidHookDestination, + #[error("destinations.hooks must bind every destination declared by the policy")] + HookDestinationInventoryMismatch, #[error("plaintext PostgreSQL is test-only")] PlaintextDatabase, #[error("the OIDC issuer could not be initialized")] @@ -937,8 +1021,11 @@ impl RuntimeConfigError { Self::InvalidListener => "listener", Self::InvalidDatabaseReference | Self::PlaintextDatabase => "database", Self::InvalidAuditReference => "audit.hashKeyRef", - Self::InvalidRetention => "retention.attemptReceiptDays", + Self::InvalidRetention => "retention", Self::InvalidDestination => "destinations.reminders.url", + Self::InvalidHookDestination | Self::HookDestinationInventoryMismatch => { + "destinations.hooks" + } Self::PolicyRead(_) | Self::PolicyParse { .. } => "package.root/scheduling.yaml", Self::PolicyFindings | Self::PolicyPackage(_) => "package.root", Self::ProductionPolicyPackageRequired => "package.root", @@ -1041,7 +1128,9 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} "scheduling-explain" ); assert_eq!(config.retention.attempt_receipt_days, 7); + assert_eq!(config.retention.hook_payload_days, 7); assert!(config.destinations.reminders.is_none()); + assert!(config.destinations.hooks.is_empty()); let policy = config.load_policy().expect("policy loads"); assert_eq!(policy.scheduling.id, "standalone-exact-time"); } @@ -1129,24 +1218,30 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} } #[test] - fn a_policy_that_fails_its_checks_never_reaches_the_runtime() { + fn a_hook_policy_requires_and_accepts_its_deployment_binding() { let root = tempfile::tempdir().unwrap(); let package = root.path().join("package"); write_policy(&package); - // A hook is the one authored shape that always fails the check: this - // milestone has no engine, so accepting it would be a silent no-op. let hooked = format!( "{POLICY}hooks:\n - {{id: h, abi: registry.scheduling-hook/v1, because: test}}\n" ); std::fs::write(package.join(AUTHORED_POLICY_FILE), hooked).unwrap(); - let operator = write_operator( - root.path(), - operator_value(&package, "development-loopback"), - ); + let mut document = operator_value(&package, "development-loopback"); + let operator = write_operator(root.path(), document.clone()); assert!(matches!( RuntimeConfig::load(&operator), - Err(RuntimeConfigError::PolicyFindings) + Err(RuntimeConfigError::HookDestinationInventoryMismatch) )); + + document["destinations"]["hooks"] = serde_json::json!({ + "appointment-events": { + "url": "https://events.example.test/scheduling", + "hmacSha256KeyRef": "secret:file/hook-key" + } + }); + let operator = write_operator(root.path(), document); + let config = RuntimeConfig::load(&operator).expect("the hook destination is bound"); + assert!(config.destinations.hooks.contains_key("appointment-events")); } #[test] diff --git a/crates/registry-scheduling/src/hooks.rs b/crates/registry-scheduling/src/hooks.rs new file mode 100644 index 0000000000..42f6d6d51a --- /dev/null +++ b/crates/registry-scheduling/src/hooks.rs @@ -0,0 +1,1204 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Scheduling's closed after-commit appointment observer runtime. +//! +//! The shared hook crate owns the envelope and delivery state machine. This +//! module owns Scheduling's policy: three appointment lifecycle triggers, +//! caller-selected projections over a closed set of non-attributable claim +//! fields, URL handlers only, exact deployment destination activation, and a +//! worker that can observe an answer but can never apply a proposal. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ops::{Deref, DerefMut}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use registry_platform_canonical_json::canonicalize_json; +use registry_platform_config::{ProtectedSecret, SecretResolver, MAX_SECRET_BYTES}; +use registry_platform_crypto::delivery_signature::{sign_v1, SignatureFields}; +use registry_platform_hooks::delivery::{ + insert_delivery, DeliveryAuditDisposition, DeliveryAuditOutcome, DeliveryAuditPhase, + DeliveryAuditRecord, DeliveryCapture, DeliveryConfig, DeliveryConnection, DeliveryError, + DeliveryOperationalEvent, DeliveryOutcome, DeliverySeams, DeliveryService, + DeliverySignatureFields, DeliverySignatureRefused, DeliveryWorker, DestinationAnswer, + HandlerRunFailure, HookDestination, HookHandler, HookHandlerBinding, ProposalApplication, + ProposalOutcome, ProposalReceiptRecovery, +}; +use registry_platform_hooks::{ + validate_hooks, BoundedText, Causation, EnvelopeLimits, EventSubject, HookDeclaration, + HookEnvelope, HookHandlerKind, HookHandlerSource, HookPhase, MAX_OUTPUT_BYTES, + MAX_REFUSAL_CODE_BYTES, MAX_REFUSAL_SUMMARY_BYTES, +}; +use registry_platform_httputil::destination::{ + DestinationProfile, DestinationRequestError, DestinationResponseError, DestinationSendError, + EventDeliveryHeaders, EventDestinationPolicy, EventDestinationRequest, + EventDestinationRequestTemplate, MAX_DESTINATION_REQUEST_BODY_BYTES, + MAX_DESTINATION_REQUEST_HEADER_BYTES, MAX_DESTINATION_TARGET_BYTES, +}; +use registry_scheduling_core::{ + valid_identifier, APPOINTMENT_CANCELLED_TRIGGER, APPOINTMENT_CONFIRMED_TRIGGER, + APPOINTMENT_RESCHEDULED_TRIGGER, +}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tokio::sync::watch; +use tokio_postgres::Transaction; +use url::Url; +use uuid::Uuid; + +use crate::config::HookDestinationConfig; +use crate::store::{ClaimRow, PostgresStore}; + +const DESTINATION_BINDING_SCHEMA: &str = "registry.scheduling-hook-destinations/v1"; +const COMPILED_HOOK_SCHEMA: &str = "registry.scheduling-hooks/v1"; +const DATA_SCHEMA_BINDING: &str = "registry.scheduling-hook-data/v1"; +const IDEMPOTENCY_DOMAIN: &[u8] = b"scheduling-hook-idempotency-v1"; +const ENTITY_ID: &str = "appointment"; + +const MIN_HMAC_SHA256_KEY_BYTES: usize = 32; +const MAX_ATTEMPT_TIMEOUT_MS: u32 = 10_000; +const MAXIMUM_ATTEMPTS: u8 = 20; +const INITIAL_BACKOFF_MS: i64 = 30_000; +const MAXIMUM_BACKOFF_MS: i64 = 3_600_000; +const BACKOFF_MULTIPLIER: i16 = 2; +const MAXIMUM_PAYLOAD_BYTES: usize = 16 * 1024; +const MAXIMUM_REQUEST_BYTES: usize = + MAX_DESTINATION_TARGET_BYTES + MAX_DESTINATION_REQUEST_HEADER_BYTES + MAXIMUM_PAYLOAD_BYTES; +const MINIMUM_RETENTION: Duration = Duration::from_secs(24 * 60 * 60); +const MAXIMUM_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const IDENTITY_LOCK_TIMEOUT_MS: i32 = 5_000; + +const RETRY_DELAYS_MS: [i64; 19] = [ + 30_000, 60_000, 120_000, 240_000, 480_000, 960_000, 1_920_000, 3_600_000, 3_600_000, 3_600_000, + 3_600_000, 3_600_000, 3_600_000, 3_600_000, 3_600_000, 3_600_000, 3_600_000, 3_600_000, + 3_600_000, +]; + +const APPOINTMENT_FIELDS: &[&str] = &[ + "appointmentId", + "end", + "offering", + "policyRevision", + "revision", + "start", + "state", +]; +const CANCELLATION_FIELDS: &[&str] = &["appointmentId", "revision", "state"]; + +const _: () = assert!(MAXIMUM_PAYLOAD_BYTES <= MAX_DESTINATION_REQUEST_BODY_BYTES); + +/// Value-free refusal from hook compilation or deployment activation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum HookActivationError { + #[error("the Scheduling hook declaration is invalid")] + InvalidDeclaration, + #[error("the Scheduling hook contract is unsupported")] + UnsupportedContract, + #[error("the Scheduling hook destination inventory is incomplete")] + DestinationInventoryMismatch, + #[error("a Scheduling hook destination binding is invalid")] + InvalidDestination, + #[error("a Scheduling hook destination widens the fixed delivery budget")] + DeliveryBudgetWidening, + #[error("a Scheduling hook signing secret is unavailable or invalid")] + InvalidSigningMaterial, + #[error("the Scheduling hook runtime identity is invalid")] + InvalidIdentity, +} + +/// Value-free refusal while capturing a committed appointment event. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum HookCaptureError { + #[error("the Scheduling hook capture is invalid")] + InvalidCapture, + #[error("the Scheduling hook outbox is unavailable")] + Unavailable, +} + +/// The immutable Scheduling identity a hook worker must observe in every +/// transaction before it reads or changes delivery state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct HookRuntimeIdentity { + pub scheduling_id: String, + pub policy_revision: i64, + pub policy_digest: String, +} + +/// Validated and compiled Scheduling observer declarations. +#[derive(Clone, Debug)] +pub struct CompiledHooks { + hooks: Vec, + destination_ids: BTreeSet, + schema_fingerprint: String, +} + +#[derive(Clone, Debug)] +struct CompiledHook { + id: String, + trigger: String, + projection: Vec, + destination_id: String, + data_schema: String, +} + +impl CompiledHooks { + /// Compile the shared declaration shape into Scheduling's closed observer + /// contract. Callers may safely compile again at runtime even when the + /// authored policy was checked earlier; no unchecked declaration reaches + /// capture. + pub fn compile(declarations: &[HookDeclaration]) -> Result { + validate_hooks(declarations).map_err(|_| HookActivationError::InvalidDeclaration)?; + + let mut hooks = Vec::with_capacity(declarations.len()); + let mut destination_ids = BTreeSet::new(); + for declaration in declarations { + if !valid_identifier(&declaration.id) + || declaration.phase != HookPhase::After + || declaration.when.is_some() + || declaration.principal.is_some() + { + return Err(HookActivationError::UnsupportedContract); + } + let destination_id = match &declaration.handler { + HookHandlerSource::Url { destination_id } + if valid_logical_destination_id(destination_id) => + { + destination_id.clone() + } + _ => return Err(HookActivationError::UnsupportedContract), + }; + let allowed = projection_fields(&declaration.trigger) + .ok_or(HookActivationError::UnsupportedContract)?; + if declaration + .projection + .iter() + .any(|field| !allowed.contains(&field.as_str())) + { + return Err(HookActivationError::UnsupportedContract); + } + let projection = declaration.projection.iter().cloned().collect::>(); + let data_schema = data_schema(&declaration.trigger, &projection)?; + destination_ids.insert(destination_id.clone()); + hooks.push(CompiledHook { + id: declaration.id.clone(), + trigger: declaration.trigger.clone(), + projection, + destination_id, + data_schema, + }); + } + + let fingerprint_value = json!({ + "schemaVersion": COMPILED_HOOK_SCHEMA, + "hooks": hooks.iter().map(|hook| json!({ + "id": hook.id, + "trigger": hook.trigger, + "projection": hook.projection, + "destinationId": hook.destination_id, + "dataSchema": hook.data_schema, + })).collect::>(), + }); + let schema_fingerprint = canonical_digest(&fingerprint_value) + .map_err(|_| HookActivationError::InvalidDeclaration)?; + Ok(Self { + hooks, + destination_ids, + schema_fingerprint, + }) + } + + #[must_use] + pub fn schema_fingerprint(&self) -> &str { + &self.schema_fingerprint + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.hooks.is_empty() + } +} + +/// Activated observer hooks and their exact deployment bindings. +#[derive(Clone)] +pub struct ActivatedHooks { + compiled: Arc, + destinations: Arc, + identity: HookRuntimeIdentity, + schema: String, + delivery_source: String, + payload_retention: Duration, +} + +impl std::fmt::Debug for ActivatedHooks { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ActivatedHooks") + .field("hook_count", &self.compiled.hooks.len()) + .field("destination_count", &self.destinations.bindings.len()) + .field("schema_fingerprint", &self.compiled.schema_fingerprint) + .field("identity", &self.identity) + .field("payload_retention", &self.payload_retention) + .finish() + } +} + +impl ActivatedHooks { + /// Compile and activate the supplied destination inventory for one + /// deployed Scheduling policy. It must cover every current declaration; + /// additional explicit bindings remain available for retained delivery + /// rows captured under an earlier policy. + #[allow(clippy::too_many_arguments)] + pub fn activate( + declarations: &[HookDeclaration], + configured: &BTreeMap, + secrets: &SecretResolver, + identity: HookRuntimeIdentity, + database_schema: String, + payload_retention: Duration, + ) -> Result { + let compiled = Arc::new(CompiledHooks::compile(declarations)?); + validate_identity(&identity, &database_schema, payload_retention)?; + let destinations = Arc::new(ActivatedDestinations::activate( + &compiled.destination_ids, + configured, + secrets, + )?); + let delivery_source = format!("urn:registrystack:scheduling:{}", identity.scheduling_id); + Ok(Self { + compiled, + destinations, + identity, + schema: database_schema, + delivery_source, + payload_retention, + }) + } + + #[must_use] + pub fn schema_fingerprint(&self) -> &str { + self.compiled.schema_fingerprint() + } + + #[must_use] + pub fn destination_binding_digest(&self) -> &str { + &self.destinations.binding_digest + } + + /// Capture one canonical envelope and one platform delivery row for every + /// declaration matching `trigger`, in declaration order and inside the + /// caller's appointment transaction. + pub async fn capture( + &self, + transaction: &Transaction<'_>, + trigger: &str, + claim: &ClaimRow, + ) -> Result { + if projection_fields(trigger).is_none() || claim.revision <= 0 { + return Err(HookCaptureError::InvalidCapture); + } + let matching = self + .compiled + .hooks + .iter() + .filter(|hook| hook.trigger == trigger) + .collect::>(); + if matching.is_empty() { + return Ok(0); + } + + let created_at = transaction + .query_one( + "SELECT date_trunc('milliseconds', transaction_timestamp())", + &[], + ) + .await + .map_err(|_| HookCaptureError::Unavailable)? + .try_get::<_, SystemTime>(0) + .map_err(|_| HookCaptureError::Unavailable)?; + let retention_seconds = i64::try_from(self.payload_retention.as_secs()) + .map_err(|_| HookCaptureError::InvalidCapture)?; + let record_reference = format!("/v1/appointments/{}", claim.claim_id); + + for hook in matching { + let event_id = Uuid::new_v4(); + let id = event_id.to_string(); + let data = projected_claim( + claim, + self.identity.policy_revision, + &hook.trigger, + &hook.projection, + )?; + let envelope = HookEnvelope { + id: id.clone(), + event_type: hook.id.clone(), + source: self.delivery_source.clone(), + time: created_at.into(), + subject: EventSubject { + record_reference: record_reference.clone(), + record_revision: claim.revision, + }, + dataschema: hook.data_schema.clone(), + data, + causation: Causation::root(&id), + }; + let payload = envelope + .to_canonical_bytes(&EnvelopeLimits::tightened_to(MAXIMUM_PAYLOAD_BYTES)) + .map_err(|_| HookCaptureError::InvalidCapture)?; + self.insert_outbox( + transaction, + event_id, + hook, + claim.revision, + &record_reference, + &payload, + created_at, + retention_seconds, + ) + .await?; + let destination = self + .destinations + .bindings + .get(&hook.destination_id) + .ok_or(HookCaptureError::InvalidCapture)?; + insert_delivery( + transaction, + &self.schema, + event_id, + DeliveryCapture { + compiled_delivery_id: &hook.id, + handler_kind: HookHandlerKind::Url, + logical_destination_id: Some(&hook.destination_id), + destination_binding_digest: &destination.binding_digest, + package_revision: &self.identity.policy_digest, + schema_fingerprint: &self.compiled.schema_fingerprint, + data_schema: &hook.data_schema, + classification_ceiling: "restricted", + authentication_profile: "hmac_sha256_v1", + delivery_mode: "after_commit", + attempt_timeout_ms: i64::from(MAX_ATTEMPT_TIMEOUT_MS), + initial_backoff_ms: INITIAL_BACKOFF_MS, + maximum_backoff_ms: MAXIMUM_BACKOFF_MS, + exponential_backoff_multiplier: BACKOFF_MULTIPLIER, + maximum_attempts: i16::from(MAXIMUM_ATTEMPTS), + retry_delays_ms: &RETRY_DELAYS_MS, + maximum_payload_bytes: MAXIMUM_PAYLOAD_BYTES as i64, + payload: &payload, + deployed_attempt_timeout_ms: i64::from(destination.attempt_timeout_ms), + deployed_maximum_attempts: i16::from(destination.maximum_attempts), + dead_letter: "required", + operator_replay: false, + }, + ) + .await + .map_err(|_| HookCaptureError::Unavailable)?; + } + Ok(self + .compiled + .hooks + .iter() + .filter(|hook| hook.trigger == trigger) + .count()) + } + + #[allow(clippy::too_many_arguments)] + async fn insert_outbox( + &self, + transaction: &Transaction<'_>, + event_id: Uuid, + hook: &CompiledHook, + revision: i64, + record_reference: &str, + payload: &[u8], + created_at: SystemTime, + retention_seconds: i64, + ) -> Result<(), HookCaptureError> { + let sql = format!( + "INSERT INTO {}.registry_outbox + (event_id, event_type, trigger, entity_id, record_reference, + record_revision, application_reference, package_revision, + schema_fingerprint, payload, payload_expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, NULL, $7, $8, $9, + $10::timestamptz + $11::bigint * interval '1 second', $10)", + self.schema + ); + let changed = transaction + .execute( + &sql, + &[ + &event_id, + &hook.id, + &hook.trigger, + &ENTITY_ID, + &record_reference, + &revision, + &self.identity.policy_digest, + &self.compiled.schema_fingerprint, + &payload, + &created_at, + &retention_seconds, + ], + ) + .await + .map_err(|_| HookCaptureError::Unavailable)?; + if changed == 1 { + Ok(()) + } else { + Err(HookCaptureError::Unavailable) + } + } + + /// Bind the generic platform service to Scheduling's database identity, + /// audit outbox, destinations, and fixed wire constants. + #[must_use] + pub fn delivery_service(&self, store: PostgresStore) -> HookDeliveryService { + let seams = SchedulingDeliverySeams { + store, + destinations: Arc::clone(&self.destinations), + identity: self.identity.clone(), + schema: self.schema.clone(), + }; + HookDeliveryService { + inner: DeliveryService::new( + seams, + DeliveryConfig { + schema: self.schema.clone(), + idempotency_domain: IDEMPOTENCY_DOMAIN.to_vec(), + delivery_source: self.delivery_source.clone(), + }, + ), + } + } +} + +#[derive(Clone)] +struct ActivatedDestinations { + bindings: BTreeMap>, + binding_digest: String, +} + +impl ActivatedDestinations { + fn activate( + expected: &BTreeSet, + configured: &BTreeMap, + secrets: &SecretResolver, + ) -> Result { + let configured_ids = configured.keys().cloned().collect::>(); + if !expected.is_subset(&configured_ids) { + return Err(HookActivationError::DestinationInventoryMismatch); + } + let mut bindings = BTreeMap::new(); + let mut digest_values = Vec::with_capacity(configured.len()); + for (logical_id, config) in configured { + let destination = ActivatedDestination::activate(logical_id, config, secrets)?; + digest_values.push(destination.digest_value.clone()); + bindings.insert(logical_id.clone(), Arc::new(destination)); + } + let binding_digest = canonical_digest(&json!({ + "schemaVersion": DESTINATION_BINDING_SCHEMA, + "destinations": digest_values, + })) + .map_err(|_| HookActivationError::InvalidDestination)?; + Ok(Self { + bindings, + binding_digest, + }) + } +} + +struct ActivatedDestination { + binding_digest: String, + digest_value: Value, + request_target: String, + policy: Arc, + request_template: EventDestinationRequestTemplate, + hmac_sha256_key: ProtectedSecret, + attempt_timeout_ms: u32, + maximum_attempts: u8, +} + +impl ActivatedDestination { + fn activate( + logical_id: &str, + config: &HookDestinationConfig, + secrets: &SecretResolver, + ) -> Result { + if !valid_logical_destination_id(logical_id) + || !(100..=MAX_ATTEMPT_TIMEOUT_MS).contains(&config.attempt_timeout_milliseconds) + || !(1..=MAXIMUM_ATTEMPTS).contains(&config.maximum_attempts) + { + return Err(HookActivationError::DeliveryBudgetWidening); + } + let (origin, request_target, profile) = destination_parts(&config.url)?; + let policy = EventDestinationPolicy::new(logical_id, &origin, profile, &[]) + .map_err(|_| HookActivationError::InvalidDestination)?; + let request_template = EventDestinationRequestTemplate::event_delivery( + &request_target, + MAXIMUM_PAYLOAD_BYTES, + MAXIMUM_REQUEST_BYTES, + ) + .map_err(|_| HookActivationError::InvalidDestination)?; + let hmac_sha256_key = secrets + .resolve(&config.hmac_sha256_key_ref) + .map_err(|_| HookActivationError::InvalidSigningMaterial)?; + if !(MIN_HMAC_SHA256_KEY_BYTES..=MAX_SECRET_BYTES).contains(&hmac_sha256_key.len()) { + return Err(HookActivationError::InvalidSigningMaterial); + } + let digest_value = json!({ + "logicalId": logical_id, + "url": config.url, + "hmacSha256KeyRef": config.hmac_sha256_key_ref, + "attemptTimeoutMilliseconds": config.attempt_timeout_milliseconds, + "maximumAttempts": config.maximum_attempts, + }); + let binding_digest = canonical_digest(&json!({ + "schemaVersion": DESTINATION_BINDING_SCHEMA, + "destination": digest_value, + })) + .map_err(|_| HookActivationError::InvalidDestination)?; + Ok(Self { + binding_digest, + digest_value, + request_target, + policy: Arc::new(policy), + request_template, + hmac_sha256_key, + attempt_timeout_ms: config.attempt_timeout_milliseconds, + maximum_attempts: config.maximum_attempts, + }) + } +} + +impl std::fmt::Debug for ActivatedDestination { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ActivatedDestination") + .field("binding_digest", &self.binding_digest) + .field("request_target", &"[REDACTED]") + .field("policy", &self.policy) + .field("request_template", &self.request_template) + .field("hmac_sha256_key", &"[REDACTED]") + .field("attempt_timeout_ms", &self.attempt_timeout_ms) + .field("maximum_attempts", &self.maximum_attempts) + .finish() + } +} + +#[derive(Clone)] +struct DestinationBinding(Arc); + +#[async_trait::async_trait] +impl HookDestination for DestinationBinding { + fn binding_digest(&self) -> &str { + &self.0.binding_digest + } + + fn attempt_timeout(&self) -> Duration { + Duration::from_millis(u64::from(self.0.attempt_timeout_ms)) + } + + fn maximum_attempts(&self) -> u8 { + self.0.maximum_attempts + } + + fn sign_delivery( + &self, + fields: DeliverySignatureFields<'_>, + ) -> Result { + sign_v1( + self.0.hmac_sha256_key.expose_secret(), + SignatureFields { + id: fields.id, + source: fields.source, + event_type: fields.event_type, + time: fields.time, + data_schema: fields.data_schema, + generation: fields.generation, + attempt: fields.attempt, + delivery_time: fields.delivery_time, + method: "POST", + request_target: &self.0.request_target, + content_type: "application/json", + idempotency_key: fields.idempotency_key, + body: fields.body, + }, + ) + .map_err(|_| DeliverySignatureRefused) + } + + fn render_delivery( + &self, + headers: EventDeliveryHeaders<'_>, + body: Vec, + ) -> Result { + self.0.request_template.render_event(headers, body) + } + + async fn send_delivery( + &self, + request: EventDestinationRequest, + remaining: Duration, + ) -> Result { + let response = self.0.policy.send(request, remaining).await?; + let status = response.status().as_u16(); + if !response.status().is_success() { + return Ok(DestinationAnswer::NonSuccess { status }); + } + match response.read_bounded(MAX_OUTPUT_BYTES).await { + Ok(body) => Ok(DestinationAnswer::Delivered { + body: body.to_event_answer(), + }), + Err(error) => Ok(DestinationAnswer::AnswerRefused(HandlerRunFailure::new( + match error { + DestinationResponseError::BodyTooLarge + | DestinationResponseError::BodyLimitTooHigh => { + registry_platform_hooks::ErrorCategory::Resource + } + DestinationResponseError::DeadlineExceeded => { + registry_platform_hooks::ErrorCategory::Deadline + } + DestinationResponseError::BodyReadFailed => { + registry_platform_hooks::ErrorCategory::Unavailable + } + }, + ))), + } + } +} + +#[derive(Clone)] +struct SchedulingDeliverySeams { + store: PostgresStore, + destinations: Arc, + identity: HookRuntimeIdentity, + schema: String, +} + +#[async_trait::async_trait] +impl DeliverySeams for SchedulingDeliverySeams { + type Destination = DestinationBinding; + type Handler = NoLocalHandler; + + async fn connection(&self) -> Result { + let client = self + .store + .hook_delivery_client() + .await + .map_err(|_| DeliveryError::Unavailable)?; + Ok(Box::new(PooledClient(client))) + } + + async fn verify_transaction(&self, transaction: &Transaction<'_>) -> Result<(), DeliveryError> { + transaction + .execute( + "SELECT set_config('lock_timeout', $1::text, true)", + &[&format!("{IDENTITY_LOCK_TIMEOUT_MS}ms")], + ) + .await + .map_err(|_| DeliveryError::Unavailable)?; + let identity_query = format!( + "SELECT scheduling_id, policy_revision, policy_digest + FROM {}.scheduling_meta WHERE singleton FOR SHARE", + self.schema + ); + let row = transaction + .query_opt(&identity_query, &[]) + .await + .map_err(|_| DeliveryError::Unavailable)? + .ok_or(DeliveryError::Unavailable)?; + let matches = row.try_get::<_, String>(0).ok().as_deref() + == Some(self.identity.scheduling_id.as_str()) + && row.try_get::<_, i64>(1).ok() == Some(self.identity.policy_revision) + && row.try_get::<_, String>(2).ok().as_deref() + == Some(self.identity.policy_digest.as_str()); + matches.then_some(()).ok_or(DeliveryError::Unavailable) + } + + fn destination(&self, logical_destination_id: &str) -> Option { + self.destinations + .bindings + .get(logical_destination_id) + .cloned() + .map(DestinationBinding) + } + + fn handler(&self, _binding: HookHandlerBinding<'_>) -> Option { + None + } + + async fn apply_proposal( + &self, + _application: ProposalApplication<'_>, + ) -> Result { + Ok(ProposalOutcome::Refused { + code: bounded_proposal_code("scheduling.hook.proposal_unsupported"), + summary: bounded_proposal_summary( + "Scheduling appointment observer hooks cannot propose changes", + ), + }) + } + + async fn recover_proposal_receipt( + &self, + _recovery: ProposalReceiptRecovery<'_>, + ) -> Result, DeliveryError> { + Ok(None) + } + + async fn recover_proposal_receipt_in_transaction( + &self, + _transaction: &Transaction<'_>, + _recovery: ProposalReceiptRecovery<'_>, + ) -> Result, DeliveryError> { + Ok(None) + } + + async fn record_audit( + &self, + transaction: &Transaction<'_>, + record: DeliveryAuditRecord<'_>, + ) -> Result<(), DeliveryError> { + let audit = json!({ + "event": "scheduling.hook-delivery", + "hookEventId": record.event_id, + "compiledDeliveryId": record.compiled_delivery_id, + "policyDigest": record.package_revision, + "generation": record.generation, + "attempt": record.attempt, + "phase": audit_phase(record.phase), + "outcome": audit_outcome(record.outcome), + "disposition": audit_disposition(record.disposition), + }); + let sql = format!( + "INSERT INTO {}.scheduling_audit_outbox(event_id, audit_record) VALUES($1, $2)", + self.schema + ); + let changed = transaction + .execute(&sql, &[&Uuid::new_v4(), &audit]) + .await + .map_err(|_| DeliveryError::Unavailable)?; + if changed == 1 { + Ok(()) + } else { + Err(DeliveryError::Unavailable) + } + } + + fn operational_event(&self, event: DeliveryOperationalEvent) { + match event { + DeliveryOperationalEvent::IterationFailed => { + tracing::warn!(event = "scheduling_hook_delivery_iteration_failed"); + } + DeliveryOperationalEvent::TransitionFailed(code) => { + tracing::warn!( + event = "scheduling_hook_delivery_transition_failed", + code = transition_code(code) + ); + } + } + } +} + +#[derive(Clone, Copy)] +enum NoLocalHandler {} + +#[async_trait::async_trait] +impl HookHandler for NoLocalHandler { + fn handler_digest(&self) -> &str { + match *self {} + } + + fn attempt_timeout(&self) -> Duration { + match *self {} + } + + fn maximum_attempts(&self) -> u8 { + match *self {} + } + + async fn run( + &self, + _envelope: &[u8], + _remaining: Duration, + ) -> Result, HandlerRunFailure> { + match *self {} + } +} + +struct PooledClient(deadpool_postgres::Client); + +impl Deref for PooledClient { + type Target = tokio_postgres::Client; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for PooledClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +/// Scheduling's usable wrapper around the generic delivery service. +#[derive(Clone)] +pub struct HookDeliveryService { + inner: DeliveryService, +} + +impl HookDeliveryService { + pub async fn deliver_once(&self) -> Result { + self.inner.deliver_once().await + } + + pub async fn verify_retained_bindings(&self) -> Result<(), DeliveryError> { + self.inner.verify_retained_bindings().await + } + + #[must_use] + pub fn worker(&self) -> HookDeliveryWorker { + HookDeliveryWorker { + inner: DeliveryWorker::new(self.inner.clone()), + } + } +} + +/// Scheduling's post-commit hook delivery loop. +pub struct HookDeliveryWorker { + inner: DeliveryWorker, +} + +impl HookDeliveryWorker { + pub async fn run(self, shutdown: watch::Receiver) { + self.inner.run(shutdown).await; + } +} + +fn projected_claim( + claim: &ClaimRow, + policy_revision: i64, + trigger: &str, + projection: &[String], +) -> Result { + let mut values = Map::new(); + for field in projection { + let value = match field.as_str() { + "appointmentId" => json!(claim.claim_id), + "offering" => json!(claim.offering), + "start" => json!(claim.displayed_start), + "end" => json!(claim.displayed_end), + "revision" => json!(claim.revision), + // Bind the event to the policy revision that authorized the + // operation. A cancellation closes a claim minted under an older + // revision, so the retained claim's creation revision is not the + // relevant authority here. + "policyRevision" => json!(policy_revision), + "state" => match trigger { + APPOINTMENT_CONFIRMED_TRIGGER | APPOINTMENT_RESCHEDULED_TRIGGER => { + json!("confirmed") + } + APPOINTMENT_CANCELLED_TRIGGER => json!("cancelled"), + _ => return Err(HookCaptureError::InvalidCapture), + }, + // Actor, reason, duplicate keys, and every credential-adjacent + // caller value are intentionally not representable here. + _ => return Err(HookCaptureError::InvalidCapture), + }; + values.insert(field.clone(), value); + } + Ok(Value::Object(values)) +} + +fn projection_fields(trigger: &str) -> Option<&'static [&'static str]> { + match trigger { + APPOINTMENT_CONFIRMED_TRIGGER | APPOINTMENT_RESCHEDULED_TRIGGER => Some(APPOINTMENT_FIELDS), + APPOINTMENT_CANCELLED_TRIGGER => Some(CANCELLATION_FIELDS), + _ => None, + } +} + +fn data_schema(trigger: &str, projection: &[String]) -> Result { + let digest = canonical_digest(&json!({ + "schemaVersion": DATA_SCHEMA_BINDING, + "trigger": trigger, + "projection": projection, + })) + .map_err(|_| HookActivationError::InvalidDeclaration)?; + Ok(format!("urn:registrystack:scheduling:hook-data:{digest}")) +} + +fn destination_parts( + configured: &str, +) -> Result<(String, String, DestinationProfile), HookActivationError> { + let parsed = Url::parse(configured).map_err(|_| HookActivationError::InvalidDestination)?; + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.host().is_none() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(HookActivationError::InvalidDestination); + } + let profile = match parsed.scheme() { + "https" => DestinationProfile::ProductionHttps, + "http" + if matches!( + parsed.host_str(), + Some("127.0.0.1") | Some("localhost") | Some("[::1]") + ) => + { + DestinationProfile::LoopbackDevelopmentHttp + } + _ => return Err(HookActivationError::InvalidDestination), + }; + let request_target = if parsed.path().is_empty() { + "/".to_owned() + } else { + parsed.path().to_owned() + }; + let mut origin = parsed; + origin.set_path(""); + origin.set_query(None); + origin.set_fragment(None); + Ok((origin.to_string(), request_target, profile)) +} + +fn validate_identity( + identity: &HookRuntimeIdentity, + schema: &str, + payload_retention: Duration, +) -> Result<(), HookActivationError> { + if !valid_identifier(&identity.scheduling_id) + || identity.policy_revision <= 0 + || !valid_sha256_digest(&identity.policy_digest) + || !valid_schema_name(schema) + || !(MINIMUM_RETENTION..=MAXIMUM_RETENTION).contains(&payload_retention) + || payload_retention.subsec_nanos() != 0 + { + return Err(HookActivationError::InvalidIdentity); + } + Ok(()) +} + +fn valid_logical_destination_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_lowercase()) + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +fn valid_schema_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value + .bytes() + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_lowercase()) + && value + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()) +} + +fn valid_sha256_digest(value: &str) -> bool { + value.len() == 71 + && value.starts_with("sha256:") + && value[7..] + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn canonical_digest(value: &Value) -> Result { + let canonical = canonicalize_json(value).map_err(|_| ())?; + let digest = Sha256::digest(canonical); + Ok(format!("sha256:{}", hex(&digest))) +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(char::from(DIGITS[usize::from(byte >> 4)])); + encoded.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + encoded +} + +fn bounded_proposal_code(value: &'static str) -> BoundedText { + BoundedText::new(value.to_owned()).expect("static proposal code fits its bound") +} + +fn bounded_proposal_summary(value: &'static str) -> BoundedText { + BoundedText::new(value.to_owned()).expect("static proposal summary fits its bound") +} + +fn audit_phase(value: DeliveryAuditPhase) -> &'static str { + match value { + DeliveryAuditPhase::Attempt => "attempt", + DeliveryAuditPhase::Terminal => "terminal", + DeliveryAuditPhase::Replay => "replay", + } +} + +fn audit_outcome(value: DeliveryAuditOutcome) -> &'static str { + match value { + DeliveryAuditOutcome::AttemptStarted => "attempt_started", + DeliveryAuditOutcome::Delivered => "delivered", + DeliveryAuditOutcome::HttpNonSuccess => "http_non_success", + DeliveryAuditOutcome::DestinationTimeout => "destination_timeout", + DeliveryAuditOutcome::DestinationResolutionRefused => "destination_resolution_refused", + DeliveryAuditOutcome::DestinationTransportUnavailable => { + "destination_transport_unavailable" + } + DeliveryAuditOutcome::DestinationPolicyRefused => "destination_policy_refused", + DeliveryAuditOutcome::DestinationBindingRefused => "destination_binding_refused", + DeliveryAuditOutcome::HandlerBindingRefused => "handler_binding_refused", + DeliveryAuditOutcome::HandlerDeadline => "handler_deadline", + DeliveryAuditOutcome::HandlerResource => "handler_resource", + DeliveryAuditOutcome::HandlerExecution => "handler_execution", + DeliveryAuditOutcome::HandlerSource => "handler_source", + DeliveryAuditOutcome::HandlerUnavailable => "handler_unavailable", + DeliveryAuditOutcome::PayloadRefused => "payload_refused", + DeliveryAuditOutcome::PayloadExpired => "payload_expired", + DeliveryAuditOutcome::WorkerInterrupted => "worker_interrupted", + DeliveryAuditOutcome::ReplayRequested => "replay_requested", + } +} + +fn audit_disposition(value: DeliveryAuditDisposition) -> &'static str { + match value { + DeliveryAuditDisposition::Leased => "leased", + DeliveryAuditDisposition::Delivered => "delivered", + DeliveryAuditDisposition::RetryPending => "retry_pending", + DeliveryAuditDisposition::DeadLettered => "dead_lettered", + DeliveryAuditDisposition::Expired => "expired", + DeliveryAuditDisposition::ReplayPending => "replay_pending", + } +} + +fn transition_code( + value: registry_platform_hooks::delivery::DeliveryTransitionCode, +) -> &'static str { + use registry_platform_hooks::delivery::DeliveryTransitionCode; + match value { + DeliveryTransitionCode::ClaimIdentityRefused => "claim_identity_refused", + DeliveryTransitionCode::ClaimRecoveryFailed => "claim_recovery_failed", + DeliveryTransitionCode::ClaimSelectFailed => "claim_select_failed", + DeliveryTransitionCode::ClaimPolicyRefused => "claim_policy_refused", + DeliveryTransitionCode::ClaimUpdateFailed => "claim_update_failed", + DeliveryTransitionCode::ClaimAuditFailed => "claim_audit_failed", + DeliveryTransitionCode::ClaimCommitFailed => "claim_commit_failed", + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeDelta, Utc}; + use registry_scheduling_core::LedgerKind; + + use super::*; + use crate::store::ClaimState; + + #[test] + fn projections_disclose_only_the_requested_closed_fields() { + let now = Utc::now(); + let claim = ClaimRow { + claim_id: Uuid::new_v4(), + kind: LedgerKind::Booking, + state: ClaimState::Active, + offering: "consultation".to_owned(), + supply_id: "secret-resource".to_owned(), + channel: Some("secret-channel".to_owned()), + displayed_start: now, + displayed_end: now + TimeDelta::minutes(30), + occupied_start: now - TimeDelta::minutes(5), + occupied_end: now + TimeDelta::minutes(35), + units: 1, + duplicate_key: Some("secret-duplicate-key".to_owned()), + hold_expires_at: None, + revision: 4, + policy_revision: 2, + actor: "secret-actor".to_owned(), + reason: Some("secret-reason".to_owned()), + created_at: now, + closed_at: None, + }; + let projection = [ + "appointmentId", + "offering", + "start", + "end", + "revision", + "policyRevision", + "state", + ] + .map(str::to_owned); + + let projected = projected_claim(&claim, 9, APPOINTMENT_CONFIRMED_TRIGGER, &projection) + .expect("the closed projection is valid"); + + assert_eq!(projected["policyRevision"], json!(9)); + assert_eq!(projected["state"], json!("confirmed")); + let serialized = projected.to_string(); + for canary in [ + "secret-resource", + "secret-channel", + "secret-duplicate-key", + "secret-actor", + "secret-reason", + ] { + assert!(!serialized.contains(canary), "projection leaked {canary}"); + } + } + + #[test] + fn cancellation_projection_uses_the_public_terminal_state() { + let now = Utc::now(); + let claim = ClaimRow { + claim_id: Uuid::new_v4(), + kind: LedgerKind::Booking, + state: ClaimState::Cancelled, + offering: "consultation".to_owned(), + supply_id: "resource".to_owned(), + channel: None, + displayed_start: now, + displayed_end: now + TimeDelta::minutes(30), + occupied_start: now, + occupied_end: now + TimeDelta::minutes(30), + units: 1, + duplicate_key: None, + hold_expires_at: None, + revision: 5, + policy_revision: 2, + actor: "actor".to_owned(), + reason: Some("private reason".to_owned()), + created_at: now, + closed_at: Some(now), + }; + let projection = ["appointmentId", "revision", "state"].map(str::to_owned); + + let projected = projected_claim(&claim, 9, APPOINTMENT_CANCELLED_TRIGGER, &projection) + .expect("the cancellation projection is valid"); + + assert_eq!(projected["state"], json!("cancelled")); + assert!(!projected.to_string().contains("private reason")); + } + + #[test] + fn destination_parsing_accepts_only_explicit_loopback_http() { + for url in [ + "http://localhost:8080/hooks", + "http://127.0.0.1:8080/hooks", + "http://[::1]:8080/hooks", + "https://receiver.example/hooks", + ] { + destination_parts(url).expect("the reviewed destination is valid"); + } + assert!(destination_parts("http://receiver.example/hooks").is_err()); + assert!(destination_parts("https://receiver.example/hooks?token=secret").is_err()); + } +} diff --git a/crates/registry-scheduling/src/lib.rs b/crates/registry-scheduling/src/lib.rs index 52c615bb28..d75a328627 100644 --- a/crates/registry-scheduling/src/lib.rs +++ b/crates/registry-scheduling/src/lib.rs @@ -19,6 +19,7 @@ pub mod auth; pub mod config; pub mod cursors; +pub mod hooks; pub mod http; pub mod runtime; #[cfg(feature = "schema")] diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index 8779e94da3..f0b58a32ec 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -2,12 +2,18 @@ //! Service assembly and the supervised background loops: `scheduling serve` //! builds the store connection, the authenticator, the audit chain, and the -//! scheduling service, then runs the HTTP listener beside four workers: hold -//! expiry, reminder-intent dispatch, retention sweeps, and audit publication. +//! scheduling service, then runs the HTTP listener beside five workers: hold +//! expiry, reminder-intent dispatch, hook delivery, retention sweeps, and +//! audit publication. //! A worker that dies stops the process rather than letting the runtime keep //! selling capacity its clocks no longer guard. //! -//! Reminder dispatch is the one place the runtime speaks to another system: +//! Reminder dispatch and declared appointment observers are the places the +//! runtime speaks to another system. Reminder dispatch renders due intents; +//! observer delivery sends canonical envelopes captured with the appointment +//! transaction and can never mutate Scheduling from a receiver's response. +//! +//! For reminders, //! each due outbox intent is rendered as one CloudEvents 1.0 JSON event and //! POSTed to the operator-configured destination, exactly once per claim, with //! the transport and the notification bus wholly external (INT-02). When no @@ -39,6 +45,7 @@ use uuid::Uuid; use crate::auth::SchedulingAuthenticator; use crate::config::{ReminderDestinationConfig, RuntimeConfig, RuntimeConfigError}; +use crate::hooks::{ActivatedHooks, HookActivationError, HookRuntimeIdentity}; use crate::http::{router, HttpState}; use crate::service::SchedulingService; use crate::store::{OutboxRow, PostgresStore, StoreError, REMINDER_SEND_TIMEOUT}; @@ -151,7 +158,7 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> // The deployment identity is read before anything is written: the store // refuses to apply a policy under a scheduling id the deployment does not // carry, and an empty one is a deployment that has not been adopted yet. - let (stored_id, _, _) = store + let (stored_id, stored_revision, stored_digest) = store .scheduling_meta() .await .map_err(database_step("deployment identity read"))?; @@ -161,6 +168,60 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> if stored_id != scheduling_id { return Err(RuntimeError::DeploymentIdentity); } + let database_schema = store + .schema_name() + .await + .map_err(database_step("hook schema discovery"))?; + let hook_payload_retention = + Duration::from_secs(u64::from(config.retention.hook_payload_days) * 24 * 60 * 60); + let expected_policy_revision = if stored_digest == policy_digest { + stored_revision + } else { + stored_revision + .checked_add(1) + .ok_or(RuntimeError::HookActivation( + HookActivationError::InvalidIdentity, + ))? + }; + + // Resolve and validate every destination, including its signing material, + // before policy publication. A first start with a bad secret must not + // advance the durable policy revision and then fail to serve it. + let hooks = ActivatedHooks::activate( + &policy.hooks, + &config.destinations.hooks, + &secrets, + HookRuntimeIdentity { + scheduling_id: scheduling_id.clone(), + policy_revision: expected_policy_revision, + policy_digest: policy_digest.clone(), + }, + database_schema.clone(), + hook_payload_retention, + )?; + + // Before publishing a changed policy, prove that every retained event can + // still use the exact destination binding captured for it. A deployment + // may retain extra explicit bindings while old deliveries drain. + if stored_revision > 0 && !stored_digest.is_empty() { + let retained_hooks = ActivatedHooks::activate( + &policy.hooks, + &config.destinations.hooks, + &secrets, + HookRuntimeIdentity { + scheduling_id: stored_id.clone(), + policy_revision: stored_revision, + policy_digest: stored_digest.clone(), + }, + database_schema.clone(), + hook_payload_retention, + )?; + retained_hooks + .delivery_service(store.clone()) + .verify_retained_bindings() + .await + .map_err(|_| RuntimeError::HookDelivery)?; + } let (verifier, keys) = config.oidc_verifier(&secrets).await?; let authenticator = Arc::new(SchedulingAuthenticator::new( @@ -216,15 +277,29 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> .await .map_err(database_step("policy publication"))?; - let service = Arc::new(SchedulingService::new( - store.clone(), - policy, - scheduling_id.clone(), - policy_revision, - policy_digest, - audit_profile.key_hasher(), - config.retention.attempt_receipt_days, - )); + if policy_revision != expected_policy_revision { + return Err(RuntimeError::HookActivation( + HookActivationError::InvalidIdentity, + )); + } + let hook_delivery = hooks.delivery_service(store.clone()); + hook_delivery + .verify_retained_bindings() + .await + .map_err(|_| RuntimeError::HookDelivery)?; + + let service = Arc::new( + SchedulingService::new( + store.clone(), + policy, + scheduling_id.clone(), + policy_revision, + policy_digest, + audit_profile.key_hasher(), + config.retention.attempt_receipt_days, + ) + .with_hooks(hooks), + ); let (worker_stopped, worker_stops) = mpsc::channel(WORKER_STOP_CAPACITY); let mut workers = Vec::new(); @@ -278,6 +353,18 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> }, )); + let (hook_shutdown, hook_shutdown_rx) = tokio::sync::watch::channel(false); + workers.push(supervise( + "hook delivery", + worker_stopped.clone(), + async move { + // Keep the sender for the lifetime of the supervised worker. Runtime + // shutdown aborts this future and releases the shared delivery loop. + let _keepalive = hook_shutdown; + hook_delivery.worker().run(hook_shutdown_rx).await; + }, + )); + // The whole of retention, and deliberately not all of retention. This // sweep erases two things: idempotency attempt receipts, after the // configured `retention.attemptReceiptDays`, and listing cursors, after @@ -368,6 +455,10 @@ pub enum RuntimeError { Listen(#[from] std::io::Error), #[error("the Scheduling reminder destination is invalid: {0}")] Destination(String), + #[error(transparent)] + HookActivation(#[from] HookActivationError), + #[error("the Scheduling retained hook delivery bindings are unavailable")] + HookDelivery, #[error("a Scheduling background worker stopped")] WorkerStopped, #[error( diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index ac5df039ec..ef17f9bdf0 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -37,6 +37,7 @@ use crate::cursors::{ bind_stored, cursor_expiry, decode_cursor, encode_cursor, CursorError, ListingPosition, StoredCursor, }; +use crate::hooks::ActivatedHooks; use crate::store::{ ClaimRow, CommitError, CommitOutcome, Commitment, PostgresStore, StoreError, SupplyContext, }; @@ -154,6 +155,7 @@ pub struct SchedulingService { policy_digest: String, hasher: AuditKeyHasher, attempt_receipt_days: i64, + hooks: Option, } impl SchedulingService { @@ -175,9 +177,16 @@ impl SchedulingService { policy_digest, hasher, attempt_receipt_days: i64::from(attempt_receipt_days), + hooks: None, } } + #[must_use] + pub fn with_hooks(mut self, hooks: ActivatedHooks) -> Self { + self.hooks = Some(hooks); + self + } + fn revision(&self) -> u64 { self.policy_revision } @@ -1166,7 +1175,7 @@ impl SchedulingService { #[allow(clippy::too_many_arguments)] fn commitment<'a>( - &self, + &'a self, caller: &'a Caller, actor: &'a str, grant: &GrantClaims, @@ -1203,6 +1212,7 @@ impl SchedulingService { grant_exp_unix: Some(grant.exp()), audit_event: Uuid::new_v4(), audit_record, + hooks: self.hooks.as_ref(), }) } @@ -1237,7 +1247,10 @@ impl SchedulingService { }), Ok(minted) => Ok(CommitmentAnswer::Minted(T::from_minted(minted))), Err(error) => { - if matches!(error, CommitError::Store(_) | CommitError::Query(_)) { + if matches!( + error, + CommitError::Store(_) | CommitError::Query(_) | CommitError::Hooks(_) + ) { // Nothing was decided: no receipt, no audit row, and the // caller sees no detail. tracing::error!(%error, "the Scheduling store failed mid-commitment"); @@ -1791,7 +1804,9 @@ impl ClaimRow { fn problem_of(error: &CommitError) -> ProblemCode { match error { - CommitError::Store(_) | CommitError::Query(_) => ProblemCode::ServiceUnavailable, + CommitError::Store(_) | CommitError::Query(_) | CommitError::Hooks(_) => { + ProblemCode::ServiceUnavailable + } CommitError::Refused(refusal) => refusal.public_code(), CommitError::KeyReused => ProblemCode::IdempotencyKeyReused, CommitError::KeyExpired => ProblemCode::IdempotencyExpired, diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 21136eb144..53cc240674 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -42,7 +42,8 @@ use registry_platform_config::SecretResolver; use registry_scheduling_core::{ evaluate_exact_time_admission, evaluate_hold_state, evaluate_window_admission, AdmissionRefusal, ExactTimeContext, LedgerClaim, LedgerKind, LedgerSnapshot, PoolMember, - SchedulingFacts, + SchedulingFacts, APPOINTMENT_CANCELLED_TRIGGER, APPOINTMENT_CONFIRMED_TRIGGER, + APPOINTMENT_RESCHEDULED_TRIGGER, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -51,13 +52,16 @@ use tokio_postgres::{Config as PgConfig, Row}; use uuid::Uuid; use crate::config::{describe_secret_failure, DatabaseConfig}; +use crate::hooks::{ActivatedHooks, HookCaptureError}; const SCHEDULING_MIGRATION: &str = include_str!("../migrations/0001_scheduling.sql"); const FACTS_REVISION_MIGRATION: &str = include_str!("../migrations/0002_facts_revision_and_suppressed.sql"); +const HOOK_DELIVERY_MIGRATION_VERSION: i64 = 3; /// Every schema version in ledger order. const MIGRATIONS: [(i64, &str); 2] = [(1, SCHEDULING_MIGRATION), (2, FACTS_REVISION_MIGRATION)]; +const SCHEMA_VERSIONS: [i64; 3] = [1, 2, HOOK_DELIVERY_MIGRATION_VERSION]; /// Serializes operator-run migrations on one session lock. A second migrator /// waits here instead of racing the ledger primary key. The key spells the @@ -254,6 +258,7 @@ pub struct Commitment<'c> { /// transaction as the state change. pub audit_event: Uuid, pub audit_record: Value, + pub hooks: Option<&'c ActivatedHooks>, } /// The policy-resolved supply an admission runs against. @@ -304,6 +309,8 @@ pub enum CommitError { /// A statement inside the capacity transaction itself failed. #[error("the Scheduling query failed")] Query(#[from] tokio_postgres::Error), + #[error("the Scheduling hook event could not be captured")] + Hooks(#[from] HookCaptureError), #[error("the admission was refused")] Refused(#[from] AdmissionRefusal), #[error("the idempotency key was reused with a different request")] @@ -432,6 +439,34 @@ impl PostgresStore { self.pool.get().await.map_err(StoreError::Pool) } + pub(crate) async fn hook_delivery_client( + &self, + ) -> Result { + self.client().await + } + + pub(crate) async fn schema_name(&self) -> Result { + let client = self.client().await?; + let schema: String = client + .query_one("SELECT current_schema()", &[]) + .await? + .get(0); + let valid = !schema.is_empty() + && schema.len() <= 63 + && schema + .bytes() + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_lowercase()) + && schema + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if valid { + Ok(schema) + } else { + Err(StoreError::Corrupt) + } + } + pub async fn migrate(&self) -> Result<(), StoreError> { let mut client = self.client().await?; client @@ -480,6 +515,40 @@ impl PostgresStore { } transaction.commit().await?; } + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&HOOK_DELIVERY_MIGRATION_VERSION], + ) + .await? + .get(0); + if !applied { + let schema: String = transaction + .query_one("SELECT current_schema()", &[]) + .await? + .get(0); + let valid = !schema.is_empty() + && schema.len() <= 63 + && schema + .bytes() + .next() + .is_some_and(|byte| byte == b'_' || byte.is_ascii_lowercase()) + && schema + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if !valid { + return Err(StoreError::Corrupt); + } + registry_platform_hooks::delivery_schema::install(&*transaction, &schema).await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&HOOK_DELIVERY_MIGRATION_VERSION], + ) + .await?; + } + transaction.commit().await?; Ok(()) } @@ -491,11 +560,11 @@ impl PostgresStore { &[], ) .await?; - let schema_is_current = applied.len() == MIGRATIONS.len() + let schema_is_current = applied.len() == SCHEMA_VERSIONS.len() && applied .iter() - .zip(MIGRATIONS.iter()) - .all(|(row, (expected, _))| { + .zip(SCHEMA_VERSIONS.iter()) + .all(|(row, expected)| { row.try_get::<_, i64>(0) .is_ok_and(|version| version == *expected) }); @@ -1057,6 +1126,11 @@ impl PostgresStore { ) .await?; mint_reminders(&transaction, &claim, offering, commitment.now).await?; + if let Some(hooks) = commitment.hooks { + hooks + .capture(&transaction, APPOINTMENT_CONFIRMED_TRIGGER, &claim) + .await?; + } transaction .insert_audit(commitment.audit_event, &commitment.audit_record) .await?; @@ -1192,6 +1266,11 @@ impl PostgresStore { ) .await?; mint_reminders(&transaction, &claim, offering, commitment.now).await?; + if let Some(hooks) = commitment.hooks { + hooks + .capture(&transaction, APPOINTMENT_CONFIRMED_TRIGGER, &claim) + .await?; + } transaction .insert_audit(commitment.audit_event, &commitment.audit_record) .await?; @@ -1400,6 +1479,11 @@ impl PostgresStore { confirmation_payload(&moved, commitment.policy_revision), ) .await?; + if let Some(hooks) = commitment.hooks { + hooks + .capture(&transaction, APPOINTMENT_RESCHEDULED_TRIGGER, &moved) + .await?; + } transaction .insert_audit(commitment.audit_event, &commitment.audit_record) .await?; @@ -1528,6 +1612,11 @@ impl PostgresStore { closed_at: Some(commitment.now), ..appointment }; + if let Some(hooks) = commitment.hooks { + hooks + .capture(&transaction, APPOINTMENT_CANCELLED_TRIGGER, &cancelled) + .await?; + } transaction .insert_audit(commitment.audit_event, &commitment.audit_record) .await?; diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index b0efdad49a..0fc04f41f5 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -22,10 +22,13 @@ use registry_platform_audit::{ AuditEnvelope, AuditHashSecret, AuditKeyHasher, AuditProfile, JsonlFileSink, }; use registry_platform_config::{SecretProvider, SecretResolver}; +use registry_platform_hooks::delivery::DeliveryOutcome; +use registry_platform_hooks::{EnvelopeLimits, HookEnvelope, HookHandlerSource}; use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; use registry_scheduling::auth::SchedulingAuthenticator; -use registry_scheduling::config::ReminderDestinationConfig; use registry_scheduling::config::{DatabaseConfig, OidcConfig, OidcJwksSource}; +use registry_scheduling::config::{HookDestinationConfig, ReminderDestinationConfig}; +use registry_scheduling::hooks::{ActivatedHooks, HookRuntimeIdentity}; use registry_scheduling::http::{router, HttpState}; use registry_scheduling::runtime::{ dispatch_due_intents, publish_audit_pass, reminder_transport, AuditPublicationState, @@ -38,7 +41,9 @@ use registry_scheduling_core::{ LocationRecord, PartyCounts, PoolMember, ResourcePool, SchedulingFacts, }; use serde_json::{json, Value}; +use std::collections::BTreeMap; use std::sync::Arc; +use std::time::Duration; use tower::ServiceExt; use uuid::Uuid; use wiremock::matchers::{body_string_contains, header, method, path}; @@ -56,6 +61,12 @@ const SECOND_OFFERING: &str = "registry-review-45"; /// appointment inside the interval it already holds. const OVERLAPPING_OFFERING: &str = "registry-update-60"; +fn observer_policy() -> String { + format!( + "{POLICY}hooks:\n - id: confirmed-observer\n phase: after\n trigger: appointment.confirmed\n projection: [appointmentId, offering, start, end, revision, policyRevision, state]\n handler: {{kind: url, destinationId: appointment-events}}\n - id: rescheduled-observer\n phase: after\n trigger: appointment.rescheduled\n projection: [appointmentId, offering, start, end, revision, policyRevision, state]\n handler: {{kind: url, destinationId: appointment-events}}\n - id: cancelled-observer\n phase: after\n trigger: appointment.cancelled\n projection: [appointmentId, revision, state]\n handler: {{kind: url, destinationId: appointment-events}}\n" + ) +} + /// One plain booking grid: slots every 30 minutes around the clock, every /// day, one hour of lead time, a four-hour cancellation cutoff, and a /// ten-minute hold. The wide opening keeps every query window these tests @@ -146,6 +157,9 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} struct Fixture { http: Router, store: PostgresStore, + hooks: ActivatedHooks, + hook_secret_ref: String, + schema: String, /// A session on the test's own schema, for the facts operator tooling /// owns and for the rows a test needs to observe or hold directly. admin: tokio_postgres::Client, @@ -257,6 +271,21 @@ async fn fixture_publishing( policy_yaml: &str, pool_ids: &[String], window_ids: &[String], +) -> Fixture { + fixture_publishing_with_hook_url( + policy_yaml, + pool_ids, + window_ids, + "http://127.0.0.1:9/scheduling-hooks", + ) + .await +} + +async fn fixture_publishing_with_hook_url( + policy_yaml: &str, + pool_ids: &[String], + window_ids: &[String], + hook_url: &str, ) -> Fixture { let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); @@ -282,7 +311,10 @@ async fn fixture_publishing( .expect("the scheduling test schema"); let secret_name = format!("SCHEDULING_SCHEMA_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + let hook_secret_name = + format!("SCHEDULING_HOOK_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); std::env::set_var(&secret_name, &scoped); + std::env::set_var(&hook_secret_name, "0123456789abcdef0123456789abcdef"); let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp") .expect("the scheduling test secret resolver"); let database = DatabaseConfig { @@ -329,15 +361,46 @@ async fn fixture_publishing( .await .expect("publish the scheduling policy"); let keying = AuditHashSecret::new(vec![0x42; 32]).expect("the test audit hash secret"); - let service = Arc::new(SchedulingService::new( - store.clone(), - policy, - SCHEDULING_ID.to_owned(), - revision, - digest, - AuditKeyHasher::Keyed(keying), - 7, - )); + let mut hook_destinations = BTreeMap::new(); + for destination_id in policy.hooks.iter().filter_map(|hook| match &hook.handler { + HookHandlerSource::Url { destination_id } => Some(destination_id), + HookHandlerSource::Rhai { .. } | HookHandlerSource::Wasm { .. } => None, + }) { + hook_destinations.insert( + destination_id.clone(), + HookDestinationConfig { + url: hook_url.to_owned(), + hmac_sha256_key_ref: format!("secret:env/{hook_secret_name}"), + attempt_timeout_milliseconds: 5_000, + maximum_attempts: 1, + }, + ); + } + let hooks = ActivatedHooks::activate( + &policy.hooks, + &hook_destinations, + &secrets, + HookRuntimeIdentity { + scheduling_id: SCHEDULING_ID.to_owned(), + policy_revision: revision, + policy_digest: digest.clone(), + }, + schema.clone(), + Duration::from_secs(7 * 24 * 60 * 60), + ) + .expect("activate the scheduling test hooks"); + let service = Arc::new( + SchedulingService::new( + store.clone(), + policy, + SCHEDULING_ID.to_owned(), + revision, + digest, + AuditKeyHasher::Keyed(keying), + 7, + ) + .with_hooks(hooks.clone()), + ); let http = router(HttpState { service, authenticator: Arc::new(authenticator()), @@ -346,6 +409,9 @@ async fn fixture_publishing( Fixture { http, store, + hooks, + hook_secret_ref: format!("secret:env/{hook_secret_name}"), + schema, admin, revision: u64::try_from(revision).expect("a bounded policy revision"), reader: reader_token(), @@ -618,9 +684,486 @@ async fn booked(fx: &Fixture, from_minutes: i64, to_minutes: i64, key: &str) -> ) } +async fn hook_fixture() -> Fixture { + hook_fixture_at("http://127.0.0.1:9/scheduling-hooks").await +} + +async fn hook_fixture_at(hook_url: &str) -> Fixture { + let policy = observer_policy(); + fixture_publishing_with_hook_url( + &policy, + &["north-counter".to_owned(), "two-counter".to_owned()], + &[], + hook_url, + ) + .await +} + +async fn captured_hook_envelopes( + fx: &Fixture, +) -> Vec<(String, String, String, i64, HookEnvelope, Vec)> { + fx.admin + .query( + "SELECT event_type, trigger, record_reference, record_revision, payload + FROM registry_outbox ORDER BY outbox_id", + &[], + ) + .await + .expect("read the captured hook outbox") + .into_iter() + .map(|row| { + let payload = row.get::<_, Vec>(4); + let envelope = HookEnvelope::from_canonical_bytes( + &payload, + &EnvelopeLimits::tightened_to(16 * 1024), + ) + .expect("the stored hook envelope is canonical"); + ( + row.get(0), + row.get(1), + row.get(2), + row.get(3), + envelope, + payload, + ) + }) + .collect() +} + +#[tokio::test] +async fn appointment_hooks_capture_matching_lifecycle_rows_with_bounded_projection() { + let fx = hook_fixture().await; + let first = first_slot(&fx, OFFERING, 300, 440).await; + let mut create = admission(&fx, OFFERING, first); + create["duplicateKey"] = json!("HOOK_DUPLICATE_CANARY"); + let (status, confirmed) = fx + .post( + "/v1/appointments", + &fx.agent, + "hook-confirmed", + json!({"hold": null, "admission": create}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "confirmation commits"); + let appointment_id = confirmed["appointmentId"] + .as_str() + .expect("an appointment id") + .to_owned(); + let first_revision = confirmed["revision"].as_u64().expect("a revision"); + + let second = first_slot(&fx, OFFERING, 480, 620).await; + let (status, rescheduled) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "hook-rescheduled", + json!({ + "observedRevision": first_revision, + "admission": admission(&fx, OFFERING, second), + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "reschedule commits"); + let second_revision = rescheduled["revision"].as_u64().expect("a revision"); + + let (status, cancelled) = fx + .post( + &format!("/v1/appointments/{appointment_id}/cancel"), + &fx.agent, + "hook-cancelled", + json!({ + "observedRevision": second_revision, + "reason": "HOOK_REASON_CANARY", + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "cancellation commits"); + + let captured = captured_hook_envelopes(&fx).await; + assert_eq!(captured.len(), 3, "one event per matching lifecycle hook"); + let expected = [ + ( + "confirmed-observer", + "appointment.confirmed", + &confirmed, + json!({ + "appointmentId": appointment_id, + "offering": OFFERING, + "start": confirmed["start"], + "end": confirmed["end"], + "revision": confirmed["revision"], + "policyRevision": confirmed["policyRevision"], + "state": "confirmed", + }), + ), + ( + "rescheduled-observer", + "appointment.rescheduled", + &rescheduled, + json!({ + "appointmentId": appointment_id, + "offering": OFFERING, + "start": rescheduled["start"], + "end": rescheduled["end"], + "revision": rescheduled["revision"], + "policyRevision": rescheduled["policyRevision"], + "state": "confirmed", + }), + ), + ( + "cancelled-observer", + "appointment.cancelled", + &cancelled, + json!({ + "appointmentId": appointment_id, + "revision": cancelled["revision"], + "state": "cancelled", + }), + ), + ]; + + for ((event_type, trigger, reference, revision, envelope, payload), expected) in + captured.iter().zip(expected) + { + let (expected_type, expected_trigger, response, expected_data) = expected; + assert_eq!(event_type, expected_type); + assert_eq!(trigger, expected_trigger); + assert_eq!(envelope.event_type, expected_type); + assert_eq!(reference, &format!("/v1/appointments/{appointment_id}")); + assert_eq!(envelope.subject.record_reference, *reference); + assert_eq!(envelope.subject.record_revision, *revision); + assert_eq!(json!(*revision), response["revision"]); + assert_eq!(envelope.data, expected_data); + assert_eq!( + envelope.source, + format!("urn:registrystack:scheduling:{SCHEDULING_ID}") + ); + assert!(envelope + .dataschema + .starts_with("urn:registrystack:scheduling:hook-data:sha256:")); + assert_eq!(envelope.causation.root, envelope.id); + assert_eq!(envelope.causation.parent, None); + assert_eq!(envelope.causation.hop, 0); + + let spelled = std::str::from_utf8(payload).expect("the envelope is UTF-8 JSON"); + for canary in [ + "HOOK_DUPLICATE_CANARY", + "HOOK_REASON_CANARY", + "principal-agent", + "registry_grant_id", + ] { + assert!(!spelled.contains(canary), "payload leaked {canary}"); + } + for excluded in ["actor", "reason", "duplicateKey", "resource", "channel"] { + assert!( + envelope.data.get(excluded).is_none(), + "projection exposed {excluded}" + ); + } + } + + let delivery_count: i64 = fx + .admin + .query_one("SELECT count(*) FROM registry_webhook_deliveries", &[]) + .await + .expect("read captured platform delivery rows") + .get(0); + assert_eq!(delivery_count, 3, "every envelope has one delivery row"); +} + +#[tokio::test] +async fn a_hook_capture_failure_rolls_back_the_whole_appointment_transaction() { + let fx = hook_fixture().await; + fx.admin + .batch_execute( + "ALTER TABLE registry_outbox + ADD CONSTRAINT test_refuse_hook_capture + CHECK (event_type <> 'confirmed-observer')", + ) + .await + .expect("install the hook capture failure seam"); + + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "hook-rollback", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(problem["code"], "service.unavailable"); + + let row = fx + .admin + .query_one( + "SELECT + (SELECT count(*) FROM scheduling_claims), + (SELECT count(*) FROM scheduling_history), + (SELECT count(*) FROM scheduling_attempts), + (SELECT count(*) FROM registry_outbox), + (SELECT count(*) FROM registry_webhook_deliveries)", + &[], + ) + .await + .expect("read the rolled-back commitment tables"); + let counts = (0..5) + .map(|index| row.get::<_, i64>(index)) + .collect::>(); + assert_eq!(counts, vec![0, 0, 0, 0, 0]); + assert!( + slot_offered(&fx, OFFERING, 300, 440, slot).await, + "the failed hook capture did not consume capacity" + ); +} + +#[tokio::test] +async fn hook_delivery_sends_the_canonical_event_audits_egress_and_refuses_proposals() { + let destination = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/scheduling-hooks")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw( + r#"{"answer":"proposal","document":{"operation":"cancel"}}"#, + "application/json", + ) + .set_delay(Duration::from_secs(2)), + ) + .expect(1) + .mount(&destination) + .await; + let fx = hook_fixture_at(&format!("{}/scheduling-hooks", destination.uri())).await; + let (appointment_id, revision) = booked(&fx, 300, 440, "hook-delivery").await; + assert_eq!(revision, 1); + let captured = captured_hook_envelopes(&fx).await; + assert_eq!(captured.len(), 1); + let (_, _, _, _, envelope, payload) = &captured[0]; + + let delivery = fx.hooks.delivery_service(fx.store.clone()); + delivery + .verify_retained_bindings() + .await + .expect("the retained destination binding is exact"); + let running = tokio::spawn(async move { delivery.deliver_once().await }); + + let mut request_seen = false; + for _ in 0..100 { + if destination + .received_requests() + .await + .expect("read received hook requests") + .len() + == 1 + { + request_seen = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(request_seen, "the hook request reached the receiver"); + + // The receiver is still delaying its answer. The attempt audit therefore + // had to commit before egress, while no terminal audit can exist yet. + let pre_answer_audit = fx + .admin + .query( + "SELECT audit_record FROM scheduling_audit_outbox + WHERE audit_record->>'event' = 'scheduling.hook-delivery' + ORDER BY recorded_seq", + &[], + ) + .await + .expect("read the pre-egress hook audit"); + assert_eq!(pre_answer_audit.len(), 1); + let attempted = pre_answer_audit[0].get::<_, Value>(0); + assert_eq!(attempted["phase"], "attempt"); + assert_eq!(attempted["outcome"], "attempt_started"); + assert_eq!(attempted["disposition"], "leased"); + + let outcome = running + .await + .expect("the hook worker task completes") + .expect("the hook delivery completes"); + assert_eq!(outcome, DeliveryOutcome::Delivered); + + let requests = destination + .received_requests() + .await + .expect("read the delivered hook request"); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!( + request.body, *payload, + "the stored canonical bytes are sent" + ); + let header = |name: &str| { + request + .headers + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_else(|| panic!("missing valid {name} header")) + }; + assert_eq!(header("ce-specversion"), "1.0"); + assert_eq!(header("ce-id"), envelope.id); + assert_eq!(header("ce-source"), envelope.source); + assert_eq!(header("ce-type"), "confirmed-observer"); + assert_eq!(header("ce-dataschema"), envelope.dataschema); + let payload_json: Value = serde_json::from_slice(payload).expect("the canonical envelope JSON"); + assert_eq!(header("ce-time"), payload_json["time"]); + assert_eq!(header("x-registry-event-generation"), "1"); + assert_eq!(header("x-registry-delivery-attempt"), "1"); + assert!(!header("x-registry-delivery-time").is_empty()); + let idempotency_key = header("idempotency-key"); + assert!(idempotency_key.starts_with("sha256:")); + assert_eq!(idempotency_key.len(), 71); + let signature = header("x-registry-signature"); + assert!(signature.starts_with("v1=")); + assert_eq!(signature.len(), 46, "v1 plus one HMAC-SHA-256 tag"); + + let state = fx + .admin + .query_one( + "SELECT state, attempt, proposal_disposition, proposal_resulting_revision, + proposal_code, proposal_summary + FROM registry_webhook_delivery_state", + &[], + ) + .await + .expect("read the settled hook delivery"); + assert_eq!(state.get::<_, String>(0), "delivered"); + assert_eq!(state.get::<_, i16>(1), 1); + assert_eq!( + state.get::<_, Option>(2).as_deref(), + Some("refused") + ); + assert_eq!(state.get::<_, Option>(3), None); + assert_eq!( + state.get::<_, Option>(4).as_deref(), + Some("scheduling.hook.proposal_unsupported") + ); + assert_eq!( + state.get::<_, Option>(5).as_deref(), + Some("Scheduling appointment observer hooks cannot propose changes") + ); + + let audit = fx + .admin + .query( + "SELECT audit_record FROM scheduling_audit_outbox + WHERE audit_record->>'event' = 'scheduling.hook-delivery' + ORDER BY recorded_seq", + &[], + ) + .await + .expect("read the settled hook audit"); + assert_eq!(audit.len(), 2); + let terminal = audit[1].get::<_, Value>(0); + assert_eq!(terminal["phase"], "terminal"); + assert_eq!(terminal["outcome"], "delivered"); + assert_eq!(terminal["disposition"], "delivered"); + + let (status, unchanged) = fx + .get(&format!("/v1/appointments/{appointment_id}"), &fx.agent) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(unchanged["state"], "confirmed"); + assert_eq!(unchanged["revision"], 1); + let history_count: i64 = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_history WHERE claim_id=$1", + &[&Uuid::parse_str(&appointment_id).expect("an appointment UUID")], + ) + .await + .expect("read unchanged appointment history") + .get(0); + assert_eq!( + history_count, 1, + "the proposal changed no appointment state" + ); +} + +#[tokio::test] +async fn hook_delivery_audit_failure_prevents_egress() { + let destination = MockServer::start().await; + let fx = hook_fixture_at(&format!("{}/scheduling-hooks", destination.uri())).await; + let _ = booked(&fx, 300, 440, "hook-audit-failure").await; + fx.admin + .batch_execute( + "ALTER TABLE scheduling_audit_outbox + ADD CONSTRAINT test_refuse_hook_delivery_audit + CHECK ((audit_record->>'event') IS DISTINCT FROM 'scheduling.hook-delivery')", + ) + .await + .expect("install the delivery audit failure seam"); + + let delivery = fx.hooks.delivery_service(fx.store.clone()); + assert!(delivery.deliver_once().await.is_err()); + assert!( + destination + .received_requests() + .await + .expect("read hook requests") + .is_empty(), + "a failed pre-egress audit prevents the send" + ); + let state = fx + .admin + .query_one( + "SELECT state, attempt FROM registry_webhook_delivery_state", + &[], + ) + .await + .expect("read the rolled-back claim state"); + assert_eq!(state.get::<_, String>(0), "pending"); + assert_eq!(state.get::<_, i16>(1), 0); +} + +#[tokio::test] +async fn changed_retained_hook_destination_binding_is_refused() { + let fx = hook_fixture().await; + let _ = booked(&fx, 300, 440, "hook-binding-change").await; + let policy = parse_policy_yaml(&observer_policy()).expect("the scheduling hook policy"); + let secrets = SecretResolver::new([SecretProvider::Environment], "/private/tmp") + .expect("the scheduling hook secret resolver"); + let destinations = BTreeMap::from([( + "appointment-events".to_owned(), + HookDestinationConfig { + url: "http://127.0.0.1:9/changed-scheduling-hooks".to_owned(), + hmac_sha256_key_ref: fx.hook_secret_ref.clone(), + attempt_timeout_milliseconds: 5_000, + maximum_attempts: 1, + }, + )]); + let changed = ActivatedHooks::activate( + &policy.hooks, + &destinations, + &secrets, + HookRuntimeIdentity { + scheduling_id: SCHEDULING_ID.to_owned(), + policy_revision: i64::try_from(fx.revision).expect("a bounded policy revision"), + policy_digest: policy.policy_digest(), + }, + fx.schema.clone(), + Duration::from_secs(7 * 24 * 60 * 60), + ) + .expect("activate the deliberately changed destination"); + + assert!( + changed + .delivery_service(fx.store.clone()) + .verify_retained_bindings() + .await + .is_err(), + "retained work cannot be retargeted under the same logical id" + ); +} + #[tokio::test] async fn a_hold_confirms_into_an_appointment_with_attributable_history() { - let fx = fixture().await; + let fx = hook_fixture().await; let slot = first_slot(&fx, OFFERING, 90, 200).await; let (status, hold) = fx .post( @@ -694,6 +1237,16 @@ async fn a_hold_confirms_into_an_appointment_with_attributable_history() { .await; assert_eq!(status, StatusCode::OK); assert_eq!(fetched["appointmentId"], appointment_id); + + let captured = captured_hook_envelopes(&fx).await; + assert_eq!( + captured.len(), + 1, + "hold confirmation captures one observer event" + ); + assert_eq!(captured[0].0, "confirmed-observer"); + assert_eq!(captured[0].1, "appointment.confirmed"); + assert_eq!(captured[0].4.data["appointmentId"], appointment_id); } #[tokio::test] @@ -3462,6 +4015,7 @@ async fn a_records_swap_refuses_a_commitment_resolving_the_old_facts() { grant_exp_unix: None, audit_event: Uuid::new_v4(), audit_record: operator_audit(), + hooks: None, }; let outcome = fx .store diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs index 80199a5cef..9f2f55ee22 100644 --- a/crates/registry-schedulingctl/src/templates.rs +++ b/crates/registry-schedulingctl/src/templates.rs @@ -588,13 +588,20 @@ destinations: {} # reminders: # url: https://bus.example.test/scheduling-reminders # bearerTokenRef: secret:file/reminder-bearer-token + # URL observers declared under scheduling.yaml hooks bind here. The policy + # carries no endpoint or secret, and retained work keeps this exact binding. + # hooks: + # appointment-events: + # url: https://integration.example.test/scheduling-events + # hmacSha256KeyRef: secret:file/appointment-events-hmac + # attemptTimeoutMilliseconds: 5000 + # maximumAttempts: 8 retention: - # The one retention period the sweep enforces today, and it covers - # idempotency attempt receipts and listing cursors and nothing else: - # appointment, history, outbox, and audit retention are deferred. Seven days - # is the default floor, not a recommendation; a jurisdiction's retention - # schedule approves the deployed value, and the audit journal records it. + # Attempt receipts/listing cursors and hook delivery payloads have separate + # bounded lifetimes. Appointment, reminder, history, and audit retention are + # deferred. Seven days is a default, not a jurisdictional recommendation. attemptReceiptDays: 7 + hookPayloadDays: 7 "#; /// The files one template writes, relative to the project directory, in diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx index 919496979c..8425ec2403 100644 --- a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -144,10 +144,16 @@ Every Scheduling problem response carries one `code` from this closed catalogue, the HTTP status listed beside it. Two codes are reserved: `eligibility.unavailable` and `hook.unavailable` are defined, titled, and -status-pinned because the reserved lifecycle-hook ABI `registry.scheduling-hook/v1` will produce them, -and no path in this milestone answers them yet. - -{/* Evidence: crates/registry-scheduling-core/src/problem.rs, ProblemCode; +status-pinned for future synchronous decision paths, and no path in this milestone answers them yet. +Scheduling hooks are asynchronous observers, so their delivery failures never become caller problem +responses. The authored policy accepts only `after` URL handlers for `appointment.confirmed`, +`appointment.rescheduled`, and `appointment.cancelled`. Confirmed and rescheduled events may project +the appointment identifier, revision, offering, start, end, state, and policy revision. Cancelled +events may project only the appointment identifier, revision, and state. Conditions, principals, +local handlers, and observer proposals are refused. + +{/* Evidence: crates/registry-scheduling-core/src/{policy,problem}.rs, SchedulingPolicy::check() and ProblemCode; + crates/registry-scheduling/src/hooks.rs; products/scheduling/generated/registry-scheduling.openapi.json, x-registry-scheduling-reserved-problems. */} diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index 97e29b1bad..44626ab8b8 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -14,6 +14,9 @@ the offering's service, its location, and the action, bounded to 64 permissions of 32 actions with no wildcard. Only the grant's expiry is re-checked inside the capacity transaction. +- Document the maintained Casework approval and stock ThunderID exchange path, + and feed its exact exchanged bearer through Scheduling's real authenticator + before the adopter uses it for an appointment. - Emit each committed scheduling change as a CloudEvents 1.0 event through the outbox to a configured reminder destination, and keep intents readable in place when no destination is configured. @@ -21,6 +24,13 @@ listing cursors after fifteen minutes. Appointment, history, outbox, and audit retention are deferred. - Provide `schedulingctl init`, `check`, `test`, `explain`, and `package`, and - write a complete `runtime.example.yaml` beside every initialized project. + write complete `runtime.example.yaml` and `records.yaml` documents beside + every initialized project. - Carry the product's own contract checks, security-invariant matrix, and offline authoring journey under this folder. +- Deliver declared `after` URL observers for confirmed, rescheduled, and + cancelled appointments from a transactionally captured, bounded projection; + refuse conditions, principals, local handlers, and observer proposals. +- Re-check hold expiry after locking its supply during confirmation, so a + delayed confirmation cannot claim capacity that became bookable and was + committed elsewhere after the hold expired. diff --git a/products/scheduling/README.md b/products/scheduling/README.md index c3cac60bad..1d897cc232 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -119,11 +119,12 @@ transaction re-reads are documented in approve them, and the approval surface stays with the product that holds the human relationship. -## Events +## Events and observers -The runtime speaks to one other system: reminder dispatch. A commitment mints -the reminder intents its offering declares, each due a fixed number of minutes -before the appointment, and a worker renders every due intent as one +The runtime has two outbound integration seams. Reminder dispatch is product +notification work: a commitment mints the reminder intents its offering +declares, each due a fixed number of minutes before the appointment, and a +worker renders every due intent as one CloudEvents 1.0 event in canonical JSON and POSTs it to the configured reminder destination with `content-type: application/cloudevents+json`, exactly once per claim. A transport failure schedules an exponential retry, a @@ -152,6 +153,32 @@ With no reminder destination configured, the intents stay readable in place: they are written to the outbox and marked local, never pretended delivered. Adopting the product does not require wiring a notification bus first. +Appointment observers are governed hooks. A policy may declare `phase: after` +with a URL handler for exactly three triggers: + +| Trigger | Allowed projected fields | +| --- | --- | +| `appointment.confirmed` | `appointmentId`, `revision`, `offering`, `start`, `end`, `state`, `policyRevision` | +| `appointment.rescheduled` | `appointmentId`, `revision`, `offering`, `start`, `end`, `state`, `policyRevision` | +| `appointment.cancelled` | `appointmentId`, `revision`, `state` | + +The runtime refuses conditions, principals, Rhai or Wasm handlers, unknown +triggers, and fields outside that table. It captures one canonical event and +its delivery row in the same transaction as the appointment change, then an +after-commit worker POSTs it to the deployment-bound destination. Delivery is +at least once, HMAC-SHA256 signed, retried within fixed product ceilings, and +audited without payload values. A receiver response is observation only: +Scheduling deterministically refuses every proposal and never turns it into a +booking, reschedule, or cancellation. + +The hook id becomes the event `type`. The source is +`urn:registrystack:scheduling:`, the subject record reference is +`/v1/appointments/`, and `data` contains only the requested +fields. `state` is the public value `confirmed` or `cancelled`; actor identity, +task grants, resource and channel identifiers, duplicate keys, and cancellation +reasons cannot be projected. Retained payloads expire after +`retention.hookPayloadDays`. Operator replay is not exposed in this slice. + ## Phase 1 scope and exclusions Phase 1 is one booking surface over anchored supply. It excludes, as tracked diff --git a/products/scheduling/RUNTIME-CONFIG.md b/products/scheduling/RUNTIME-CONFIG.md index ac2d54780e..fd3027b557 100644 --- a/products/scheduling/RUNTIME-CONFIG.md +++ b/products/scheduling/RUNTIME-CONFIG.md @@ -99,18 +99,25 @@ absent reminders destination is a supported deployment: the intents stay readable in the outbox and are marked local, never pretended delivered. The README documents the envelope and the event types. -`retention.attemptReceiptDays` is the one retention period the sweep enforces, -and it covers idempotency attempt receipts and listing cursors and nothing -else. Appointment, history, outbox, and audit retention are deferred: nothing -in this milestone sweeps committed scheduling data, and this reference says so -explicitly rather than implying a sweep that does not run. The default is -seven days, and it is a floor, not a recommendation: a jurisdiction's -retention schedule approves the deployed value, and the audit journal records -it. An idempotency receipt past its period is erased, so an exact retry after -expiry answers `idempotency.expired` with HTTP 410 instead of replaying the -first answer; a listing cursor expires after its own fifteen minutes and is -then forgotten. Both sweeps run in the same retention worker, on a fixed -cadence that is not operator-tunable configuration. +`destinations.hooks` binds the logical destination ids named by policy hooks to +deployment-owned URLs and HMAC-SHA256 keys. Each entry requires `url` and +`hmacSha256KeyRef`; `attemptTimeoutMilliseconds` defaults to 5000 and is bounded +from 100 through 10000, while `maximumAttempts` defaults to 8 and is bounded +from 1 through 20. URLs use the same HTTPS and explicit-loopback rules as the +reminder destination. A signing key must contain at least 32 bytes. Every +destination the current policy names must be configured. Explicit extra +bindings are allowed so retained events captured under an earlier policy can +finish with their exact original binding. Startup refuses if a retained event's +binding is no longer available. + +`retention.attemptReceiptDays` covers idempotency attempt receipts; listing +cursors keep their fixed fifteen-minute lifetime. `retention.hookPayloadDays` +sets the canonical observer payload's retry and dead-letter lifetime from 1 +through 30 days. Both configured values default to seven days, which is not a +jurisdictional recommendation. Appointment, history, reminder outbox, and +audit retention remain deferred. An idempotency receipt past its period is +erased, so an exact retry after expiry answers `idempotency.expired` with HTTP +410 instead of replaying the first answer. See the complete maintained [`runtime.example.yaml`](examples/standalone-exact-time/runtime.example.yaml), diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index 16c0b232a0..61ba0b139b 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -381,6 +381,25 @@ invariants: negativeTest: path: crates/registry-scheduling/src/auth.rs name: an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it + - id: SCHEDULING-SEC-24 + state: enforced + threat: >- + An appointment observer gains mutation authority, receives caller or + capacity secrets, or observes an event not durably bound to the state + change it describes. + enforcementPoint: >- + A closed policy compiler admitting only after-phase URL handlers for + three appointment lifecycle triggers; product-owned projections that + cannot name actor, credential, capacity, duplicate-key, or cancellation + reason fields; outbox capture in the appointment transaction; and a + delivery seam that refuses every proposal. + refusal: >- + Refuse unsupported declarations before startup, fail the appointment + transaction if its matching event cannot be captured, and record a + value-free proposal refusal without applying receiver output. + negativeTest: + path: crates/registry-scheduling/src/hooks.rs + name: projections_disclose_only_the_requested_closed_fields deferred: - id: SCHEDULING-DEF-01 subject: Full task-grant bounds re-checked inside the capacity transaction diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index 9b91220ed9..28bd72540b 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -31,6 +31,7 @@ entries: - {path: crates/registry-scheduling/src/auth.rs, name: the_explain_path_demands_its_own_scope} - {path: crates/registry-scheduling/src/config.rs, name: explain_and_read_scopes_must_differ_and_retention_must_be_positive} - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: any_partial_core_grant_set_is_rejected} + - {path: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid, features: [postgres-test]} - id: SCHEDULING-SEC-05 tests: - {path: crates/registry-scheduling/src/auth.rs, name: a_grant_naming_another_resource_is_a_profile_refusal} @@ -77,6 +78,7 @@ entries: - {path: crates/registry-scheduling-core/src/admission.rs, name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed} - {path: crates/registry-scheduling-core/src/admission.rs, name: a_live_hold_still_consumes} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_expired_hold_returns_capacity_and_refuses_confirmation} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_delayed_confirmation_cannot_transfer_rebooked_capacity_after_expiry} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_release_returns_capacity_answers_replay_and_blocks_confirmation} - id: SCHEDULING-SEC-12 tests: @@ -106,7 +108,7 @@ entries: - id: SCHEDULING-SEC-15 tests: - {path: crates/registry-scheduling/src/config.rs, name: production_requires_a_verified_package_while_loopback_accepts_authoring} - - {path: crates/registry-scheduling/src/config.rs, name: a_policy_that_fails_its_checks_never_reaches_the_runtime} + - {path: crates/registry-scheduling/src/config.rs, name: a_refused_authored_policy_names_the_member_and_the_cause} - {path: crates/registry-scheduling-core/src/policy.rs, name: the_policy_digest_is_stable_and_moves_with_content} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: adoption_binds_one_identity_and_refuses_a_second} - id: SCHEDULING-SEC-16 @@ -119,7 +121,8 @@ entries: - {path: crates/registry-scheduling-core/src/model.rs, name: ledger_and_request_shapes_refuse_unknown_fields} - {path: crates/registry-scheduling-core/src/policy.rs, name: unknown_policy_fields_are_refused} - {path: crates/registry-scheduling-core/src/policy.rs, name: collection_bounds_are_enforced} - - {path: crates/registry-scheduling-core/src/policy.rs, name: declared_hooks_are_refused_because_nothing_can_run_them} + - {path: crates/registry-scheduling-core/src/policy.rs, name: scheduling_observer_hooks_accept_only_the_closed_runtime_contract} + - {path: crates/registry-scheduling-core/src/policy.rs, name: legacy_duplicate_and_mismatched_hook_members_are_refused_by_the_shared_shape} - {path: crates/registry-scheduling-client/tests/http_boundary.rs, name: invalid_client_input_never_reaches_the_wire} - id: SCHEDULING-SEC-18 tests: @@ -145,6 +148,18 @@ entries: - {path: crates/registry-scheduling/src/auth.rs, name: an_exchanged_token_is_refused_until_its_authority_is_declared} - {path: crates/registry-scheduling/src/config.rs, name: a_declared_assertion_authority_binds_the_client_that_may_exchange_from_it} - {path: crates/registry-scheduling/src/config.rs, name: an_assertion_issuer_map_outside_its_bounds_is_refused} + - {path: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid, features: [postgres-test]} - id: SCHEDULING-SEC-23 tests: - {path: crates/registry-scheduling/src/auth.rs, name: an_unreadable_clock_refuses_to_judge_an_expiry_rather_than_passing_it} + - id: SCHEDULING-SEC-24 + tests: + - {path: crates/registry-scheduling-core/src/policy.rs, name: scheduling_observer_hooks_accept_only_the_closed_runtime_contract} + - {path: crates/registry-scheduling/src/config.rs, name: a_hook_policy_requires_and_accepts_its_deployment_binding} + - {path: crates/registry-scheduling/src/hooks.rs, name: projections_disclose_only_the_requested_closed_fields} + - {path: crates/registry-scheduling/src/hooks.rs, name: cancellation_projection_uses_the_public_terminal_state} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: appointment_hooks_capture_matching_lifecycle_rows_with_bounded_projection} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_hook_capture_failure_rolls_back_the_whole_appointment_transaction} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: hook_delivery_sends_the_canonical_event_audits_egress_and_refuses_proposals} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: hook_delivery_audit_failure_prevents_egress} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: changed_retained_hook_destination_binding_is_refused} diff --git a/products/scheduling/demo/support/demo.py b/products/scheduling/demo/support/demo.py index d94626ee3b..47e7e0b0d5 100644 --- a/products/scheduling/demo/support/demo.py +++ b/products/scheduling/demo/support/demo.py @@ -423,6 +423,7 @@ def runtime_config( retention: {{}} destinations: reminders: null + hooks: {{}} """ diff --git a/products/scheduling/examples/standalone-exact-time/runtime.example.yaml b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml index 4c533b6ba5..7e15587001 100644 --- a/products/scheduling/examples/standalone-exact-time/runtime.example.yaml +++ b/products/scheduling/examples/standalone-exact-time/runtime.example.yaml @@ -91,10 +91,17 @@ destinations: {} # reminders: # url: https://bus.example.test/scheduling-reminders # bearerTokenRef: secret:file/reminder-bearer-token + # URL observers declared under scheduling.yaml hooks bind here. The policy + # carries no endpoint or secret, and retained work keeps this exact binding. + # hooks: + # appointment-events: + # url: https://integration.example.test/scheduling-events + # hmacSha256KeyRef: secret:file/appointment-events-hmac + # attemptTimeoutMilliseconds: 5000 + # maximumAttempts: 8 retention: - # The one retention period the sweep enforces today, and it covers - # idempotency attempt receipts and listing cursors and nothing else: - # appointment, history, outbox, and audit retention are deferred. Seven days - # is the default floor, not a recommendation; a jurisdiction's retention - # schedule approves the deployed value, and the audit journal records it. + # Attempt receipts/listing cursors and hook delivery payloads have separate + # bounded lifetimes. Appointment, reminder, history, and audit retention are + # deferred. Seven days is a default, not a jurisdictional recommendation. attemptReceiptDays: 7 + hookPayloadDays: 7 diff --git a/products/scheduling/generated/runtime/runtime.schema.json b/products/scheduling/generated/runtime/runtime.schema.json index e3455bbb13..2581a34493 100644 --- a/products/scheduling/generated/runtime/runtime.schema.json +++ b/products/scheduling/generated/runtime/runtime.schema.json @@ -64,6 +64,13 @@ "additionalProperties": false, "description": "Where due reminder intents are dispatched. An absent reminders destination\nis a supported deployment: intents are written and stay local, so an\noperator can adopt the product before wiring a notification bus.", "properties": { + "hooks": { + "additionalProperties": { + "$ref": "#/$defs/HookDestinationConfig" + }, + "description": "Logical URL hook destinations. The governed policy names only these\nids; URL, signing secret, and retry ceilings stay deployment-owned.", + "type": "object" + }, "reminders": { "anyOf": [ { @@ -94,6 +101,35 @@ ], "type": "object" }, + "HookDestinationConfig": { + "additionalProperties": false, + "properties": { + "attemptTimeoutMilliseconds": { + "default": 5000, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "hmacSha256KeyRef": { + "type": "string" + }, + "maximumAttempts": { + "default": 8, + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "url": { + "type": "string" + } + }, + "required": [ + "url", + "hmacSha256KeyRef" + ], + "type": "object" + }, "ListenerConfig": { "additionalProperties": false, "properties": { @@ -254,6 +290,14 @@ "maximum": 65535, "minimum": 0, "type": "integer" + }, + "hookPayloadDays": { + "default": 7, + "description": "Lifetime of canonical hook envelopes retained for retry, dead-letter\ninspection, and optional operator replay.", + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": "integer" } }, "type": "object" From e087b75e8d4916e61fbf6599b8b94fbab879daac Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 02:23:31 +0700 Subject: [PATCH 105/115] fix(scheduling): close beta release gaps Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 7 +- .github/workflows/release-canary.yml | 3 +- .github/workflows/release-candidate.yml | 13 +- .github/workflows/release-rehearsal.yml | 13 +- .../registry-casework-client-node/client.d.ts | 3 +- .../registry_casework_client/__init__.pyi | 9 +- crates/registry-casework-client/src/lib.rs | 4 +- .../registry-casework-core/src/task_grant.rs | 98 +++++++- crates/registry-casework/src/task_grants.rs | 41 +++- .../src/task_grants/native_exchange_tests.rs | 135 ++++++++++- .../examples/scheduling-auth-probe.rs | 133 +++++++++++ crates/registry-scheduling/src/config.rs | 6 +- crates/registry-scheduling/src/store.rs | 19 +- .../tests/postgres_commitments.rs | 217 +++++++++++++++++- crates/registry-schedulingctl/src/project.rs | 4 +- .../registry-schedulingctl/src/templates.rs | 75 +++++- .../casework/client.d.ts | 3 +- crates/registry-stack-client/src/lib.rs | 10 +- products/casework/CHANGELOG.md | 3 + products/casework/DEV-SOURCES.md | 9 + products/casework/TASK_GRANTS.md | 14 +- .../contracts/security-invariant-matrix.yaml | 2 +- .../contracts/security-test-traceability.yaml | 2 +- .../generated/registry-casework.openapi.json | 58 +++++ products/casework/scripts/generate_openapi.py | 6 +- .../acceptance-test-traceability.yaml | 2 +- .../identifiers/generated/catalog.v1.json | 2 +- products/scheduling/AGENTS.md | 9 +- products/scheduling/README.md | 8 +- products/scheduling/TASK_GRANTS.md | 116 +++++++++- .../contracts/security-invariant-matrix.yaml | 6 +- .../fixtures/household-afternoon.yaml | 52 +++++ .../fixtures/household-morning.yaml | 55 +++++ .../standalone-arrival-window/records.yaml | 5 + .../runtime.example.yaml | 107 +++++++++ .../standalone-arrival-window/scheduling.yaml | 107 +++++++++ .../standalone-exact-time/records.yaml | 26 +++ .../generated/runtime/runtime.schema.json | 4 +- .../scheduling/scripts/check-checkpoint.sh | 16 +- release/OPERATIONS.md | 16 +- release/VERIFY.md | 14 +- release/docker/Dockerfile.scheduling | 2 + release/scripts/build-release-binaries.sh | 27 ++- release/scripts/build-release-image.sh | 2 +- release/scripts/check-gates-inventory.py | 7 +- release/scripts/cleanup-release-candidates.py | 1 + .../collect-rehearsal-advisory-evidence.py | 4 +- .../scripts/merge-release-binary-shards.py | 34 ++- release/scripts/registry-release | 3 + release/scripts/release_candidate.py | 8 +- .../scripts/smoke-release-image-oci-labels.sh | 2 +- .../test_check_release_image_oci_labels.py | 14 +- .../test_cleanup_release_candidates.py | 12 + ...est_collect_rehearsal_advisory_evidence.py | 18 ++ .../test_merge_release_binary_shards.py | 21 +- release/scripts/test_registry_release.py | 70 +++++- .../scripts/test_registry_release_plans.py | 5 + release/scripts/test_release_candidate.py | 45 +++- release/scripts/test_release_rehearsal.py | 8 +- .../test_release_workflow_structure.py | 62 ++++- release/scripts/test_zig_glibc_compiler.py | 7 +- 61 files changed, 1668 insertions(+), 116 deletions(-) create mode 100644 crates/registry-scheduling/examples/scheduling-auth-probe.rs create mode 100644 products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml create mode 100644 products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml create mode 100644 products/scheduling/examples/standalone-arrival-window/records.yaml create mode 100644 products/scheduling/examples/standalone-arrival-window/runtime.example.yaml create mode 100644 products/scheduling/examples/standalone-arrival-window/scheduling.yaml create mode 100644 products/scheduling/examples/standalone-exact-time/records.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2027963572..f76b18ef93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -870,15 +870,18 @@ jobs: BREG_POSTGRES_LANE: ${{ matrix.lane }} run: products/breg/scripts/test-postgres.sh --lane "$BREG_POSTGRES_LANE" - - name: Casework task approval through stock issuer and BReg + - name: Casework task approval through stock issuer to Evidence, BReg, and Scheduling if: matrix.lane == 'postgres' shell: bash run: | set -euo pipefail + cargo build --locked --profile ci -p registry-scheduling \ + --example scheduling-auth-probe + SCHEDULING_AUTH_PROBE_BIN="${GITHUB_WORKSPACE}/target/ci/examples/scheduling-auth-probe" \ CASEWORK_ASSIGNMENT_TEST_DATABASE_URL="${BREG_TEST_DATABASE_URL}" \ cargo test --locked --profile ci -p registry-casework \ --features postgres-test --lib \ - approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes \ + approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid \ -- --ignored cargo build --locked --profile ci -p registry-casework -p registry-caseworkctl cargo test --locked --profile ci -p registry-casework \ diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml index c0a4face13..1ed9982d57 100644 --- a/.github/workflows/release-canary.yml +++ b/.github/workflows/release-canary.yml @@ -69,7 +69,7 @@ jobs: PY printf '{"spdxVersion":"SPDX-2.3","name":"release-canary"}\n' \ > canary/bundle-root/registry-stack-${tag}.sbom.spdx.json - image_names=(relay evidence discovery breg casework) + image_names=(relay evidence discovery breg casework scheduling) write_image_reports() { local name="$1" @@ -109,6 +109,7 @@ jobs: "evidence-image", "breg-image", "casework-image", + "scheduling-image", "relay-image" ] }' > "${evidence_root}/advisory-verdict.json" diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index dd5cefd9e6..2a7b1dfba9 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -255,7 +255,7 @@ jobs: strategy: fail-fast: false matrix: - group: [core, breg, casework] + group: [core, breg, casework, scheduling] steps: - name: Checkout exact candidate source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -340,6 +340,12 @@ jobs: name: canonical-linux-binaries-casework-${{ needs.validate.outputs.source_sha }}-${{ github.run_id }}-${{ github.run_attempt }} path: binary-shards/casework + - name: Download canonical Linux Scheduling binary shard + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: canonical-linux-binaries-scheduling-${{ needs.validate.outputs.source_sha }}-${{ github.run_id }}-${{ github.run_attempt }} + path: binary-shards/scheduling + - name: Merge and smoke the canonical Linux payload run: | set -euo pipefail @@ -349,6 +355,7 @@ jobs: --core binary-shards/core \ --breg binary-shards/breg \ --casework binary-shards/casework \ + --scheduling binary-shards/scheduling \ --output dist \ --builder-image "${RELEASE_BUILDER_IMAGE}" test "$("dist/bin/relay-${{ needs.validate.outputs.tag }}-linux-amd64" --version)" = \ @@ -369,6 +376,10 @@ jobs: test "$("dist/bin/caseworkctl-${{ needs.validate.outputs.tag }}-linux-amd64" --version)" = \ "caseworkctl ${{ needs.validate.outputs.version }}" fi + if (( release_major > 0 || release_minor >= 33 )); then + test "$(dist/image-bin/scheduling --version)" = \ + "scheduling ${{ needs.validate.outputs.version }}" + fi - name: Set up Docker Buildx id: buildx diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml index d842c7d803..68509bb8d1 100644 --- a/.github/workflows/release-rehearsal.yml +++ b/.github/workflows/release-rehearsal.yml @@ -128,7 +128,7 @@ jobs: strategy: fail-fast: false matrix: - group: [core, breg, casework] + group: [core, breg, casework, scheduling] steps: - name: Checkout exact prepared source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -225,6 +225,12 @@ jobs: name: release-rehearsal-binaries-casework-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} path: binary-shards/casework + - name: Download canonical Linux Scheduling binary shard + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-rehearsal-binaries-scheduling-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} + path: binary-shards/scheduling + - name: Merge and smoke the canonical Linux payload env: REHEARSAL_VERSION: ${{ inputs.version }} @@ -237,6 +243,7 @@ jobs: --core binary-shards/core \ --breg binary-shards/breg \ --casework binary-shards/casework \ + --scheduling binary-shards/scheduling \ --output dist \ --builder-image "${RELEASE_BUILDER_IMAGE}" test "$("dist/bin/breg-v${REHEARSAL_VERSION}-linux-amd64" --version)" = \ @@ -251,6 +258,10 @@ jobs: test "$("dist/bin/caseworkctl-v${REHEARSAL_VERSION}-linux-amd64" --version)" = \ "caseworkctl ${REHEARSAL_VERSION}" fi + if (( release_major > 0 || release_minor >= 33 )); then + test "$(dist/image-bin/scheduling --version)" = \ + "scheduling ${REHEARSAL_VERSION}" + fi - name: Set up Docker Buildx for advisory evidence if: ${{ inputs.advisory_evidence }} diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index 0a368b69a8..2948ef4bcd 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -24,8 +24,9 @@ export type PageStatus = 'complete' | 'budget_exhausted' | 'source_unavailable' export type HistoryKind = 'observed' | 'opened' | 'claimed' | 'assigned' | 'delegated' | 'caseload_moved' | 'clock_reminder' | 'clock_step_applied' | 'clock_recomputed' | 'released' | 'draft_saved' | 'attempt_reserved' | 'attempt_uncertain' | 'action_completed' | 'attempt_settled' | 'superseded' | 'completed' export interface IssuerPrincipal { issuer: string; subject: string } -export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } +export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } | { type: 'scheduling'; permissions: ReadonlyArray } export interface TaskPermission { collection: string; operations: ReadonlyArray } +export interface SchedulingTaskPermission { service: string; location: string; actions: ReadonlyArray } export interface EvidenceRequesterContext { requesterTags: ReadonlyArray; audience: string } export interface TaskApprovalRequest { templateId: string; templateVersion: string } export interface TaskTemplatePreview { diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index 3d9dfbeab0..3b57fe97ad 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -35,7 +35,14 @@ class EvidenceTaskBounds(TypedDict): class BregTaskBounds(TypedDict): type: Literal["breg"] permissions: list[TaskPermission] -TaskGrantBounds: TypeAlias = EvidenceTaskBounds | BregTaskBounds +class SchedulingTaskPermission(TypedDict): + service: str + location: str + actions: list[str] +class SchedulingTaskBounds(TypedDict): + type: Literal["scheduling"] + permissions: list[SchedulingTaskPermission] +TaskGrantBounds: TypeAlias = EvidenceTaskBounds | BregTaskBounds | SchedulingTaskBounds class EvidenceRequesterContext(TypedDict): requesterTags: list[str] audience: str diff --git a/crates/registry-casework-client/src/lib.rs b/crates/registry-casework-client/src/lib.rs index 19fb3ab2a4..c47dcd9fe9 100644 --- a/crates/registry-casework-client/src/lib.rs +++ b/crates/registry-casework-client/src/lib.rs @@ -41,8 +41,8 @@ pub use registry_casework_core::{ ReviewTaskContext, ReviewTaskContextData, ReviewTaskDraft, ReviewTaskDraftInput, ReviewTaskPage, ReviewValidationError, ReviewValidationReason, ReviewerDecisionKind, ReviewerTask, ReviewerTaskState, RoutingActivity, RoutingCondition, RoutingPredicate, - RoutingRule, SaveDraftRequest, SourceBinding, SourceContextBinding, SourcePolicy, - SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, SubjectClockAnchor, + RoutingRule, SaveDraftRequest, SchedulingTaskPermission, SourceBinding, SourceContextBinding, + SourcePolicy, SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, SubjectRef, SubmissionDigest, TaskApprovalRequest, TaskAssertionResponse, TaskGrantBounds, TaskGrantList, TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, TaskPermission, TaskTemplatePreview, diff --git a/crates/registry-casework-core/src/task_grant.rs b/crates/registry-casework-core/src/task_grant.rs index 0167be7c21..c08e7ae343 100644 --- a/crates/registry-casework-core/src/task_grant.rs +++ b/crates/registry-casework-core/src/task_grant.rs @@ -38,8 +38,8 @@ pub struct TaskTemplate { pub purpose: String, pub bounds: TaskGrantBounds, /// Evidence-only requester context signed into the authority assertion. - /// BREG derives neither requester admission nor relying-party audience - /// from these fields and therefore forbids the block entirely. + /// Other products derive neither requester admission nor relying-party + /// audience from these fields and therefore forbid the block entirely. #[serde(default, skip_serializing_if = "Option::is_none")] pub evidence_context: Option, /// Exact token identity keys mapped to governed source logical fields. @@ -76,8 +76,15 @@ impl fmt::Debug for TaskTemplate { #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum TaskGrantBounds { - Evidence { requirement: String }, - Breg { permissions: Vec }, + Evidence { + requirement: String, + }, + Breg { + permissions: Vec, + }, + Scheduling { + permissions: Vec, + }, } #[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] @@ -87,6 +94,19 @@ pub struct TaskPermission { pub operations: Vec, } +/// Exact Scheduling commitment authority copied into a task grant. +/// +/// This duplicates the public wire shape deliberately: Casework is an +/// authority for governed templates, not a dependency on Scheduling's runtime +/// or model crate. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SchedulingTaskPermission { + pub service: String, + pub location: String, + pub actions: Vec, +} + impl TaskGrantBounds { pub fn check(&self) -> Result<(), TaskGrantError> { match self { @@ -107,6 +127,25 @@ impl TaskGrantBounds { } Ok(()) } + Self::Scheduling { permissions } + if !permissions.is_empty() && permissions.len() <= 64 => + { + let mut offerings = BTreeSet::new(); + for permission in permissions { + if !bounded_grant_identifier(&permission.service, 512) + || !bounded_grant_identifier(&permission.location, 512) + || !offerings.insert((&permission.service, &permission.location)) + || !unique(&permission.actions, 32) + || permission + .actions + .iter() + .any(|action| !valid_operation(action)) + { + return Err(TaskGrantError::Policy); + } + } + Ok(()) + } _ => Err(TaskGrantError::Policy), } } @@ -204,7 +243,7 @@ impl TaskTemplate { self.bounds.check()?; match (&self.bounds, &self.evidence_context) { (TaskGrantBounds::Evidence { .. }, Some(context)) => context.check(), - (TaskGrantBounds::Breg { .. }, None) => Ok(()), + (TaskGrantBounds::Breg { .. } | TaskGrantBounds::Scheduling { .. }, None) => Ok(()), _ => Err(TaskGrantError::Policy), } } @@ -365,6 +404,17 @@ fn bounded(value: &str, maximum: usize) -> bool { fn bounded_grant_identifier(value: &str, maximum: usize) -> bool { bounded(value, maximum) && !value.chars().any(char::is_whitespace) } +fn valid_operation(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(b'a'..=b'z')) + && value.len() <= 128 + && !value.contains('*') + && bytes.all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b':' | b'-') + }) +} fn unique(values: &[String], maximum: usize) -> bool { !values.is_empty() && values.len() <= maximum @@ -457,7 +507,34 @@ mod tests { } #[test] - fn evidence_templates_require_closed_requester_context_and_breg_forbids_it() { + fn scheduling_bounds_match_the_runtime_claim_grammar() { + let bounds: TaskGrantBounds = serde_json::from_value(serde_json::json!({ + "type":"scheduling", + "permissions":[{ + "service":"registry-update", + "location":"bangkok-counter", + "actions":["appointment.create","appointment.reschedule"] + }] + })) + .unwrap(); + assert!(bounds.check().is_ok()); + + for value in [ + serde_json::json!({"type":"scheduling","permissions":[]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry update","location":"bangkok-counter","actions":["appointment.create"]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"*","actions":["appointment.create"]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":[]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":["Appointment.create"]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":["appointment.create","appointment.create"]}]}), + serde_json::json!({"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":["appointment.create"]},{"service":"registry-update","location":"bangkok-counter","actions":["appointment.cancel"]}]}), + ] { + let bounds: TaskGrantBounds = serde_json::from_value(value).unwrap(); + assert!(bounds.check().is_err()); + } + } + + #[test] + fn evidence_templates_require_closed_requester_context_and_other_products_forbid_it() { let project: CaseworkProject = serde_json::from_value(serde_json::json!({ "apiVersion": crate::CASEWORK_API_VERSION, "kind": crate::CASEWORK_KIND, "casework": {"id":"tasks", "version":"1"}, @@ -492,6 +569,15 @@ mod tests { .unwrap(); assert!(template.check(&project).is_err()); + template.bounds = serde_json::from_value(serde_json::json!({ + "type":"scheduling", "permissions":[{ + "service":"registry-update", "location":"bangkok-counter", + "actions":["appointment.create"] + }] + })) + .unwrap(); + assert!(template.check(&project).is_err()); + template.bounds = serde_json::from_value(serde_json::json!({ "type":"evidence", "requirement":"urn:test:requirement:adult" })) diff --git a/crates/registry-casework/src/task_grants.rs b/crates/registry-casework/src/task_grants.rs index af01767c53..aece63f642 100644 --- a/crates/registry-casework/src/task_grants.rs +++ b/crates/registry-casework/src/task_grants.rs @@ -1750,7 +1750,7 @@ fn review_source_binding_matches( mod evidence_assertion_tests { use super::*; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - use registry_casework_core::{IssuerPrincipal, SubjectRef}; + use registry_casework_core::{IssuerPrincipal, SubjectRef, TaskGrantBounds}; use std::collections::BTreeMap; #[test] @@ -1837,6 +1837,45 @@ mod evidence_assertion_tests { assert!(!serialized.contains("approver-subject-canary")); assert!(payload.get("evidence_context").is_none()); } + + #[test] + fn governed_scheduling_bounds_are_the_platform_verifiers_wire_contract() { + let bounds: TaskGrantBounds = serde_json::from_value(json!({ + "type":"scheduling", + "permissions":[{ + "service":"registry-update", + "location":"bangkok-counter", + "actions":["appointment.create"] + }] + })) + .unwrap(); + bounds.check().unwrap(); + + let claims: registry_platform_oidc::Claims = serde_json::from_value(json!({ + "sub":"agent", + "exp":2000, + "registry_grant_id":"00000000-0000-4000-8000-000000000001", + "registry_grant_source_issuer":"https://casework.test", + "registry_grant_client":"scheduling-agent", + "registry_grant_resource":"urn:test:scheduling", + "registry_grant_exp":1900, + "registry_grant_bounds":serde_json::to_value(bounds).unwrap(), + "registry_purpose":"schedule-registry-update", + "registry_approver":"approver-reference" + })) + .unwrap(); + let grant = registry_platform_oidc::grant_claims( + &claims, + ®istry_platform_oidc::ClaimNames::default(), + 1000, + ) + .unwrap() + .unwrap(); + let permissions = grant.bounds().scheduling_permissions().unwrap(); + assert_eq!(permissions[0].service(), "registry-update"); + assert_eq!(permissions[0].location(), "bangkok-counter"); + assert_eq!(permissions[0].actions(), ["appointment.create"]); + } } #[cfg(all(test, feature = "postgres-test"))] diff --git a/crates/registry-casework/src/task_grants/native_exchange_tests.rs b/crates/registry-casework/src/task_grants/native_exchange_tests.rs index 45c662de63..c7f78f12a9 100644 --- a/crates/registry-casework/src/task_grants/native_exchange_tests.rs +++ b/crates/registry-casework/src/task_grants/native_exchange_tests.rs @@ -1,8 +1,10 @@ -//! Actual Casework approval -> native RFC 8693 exchange -> Evidence and BREG resources. +//! Actual Casework approval -> native RFC 8693 exchange -> Evidence, BREG, and +//! Scheduling's real bearer authenticator, without a product crate dependency. //! Credentials and protected response bodies stay in memory and never enter logs or argv. -//! Set the two disposable database variables named by the ignore reason, then run +//! Set the two disposable database variables and absolute Scheduling probe path +//! named by the ignore reason, then run //! `cargo test --locked -p registry-casework --features postgres-test --lib -//! approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes -- --ignored`. +//! approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid -- --ignored`. use super::native_resource as resource; use super::*; use async_trait::async_trait; @@ -21,8 +23,10 @@ use registry_thunderid_tooling::{container::Session, description::*, local, rend use std::{ collections::BTreeMap, fs, + io::Write, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, + process::{Command, Stdio}, sync::Arc, }; use wiremock::{ @@ -32,10 +36,12 @@ use wiremock::{ const CASEWORK_RESOURCE: &str = "urn:casework:native-task"; const BREG_RESOURCE: &str = "urn:breg:task-test"; const EVIDENCE_RESOURCE: &str = "urn:registry:evidence:fixture"; +const SCHEDULING_RESOURCE: &str = "urn:registry:scheduling:fixture"; const EVIDENCE_REQUIREMENT: &str = "urn:example:fixture:requirement:adult-status:v1"; const EVIDENCE_AUDIENCE: &str = "https://relying.invalid/procedure"; const EVIDENCE_TAG: &str = "fixture-agency"; const AUTHORITY: &str = "https://casework.example"; +const SCHEDULING_AUTH_PROBE_ENV: &str = "SCHEDULING_AUTH_PROBE_BIN"; const EVIDENCE_SIGNING_KEY: &str = r#"{"kty":"EC","crv":"P-256","d":"MInq88dvxx-e1-MEfmdes4I6Gt2QbsKoEmYyk2j0Oj4","x":"3kpzAK6fK6xyfqbdp0HvfZCqfgz7MajMviKyM6bsNE4","y":"GkSdSn8xqge52rp9Sv-4qPaw1Q9TJ2eMUyY22flavLU","alg":"ES256","kid":"_QkPweRjMZxmIHnz7v8tj3coTKx-90L2LRsZbkeP_Bo"}"#; fn binding() -> SourceBinding { @@ -500,6 +506,23 @@ fn start_issuer( }], }], }); + description.resource_servers.push(ResourceServer { + id: Uuid::new_v4().to_string(), + name: "Scheduling task target".into(), + identifier: SCHEDULING_RESOURCE.into(), + description: "Scheduling booking reached only after task exchange".into(), + resources: vec![Resource { + name: "Scheduling".into(), + handle: "scheduling".into(), + parent: None, + description: "Scheduling commitment".into(), + actions: vec![Action { + name: "Commit".into(), + handle: "commit".into(), + description: "Commit bounded Scheduling capacity".into(), + }], + }], + }); description .machine_clients .iter_mut() @@ -597,6 +620,53 @@ fn payload(token: &str) -> Value { ) .unwrap() } + +/// Cross the process boundary into Scheduling without introducing a product +/// crate dependency. The credential and public verifier material travel only +/// over stdin; the probe returns one non-sensitive verdict on stdout. +fn prove_scheduling_authentication(token: &str, issuer: &Issuer) { + let probe = PathBuf::from( + std::env::var_os(SCHEDULING_AUTH_PROBE_ENV) + .expect("the absolute Scheduling authentication probe path is required"), + ); + assert!( + probe.is_absolute(), + "the Scheduling probe path must be absolute" + ); + let input = serde_json::to_vec(&json!({ + "token": token, + "issuer": issuer.url(), + "audience": SCHEDULING_RESOURCE, + "client": "task-agent", + "assertionIssuer": AUTHORITY, + "jwks": issuer.jwks, + "service": "registry-update", + "location": "bangkok-counter", + "action": "appointment.create" + })) + .unwrap(); + let mut child = Command::new(&probe) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the Scheduling authentication probe starts"); + child + .stdin + .take() + .expect("the Scheduling authentication probe has stdin") + .write_all(&input) + .expect("the Scheduling authentication probe reads its bounded input"); + let output = child + .wait_with_output() + .expect("the Scheduling authentication probe finishes"); + assert!( + output.status.success(), + "the exact exchanged bearer was refused by Scheduling" + ); + assert_eq!(output.stdout, b"allowed\n"); + assert!(output.stderr.is_empty()); +} struct Fixture { app: Router, item: Uuid, @@ -661,7 +731,8 @@ async fn fixture(issuer: &Issuer, key: registry_platform_crypto::PrivateJwk) -> let operations = breg["accessProfiles"][1]["permissions"][0]["operations"].clone(); let template:TaskTemplate=serde_json::from_value(json!({"id":"draft","version":"1","label":"Prepare correction draft","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":agent},"client":"task-agent","resource":BREG_RESOURCE,"purpose":"review","scopes":["records:get"],"bounds":{"type":"breg","permissions":[{"collection":"correction-requests","operations":operations}]},"subjects":{"tenant_claim":"tenant"},"lifetimeSeconds":900})).unwrap(); let evidence_template:TaskTemplate=serde_json::from_value(json!({"id":"evidence-check","version":"1","label":"Check adult status","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":evidence_agent},"client":"evidence-task-agent","resource":EVIDENCE_RESOURCE,"purpose":"fixture-eligibility","scopes":["evidence:invoke"],"bounds":{"type":"evidence","requirement":EVIDENCE_REQUIREMENT},"evidenceContext":{"requesterTags":[EVIDENCE_TAG],"audience":EVIDENCE_AUDIENCE},"subjects":{"birth_date":"birth_date","family_name":"family_name","given_name":"given_name"},"lifetimeSeconds":900})).unwrap(); - let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"native-tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Synthetic source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template,evidence_template]})).unwrap(); + let scheduling_template:TaskTemplate=serde_json::from_value(json!({"id":"schedule-update","version":"1","label":"Book a registry update","eligibleTeams":["team"],"eligibleProfiles":["staff"],"source":"source","itemKinds":["request"],"itemStates":["claimed"],"agent":{"issuer":issuer.url(),"subject":agent},"client":"task-agent","resource":SCHEDULING_RESOURCE,"purpose":"schedule-registry-update","scopes":["scheduling:commit"],"bounds":{"type":"scheduling","permissions":[{"service":"registry-update","location":"bangkok-counter","actions":["appointment.create"]}]},"subjects":{"tenant_claim":"tenant"},"lifetimeSeconds":900})).unwrap(); + let project:CaseworkProject=serde_json::from_value(json!({"apiVersion":CASEWORK_API_VERSION,"kind":CASEWORK_KIND,"casework":{"id":"native-tasks","version":"1"},"accessProfiles":[{"id":"staff","principalClaim":"sub","requiredScopes":["casework:staff"],"role":"staff"}],"queues":[{"id":"review","label":"Review"}],"sources":[{"id":"source","adapter":"test","description":"Synthetic source","requests":[{"entity":"request","queue":"review"}]}],"taskTemplates":[template,evidence_template,scheduling_template]})).unwrap(); store .activate_task_templates(&project.task_templates) .await @@ -764,9 +835,9 @@ async fn request( ) } -#[ignore = "requires Docker plus disposable CASEWORK_ASSIGNMENT_TEST_DATABASE_URL and BREG_TEST_DATABASE_URL"] +#[ignore = "requires Docker, disposable CASEWORK_ASSIGNMENT_TEST_DATABASE_URL and BREG_TEST_DATABASE_URL, and absolute SCHEDULING_AUTH_PROBE_BIN"] #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes() { +async fn approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid() { let root = tempfile::tempdir().unwrap(); let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap(); let casework_port = listener.local_addr().unwrap().port(); @@ -969,6 +1040,58 @@ async fn approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_re "re-exchange must preserve {name}" ); } + let (code, scheduling_grant) = request( + &f.app, + "POST", + &base, + &human, + true, + Some(json!({"templateId":"schedule-update","templateVersion":"1"})), + Some("native-scheduling-approval"), + ) + .await; + assert_eq!(code, StatusCode::OK); + let scheduling_grant_id = scheduling_grant["id"].as_str().unwrap(); + let (code, scheduling_assertion) = request( + &f.app, + "POST", + &format!("/v1/task-grants/{scheduling_grant_id}/assertion"), + &bootstrap, + false, + None, + None, + ) + .await; + assert_eq!(code, StatusCode::OK); + let scheduling_exchange = provider( + &issuer.url(), + "task-agent", + &agent_key, + SCHEDULING_RESOURCE, + "scheduling:commit", + ); + let scheduling_token = bearer( + &scheduling_exchange + .exchange(scheduling_assertion["assertion"].as_str().unwrap()) + .await + .unwrap(), + ); + let scheduling_claims = payload(&scheduling_token); + assert_eq!( + scheduling_claims["registry_grant_bounds"], + json!({"type":"scheduling","permissions":[{ + "service":"registry-update", + "location":"bangkok-counter", + "actions":["appointment.create"] + }]}) + ); + assert_eq!( + scheduling_claims["registry_grant_resource"], + SCHEDULING_RESOURCE + ); + assert_eq!(scheduling_claims["registry_grant_client"], "task-agent"); + assert_eq!(scheduling_claims["scope"], "scheduling:commit"); + prove_scheduling_authentication(&scheduling_token, &issuer); let (code, evidence_grant) = request( &f.app, "POST", diff --git a/crates/registry-scheduling/examples/scheduling-auth-probe.rs b/crates/registry-scheduling/examples/scheduling-auth-probe.rs new file mode 100644 index 0000000000..559972127d --- /dev/null +++ b/crates/registry-scheduling/examples/scheduling-auth-probe.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Test-only process boundary for proving that a bearer minted by the stock +//! institutional exchange is accepted by Scheduling's real authenticator. +//! +//! The complete input arrives on stdin so the credential never enters argv or +//! a process listing. Output is exactly `allowed` or `refused`; no input or +//! verifier detail is logged. + +use std::collections::BTreeMap; +use std::io::Read; +use std::sync::Arc; + +use jsonwebtoken::{jwk::JwkSet, Algorithm}; +use registry_platform_oidc::{JwksFetcher, JwksFetcherConfig, TokenVerifierConfig}; +use registry_scheduling::auth::SchedulingAuthenticator; +use registry_scheduling::config::{OidcConfig, OidcJwksSource}; +use serde::Deserialize; + +const MAXIMUM_INPUT_BYTES: u64 = 1024 * 1024; +const MAXIMUM_VALUE_BYTES: usize = 512; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProbeInput { + token: String, + issuer: String, + audience: String, + client: String, + assertion_issuer: String, + jwks: serde_json::Value, + service: String, + location: String, + action: String, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> std::process::ExitCode { + let allowed = match read_input() { + Ok(input) if validate_input(&input) => authenticate(input).await.unwrap_or(false), + _ => false, + }; + if allowed { + println!("allowed"); + std::process::ExitCode::SUCCESS + } else { + println!("refused"); + std::process::ExitCode::FAILURE + } +} + +fn read_input() -> Result { + let mut bytes = Vec::new(); + std::io::stdin() + .take(MAXIMUM_INPUT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| ())?; + if bytes.len() as u64 > MAXIMUM_INPUT_BYTES { + return Err(()); + } + serde_json::from_slice(&bytes).map_err(|_| ()) +} + +fn validate_input(input: &ProbeInput) -> bool { + !input.token.is_empty() + && input.token.len() <= 64 * 1024 + && [ + &input.issuer, + &input.audience, + &input.client, + &input.assertion_issuer, + &input.service, + &input.location, + &input.action, + ] + .into_iter() + .all(|value| { + !value.is_empty() + && value.len() <= MAXIMUM_VALUE_BYTES + && !value.chars().any(char::is_control) + }) +} + +async fn authenticate(input: ProbeInput) -> Result { + let keys: JwkSet = serde_json::from_value(input.jwks).map_err(|_| ())?; + let assertion_issuers = + BTreeMap::from([(input.client.clone(), vec![input.assertion_issuer.clone()])]); + let oidc = OidcConfig { + allowed_clients: vec![input.client.clone()], + assertion_issuers: assertion_issuers.clone(), + issuer: input.issuer.clone(), + audience: input.audience.clone(), + jwks_uri: None, + jwks_source: OidcJwksSource::Discovery, + scope_claim: "scope".to_owned(), + reads_scope: "scheduling-read".to_owned(), + explain_scope: "scheduling-explain".to_owned(), + }; + let verifier = TokenVerifierConfig::access_token_profile( + input.issuer, + vec![input.audience], + vec![Algorithm::RS256], + vec!["at+jwt".to_owned()], + ) + .with_scope_claim("scope") + .with_allowed_clients(vec![input.client]) + .with_assertion_issuers(assertion_issuers); + let authenticator = SchedulingAuthenticator::new( + &oidc, + verifier, + Arc::new(JwksFetcher::new_static(keys, JwksFetcherConfig::defaults())), + ); + let caller = authenticator + .authenticate_mutate(&input.token) + .await + .map_err(|_| ())?; + let Some(grant) = caller.grant else { + return Ok(false); + }; + Ok(grant + .bounds() + .scheduling_permissions() + .is_some_and(|permissions| { + permissions.iter().any(|permission| { + permission.service() == input.service + && permission.location() == input.location + && permission + .actions() + .iter() + .any(|action| action == &input.action) + }) + })) +} diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index dd0d1324ea..0e4b29749c 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -286,9 +286,9 @@ pub struct AuditConfig { pub hash_key_ref: String, } -/// Where due reminder intents are dispatched. An absent reminders destination -/// is a supported deployment: intents are written and stay local, so an -/// operator can adopt the product before wiring a notification bus. +/// Where due reminder and lifecycle-hook intents are dispatched. An absent +/// reminders destination and an empty hook map are supported: intents stay +/// local, so an operator can adopt the product before wiring delivery buses. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 53cc240674..069a25a3ac 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -230,9 +230,12 @@ impl ClaimState { /// The caller-side facts every commitment carries. `now` is observed once /// per request and used for every decision the caller reads inside it, so /// a transaction never mixes two observations of the time it answers with. -/// Authorization currency is the one deliberate exception: the grant's -/// expiry is re-checked against a fresh observation taken inside the -/// transaction, after every wait and immediately before the final write. +/// Authorization currency and transfer of a hold are the two deliberate +/// exceptions. The grant's expiry is re-checked against a fresh observation +/// taken inside the transaction, after every wait and immediately before the +/// final write. A hold's expiry is re-checked after its supply anchor is +/// locked, so capacity another transaction reclaimed after expiry cannot also +/// be transferred into an appointment by an older confirmation request. pub struct Commitment<'c> { pub now: DateTime, /// The revision of the policy this process evaluated, not the stored @@ -1193,9 +1196,13 @@ impl PostgresStore { return Err(AdmissionRefusal::HoldReleased.into()); } guard_revisions(&transaction, &commitment).await?; - // The expiry check stays here so a hold whose TTL lapsed inside this - // very transaction is refused by the same clock the snapshot used. - evaluate_hold_state(&hold_ledger_claim(&hold), commitment.now)?; + // A confirmation request may have entered while the hold was live but + // reached this anchor after its capacity was reclaimed. Re-read the + // clock only after taking the anchor: accepting the older request time + // here could transfer capacity that a later transaction already + // booked. Other caller-visible decisions keep the request's single + // clock observation. + evaluate_hold_state(&hold_ledger_claim(&hold), self.observed_now())?; if hold.policy_revision != commitment.policy_revision { return Err(AdmissionRefusal::PolicyChanged.into()); } diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 0fc04f41f5..43c27b8d8b 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -35,10 +35,13 @@ use registry_scheduling::runtime::{ AuditPublisher, }; use registry_scheduling::service::SchedulingService; -use registry_scheduling::store::{CommitError, Commitment, PostgresStore, SupplyContext}; +use registry_scheduling::store::{ + CommitError, CommitOutcome, Commitment, PostgresStore, SupplyContext, +}; use registry_scheduling_core::{ - location_closure_intervals, location_open_intervals, parse_policy_yaml, AdmissionRequest, - LocationRecord, PartyCounts, PoolMember, ResourcePool, SchedulingFacts, + location_closure_intervals, location_open_intervals, parse_policy_yaml, AdmissionRefusal, + AdmissionRequest, LocationRecord, OfferingPolicy, PartyCounts, PoolMember, ResourcePool, + SchedulingFacts, SchedulingPolicy, }; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -453,6 +456,45 @@ fn authenticator() -> SchedulingAuthenticator { SchedulingAuthenticator::new(&oidc, verifier, keys) } +/// Resolve the owned exact-time supply pieces a direct store commitment can +/// borrow. The HTTP service performs the same resolution before it opens the +/// capacity transaction. +fn exact_supply_parts( + policy: &SchedulingPolicy, + facts: &SchedulingFacts, + offering: &OfferingPolicy, +) -> ( + Vec, + Vec, + Vec, +) { + let exact = offering + .exact_time + .as_ref() + .expect("the test offering is exact-time"); + let members = facts + .pool(&exact.pool) + .expect("the seeded pool") + .members + .clone(); + let timezone = facts + .location(&offering.location) + .expect("the seeded location") + .timezone + .clone(); + let exceptions: Vec<_> = facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let open = location_open_intervals(policy, &offering.location, &timezone, &exceptions) + .expect("resolve the openings"); + let closures = location_closure_intervals(facts, &offering.location, &timezone) + .expect("resolve the closures"); + (members, open, closures) +} + /// Sign an access token, filling in the claims a real token always carries. fn token(mut claims: Value) -> String { let now = Utc::now().timestamp(); @@ -1623,6 +1665,175 @@ async fn an_expired_hold_returns_capacity_and_refuses_confirmation() { assert_eq!(problem["code"], "hold.released"); } +/// SEC-01 and SEC-11 at their shared transaction boundary. A confirmation may +/// enter while its hold is live and pause before it reaches the supply anchor. +/// Once the hold expires, a later transaction may reclaim and book that +/// capacity. The delayed confirmation must observe expiry after it takes the +/// anchor; transferring under its older request time would leave two active +/// bookings on one member. +#[tokio::test] +async fn a_delayed_confirmation_cannot_transfer_rebooked_capacity_after_expiry() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + let (status, hold) = fx + .post( + "/v1/holds", + &fx.agent, + "expiry-race-hold", + admission(&fx, OFFERING, slot), + ) + .await; + assert_eq!( + status, + StatusCode::CREATED, + "the setup hold commits: {hold}" + ); + let hold_id = + Uuid::parse_str(hold["holdId"].as_str().expect("a hold id")).expect("a hold UUID"); + let expires_at = moment(&hold, "expiresAt"); + let confirmation_started_at = expires_at - TimeDelta::seconds(1); + let after_expiry = expires_at + TimeDelta::seconds(1); + let hold_actor = fx + .store + .claim(hold_id) + .await + .expect("read the held claim") + .expect("the hold stands") + .actor; + + // Capture the confirmation's request clock and resolved supply while the + // hold is live, then stop it before the capacity transaction opens. This + // is the same gap as authentication and supply resolution in the service. + let delayed_store = fx.store.clone(); + let delayed_revision = fx.revision; + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (resume_tx, resume_rx) = tokio::sync::oneshot::channel(); + let delayed = tokio::spawn(async move { + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let offering = policy.offering(OFFERING).expect("the test offering"); + let (facts, facts_revision) = delayed_store + .facts() + .await + .expect("resolve supply before the stall"); + let (members, open, closures) = exact_supply_parts(&policy, &facts, offering); + let supply = SupplyContext::ExactTime { + exact: offering + .exact_time + .as_ref() + .expect("the exact-time offering"), + members: &members, + open: &open, + closures: &closures, + }; + let commitment = Commitment { + now: confirmation_started_at, + policy_revision: i64::try_from(delayed_revision).expect("a bounded revision"), + facts_revision, + actor: &hold_actor, + actor_issuer: ISSUER, + actor_subject: "principal-agent", + idempotency_key: "expiry-race-confirm", + request_hash: "sha256:expiry-race-confirm", + attempt_expires_at: confirmation_started_at + TimeDelta::days(7), + grant_exp_unix: None, + audit_event: Uuid::new_v4(), + audit_record: operator_audit(), + hooks: None, + }; + started_tx + .send(()) + .expect("the test is waiting for the captured confirmation"); + resume_rx.await.expect("resume the delayed confirmation"); + delayed_store + .confirm_hold(hold_id, offering, &supply, commitment) + .await + }); + started_rx + .await + .expect("the confirmation captured its live-hold context"); + + // A later request sees the hold as expired and legitimately books the + // released capacity before the older confirmation reaches its anchor. + let policy = parse_policy_yaml(POLICY).expect("the scheduling test policy"); + let offering = policy.offering(OFFERING).expect("the test offering"); + let (facts, facts_revision) = fx.store.facts().await.expect("resolve current supply"); + let (members, open, closures) = exact_supply_parts(&policy, &facts, offering); + let supply = SupplyContext::ExactTime { + exact: offering + .exact_time + .as_ref() + .expect("the exact-time offering"), + members: &members, + open: &open, + closures: &closures, + }; + let request: AdmissionRequest = + serde_json::from_value(admission(&fx, OFFERING, slot)).expect("the admission request"); + let competing = fx + .store + .create_appointment( + offering, + &supply, + &request, + Commitment { + now: after_expiry, + policy_revision: i64::try_from(fx.revision).expect("a bounded revision"), + facts_revision, + actor: "competing-actor", + actor_issuer: ISSUER, + actor_subject: "competing-principal", + idempotency_key: "expiry-race-booking", + request_hash: "sha256:expiry-race-booking", + attempt_expires_at: after_expiry + TimeDelta::days(7), + grant_exp_unix: None, + audit_event: Uuid::new_v4(), + audit_record: operator_audit(), + hooks: None, + }, + ) + .await + .expect("the expired hold releases capacity to the competing booking"); + assert!(matches!(competing, CommitOutcome::Booking(_))); + + // The delayed confirmation now reaches the anchor under a clock after the + // hold's expiry. It must refuse instead of transferring already-booked + // capacity under its older request observation. + fx.store.pin_clock(Arc::new(move || after_expiry)); + resume_tx + .send(()) + .expect("the delayed confirmation is still waiting"); + let confirmation = delayed.await.expect("the confirmation task answers"); + assert!( + matches!( + confirmation, + Err(CommitError::Refused(AdmissionRefusal::HoldExpired)) + ), + "the delayed confirmation is refused as expired: {confirmation:?}" + ); + + let counts = fx + .admin + .query_one( + "SELECT \ + (SELECT count(*) FROM scheduling_claims \ + WHERE kind='booking' AND state='active' AND displayed_start=$1), \ + (SELECT state FROM scheduling_claims WHERE claim_id=$2)", + &[&slot, &hold_id], + ) + .await + .expect("read the capacity decision"); + assert_eq!( + counts.get::<_, i64>(0), + 1, + "the expired hold never creates a second active booking" + ); + assert_eq!( + counts.get::<_, String>(1), + "active", + "the refused confirmation rolls back without consuming the hold row" + ); +} + #[tokio::test] async fn cursors_page_their_own_listing_and_refuse_foreign_contexts() { let fx = fixture().await; diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index fcb6096b6b..cf6a0f162d 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -63,7 +63,7 @@ pub(super) fn init(project: &Path, template: &str) -> Result { "created": created, "next": [ "Run schedulingctl check PROJECT, then schedulingctl test PROJECT.", - "Copy runtime.example.yaml to runtime.yaml, set its absolute paths, and run scheduling migrate and scheduling serve with it.", + "Copy runtime.example.yaml to runtime.yaml, set its absolute paths, run scheduling migrate, apply records.yaml, then run scheduling serve with it.", ], })) } @@ -438,12 +438,14 @@ mod tests { json!([ "scheduling.yaml", "runtime.example.yaml", + "records.yaml", "fixtures/household-morning.yaml", "fixtures/household-afternoon.yaml" ]) ); assert!(project.join(AUTHORED_POLICY_FILE).is_file()); assert!(project.join("runtime.example.yaml").is_file()); + assert!(project.join("records.yaml").is_file()); assert!(project.join("fixtures/household-morning.yaml").is_file()); assert!(project.join("fixtures/household-afternoon.yaml").is_file()); let error = init(&project, "standalone-arrival-window").unwrap_err(); diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs index 9f2f55ee22..0683e423b5 100644 --- a/crates/registry-schedulingctl/src/templates.rs +++ b/crates/registry-schedulingctl/src/templates.rs @@ -262,6 +262,37 @@ cases: code: schedule.unpublished "#; +/// Live environment records for the exact-time starter. They carry every +/// location and resource pool its policy references, plus the fold-day closure +/// the example explains and exercises. +const EXACT_TIME_RECORDS: &str = r#"locations: + - id: bangkok-counter + timezone: Asia/Bangkok + - id: new-york-hall + timezone: America/New_York +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true +exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" +"#; + /// The `standalone-arrival-window` policy: a Saturday morning household block /// with a public and an assisted subquota over a per-recipient units table, /// and a Saturday afternoon block over a banded table that refuses a party @@ -491,6 +522,15 @@ cases: units: 2 "#; +/// Live environment records for the arrival-window starter. Published windows +/// carry their own supply, so this project needs only its referenced location. +const ARRIVAL_WINDOW_RECORDS: &str = r#"locations: + - id: civic-hall + timezone: Asia/Bangkok +pools: [] +exceptions: [] +"#; + /// The operator runtime configuration example every template writes beside /// the authored policy. It names no project path, so one document serves every /// template and the committed example can never drift from what an adopter @@ -611,6 +651,7 @@ pub(super) fn template_files(template: &str) -> Option Some(vec![ ("scheduling.yaml", EXACT_TIME_POLICY), ("runtime.example.yaml", RUNTIME_EXAMPLE), + ("records.yaml", EXACT_TIME_RECORDS), ("fixtures/counter-stations.yaml", COUNTER_STATIONS_FIXTURE), ( "fixtures/fold-day-rebooking.yaml", @@ -620,6 +661,7 @@ pub(super) fn template_files(template: &str) -> Option Some(vec![ ("scheduling.yaml", ARRIVAL_WINDOW_POLICY), ("runtime.example.yaml", RUNTIME_EXAMPLE), + ("records.yaml", ARRIVAL_WINDOW_RECORDS), ("fixtures/household-morning.yaml", HOUSEHOLD_MORNING_FIXTURE), ( "fixtures/household-afternoon.yaml", @@ -635,8 +677,8 @@ mod tests { use super::*; use registry_scheduling_core::{ parse_fixture_yaml, parse_policy_yaml, AboveHighestBand, CaseStatus, RequiredUnitsPolicy, - AUTHORED_POLICY_FILE, SCHEDULING_FIXTURE_API_VERSION, SCHEDULING_FIXTURE_KIND, - SCHEDULING_POLICY_API_VERSION, SCHEDULING_POLICY_KIND, + SchedulingFacts, AUTHORED_POLICY_FILE, SCHEDULING_FIXTURE_API_VERSION, + SCHEDULING_FIXTURE_KIND, SCHEDULING_POLICY_API_VERSION, SCHEDULING_POLICY_KIND, }; const TEMPLATE_NAMES: [&str; 2] = ["standalone-exact-time", "standalone-arrival-window"]; @@ -650,6 +692,34 @@ mod tests { assert!(template_files("").is_none()); } + #[test] + fn every_template_carries_parseable_records_for_its_policy_references() { + for template in TEMPLATE_NAMES { + let files = template_files(template).unwrap(); + let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); + let records = files + .iter() + .find(|(relative, _)| *relative == "records.yaml") + .expect("the template carries live environment records"); + let facts: SchedulingFacts = + serde_norway::from_str(records.1).expect("the template records parse"); + for offering in &policy.offerings { + assert!( + facts.location(&offering.location).is_some(), + "{template}: missing location {}", + offering.location + ); + if let Some(exact_time) = &offering.exact_time { + assert!( + facts.pool(&exact_time.pool).is_some(), + "{template}: missing pool {}", + exact_time.pool + ); + } + } + } + } + #[test] fn every_template_policy_parses_and_checks_clean_with_pinned_names() { for template in TEMPLATE_NAMES { @@ -778,6 +848,7 @@ mod tests { vec![ "scheduling.yaml", "runtime.example.yaml", + "records.yaml", "fixtures/counter-stations.yaml", "fixtures/fold-day-rebooking.yaml", ] diff --git a/crates/registry-stack-client-node/casework/client.d.ts b/crates/registry-stack-client-node/casework/client.d.ts index 0a368b69a8..2948ef4bcd 100644 --- a/crates/registry-stack-client-node/casework/client.d.ts +++ b/crates/registry-stack-client-node/casework/client.d.ts @@ -24,8 +24,9 @@ export type PageStatus = 'complete' | 'budget_exhausted' | 'source_unavailable' export type HistoryKind = 'observed' | 'opened' | 'claimed' | 'assigned' | 'delegated' | 'caseload_moved' | 'clock_reminder' | 'clock_step_applied' | 'clock_recomputed' | 'released' | 'draft_saved' | 'attempt_reserved' | 'attempt_uncertain' | 'action_completed' | 'attempt_settled' | 'superseded' | 'completed' export interface IssuerPrincipal { issuer: string; subject: string } -export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } +export type TaskGrantBounds = { type: 'evidence'; requirement: string } | { type: 'breg'; permissions: ReadonlyArray } | { type: 'scheduling'; permissions: ReadonlyArray } export interface TaskPermission { collection: string; operations: ReadonlyArray } +export interface SchedulingTaskPermission { service: string; location: string; actions: ReadonlyArray } export interface EvidenceRequesterContext { requesterTags: ReadonlyArray; audience: string } export interface TaskApprovalRequest { templateId: string; templateVersion: string } export interface TaskTemplatePreview { diff --git a/crates/registry-stack-client/src/lib.rs b/crates/registry-stack-client/src/lib.rs index 291cb48cbc..afbd69132b 100644 --- a/crates/registry-stack-client/src/lib.rs +++ b/crates/registry-stack-client/src/lib.rs @@ -51,11 +51,11 @@ pub mod casework { ReviewTaskContextData, ReviewTaskDecisionRequest, ReviewTaskDraft, ReviewTaskDraftInput, ReviewTaskPage, ReviewTaskQuery, ReviewValidationError, ReviewValidationReason, ReviewerDecisionKind, ReviewerTask, ReviewerTaskState, RoutingActivity, RoutingCondition, - RoutingPredicate, RoutingRule, SaveDraftRequest, SourceBinding, SourceContextBinding, - SourcePolicy, SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, SubjectClockAnchor, - SubjectClockCompletion, SubjectClockPause, SubjectRef, SubmissionDigest, - TaskApprovalRequest, TaskAssertionResponse, TaskGrantBounds, TaskGrantList, - TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, + RoutingPredicate, RoutingRule, SaveDraftRequest, SchedulingTaskPermission, SourceBinding, + SourceContextBinding, SourcePolicy, SourceReceipt, SourceRequestPolicy, StaffingDiagnostic, + SubjectClockAnchor, SubjectClockCompletion, SubjectClockPause, SubjectRef, + SubmissionDigest, TaskApprovalRequest, TaskAssertionResponse, TaskGrantBounds, + TaskGrantList, TaskGrantRevocation, TaskGrantStatus, TaskGrantStatusDetails, TaskGrantView, TaskPermission, TaskTemplatePreview, TaskTemplatePreviews, TeamRecord, Uuid, WorkItem, WorkItemHistoryQuery, WorkItemPage, WorkItemRouting, WorkingDaysAfter, WorkingDaysBefore, WorkingWeekday, diff --git a/products/casework/CHANGELOG.md b/products/casework/CHANGELOG.md index 3c9874692b..719ad9bf33 100644 --- a/products/casework/CHANGELOG.md +++ b/products/casework/CHANGELOG.md @@ -31,6 +31,9 @@ back on the terminal request. Results and their constraints erase with the display payload at `terminalDays`; the accountability record keeps only a sha256 digest of the result until its own `accountabilityDays` closes. +- Allow governed Casework task templates to approve exact Scheduling service, + location, and commitment-action bounds. The existing generic ThunderID + exchange carries them to Scheduling without a product crate dependency. - The inbox, the next-item result, holdings, the item view, history, and clocks stay readable while a subject's source binding has moved within its source generation but reconciliation has not applied it yet. The retained occurrence diff --git a/products/casework/DEV-SOURCES.md b/products/casework/DEV-SOURCES.md index 98e9471a23..16979acacc 100644 --- a/products/casework/DEV-SOURCES.md +++ b/products/casework/DEV-SOURCES.md @@ -158,3 +158,12 @@ or bounds. Unknown, expired or revoked approvals fail. Restart retains the issuer keys, directory, database and grant deadline. `dev stop` stops owned services; `dev stop --remove` explicitly removes that session's database and private state according to the normal dev lifecycle. + +The destination is not limited to BREG. A shared issuer that registers a +Scheduling resource and scope can exchange an approved `type: scheduling` +template through the same `taskExchange` client and `dev grant` command. Set +that client's `resource` and `scopes` in the connection file to the Scheduling +audience and registered exchange scope. Scheduling then verifies its own exact +service, location, action, client, resource, deadline, and assertion-issuer +bounds; see [`../scheduling/TASK_GRANTS.md`](../scheduling/TASK_GRANTS.md) for +the booking request. diff --git a/products/casework/TASK_GRANTS.md b/products/casework/TASK_GRANTS.md index 9fb7481168..f07bb83d9f 100644 --- a/products/casework/TASK_GRANTS.md +++ b/products/casework/TASK_GRANTS.md @@ -16,8 +16,10 @@ Add `taskTemplates` to the Casework project. Each template declares: `itemStates` for retained source work items. A template cannot mix the two. - Exact `agent: {issuer, subject}`, OAuth `client`, one absolute `resource`, and `purpose`, plus explicit OAuth `scopes` for that resource. -- `bounds`, either `{type: evidence, requirement: ...}` or - `{type: breg, permissions: [{collection: ..., operations: [...]}]}`. +- `bounds`, one of `{type: evidence, requirement: ...}`, + `{type: breg, permissions: [{collection: ..., operations: [...]}]}`, or + `{type: scheduling, permissions: [{service: ..., location: ..., + actions: [...]}]}`. - `subjects`, mapping token identity keys to governed source logical fields. - `lifetimeSeconds`, no more than 900 seconds. @@ -29,6 +31,14 @@ the configured service reader for later checks. Callers cannot supply subject values. Changing a template requires a new version. Retiring a version invalidates its live grants; reactivation does not restore those grants. +Scheduling permissions use the same strict claim grammar its runtime verifies: +one to 64 unique `(service, location)` pairs, each with one to 32 unique action +names. Services and locations are exact, whitespace-free values with no +wildcard. Actions are lowercase operation names such as +`appointment.create`, `appointment.reschedule`, `appointment.cancel`, +`hold.create`, or `hold.release`. Casework copies these governed bounds into +the signed assertion; it does not import Scheduling or infer an offering. + ## Configure the authority Runtime `taskAuthority` declares assertion `issuer`, `exchangeAudience`, diff --git a/products/casework/contracts/security-invariant-matrix.yaml b/products/casework/contracts/security-invariant-matrix.yaml index 295c9e6931..ff44fcc77c 100644 --- a/products/casework/contracts/security-invariant-matrix.yaml +++ b/products/casework/contracts/security-invariant-matrix.yaml @@ -23,7 +23,7 @@ invariants: - {id: CASEWORK-SEC-15, invariants: [11, 18], state: enforced, targetWave: mvp, threat: A stale timer or recompute fires an effect for completed work repeats an effect after restart or lets one review kind consume another kind's subject budget., enforcementPoint: pinned clock calculation and generation with review-kind-bound subject occurrence identity plus transactional source revision and occurrence fences, refusal: Keep concurrent review kinds on separate subject occurrences while continuing one kind across its versions; cancel terminal or superseded clocks and reject stale claims or recompute previews before recording reminders or local reassignment., negativeId: CASEWORK-NEG-15, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: subject_clocks_are_independent_across_concurrent_review_kinds}} - {id: CASEWORK-SEC-16, invariants: [1, 3, 17, 18], state: enforced, targetWave: mvp, threat: A producer or former team member records an outcome or replays a protected response; cancellation and a final decision both settle one request; one selected profile crosses into another profile's authority; or a non-exact receiver response marks completion delivered., enforcementPoint: pinned unified review policy with exact producer admission; one request-row transaction fence for terminal settlement; exact-role membership and selected-profile checks against the addressed pinned stage before mutation replay disclosure; and an empty 204 completion acknowledgement., refusal: Commit one cancellation-or-decision winner with one result and terminal event; require the selected Supervisor role and supervisor membership for assignment; require a pinned deciding profile with its matching membership for reviewer mutations and cached response replay; and retry any completion response other than an empty 204 under the bounded attempt policy., negativeId: CASEWORK-NEG-16, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: cancellation_and_final_decision_commit_exactly_one_terminal_result}} - {id: CASEWORK-SEC-17, invariants: [6, 9, 17, 18], state: enforced, targetWave: mvp, threat: Retained payloads or cached replay responses disclose erased source or review data or allow rediscovery to restore it., enforcementPoint: explicit source erasure and unified review retention with transactional tombstones and bounded cursor cleanup, refusal: Preserve live recovery before erasure and refuse expired replay without returning retained payloads or rehydrating erased source items., negativeId: CASEWORK-NEG-17, negativeTest: {path: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}} - - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human, an approved task loses accountable approver attribution, or restart extends approved authority., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval, a keyed pseudonymous approver claim, and standard token exchange, refusal: Refuse bootstrap and status clients at the source human profile and refuse revoked grants before a new BREG write while retaining the original grant deadline across restart., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}} + - {id: CASEWORK-SEC-18, invariants: [1, 3, 14, 17, 18], state: enforced, targetWave: contextual-authorization, threat: A local bootstrap credential manufactures task bounds or reads the source as a human, an approved task loses accountable approver attribution, or restart extends approved authority., enforcementPoint: explicit single-issuer resource group with separate source profiles plus real source-derived Casework approval, closed Evidence, BREG, or Scheduling bounds, a keyed pseudonymous approver claim, standard token exchange, and a process-boundary proof that the exact exchanged Scheduling bearer passes Scheduling's real authenticator, refusal: Refuse bootstrap and status clients at the source human profile, malformed product bounds at template activation, and revoked grants before a new BREG write while retaining the original grant deadline across restart., negativeId: CASEWORK-NEG-18, negativeTest: {path: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}} - {id: CASEWORK-SEC-19, invariants: [1, 9, 17, 18], state: enforced, targetWave: mvp, threat: A producer narrows a field the review kind never declared or a decision carries a result outside the pinned schema or request constraints., enforcementPoint: unified review create and decide validation against the pinned kind result schema and stored constraints with bounded instance paths, refusal: Accept a result only inside the pinned schema and constraints and bind changed constraints to a conflicting submission digest., negativeId: CASEWORK-NEG-19, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: prior_stage_identity_and_invalid_structured_result_emit_nothing}} - {id: CASEWORK-SEC-20, invariants: [9, 17], state: enforced, targetWave: mvp, threat: Any review result or private draft or history event or replay payload or constraint outlives terminal retention or survives as content inside accountability., enforcementPoint: bounded lock-skipping unified review retention erases private work and non-accountability payloads at terminalDays while retaining only minimized accountability through accountabilityDays anchored to terminal settlement, refusal: Scrub private content and replay responses at terminalDays then delete review-owned tombstones and minimized accountability at accountabilityDays., negativeId: CASEWORK-NEG-20, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: terminal_retention_scrubs_private_work_and_expires_owned_idempotency}} - {id: CASEWORK-SEC-21, invariants: [6, 17, 18], state: enforced, targetWave: mvp, threat: Raw reviewer identity or a private decision reason is released after accountability expiry or without a durable record of the disclosure., enforcementPoint: one PostgreSQL transaction checks live retention and current Supervisor queue authority then enqueues the accountability-read audit before returning protected fields, refusal: Return absence for expired or unauthorized accountability and return an error without protected fields when the audit cannot commit., negativeId: CASEWORK-NEG-21, negativeTest: {path: crates/registry-casework/tests/review_postgres.rs, name: accountability_read_requires_live_retention_and_a_committed_audit}} diff --git a/products/casework/contracts/security-test-traceability.yaml b/products/casework/contracts/security-test-traceability.yaml index 0718d6609e..c3be1d6948 100644 --- a/products/casework/contracts/security-test-traceability.yaml +++ b/products/casework/contracts/security-test-traceability.yaml @@ -19,7 +19,7 @@ entries: - {id: CASEWORK-SEC-15, tests: [{file: crates/registry-casework/src/clocks.rs, name: source_clocks_survive_restart_and_preserve_subject_budget}, {file: crates/registry-casework/tests/review_postgres.rs, name: subject_clock_pauses_and_continues_across_review_rounds}, {file: crates/registry-casework/tests/review_postgres.rs, name: subject_clocks_are_independent_across_concurrent_review_kinds}, {file: crates/registry-casework/tests/review_postgres.rs, name: review_activity_clock_recovers_after_holiday_publication_and_applies_effects_once}]} - {id: CASEWORK-SEC-16, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: recovery_pins_policy_and_terminal_settlement_emits_atomically}, {file: crates/registry-casework/tests/review_postgres.rs, name: concurrent_round_creation_and_duplicate_vote_leave_one_current_state}, {file: crates/registry-casework/tests/review_postgres.rs, name: cancellation_and_final_decision_commit_exactly_one_terminal_result}, {file: crates/registry-casework/tests/review_postgres.rs, name: delegation_requires_the_selected_profile_in_pinned_deciding_profiles}, {file: crates/registry-casework/tests/review_postgres.rs, name: delegation_requires_membership_matching_the_selected_role_in_a_mixed_stage}, {file: crates/registry-casework/tests/review_postgres.rs, name: assignment_requires_a_selected_supervisor_profile_despite_directory_membership}, {file: crates/registry-casework/tests/review_postgres.rs, name: reviewer_mutation_replays_require_current_exact_role_authority}, {file: crates/registry-casework/tests/review_postgres.rs, name: assignment_and_delegation_replay_use_the_addressed_pinned_stage}, {file: crates/registry-casework/tests/review_http.rs, name: requester_history_and_notes_require_the_admitted_producer_id_over_http}, {file: crates/registry-casework/tests/review_http.rs, name: standalone_structured_answer_can_be_claimed_decided_and_polled_over_http}, {file: crates/registry-casework/tests/review_payment_fixture_postgres.rs, name: payment_http_push_and_feed_recovery_preserve_receiver_and_release_boundaries}, {file: crates/registry-casework/src/runtime.rs, name: review_completion_delivery_is_minimal_authenticated_and_stable_after_lost_ack}]} - {id: CASEWORK-SEC-17, tests: [{file: crates/registry-casework/tests/source_retention_postgres.rs, name: source_erasure_scrubs_payloads_fences_rehydration_and_preserves_expired_replay}, {file: crates/registry-casework/tests/review_postgres.rs, name: recovery_pins_policy_and_terminal_settlement_emits_atomically}, {file: crates/registry-casework/tests/review_payment_fixture_postgres.rs, name: payment_http_push_and_feed_recovery_preserve_receiver_and_release_boundaries}]} - - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-casework/src/task_grants.rs, name: task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} + - {id: CASEWORK-SEC-18, tests: [{file: crates/registry-casework/src/task_grants/local_session_tests.rs, name: source_backed_dev_approves_exchanges_and_revokes_on_stock_issuer}, {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid}, {file: crates/registry-casework/src/task_grants.rs, name: task_assertion_pseudonymizes_approver_and_keeps_evidence_context_explicit}, {file: crates/registry-casework/src/task_grants.rs, name: governed_scheduling_bounds_are_the_platform_verifiers_wire_contract}, {file: crates/registry-caseworkctl/src/dev/tests.rs, name: explicit_local_integrations_render_only_governed_authority_and_bind_the_source}, {file: crates/registry-caseworkctl/src/dev/public_jwks.rs, name: serves_only_public_keys_and_releases_its_listener}]} - {id: CASEWORK-SEC-19, tests: [{file: crates/registry-casework-core/src/review.rs, name: structured_answers_reuse_required_result_and_schema_rules}, {file: crates/registry-casework/tests/review_postgres.rs, name: prior_stage_identity_and_invalid_structured_result_emit_nothing}, {file: crates/registry-casework/tests/review_http.rs, name: standalone_structured_answer_can_be_claimed_decided_and_polled_over_http}]} - {id: CASEWORK-SEC-20, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: terminal_retention_scrubs_private_work_and_expires_owned_idempotency}, {file: crates/registry-casework/tests/review_postgres.rs, name: retention_cleanup_skips_locked_rows_and_processes_one_bounded_batch}, {file: crates/registry-casework/tests/review_postgres.rs, name: canonical_json_bounds_allow_safe_postgresql_expansion_for_context_and_drafts}]} - {id: CASEWORK-SEC-21, tests: [{file: crates/registry-casework/tests/review_postgres.rs, name: accountability_read_requires_live_retention_and_a_committed_audit}]} diff --git a/products/casework/generated/registry-casework.openapi.json b/products/casework/generated/registry-casework.openapi.json index acc6762793..2d9338bd24 100644 --- a/products/casework/generated/registry-casework.openapi.json +++ b/products/casework/generated/registry-casework.openapi.json @@ -4998,6 +4998,43 @@ ], "type": "object" }, + "SchedulingTaskPermission": { + "additionalProperties": false, + "properties": { + "actions": { + "items": { + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9._:-]*$", + "type": "string" + }, + "maxItems": 32, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "location": { + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1f\\x7f*]+$", + "type": "string", + "x-maximum-utf8-bytes": 512 + }, + "service": { + "maxLength": 512, + "minLength": 1, + "pattern": "^[^\\s\\x00-\\x1f\\x7f*]+$", + "type": "string", + "x-maximum-utf8-bytes": 512 + } + }, + "required": [ + "service", + "location", + "actions" + ], + "type": "object" + }, "SourceBinding": { "additionalProperties": false, "properties": { @@ -5328,6 +5365,27 @@ "permissions" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/SchedulingTaskPermission" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "type": { + "const": "scheduling" + } + }, + "required": [ + "type", + "permissions" + ], + "type": "object" } ] }, diff --git a/products/casework/scripts/generate_openapi.py b/products/casework/scripts/generate_openapi.py index c5db7c8d4d..c7ae21862b 100644 --- a/products/casework/scripts/generate_openapi.py +++ b/products/casework/scripts/generate_openapi.py @@ -107,6 +107,7 @@ "TaskTemplatePreview": "TaskTemplatePreview", "TaskTemplatePreviews": "TaskTemplatePreviews", "TaskPermission": "TaskPermission", + "SchedulingTaskPermission": "SchedulingTaskPermission", "TaskApprovalRequest": "TaskApprovalRequest", "TaskGrantView": "TaskGrantView", "TaskGrantList": "TaskGrantList", @@ -1261,6 +1262,8 @@ def task_schemas() -> dict: uuid = {"type":"string", "format":"uuid"} subjects = {"type":"object", "maxProperties":32, "additionalProperties":{"type":["string","integer","boolean"]}} permission = obj({"collection":text, "operations":{"type":"array", "minItems":1, "maxItems":32, "uniqueItems":True, "items":text}}, ["collection","operations"]) + grant_identifier = {"type":"string", "minLength":1, "maxLength":512, "x-maximum-utf8-bytes":512, "pattern":r"^[^\s\x00-\x1f\x7f*]+$"} + scheduling_permission = obj({"service":grant_identifier, "location":grant_identifier, "actions":{"type":"array", "minItems":1, "maxItems":32, "uniqueItems":True, "items":{"type":"string", "minLength":1, "maxLength":128, "pattern":r"^[a-z][a-z0-9._:-]*$"}}}, ["service","location","actions"]) common = {"agent":ref("IssuerPrincipal"), "client":text, "resource":text, "scopes":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[\x21\x23-\x29\x2b-\x5b\x5d-\x7e]+$"}}, "purpose":text, "bounds":ref("TaskGrantBounds")} evidence_context = obj({"requesterTags":{"type":"array","minItems":1,"maxItems":32,"uniqueItems":True,"items":{"type":"string","minLength":1,"maxLength":128,"pattern":r"^[a-z][a-z0-9._-]*$"}}, "audience":{"type":"string","format":"uri","minLength":1,"maxLength":4096}}, ["requesterTags","audience"]) preview = {"id":text, "version":text, "label":text, **common, "evidenceContext":ref("EvidenceRequesterContext"), "subjects":subjects, "lifetimeSeconds":{"type":"integer","minimum":1,"maximum":900}} @@ -1274,7 +1277,8 @@ def task_schemas() -> dict: "TaskTemplatePreview":obj(preview,[name for name in preview if name != "evidenceContext"]), "TaskTemplatePreviews":obj({"itemRevision":number,"templates":array(ref("TaskTemplatePreview"))},["itemRevision","templates"]), "TaskPermission":permission, - "TaskGrantBounds":{"oneOf":[obj({"type":{"const":"evidence"},"requirement":text},["type","requirement"]), obj({"type":{"const":"breg"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("TaskPermission")}},["type","permissions"])]}, + "SchedulingTaskPermission":scheduling_permission, + "TaskGrantBounds":{"oneOf":[obj({"type":{"const":"evidence"},"requirement":text},["type","requirement"]), obj({"type":{"const":"breg"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("TaskPermission")}},["type","permissions"]), obj({"type":{"const":"scheduling"},"permissions":{"type":"array","minItems":1,"maxItems":64,"items":ref("SchedulingTaskPermission")}},["type","permissions"])]}, "TaskApprovalRequest":obj({"templateId":text,"templateVersion":text},["templateId","templateVersion"]), "TaskGrantView":obj(view,[name for name in view if name != "evidenceContext"]), "TaskGrantList":obj({"grants":{"type":"array","maxItems":128,"items":ref("TaskGrantView")}},["grants"]), diff --git a/products/evidence/contracts/acceptance-test-traceability.yaml b/products/evidence/contracts/acceptance-test-traceability.yaml index 395864b385..5dc6073e1c 100644 --- a/products/evidence/contracts/acceptance-test-traceability.yaml +++ b/products/evidence/contracts/acceptance-test-traceability.yaml @@ -589,7 +589,7 @@ entries: summary: Pinned stock ThunderID exchanges concurrent task grants into Evidence-bound tokens; different subject bounds remain separate, equivalent bounds may answer the same subject with distinct grant audit correlation, an ordinary service token and body retargeting are refused, and an issued token remains usable after authority revocation only until its immutable grant deadline. tests: - {file: crates/registry-evidence-client/tests/against_a_real_deployment.rs, name: stock_issuer_task_grants_are_subject_bound_at_the_evidence_boundary} - - {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_exchange_on_stock_thunderid_for_evidence_and_revoke_breg_writes} + - {file: crates/registry-casework/src/task_grants/native_exchange_tests.rs, name: approved_casework_tasks_reach_evidence_breg_and_scheduling_through_stock_thunderid} - {file: crates/registry-evidence/tests/selector_conformance.rs, name: task_grant_context_is_bound_before_selector_or_source_access} - {file: crates/registry-evidence/src/config.rs, name: task_grant_profiles_bind_trusted_source_and_verified_client} - {file: crates/registry-evidence/src/auth.rs, name: purpose_only_service_token_is_not_a_task_grant} diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index b3e603e133..12c0078838 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -2887,7 +2887,7 @@ }, "artifact": { "path": "products/scheduling/generated/runtime/runtime.schema.json", - "sha256": "ad049c6b8d0fc70d989197131cd4c674cf728548415b265fd1f2147814f41247", + "sha256": "2158f213b60c1da4498818dc6ac456d937ac965984552a0007ad6bd5dfba4938", "mediaType": "application/schema+json" } }, diff --git a/products/scheduling/AGENTS.md b/products/scheduling/AGENTS.md index 306de9476c..771ea7b2cd 100644 --- a/products/scheduling/AGENTS.md +++ b/products/scheduling/AGENTS.md @@ -17,11 +17,10 @@ product. through any shared dependency, reaches a Base Registry Engine, Casework, or Evidence crate, and none of those products reach a scheduling crate. Run `scripts/check_dependency_direction.py` after dependency changes. -- `examples/standalone-exact-time/` is exactly what - `schedulingctl init --template standalone-exact-time` writes, and the - checkpoint fails if the two drift apart. When `schedulingctl init` starts - emitting or changing a file, copy that output into the committed example in - the same commit. +- Each directory under `examples/` is exactly what `schedulingctl init` writes + for the template with the same name, and the checkpoint fails if either pair + drifts apart. When `schedulingctl init` starts emitting or changing a file, + copy that output into both applicable committed examples in the same commit. - Keep authored examples and the checkpoint demo small. Do not add a later-wave surface to close a documentation obligation; record the exclusion instead. diff --git a/products/scheduling/README.md b/products/scheduling/README.md index 1d897cc232..c44dd1346e 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -53,8 +53,9 @@ reachability or deployment readiness. Explain publishes what the runtime would serve: identity, policy digest, offerings, windows with their subquotas, and the hold policy. -`schedulingctl init` writes `runtime.example.yaml` beside the policy: a -complete, commented operator document. Copy it to `runtime.yaml`, set its +`schedulingctl init` writes `runtime.example.yaml` and `records.yaml` beside +the policy. The records document contains every location and resource pool the +selected starter needs. Copy the runtime example to `runtime.yaml`, set its absolute paths and secret references, and read [RUNTIME-CONFIG.md](RUNTIME-CONFIG.md) for every block, field, and default. Then apply the live environment records and start: @@ -117,7 +118,8 @@ bounds, the closed action vocabulary, the value limits, and the one fact the transaction re-reads are documented in [TASK_GRANTS.md](TASK_GRANTS.md). Scheduling verifies grants; it does not approve them, and the approval surface stays with the product that holds the -human relationship. +human relationship. The maintained Casework template and stock ThunderID +exchange path is documented there through an actual appointment request. ## Events and observers diff --git a/products/scheduling/TASK_GRANTS.md b/products/scheduling/TASK_GRANTS.md index 6a5d5699a5..cc2d14861f 100644 --- a/products/scheduling/TASK_GRANTS.md +++ b/products/scheduling/TASK_GRANTS.md @@ -7,15 +7,17 @@ authority, and it never decides eligibility: whether a party may hold a service remains with the source system that owns the rule. The three authority profiles are disjoint. A token carrying a product scope -does not thereby gain a commitment path, and a task grant does not thereby -gain a catalogue read. Scopes authorize reads and the separately configured +does not thereby gain a commitment path, and a task grant does not by itself +grant a catalogue read. Scopes authorize reads and the separately configured explain scope authorizes the diagnostic read; every commitment takes its -authority from the grant alone. +authority from the grant alone. One access token may carry both profiles, but +each operation still checks only its own authority. ## The claim set -A commitment presents no product scope. Its authority is the grant inside the -access token: the six `registry_grant_*` members (`registry_grant_id`, +A commitment requires no product scope. Its authority is the grant inside the +access token, whether or not that token also carries read scopes: the six +`registry_grant_*` members (`registry_grant_id`, `registry_grant_source_issuer`, `registry_grant_client`, `registry_grant_resource`, `registry_grant_exp`, `registry_grant_bounds`), plus `registry_purpose` and `registry_approver`. A token carrying none of @@ -110,10 +112,108 @@ bound value, service, location, or action appears in the journal. Scheduling verifies grants; it does not approve them. A grant is minted by the token-exchange issuer from a template a current human holder approved, and the -approver's identity travels in the claim. The approval surface, the template -grammar, the exchange flow, and revocation are documented in -[`../casework/TASK_GRANTS.md`](../casework/TASK_GRANTS.md); Scheduling is a +approver's identity travels in the claim. Casework's maintained task authority +accepts Scheduling bounds directly, and stock ThunderID's institutional grant +exchange copies those verified bounds into the access token. Scheduling is a relying party of that authority and inherits no Casework authorization. +The maintained native boundary fixture passes that exact stock-issued bearer, +its public JWKS, and bounded deployment identity over stdin to Scheduling's +real `SchedulingAuthenticator`; the credential never enters argv or test logs. + +The Casework project declares the exact destination in a governed template: + +```yaml +taskTemplates: + - id: schedule-registry-update + version: "1" + label: Book a registry update + eligibleTeams: [registry-review] + eligibleProfiles: [staff] + source: registry-requests + itemKinds: [request] + itemStates: [claimed] + agent: + issuer: https://identity.example.test/realms/registry + subject: scheduling-agent-service-account + client: scheduling-agent + resource: urn:example:scheduling + scopes: [scheduling-read, scheduling:commit] + purpose: schedule-registry-update + bounds: + type: scheduling + permissions: + - service: registry-update + location: bangkok-counter + actions: [appointment.create] + subjects: + subject_reference: subject-reference + lifetimeSeconds: 900 +``` + +`scheduling:commit` is an explicit scope registered at the exchange issuer. The +Scheduling runtime derives mutation authority from the grant bounds rather than +that scope. `scheduling-read` is the starter runtime's default read scope and +lets the same short-lived token select a currently published free start. + +Register `scheduling-agent` as a Casework `taskExchange` service client, and +register `urn:example:scheduling` plus those scopes at the shared issuer. In the +Scheduling runtime, use that issuer and audience, admit the client, and bind it +to the Casework task authority: + +```yaml +authentication: + oidc: + issuer: https://identity.example.test/realms/registry + audience: urn:example:scheduling + scopeClaim: scope + allowedClients: [scheduling-agent] + assertionIssuers: + scheduling-agent: [https://casework.example.test/task-authority] +``` + +After a current holder previews and approves the template, put the approved +grant UUID into the existing Casework development exchange path. Its connection +entry selects the Scheduling destination, not policy fields: + +```yaml +clients: + scheduling-agent: + assertionKeyFile: /absolute/casework/.casework/dev/credentials/scheduling-agent/assertion-key.jwk + resource: urn:example:scheduling + scopes: [scheduling-read, scheduling:commit] +``` + +```sh +caseworkctl dev grant scheduling-agent \ + --grant APPROVED_UUID \ + --connection /absolute/scheduling-connection.yaml \ + /absolute/casework +``` + +The command reports an owner-only header file and the immutable grant deadline. +Use that header to read a free start, then book it. Set `START` and +`POLICY_REVISION` from the availability response: + +```sh +curl --fail-with-body \ + -H "$(cat "$HEADER_FILE")" \ + "$SCHEDULING_URL/v1/availability?offering=registry-update-30" + +curl --fail-with-body -X POST \ + -H "$(cat "$HEADER_FILE")" \ + -H 'content-type: application/json' \ + -H 'idempotency-key: approved-registry-update-1' \ + --data @- "$SCHEDULING_URL/v1/appointments" <- - Hold lifetime evaluated against the commitment's clock in the ledger, - never against a sweeper having already run. + Hold lifetime evaluated in the ledger, never against a sweeper having + already run. Capacity snapshots use the commitment's clock; confirmation + re-checks expiry against a fresh clock observation after it locks the + supply anchor, before transferring the reservation into an appointment. refusal: >- Count an expired hold as consuming nothing and refuse its confirmation; the sweeper is housekeeping, not the authority on expiry. diff --git a/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml b/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml new file mode 100644 index 0000000000..f5001c987b --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml @@ -0,0 +1,52 @@ +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-afternoon +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok +initial: [] +cases: + - name: a-small-household-fits-the-first-band + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 3 + attendees: 3 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 + - name: a-household-above-the-highest-band-is-refused + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 9 + attendees: 9 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: party.capacity-inadequate + - name: a-mid-size-household-fits-the-second-band + request: + offering: household-afternoon + start: 2026-10-10T07:00:00Z + party: + recipients: 6 + attendees: 6 + policyRevision: 3 + windowRevision: 1 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 diff --git a/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml b/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml new file mode 100644 index 0000000000..7e511f9e7f --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml @@ -0,0 +1,55 @@ +apiVersion: registry.registrystack.org/scheduling-fixture/v1alpha1 +kind: SchedulingFixture +name: household-morning +now: 2026-10-09T20:00:00Z +facts: + locations: + - id: civic-hall + timezone: Asia/Bangkok +initial: [] +cases: + - name: first-household-takes-the-public-quota + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 2 + attendees: 3 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 2 + - name: next-public-household-finds-the-quota-spent + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: public + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: refused + code: capacity.exhausted + - name: assisted-household-fits-the-protected-unit + request: + offering: household-morning + start: 2026-10-10T01:00:00Z + party: + recipients: 1 + attendees: 1 + channel: assisted + policyRevision: 3 + windowRevision: 2 + capabilities: [] + prerequisites: [] + expect: + outcome: admitted + units: 1 diff --git a/products/scheduling/examples/standalone-arrival-window/records.yaml b/products/scheduling/examples/standalone-arrival-window/records.yaml new file mode 100644 index 0000000000..555f4b0b3e --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/records.yaml @@ -0,0 +1,5 @@ +locations: + - id: civic-hall + timezone: Asia/Bangkok +pools: [] +exceptions: [] diff --git a/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml b/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml new file mode 100644 index 0000000000..7e15587001 --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/runtime.example.yaml @@ -0,0 +1,107 @@ +# A complete Scheduling runtime configuration: the document +# `scheduling serve --runtime-config` reads and `scheduling migrate` reads. +# Copy it to runtime.yaml, set every absolute path to this deployment's real +# location, and point the secret references at secrets the configured provider +# can resolve. Scheduling reads the authored policy at +# package.root/scheduling.yaml and refuses to start when that file is missing +# or does not pass its checks. +apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1 +kind: SchedulingRuntimeConfig +package: + # The selected package always contains the policy at scheduling.yaml; no + # second selector can override it. Under + # listener.tlsTermination: operator-controlled-upstream the directory must + # also contain the scheduling.package.json manifest `schedulingctl package` + # writes; development loopback may select an unpackaged project directory. + root: /srv/registry-scheduling/package +listener: + bind: 127.0.0.1:8105 + tlsTermination: development-loopback + networkExposure: private-address + # `operator-controlled-upstream` is the production value: the runtime itself + # stays plaintext and an operator-managed TLS edge terminates transport in + # front of it. `development-loopback` is direct plaintext, refused on any + # non-loopback bind. `networkExposure: container-private` additionally + # permits an unspecified bind for a listener kept on a private container + # network. +secretProviders: + file: + # Every secret:file reference resolves under this root. A secret file must + # be a regular file owned by the runtime user, with mode 0400 or 0600 and + # exactly one hard link, carrying non-empty text of at most 64 KiB without + # NUL bytes; `openssl rand -hex 32` generates the audit key. + root: /run/secrets/registry-scheduling + # Declare `environment: {}` to enable secret:env/NAME references. The + # runtime does not fall back from one provider to another; every configured + # reference must resolve under a provider declared here. + # environment: {} +database: + # The least-privilege service connection and the operator-run migration + # connection, each a PostgreSQL URL held in the secret provider. The + # runtime requires TLS on the database connection: it sets sslmode Require + # and refuses a connection that cannot be verified. + runtimeUrlRef: secret:file/runtime-database-url + migrationUrlRef: secret:file/migration-database-url + # The PEM bundle of the private CA in front of PostgreSQL, when the + # deployment terminates database TLS with one. + # trustedRootCertificateRef: secret:file/postgres-ca.pem + # `testOnlyPlaintext: true` is the one plaintext escape, and only a build + # carrying the `postgres-test` feature accepts it: a production build + # refuses the setting at startup instead of opening an unencrypted + # connection. + # testOnlyPlaintext: true +authentication: + oidc: + issuer: https://identity.example.test/realms/registry + audience: urn:example:scheduling + # The claim carrying the accepted OAuth scopes. The default is + # `registry_scopes` for deployments that omit this field; stock ThunderID + # emits `scope`. + scopeClaim: scope + # The read scope defaults to `scheduling-read` and the explain scope to + # `scheduling-explain`; the two must differ. `allowedClients` names the + # client ids the runtime admits. Development loopback leaves it empty by + # default, which admits every client the issuer verifies; behind + # listener.tlsTermination: operator-controlled-upstream an empty or + # absent list is a startup refusal, so a production deployment must name + # the clients it admits. + # allowedClients: [scheduling-booking-agent] + # `assertionIssuers` maps a client id to the assertion authorities it may + # exchange a subject token from. A deployment that performs no token + # exchange leaves it empty; once a client is listed, a token it + # exchanged is accepted only for one of that client's declared + # authorities. + # assertionIssuers: + # scheduling-booking-agent: [https://issuer.example.test/assertions] + # Signing keys come from issuer discovery by default. Declare the static + # alternative instead when the runtime cannot reach the issuer's discovery + # document, when the deployment is air-gapped, or when a test issuer's + # keys are pinned by hand. It does no rotation of its own: rolling a key + # means replacing the referenced document and restarting Scheduling. + # jwksSource: + # kind: static + # documentRef: secret:file/jwks.json +audit: + path: /var/lib/registry-scheduling/audit.ndjson + hashKeyRef: secret:file/scheduling-audit-key +destinations: {} + # Where due reminder intents are delivered as CloudEvents 1.0 events. An + # absent reminders destination is a supported deployment: the intents stay + # readable in the outbox and are marked local, never pretended delivered. + # reminders: + # url: https://bus.example.test/scheduling-reminders + # bearerTokenRef: secret:file/reminder-bearer-token + # URL observers declared under scheduling.yaml hooks bind here. The policy + # carries no endpoint or secret, and retained work keeps this exact binding. + # hooks: + # appointment-events: + # url: https://integration.example.test/scheduling-events + # hmacSha256KeyRef: secret:file/appointment-events-hmac + # attemptTimeoutMilliseconds: 5000 + # maximumAttempts: 8 +retention: + # Attempt receipts/listing cursors and hook delivery payloads have separate + # bounded lifetimes. Appointment, reminder, history, and audit retention are + # deferred. Seven days is a default, not a jurisdictional recommendation. + attemptReceiptDays: 7 + hookPayloadDays: 7 diff --git a/products/scheduling/examples/standalone-arrival-window/scheduling.yaml b/products/scheduling/examples/standalone-arrival-window/scheduling.yaml new file mode 100644 index 0000000000..0fa0b219ef --- /dev/null +++ b/products/scheduling/examples/standalone-arrival-window/scheduling.yaml @@ -0,0 +1,107 @@ +apiVersion: registry.registrystack.org/scheduling-policy-package/v1alpha1 +kind: SchedulingPolicyPackage +scheduling: + id: household-days + version: 3 +services: + - id: household-day + label: Household registration day +offerings: + - id: household-morning + service: household-day + label: Morning household block + mode: arrival-window + location: civic-hall + because: Households arrive in a served morning block. + arrival: + window: household-morning-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] + - id: household-afternoon + service: household-day + label: Afternoon household block + mode: arrival-window + location: civic-hall + because: Larger households arrive in an afternoon block sized by party, not a flat headcount. + arrival: + window: household-afternoon-window + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 1440 + requiresCapabilities: [] + prerequisites: [] +holidaySets: + - id: office-holidays + revision: 1 + because: Public holidays observed by the registry office. + dates: [] +openings: + - id: hall-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "08:00" + endTime: "12:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall opens on Saturday mornings. + - id: hall-afternoon-hours + location: civic-hall + holidaySet: office-holidays + weekdays: [sat] + startTime: "13:00" + endTime: "17:00" + effectiveFrom: "2026-10-01" + effectiveUntil: "2026-12-31" + because: The hall reopens on Saturday afternoons for banded household visits. +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. diff --git a/products/scheduling/examples/standalone-exact-time/records.yaml b/products/scheduling/examples/standalone-exact-time/records.yaml new file mode 100644 index 0000000000..546d942434 --- /dev/null +++ b/products/scheduling/examples/standalone-exact-time/records.yaml @@ -0,0 +1,26 @@ +locations: + - id: bangkok-counter + timezone: Asia/Bangkok + - id: new-york-hall + timezone: America/New_York +pools: + - id: update-stations + members: + - resourceId: station-1 + capabilities: [] + available: true + - resourceId: station-2 + capabilities: [] + available: true + - id: hall-stations + members: + - resourceId: hall-station-1 + capabilities: [] + available: true +exceptions: + - id: hall-prep + location: new-york-hall + kind: closure + date: "2026-11-01" + startTime: "00:15" + endTime: "00:45" diff --git a/products/scheduling/generated/runtime/runtime.schema.json b/products/scheduling/generated/runtime/runtime.schema.json index 2581a34493..59ec7a9323 100644 --- a/products/scheduling/generated/runtime/runtime.schema.json +++ b/products/scheduling/generated/runtime/runtime.schema.json @@ -62,7 +62,7 @@ }, "DestinationsConfig": { "additionalProperties": false, - "description": "Where due reminder intents are dispatched. An absent reminders destination\nis a supported deployment: intents are written and stay local, so an\noperator can adopt the product before wiring a notification bus.", + "description": "Where due reminder and lifecycle-hook intents are dispatched. An absent\nreminders destination and an empty hook map are supported: intents stay\nlocal, so an operator can adopt the product before wiring delivery buses.", "properties": { "hooks": { "additionalProperties": { @@ -293,7 +293,7 @@ }, "hookPayloadDays": { "default": 7, - "description": "Lifetime of canonical hook envelopes retained for retry, dead-letter\ninspection, and optional operator replay.", + "description": "Lifetime of canonical hook envelopes retained for retry and\ndead-letter inspection.", "format": "uint16", "maximum": 65535, "minimum": 0, diff --git a/products/scheduling/scripts/check-checkpoint.sh b/products/scheduling/scripts/check-checkpoint.sh index a8fee2ea3c..099adf2367 100755 --- a/products/scheduling/scripts/check-checkpoint.sh +++ b/products/scheduling/scripts/check-checkpoint.sh @@ -108,12 +108,14 @@ fi "$schedulingctl_bin" init "$work/package" --template standalone-exact-time >/dev/null "$schedulingctl_bin" package "$work/package" >/dev/null -# The committed example is the starter template's output, so it can never -# drift from what an adopter initializes. -example="$repo_root/products/scheduling/examples/standalone-exact-time" -"$schedulingctl_bin" init "$work/example" --template standalone-exact-time >/dev/null -diff -r "$work/example" "$example" >/dev/null -"$schedulingctl_bin" check "$example" >/dev/null -"$schedulingctl_bin" test "$example" >/dev/null +# The committed examples are the starter templates' output, so neither can +# drift from what an adopter initializes, including its live records document. +for template in standalone-exact-time standalone-arrival-window; do + example="$repo_root/products/scheduling/examples/$template" + "$schedulingctl_bin" init "$work/example-$template" --template "$template" >/dev/null + diff -r "$work/example-$template" "$example" >/dev/null + "$schedulingctl_bin" check "$example" >/dev/null + "$schedulingctl_bin" test "$example" >/dev/null +done echo "Scheduling product contracts and offline authoring journey passed." diff --git a/release/OPERATIONS.md b/release/OPERATIONS.md index 6d7ae0b596..3eb9b1d3b8 100644 --- a/release/OPERATIONS.md +++ b/release/OPERATIONS.md @@ -53,9 +53,9 @@ Complete new-image onboarding outside the release clock, in this order: token on the command line: ```sh -package="${PACKAGE:?set PACKAGE to relay, evidence, discovery, breg, or casework}" +package="${PACKAGE:?set PACKAGE to relay, evidence, discovery, breg, casework, or scheduling}" case "${package}" in - relay|evidence|discovery|breg|casework) ;; + relay|evidence|discovery|breg|casework|scheduling) ;; *) echo "unsupported release image package: ${package}" >&2; exit 1 ;; esac @@ -112,7 +112,8 @@ printf '%s' "${GHCR_BOOTSTRAP_TOKEN:?set a classic PAT with write:packages}" \ Starting with `v0.21.0`, the release requires public `relay`, `evidence`, and `mint` packages, joined by `discovery` from `v0.24.0`, `breg` from `v0.26.0`, and `casework` from `v0.30.0`. Mint is retired from `v0.31.0`; -the published `v0.30.0` and older release inventories remain unchanged. After selecting +`scheduling` joins from `v0.33.0`. The published `v0.32.0` and older release +inventories remain unchanged. After selecting the candidate version, derive its exact image roster and verify each final destination: @@ -160,6 +161,13 @@ provisioned. Complete the onboarding steps above, add `casework-candidate` to the cleanup allowlist only after its private package exists, and merge its reviewed advisory baseline before requesting that candidate. +Selecting `v0.33.0` or later also includes Scheduling in both checks. The +release source deliberately deny-lists the public `scheduling` package while +leaving `scheduling-candidate` out of scheduled cleanup until its private +package identity exists. Provision both identities, add `scheduling-candidate` +to the cleanup allowlist with its matching test, and merge a reviewed +Scheduling advisory baseline before requesting a `v0.33.0` or later candidate. + The daily cleanup tolerates one delete failure: GitHub's 400 stating that publicly visible package versions with more than 5000 downloads cannot be deleted, a limit no caller can clear. The run records that version in its @@ -671,7 +679,7 @@ evidence with the scanner versions pinned in the candidate workflow: ```sh run_id= run_attempt= -name=relay # or evidence, discovery, or breg +name=relay # or evidence, discovery, breg, casework, or scheduling candidate_tag="ghcr.io/registrystack/${name}-candidate:candidate-${run_id}-${run_attempt}" digest="$(crane digest "${candidate_tag}")" candidate_ref="ghcr.io/registrystack/${name}-candidate@${digest}" diff --git a/release/VERIFY.md b/release/VERIFY.md index 9742a80b8e..403ee5a71b 100644 --- a/release/VERIFY.md +++ b/release/VERIFY.md @@ -106,7 +106,9 @@ manifest="registry-stack-${tag}-release-manifest.json" jq -e --arg tag "${tag}" ' ($tag | capture("^v(?[0-9]+)\\.(?[0-9]+)\\.(?[0-9]+)$") | {major: (.major | tonumber), minor: (.minor | tonumber)}) as $version | - (if ($version.major > 0 or $version.minor >= 31) + (if ($version.major > 0 or $version.minor >= 33) + then ["breg", "casework", "discovery", "evidence", "relay", "scheduling"] + elif $version.minor >= 31 then ["breg", "casework", "discovery", "evidence", "relay"] elif $version.minor >= 30 then ["breg", "casework", "discovery", "evidence", "mint", "relay"] elif $version.minor >= 26 then ["breg", "discovery", "evidence", "mint", "relay"] @@ -132,15 +134,16 @@ jq -e --arg tag "${tag}" ' Starting with `v0.21.0`, the exact image set is Evidence Gateway, Registry Mint, and Registry Relay. Registry Discovery joins at `v0.24.0`, and Base -Registry Engine joins at `v0.26.0`, and Registry Casework joins at `v0.30.0`. -Mint is retired from `v0.31.0`; historical releases retain their original roster. +Registry Engine joins at `v0.26.0`, Registry Casework joins at `v0.30.0`, and +Registry Scheduling joins at `v0.33.0`. Mint is retired from `v0.31.0`; +historical releases retain their original roster. The final release tags recorded in the manifest must resolve to the same digests as their candidate bindings: ```sh while IFS=$'\t' read -r name digest final_ref; do case "${name}" in - breg|casework|discovery|evidence|mint|relay) ;; + breg|casework|discovery|evidence|mint|relay|scheduling) ;; *) echo "unexpected release image: ${name}" >&2; exit 1 ;; esac resolved_digest="$(crane digest "${final_ref}")" @@ -176,7 +179,8 @@ tar -tzf "${evidence}" Starting with `v0.21.0`, the archive contains image-specific SPDX and Syft reports and Grype reports for `evidence`, `mint`, and `relay`, joined by `discovery` from `v0.24.0`, `breg` from `v0.26.0`, and `casework` from -`v0.30.0`. Mint reports are excluded from `v0.31.0` onward; `v0.19.x` and +`v0.30.0`, with `scheduling` from `v0.33.0`. Mint reports are excluded from +`v0.31.0` onward; `v0.19.x` and `v0.20.x` archives contain those reports for `relay` only. The archive also contains the advisory verdict used for candidate acceptance. Each report names diff --git a/release/docker/Dockerfile.scheduling b/release/docker/Dockerfile.scheduling index dd56eb7c90..18ccb56973 100644 --- a/release/docker/Dockerfile.scheduling +++ b/release/docker/Dockerfile.scheduling @@ -14,6 +14,7 @@ ADD --checksum=sha256:8784eda966b189c777a384dac5ce009e8fc9b52d006926c5a013e7fa8a # chain needs a writable persistent directory. RUN --mount=type=bind,source=dist/image-bin,target=/workspace/image-bin \ --mount=type=bind,source=LICENSE,target=/workspace/LICENSE \ + --mount=type=bind,source=THIRD_PARTY_NOTICES,target=/workspace/THIRD_PARTY_NOTICES,readonly \ --mount=type=bind,source=release/scripts/install-runtime-libc6.sh,target=/workspace/install-runtime-libc6.sh,readonly \ /workspace/install-runtime-libc6.sh /workspace/runtime-root /workspace/runtime-packages \ && mkdir -p \ @@ -23,6 +24,7 @@ RUN --mount=type=bind,source=dist/image-bin,target=/workspace/image-bin \ /workspace/runtime-root/var/lib/registry-scheduling/audit \ && install -m 0755 /workspace/image-bin/scheduling /workspace/runtime-root/usr/local/bin/scheduling \ && install -m 0644 /workspace/LICENSE /workspace/runtime-root/licenses/scheduling/LICENSE \ + && install -m 0644 /workspace/THIRD_PARTY_NOTICES /workspace/runtime-root/licenses/scheduling/THIRD_PARTY_NOTICES \ && chown -R 65532:65532 /workspace/runtime-root/var/lib/registry-scheduling \ && chmod 0700 /workspace/runtime-root/var/lib/registry-scheduling/audit \ && find /workspace/runtime-root -exec touch -h --date="@${SOURCE_DATE_EPOCH}" {} + diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index c25469b082..8f8d4066a1 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -16,13 +16,13 @@ done if [[ "$#" -eq 1 ]]; then version="$1" else - printf 'usage: %s [--include-casework] [--group core|breg|casework] \n' "$0" >&2 + printf 'usage: %s [--include-casework] [--group core|breg|casework|scheduling] \n' "$0" >&2 exit 2 fi if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ || ("${group}" != all && "${group}" != core && "${group}" != breg && - "${group}" != casework) ]]; then - printf 'usage: %s [--include-casework] [--group core|breg|casework] \n' "$0" >&2 + "${group}" != casework && "${group}" != scheduling) ]]; then + printf 'usage: %s [--include-casework] [--group core|breg|casework|scheduling] \n' "$0" >&2 exit 2 fi tag="v${version}" @@ -43,6 +43,10 @@ if ((version_major > 0 || version_minor >= 30)) || [[ "${include_casework_override}" -eq 1 ]]; then include_casework=1 fi +include_scheduling=0 +if ((version_major > 0 || version_minor >= 33)); then + include_scheduling=1 +fi # Compile and link every product binary through Zig against the glibc stubs of # the release floor. The builder carries a much newer glibc, and without this @@ -188,6 +192,16 @@ build_payload() { cp target/release/casework dist/image-bin/casework fi + if [[ ("${group}" == all || "${group}" == scheduling) && "${include_scheduling}" -eq 1 ]]; then + cargo build --release --locked \ + -p registry-scheduling --bin scheduling + cp target/release/scheduling dist/image-bin/scheduling + if [[ "${group}" == scheduling ]]; then + cp target/release/scheduling \ + "dist/bin/scheduling-${RELEASE_TAG}-linux-amd64" + fi + fi + # Nothing but the staged payload is in these directories yet: the checksum # files and the builder image record are written by the outer invocation # after this container exits. Every staged binary is checked, so a build that @@ -281,6 +295,7 @@ docker run --rm \ --env RELEASE_INCLUDE_DISCOVERY="${include_discovery}" \ --env RELEASE_INCLUDE_BREG="${include_breg}" \ --env RELEASE_INCLUDE_CASEWORK="${include_casework}" \ + --env RELEASE_INCLUDE_SCHEDULING="${include_scheduling}" \ --env RELEASE_TAG="${tag}" \ --env REGISTRY_RELEASE_TAG="${tag}" \ --env RELEASE_RUSTFLAGS="${release_rustflags}" \ @@ -325,6 +340,12 @@ if [[ ("${group}" == all || "${group}" == casework) && "${include_casework}" -eq ) image_bin_binaries+=(casework) fi +if [[ "${group}" == scheduling && "${include_scheduling}" -eq 1 ]]; then + bin_assets+=("scheduling-${tag}-linux-amd64") +fi +if [[ "${group}" == all && "${include_scheduling}" -eq 1 ]]; then + image_bin_binaries+=(scheduling) +fi if [[ "${group}" == all || "${group}" == core ]]; then bin_assets+=( "evidence-${tag}-linux-amd64" diff --git a/release/scripts/build-release-image.sh b/release/scripts/build-release-image.sh index a7b8966996..8f6c1dd42d 100755 --- a/release/scripts/build-release-image.sh +++ b/release/scripts/build-release-image.sh @@ -24,7 +24,7 @@ release_image_context="${RELEASE_IMAGE_CONTEXT:-${repo_root}}" created_builder=false case "${name}" in - discovery|evidence|breg|casework|relay) + discovery|evidence|breg|casework|scheduling|relay) dockerfile="${repo_root}/release/docker/Dockerfile.${name}" ;; *) diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index 051c662e8a..bb0438b009 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -872,7 +872,7 @@ ( "build-canonical-binaries:\n name: Build canonical Linux ${{ matrix.group }} binary shard", "fail-fast: false", - "group: [core, breg, casework]", + "group: [core, breg, casework, scheduling]", "build-canonical:\n name: Build Linux payload and private images once", "name: Restore reusable Cargo cache", "restore-keys:", @@ -948,6 +948,7 @@ ' "mint",\n', ' "breg",\n', ' "casework",\n', + ' "scheduling",\n', ' "relay",\n', "if package in PUBLIC_PACKAGES:", "if package not in CANDIDATE_PACKAGES:", @@ -1435,7 +1436,7 @@ def candidate_build_isolation_violations(workflow: str | None) -> list[str]: if ( "needs: validate" not in shards or "actions/cache@" not in shards - or "group: [core, breg, casework]" not in shards + or "group: [core, breg, casework, scheduling]" not in shards or "fail-fast: false" not in shards or shards.count("name: Build canonical Linux binary shard") != 1 or shards.count("actions/upload-artifact@") != 1 @@ -1444,7 +1445,7 @@ def candidate_build_isolation_violations(workflow: str | None) -> list[str]: or " - build-canonical-binaries" not in build_a or "actions/cache@" in build_a or "release/scripts/build-release-binaries.sh" in build_a - or build_a.count("actions/download-artifact@") != 3 + or build_a.count("actions/download-artifact@") != 4 or build_a.count("name: Merge and smoke the canonical Linux payload") != 1 or build_a.count("name: Build private candidate image layouts once") != 1 ): diff --git a/release/scripts/cleanup-release-candidates.py b/release/scripts/cleanup-release-candidates.py index e6e61cadcc..19f3e0e2e9 100755 --- a/release/scripts/cleanup-release-candidates.py +++ b/release/scripts/cleanup-release-candidates.py @@ -37,6 +37,7 @@ "mint", "breg", "casework", + "scheduling", "relay", ) diff --git a/release/scripts/collect-rehearsal-advisory-evidence.py b/release/scripts/collect-rehearsal-advisory-evidence.py index 2d82b419bc..7e94d31464 100755 --- a/release/scripts/collect-rehearsal-advisory-evidence.py +++ b/release/scripts/collect-rehearsal-advisory-evidence.py @@ -26,7 +26,9 @@ ROOT = Path(__file__).resolve().parents[2] -IMAGE_NAMES = frozenset({"breg", "casework", "discovery", "evidence", "relay"}) +IMAGE_NAMES = frozenset( + {"breg", "casework", "discovery", "evidence", "relay", "scheduling"} +) SEMVER_RE = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") SHA256_RE = re.compile(r"sha256:[0-9a-f]{64}") REVISION_RE = re.compile(r"[0-9a-f]{40}") diff --git a/release/scripts/merge-release-binary-shards.py b/release/scripts/merge-release-binary-shards.py index 7017d7e644..6daf30cce9 100755 --- a/release/scripts/merge-release-binary-shards.py +++ b/release/scripts/merge-release-binary-shards.py @@ -45,6 +45,10 @@ def rosters(version: str) -> tuple[dict[str, list[str]], list[tuple[str, str]]]: f"caseworkctl-{tag}-linux-amd64", ] image_bins.append(("casework", casework[0])) + scheduling: list[str] = [] + if parsed >= (0, 33, 0): + scheduling = [f"scheduling-{tag}-linux-amd64"] + image_bins.append(("scheduling", scheduling[0])) common = [ f"evidence-{tag}-linux-amd64", f"evidencectl-{tag}-linux-amd64", @@ -60,7 +64,12 @@ def rosters(version: str) -> tuple[dict[str, list[str]], list[tuple[str, str]]]: for image_name in ("evidence", "mint", "relay"): if image_name != "mint" or parsed < MINT_RETIREMENT_VERSION: image_bins.append((image_name, f"{image_name}-{tag}-linux-amd64")) - return {"core": core, "breg": breg, "casework": casework}, image_bins + return { + "core": core, + "breg": breg, + "casework": casework, + "scheduling": scheduling, + }, image_bins def sha256(path: Path) -> str: @@ -167,6 +176,7 @@ def merge( core: Path, breg: Path, casework: Path | None, + scheduling: Path | None, output: Path, builder_image: str, ) -> None: @@ -199,7 +209,25 @@ def merge( version, source_sha, ) - sources = inputs["core"] | inputs["breg"] | inputs["casework"] + if scheduling is None: + if shard_rosters["scheduling"]: + raise ShardError("scheduling shard is required from version 0.33.0") + inputs["scheduling"] = {} + else: + inputs["scheduling"] = validate_shard( + "scheduling", + scheduling, + shard_rosters["scheduling"], + builder_image, + version, + source_sha, + ) + sources = ( + inputs["core"] + | inputs["breg"] + | inputs["casework"] + | inputs["scheduling"] + ) final_bin_roster: list[str] = [] if shard_rosters["core"] and shard_rosters["core"][0].startswith("discovery-"): final_bin_roster.append(shard_rosters["core"][0]) @@ -244,6 +272,7 @@ def main() -> int: parser.add_argument("--core", required=True, type=Path) parser.add_argument("--breg", required=True, type=Path) parser.add_argument("--casework", type=Path) + parser.add_argument("--scheduling", type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--builder-image", required=True) args = parser.parse_args() @@ -254,6 +283,7 @@ def main() -> int: core=args.core, breg=args.breg, casework=args.casework, + scheduling=args.scheduling, output=args.output, builder_image=args.builder_image, ) diff --git a/release/scripts/registry-release b/release/scripts/registry-release index 8b7cbd9e70..5603de328c 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -57,6 +57,7 @@ RELAY_V2_ARTIFACT_INVENTORY = { DISCOVERY_RUNTIME_MINIMUM_VERSION = (0, 24, 0) BREG_RELEASE_MINIMUM_VERSION = (0, 26, 0) CASEWORK_RELEASE_MINIMUM_VERSION = (0, 30, 0) +SCHEDULING_RELEASE_MINIMUM_VERSION = (0, 33, 0) UNIFIED_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 26, 1) RELEASE_PLAN_SCHEMA = "registry-release.plan.v1" CANDIDATE_VERIFICATION_PLAN_SCHEMA = "registry-release.candidate-verification.v3" @@ -223,6 +224,8 @@ def artifact_inventory_errors(version: str, artifacts: dict[Any, Any]) -> list[s "casework-installer", } ) + if parsed_version >= SCHEDULING_RELEASE_MINIMUM_VERSION: + expected_inventory.add("scheduling") missing = sorted(expected_inventory - artifact_names) unexpected = sorted(artifact_names - expected_inventory) if missing or unexpected: diff --git a/release/scripts/release_candidate.py b/release/scripts/release_candidate.py index 30ae7d5467..76cdc34198 100644 --- a/release/scripts/release_candidate.py +++ b/release/scripts/release_candidate.py @@ -50,6 +50,7 @@ DISCOVERY_RUNTIME_IMAGE_NAMES = OFFICIAL_RUNTIME_IMAGE_NAMES | {"discovery"} BREG_RELEASE_MINIMUM_VERSION = (0, 26, 0) CASEWORK_RELEASE_MINIMUM_VERSION = (0, 30, 0) +SCHEDULING_RELEASE_MINIMUM_VERSION = (0, 33, 0) UNIFIED_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 26, 1) RELEASE_PROVENANCE_ASSET_MINIMUM_VERSION = (0, 27, 1) BREG_RUNTIME_IMAGE_NAMES = DISCOVERY_RUNTIME_IMAGE_NAMES | { @@ -59,6 +60,7 @@ # Every v0.30.x release retains Mint. v0.31.0 and later omit it. CASEWORK_RUNTIME_IMAGE_NAMES = (BREG_RUNTIME_IMAGE_NAMES - {"mint"}) | {"casework"} THIRD_PARTY_NOTICES_MINIMUM_VERSION = (0, 33, 0) +SCHEDULING_RUNTIME_IMAGE_NAMES = CASEWORK_RUNTIME_IMAGE_NAMES | {"scheduling"} V2_TOP_LEVEL_FIELDS = { "schema_version", "repository", @@ -98,7 +100,7 @@ } SECURITY_EVIDENCE_REQUIRED_FILES = SECURITY_EVIDENCE_COMMON_REQUIRED_FILES | { f"{directory}/{image}.{suffix}.json" - for image in CASEWORK_RUNTIME_IMAGE_NAMES + for image in SCHEDULING_RUNTIME_IMAGE_NAMES for directory, suffix in ( ("image-sbom", "spdx"), ("syft", "syft"), @@ -128,7 +130,9 @@ def _candidate_image_names(version: str) -> set[str]: return BREG_RUNTIME_IMAGE_NAMES if parsed < MINT_RETIREMENT_VERSION: return CASEWORK_RUNTIME_IMAGE_NAMES | {"mint"} - return CASEWORK_RUNTIME_IMAGE_NAMES + if parsed < SCHEDULING_RELEASE_MINIMUM_VERSION: + return CASEWORK_RUNTIME_IMAGE_NAMES + return SCHEDULING_RUNTIME_IMAGE_NAMES def _literal_string_roster(path: Path, name: str) -> set[str]: diff --git a/release/scripts/smoke-release-image-oci-labels.sh b/release/scripts/smoke-release-image-oci-labels.sh index 8732f97b14..7fc1f00bc9 100755 --- a/release/scripts/smoke-release-image-oci-labels.sh +++ b/release/scripts/smoke-release-image-oci-labels.sh @@ -6,7 +6,7 @@ repo_root="$(cd -- "${script_dir}/../.." && pwd)" checker="${script_dir}/check-release-image-oci-labels.py" image_builder="${script_dir}/build-release-image.sh" layout_comparator="${script_dir}/compare-release-image-layouts.py" -images=(relay evidence discovery breg casework) +images=(relay evidence discovery breg casework scheduling) relay_dockerfile="${repo_root}/release/docker/Dockerfile.relay" source_label="https://github.com/registrystack/registry-stack" diff --git a/release/scripts/test_check_release_image_oci_labels.py b/release/scripts/test_check_release_image_oci_labels.py index 662ff3a10a..a914424e30 100644 --- a/release/scripts/test_check_release_image_oci_labels.py +++ b/release/scripts/test_check_release_image_oci_labels.py @@ -640,7 +640,7 @@ def read_calls(path: Path) -> list[list[str]]: build_calls = [ call for call in read_calls(docker_log) if call[:2] == ["buildx", "build"] ] - self.assertEqual(12, len(build_calls)) + self.assertEqual(14, len(build_calls)) dockerfiles = [] for call in build_calls: self.assertEqual(["buildx", "build"], call[:2]) @@ -669,6 +669,7 @@ def read_calls(path: Path) -> list[list[str]]: str(ROOT / "release/docker/Dockerfile.evidence"), str(ROOT / "release/docker/Dockerfile.breg"), str(ROOT / "release/docker/Dockerfile.casework"), + str(ROOT / "release/docker/Dockerfile.scheduling"), str(ROOT / "release/docker/Dockerfile.relay"), }, set(dockerfiles), @@ -679,7 +680,13 @@ def read_calls(path: Path) -> list[list[str]]: str(ROOT / "release/docker/Dockerfile.relay") ), ) - for name in ("discovery", "evidence", "breg", "casework"): + for name in ( + "discovery", + "evidence", + "breg", + "casework", + "scheduling", + ): self.assertEqual( 2, dockerfiles.count( @@ -700,6 +707,7 @@ def read_calls(path: Path) -> list[list[str]]: "correct-evidence-first", "correct-breg-first", "correct-casework-first", + "correct-scheduling-first", "correct-relay-first", }, { @@ -723,7 +731,7 @@ def read_calls(path: Path) -> list[list[str]]: for call in python_calls if call and call[0].endswith("compare-release-image-layouts.py") ] - self.assertEqual(6, len(comparisons)) + self.assertEqual(7, len(comparisons)) self.assertEqual(1, sum("--rootfs-only" in call for call in comparisons)) diff --git a/release/scripts/test_cleanup_release_candidates.py b/release/scripts/test_cleanup_release_candidates.py index 624fb950c4..4f1bb4a959 100644 --- a/release/scripts/test_cleanup_release_candidates.py +++ b/release/scripts/test_cleanup_release_candidates.py @@ -295,6 +295,7 @@ def test_public_and_unknown_packages_are_rejected_before_listing(self) -> None: "mint", "breg", "casework", + "scheduling", "relay", "other-candidate", ): @@ -319,6 +320,17 @@ def test_casework_candidate_is_allowlisted_after_package_bootstrap(self) -> None self.assertIn("casework-candidate", self.module.CANDIDATE_PACKAGES) self.module.assert_candidate_package("casework-candidate") + def test_scheduling_remains_publicly_denylisted_before_candidate_onboarding( + self, + ) -> None: + self.assertIn("scheduling", self.module.PUBLIC_PACKAGES) + self.assertNotIn("scheduling-candidate", self.module.CANDIDATE_PACKAGES) + with self.assertRaisesRegex( + self.module.CleanupError, + "package is not in the exact candidate allowlist", + ): + self.module.assert_candidate_package("scheduling-candidate") + def test_malformed_or_future_timestamp_fails_without_deleting(self) -> None: for timestamp in ("not-a-date", "2026-07-25T12:00:01Z"): with self.subTest(timestamp=timestamp): diff --git a/release/scripts/test_collect_rehearsal_advisory_evidence.py b/release/scripts/test_collect_rehearsal_advisory_evidence.py index 1702ae49a7..520224897e 100644 --- a/release/scripts/test_collect_rehearsal_advisory_evidence.py +++ b/release/scripts/test_collect_rehearsal_advisory_evidence.py @@ -263,6 +263,24 @@ def test_post_mint_roster_is_owned_and_complete(self) -> None: ("breg", "casework", "discovery", "evidence", "relay"), ) + def test_v0_33_roster_includes_scheduling(self) -> None: + result = subprocess.run( + [ + "python3", + str(ROOT / "release/scripts/release_candidate.py"), + "image-names", + "--version", + "0.33.0", + ], + check=True, + capture_output=True, + text=True, + ) + self.assertEqual( + MODULE.parse_roster(result.stdout), + ("breg", "casework", "discovery", "evidence", "relay", "scheduling"), + ) + def test_collects_every_owned_image_with_exact_daemon_context(self) -> None: fake = FakeCommands() with tempfile.TemporaryDirectory() as temporary: diff --git a/release/scripts/test_merge_release_binary_shards.py b/release/scripts/test_merge_release_binary_shards.py index 1fb682a1d0..9c7824ecde 100644 --- a/release/scripts/test_merge_release_binary_shards.py +++ b/release/scripts/test_merge_release_binary_shards.py @@ -16,7 +16,7 @@ MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) -VERSION = "0.31.0" +VERSION = "0.33.0" TAG = f"v{VERSION}" BUILDER = "rust:fixture@sha256:" + "a" * 64 SOURCE_SHA = "1" * 40 @@ -34,11 +34,13 @@ f"casework-{TAG}-linux-amd64", f"caseworkctl-{TAG}-linux-amd64", ] +SCHEDULING = [f"scheduling-{TAG}-linux-amd64"] FINAL = [CORE[0], *BREG, *CASEWORK, *CORE[1:]] IMAGE_SOURCES = { "discovery": CORE[0], "breg": BREG[0], "casework": CASEWORK[0], + "scheduling": SCHEDULING[0], "evidence": CORE[1], "relay": CORE[5], } @@ -56,6 +58,7 @@ def setUp(self) -> None: self.core = self.write_shard("core", CORE) self.breg = self.write_shard("breg", BREG) self.casework = self.write_shard("casework", CASEWORK) + self.scheduling = self.write_shard("scheduling", SCHEDULING) self.output = self.root / "dist" def write_shard(self, name: str, assets: list[str]) -> Path: @@ -85,6 +88,7 @@ def merge(self) -> None: core=self.core, breg=self.breg, casework=self.casework, + scheduling=self.scheduling, output=self.output, builder_image=BUILDER, ) @@ -107,6 +111,8 @@ def test_reconstructs_the_exact_full_layout_from_download_binary_bytes(self) -> if asset in BREG else self.casework if asset in CASEWORK + else self.scheduling + if asset in SCHEDULING else self.core ) self.assertEqual((source_root / "bin" / asset).read_bytes(), (bin_dir / asset).read_bytes()) @@ -117,6 +123,8 @@ def test_reconstructs_the_exact_full_layout_from_download_binary_bytes(self) -> if source_name in BREG else self.casework if source_name in CASEWORK + else self.scheduling + if source_name in SCHEDULING else self.core ) self.assertEqual( @@ -158,6 +166,14 @@ def test_version_gates_select_the_historical_exact_rosters(self) -> None: ["discovery", "breg", "casework", "evidence", "mint", "relay"], [name for name, _ in images_030], ) + rosters_032, images_032 = MODULE.rosters("0.32.0") + self.assertEqual([], rosters_032["scheduling"]) + self.assertNotIn("scheduling", dict(images_032)) + rosters_033, images_033 = MODULE.rosters("0.33.0") + self.assertEqual( + ["scheduling-v0.33.0-linux-amd64"], rosters_033["scheduling"] + ) + self.assertIn("scheduling", dict(images_033)) def test_mint_is_retired_only_from_v0_31_0(self) -> None: for version in ("0.30.0", "0.30.1"): @@ -196,6 +212,9 @@ def test_rejects_incomplete_or_ambiguous_shards_before_staging_output(self) -> N self.core = self.write_shard("core", CORE) self.breg = self.write_shard("breg", BREG) self.casework = self.write_shard("casework", CASEWORK) + self.scheduling = self.write_shard( + "scheduling", SCHEDULING + ) self.output = fixture / "dist" mutate() with self.assertRaises(MODULE.ShardError): diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 3984b66b9a..ae09fb85a3 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -2265,13 +2265,14 @@ def test_release_canary_exercises_the_current_image_contract(self) -> None: for current in ( "_relay_v2_payload_inventory", "payloads: $payloads[0]", - "image_names=(relay evidence discovery breg casework)", + "image_names=(relay evidence discovery breg casework scheduling)", "images: $images[0]", "scans: $scans[0]", '"discovery-image"', '"evidence-image"', '"breg-image"', '"casework-image"', + '"scheduling-image"', '"relay-image"', ): self.assertIn(current, workflow) @@ -2344,6 +2345,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: "evidence", "breg", "casework", + "scheduling", "relay", ) } @@ -2368,6 +2370,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: "evidence", "breg", "casework", + "scheduling", "relay", ): self.assertIn( @@ -2379,7 +2382,7 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: f"/workspace/runtime-root/usr/local/bin/{name}", release_dockerfiles[name], ) - for name in ("evidence", "breg", "casework", "relay"): + for name in ("evidence", "breg", "casework", "scheduling", "relay"): self.assertIn( "--mount=type=bind,source=THIRD_PARTY_NOTICES," "target=/workspace/THIRD_PARTY_NOTICES,readonly", @@ -2391,7 +2394,9 @@ def test_release_packaging_uses_relay_v2_artifact_identities(self) -> None: release_dockerfiles[name], ) self.assertNotIn("THIRD_PARTY_NOTICES", release_dockerfiles["discovery"]) - self.assertIn("discovery|evidence|breg|casework|relay)", image_recipe) + self.assertIn( + "discovery|evidence|breg|casework|scheduling|relay)", image_recipe + ) self.assertNotIn("registry-relay)", image_recipe) def test_breg_release_image_keeps_deployment_inputs_external(self) -> None: @@ -2644,6 +2649,55 @@ def test_casework_release_surface_begins_after_published_v0_29(self) -> None: self.assertIn('"caseworkctl-${tag}-linux-amd64"', recipe) self.assertIn("image_bin_binaries+=(casework)", recipe) + def test_scheduling_image_release_surface_begins_after_published_v0_32( + self, + ) -> None: + module = load_registry_release() + published = { + name: "0.32.0" + for name in ( + module.RELAY_V2_ARTIFACT_INVENTORY + | { + "relay-installer", + "registry-docs", + "discovery", + "breg", + "bregctl", + "breg-installer", + "casework", + "caseworkctl", + "casework-installer", + "registry-client-node", + "registry-client-python", + } + ) + if name != "mint" + and name + not in { + "evidence-client-node", + "evidence-client-python", + } + } + self.assertEqual([], module.artifact_inventory_errors("0.32.0", published)) + future = {name: "0.33.0" for name in published} + future["scheduling"] = "0.33.0" + self.assertEqual([], module.artifact_inventory_errors("0.33.0", future)) + self.assertNotEqual( + [], + module.artifact_inventory_errors( + "0.32.0", published | {"scheduling": "0.32.0"} + ), + ) + del future["scheduling"] + self.assertNotEqual([], module.artifact_inventory_errors("0.33.0", future)) + + recipe = (ROOT / "release/scripts/build-release-binaries.sh").read_text( + encoding="utf-8" + ) + self.assertIn("-p registry-scheduling --bin scheduling", recipe) + self.assertIn('"scheduling-${tag}-linux-amd64"', recipe) + self.assertIn("image_bin_binaries+=(scheduling)", recipe) + def test_unified_client_manifest_surface_replaces_individual_clients(self) -> None: module = load_registry_release() base = { @@ -3318,6 +3372,14 @@ def test_validate_current_selects_highest_semver_manifest(self) -> None: data["stack"]["release"] = release_id data["stack"].pop("source_ref") data["stack"].pop("status") + catalog_bytes = ( + ROOT / "products/identifiers/generated/catalog.v1.json" + ).read_bytes() + data["identifier_catalog"] = { + "path": "products/identifiers/generated/catalog.v1.json", + "sha256": hashlib.sha256(catalog_bytes).hexdigest(), + "entry_count": len(json.loads(catalog_bytes)["entries"]), + } manifest.write_text( yaml.safe_dump(data, sort_keys=False), encoding="utf-8" ) @@ -3967,6 +4029,8 @@ def write_manifest( artifacts["casework-installer"] = version if version_tuple >= (0, 31, 0): artifacts.pop("mint") + if version_tuple >= (0, 33, 0): + artifacts["scheduling"] = version manifest = { "stack": { "release": "beta-6", diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index bb284745c6..c2810db771 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -40,6 +40,9 @@ def _load_registry_release(): CASEWORK_RELEASE_MINIMUM_VERSION = ( REGISTRY_RELEASE.CASEWORK_RELEASE_MINIMUM_VERSION ) +SCHEDULING_RELEASE_MINIMUM_VERSION = ( + REGISTRY_RELEASE.SCHEDULING_RELEASE_MINIMUM_VERSION +) MINT_RETIREMENT_VERSION = REGISTRY_RELEASE.release_candidate.MINT_RETIREMENT_VERSION FIXTURE_IDENTIFIER_CATALOG = { "version": 1, @@ -149,6 +152,8 @@ def manifest(version: str, release_id: str, source_ref: str, status: str) -> dic "caseworkctl", "casework-installer", ) + if version_tuple >= SCHEDULING_RELEASE_MINIMUM_VERSION: + inventory += ("scheduling",) data = { "stack": { "release": release_id, diff --git a/release/scripts/test_release_candidate.py b/release/scripts/test_release_candidate.py index b9733a295e..82d0c00547 100644 --- a/release/scripts/test_release_candidate.py +++ b/release/scripts/test_release_candidate.py @@ -49,6 +49,7 @@ def security_evidence_members( "casework", "breg", "relay", + "scheduling", ), ) -> dict[str, bytes]: refs = { @@ -381,6 +382,7 @@ def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: "breg", "casework", "relay", + "scheduling", ) evidence_members = security_evidence_members(image_names) evidence_name = "registry-stack-v1.2.3-security-evidence.tar.gz" @@ -858,6 +860,24 @@ def test_third_party_notices_join_only_the_v0_33_roster(self) -> None: self.assertNotIn("THIRD_PARTY_NOTICES", historical) self.assertEqual("notice", current["THIRD_PARTY_NOTICES"]) + def test_scheduling_image_joins_only_the_v0_33_roster(self) -> None: + self.assertNotIn("scheduling", self.module._candidate_image_names("0.32.0")) + self.assertEqual( + { + "breg", + "casework", + "discovery", + "evidence", + "relay", + "scheduling", + }, + self.module._candidate_image_names("0.33.0"), + ) + self.assertNotIn( + "scheduling-v0.33.0-linux-amd64", + self.module._relay_v2_payload_inventory("0.33.0"), + ) + def test_retirement_preserves_every_v0_30_release(self) -> None: for version, expected in ( ("0.30.0", True), @@ -879,6 +899,8 @@ def test_image_names_cli_emits_the_version_appropriate_roster(self) -> None: ("0.30.0", "breg casework discovery evidence mint relay\n"), ("0.30.1", "breg casework discovery evidence mint relay\n"), ("0.31.0", "breg casework discovery evidence relay\n"), + ("0.32.0", "breg casework discovery evidence relay\n"), + ("0.33.0", "breg casework discovery evidence relay scheduling\n"), ) for version, expected in cases: with self.subTest(version=version): @@ -920,6 +942,25 @@ def test_image_onboarding_after_0_30_0_requires_reviewed_casework_baseline(self) self.module.check_image_onboarding(root, "0.31.0"), ) + def test_v0_33_scheduling_onboarding_stays_closed_until_external_setup(self) -> None: + root = self.onboarding_repository() + shutil.copy2( + ROOT / "release/docker/Dockerfile.scheduling", + root / "release/docker/Dockerfile.scheduling", + ) + with self.assertRaisesRegex( + self.module.CandidateError, + "scheduling advisory baseline is missing", + ): + self.module.check_image_onboarding(root, "0.33.0") + with self.assertRaisesRegex( + self.module.CandidateError, + "CANDIDATE_PACKAGES must contain scheduling-candidate", + ): + self.module.check_image_onboarding( + root, "0.33.0", allow_missing_baseline=True + ) + def test_image_onboarding_rejects_a_noncanonical_version(self) -> None: with self.assertRaisesRegex( self.module.CandidateError, @@ -969,7 +1010,7 @@ def test_image_onboarding_rejects_an_unsupported_build_name(self) -> None: recipe = root / "release/scripts/build-release-image.sh" recipe.write_text( recipe.read_text(encoding="utf-8").replace( - "discovery|evidence|breg|casework|relay", + "discovery|evidence|breg|casework|scheduling|relay", "discovery|evidence|mint|relay", ), encoding="utf-8", @@ -1294,7 +1335,7 @@ def test_v2_security_evidence_archive_requires_every_expected_file( candidate, _, bundle_root, _ = self.make_v2_candidate() members = security_evidence_members() required = self.module._security_evidence_required_files( - self.module.CASEWORK_RUNTIME_IMAGE_NAMES + self.module.SCHEDULING_RUNTIME_IMAGE_NAMES ) for missing in sorted(required): with self.subTest(missing=missing): diff --git a/release/scripts/test_release_rehearsal.py b/release/scripts/test_release_rehearsal.py index d306413d56..24f52f435a 100644 --- a/release/scripts/test_release_rehearsal.py +++ b/release/scripts/test_release_rehearsal.py @@ -222,7 +222,8 @@ def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: binary_job = document["jobs"]["canonical-linux-binaries"] self.assertFalse(binary_job["strategy"]["fail-fast"]) self.assertEqual( - ["core", "breg", "casework"], binary_job["strategy"]["matrix"]["group"] + ["core", "breg", "casework", "scheduling"], + binary_job["strategy"]["matrix"]["group"], ) self.assertEqual( "${{ github.sha }}", binary_job["steps"][0]["with"]["ref"] @@ -263,6 +264,7 @@ def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: self.assertIn("breg-v${REHEARSAL_VERSION}-linux-amd64", merge) self.assertIn("bregctl-v${REHEARSAL_VERSION}-linux-amd64", merge) self.assertIn("--casework binary-shards/casework", merge) + self.assertIn("--scheduling binary-shards/scheduling", merge) self.assertIn("casework-v${REHEARSAL_VERSION}-linux-amd64", merge) self.assertIn("caseworkctl-v${REHEARSAL_VERSION}-linux-amd64", merge) self.assertIn( @@ -272,6 +274,10 @@ def test_workflow_is_manual_read_only_and_ubuntu_bounded(self) -> None: self.assertIn( "if (( release_major > 0 || release_minor >= 30 )); then", merge ) + self.assertIn( + "if (( release_major > 0 || release_minor >= 33 )); then", merge + ) + self.assertIn("dist/image-bin/scheduling --version", merge) self.assertNotIn("${{ inputs.", merge) for forbidden in ( "npm publish", diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index dfccd7e8d6..8339652133 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -391,6 +391,21 @@ def test_current_release_pipeline_has_no_pre_v0_19_surface(self) -> None: {"discovery", "evidence", "mint", "breg", "relay"}, module._candidate_image_names("0.26.0"), ) + self.assertEqual( + {"breg", "casework", "discovery", "evidence", "relay"}, + module._candidate_image_names("0.32.0"), + ) + self.assertEqual( + { + "breg", + "casework", + "discovery", + "evidence", + "relay", + "scheduling", + }, + module._candidate_image_names("0.33.0"), + ) self.assertFalse( any( "registry-notary" in name @@ -568,7 +583,7 @@ def test_canonical_binary_shards_are_exact_source_inputs_to_one_consumer( self.assertEqual("validate", shards["needs"]) self.assertFalse(shards["strategy"]["fail-fast"]) self.assertEqual( - ["core", "breg", "casework"], + ["core", "breg", "casework", "scheduling"], shards["strategy"]["matrix"]["group"], ) checkout = shards["steps"][0] @@ -595,9 +610,14 @@ def test_canonical_binary_shards_are_exact_source_inputs_to_one_consumer( downloads = [ step for step in consumer["steps"] if "download-artifact@" in str(step) ] - self.assertEqual(3, len(downloads)) + self.assertEqual(4, len(downloads)) self.assertEqual( - {"binary-shards/core", "binary-shards/breg", "binary-shards/casework"}, + { + "binary-shards/core", + "binary-shards/breg", + "binary-shards/casework", + "binary-shards/scheduling", + }, {step["with"]["path"] for step in downloads}, ) merge = step_run( @@ -608,6 +628,7 @@ def test_canonical_binary_shards_are_exact_source_inputs_to_one_consumer( "--core binary-shards/core", "--breg binary-shards/breg", "--casework binary-shards/casework", + "--scheduling binary-shards/scheduling", '--builder-image "${RELEASE_BUILDER_IMAGE}"', ): self.assertIn(binding, merge) @@ -1993,6 +2014,21 @@ def test_public_verification_selects_the_versioned_image_roster(self) -> None: "evidence", "relay", ], + "v0.32.0": [ + "breg", + "casework", + "discovery", + "evidence", + "relay", + ], + "v0.33.0": [ + "breg", + "casework", + "discovery", + "evidence", + "relay", + "scheduling", + ], } manifests = {} for tag, image_names in cases.items(): @@ -2046,8 +2082,26 @@ def test_public_verification_selects_the_versioned_image_roster(self) -> None: ) self.assertNotEqual(0, rejected.returncode) - self.assertIn("breg|casework|discovery|evidence|mint|relay)", verify) + missing_scheduling = dict(manifests["v0.33.0"]) + missing_scheduling["images"] = [ + image + for image in missing_scheduling["images"] + if image["name"] != "scheduling" + ] + rejected = subprocess.run( + ["jq", "-e", "--arg", "tag", "v0.33.0", jq_filter], + input=json.dumps(missing_scheduling), + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(0, rejected.returncode) + + self.assertIn( + "breg|casework|discovery|evidence|mint|relay|scheduling)", verify + ) self.assertIn("`casework` from\n`v0.30.0`", verify) + self.assertIn("Registry Scheduling joins at `v0.33.0`", verify) def test_operator_docs_match_the_latest_non_prerelease_contract(self) -> None: operations = (ROOT / "release/OPERATIONS.md").read_text(encoding="utf-8") diff --git a/release/scripts/test_zig_glibc_compiler.py b/release/scripts/test_zig_glibc_compiler.py index 684cb6a4da..74a69bc3e1 100644 --- a/release/scripts/test_zig_glibc_compiler.py +++ b/release/scripts/test_zig_glibc_compiler.py @@ -294,7 +294,7 @@ def setUp(self) -> None: "target.mkdir(parents=True, exist_ok=True)\n" "for binary in ('registry-manifest', 'relay', 'relayctl', 'evidence', " "'evidencectl', 'evidence-oid4vci', 'discovery', 'breg', 'bregctl', " - "'casework', 'caseworkctl'):\n" + "'casework', 'caseworkctl', 'scheduling'):\n" " (target / binary).write_text('fixture binary\\n')\n", encoding="utf-8", ) @@ -322,10 +322,11 @@ def setUp(self) -> None: "breg = ['breg', 'bregctl'] if parsed >= (0, 26, 0) else []\n" "selected = (core if group in ('all', 'core') else []) + " "(breg if group in ('all', 'breg') else []) + " - "(['casework', 'caseworkctl'] if parsed >= (0, 30, 0) and group in ('all', 'casework') else [])\n" + "(['casework', 'caseworkctl'] if parsed >= (0, 30, 0) and group in ('all', 'casework') else []) + " + "(['scheduling'] if parsed >= (0, 33, 0) and group in ('all', 'scheduling') else [])\n" "for name in selected:\n" " (bin_dir / f'{name}-{tag}-linux-amd64').write_text(name + '\\n')\n" - "for name in ('discovery', 'breg', 'casework', 'evidence', 'relay'):\n" + "for name in ('discovery', 'breg', 'casework', 'scheduling', 'evidence', 'relay'):\n" " if name in selected:\n" " (image_dir / name).write_text(name + '\\n')\n", encoding="utf-8", From 83fe97abde3460a7d396bb9509f40d26fe858075 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 08:46:22 +0700 Subject: [PATCH 106/115] fix(scheduling): address beta review findings Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 4 +- Cargo.lock | 1 + crates/registry-scheduling-core/Cargo.toml | 1 + .../registry-scheduling-core/src/admission.rs | 83 +++++- .../registry-scheduling-core/src/fixture.rs | 2 + crates/registry-scheduling-core/src/model.rs | 28 +- crates/registry-scheduling-core/src/naming.rs | 5 - crates/registry-scheduling-core/src/policy.rs | 5 + .../registry-scheduling-core/src/problem.rs | 2 +- .../migrations/0004_policy_document.sql | 2 + crates/registry-scheduling/src/config.rs | 2 +- crates/registry-scheduling/src/runtime.rs | 8 +- crates/registry-scheduling/src/service.rs | 22 +- crates/registry-scheduling/src/store.rs | 256 +++++++++++++---- .../tests/postgres_commitments.rs | 270 +++++++++++++++++- products/scheduling/CHANGELOG.md | 6 + products/scheduling/README.md | 6 + .../contracts/security-invariant-matrix.yaml | 15 + .../contracts/security-test-traceability.yaml | 10 + .../registry-scheduling.openapi.json | 2 +- 20 files changed, 630 insertions(+), 100 deletions(-) create mode 100644 crates/registry-scheduling/migrations/0004_policy_document.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f76b18ef93..c95fc409ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -715,8 +715,8 @@ jobs: scheduling-contracts: name: Scheduling product contracts - needs: changes - if: needs.changes.outputs.scheduling_contracts == 'true' + needs: [changes, rust-policy] + if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.scheduling_contracts == 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 20 steps: diff --git a/Cargo.lock b/Cargo.lock index 13b62f8e12..5ba158738a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6160,6 +6160,7 @@ dependencies = [ "chrono", "registry-platform-calendar", "registry-platform-canonical-json", + "registry-platform-hooks", "serde", "serde_json", "serde_norway", diff --git a/crates/registry-scheduling-core/Cargo.toml b/crates/registry-scheduling-core/Cargo.toml index d4954233b0..1f4a2b6b28 100644 --- a/crates/registry-scheduling-core/Cargo.toml +++ b/crates/registry-scheduling-core/Cargo.toml @@ -15,6 +15,7 @@ workspace = true chrono = { workspace = true, features = ["serde"] } registry-platform-calendar.workspace = true registry-platform-canonical-json.workspace = true +registry-platform-hooks.workspace = true serde.workspace = true serde_json.workspace = true serde_norway.workspace = true diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index a1bd1cfb34..bb493f74ed 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -74,7 +74,7 @@ pub enum AdmissionRefusal { PrerequisiteMissing { missing: Vec }, #[error("the request names a channel the closed vocabulary or the policy does not serve")] ChannelUnknown, - #[error("no backing member carries the required capabilities")] + #[error("the party or backing supply lacks a required capability")] CapabilityUnmatched, #[error("an active booking already holds this party's duplicate key")] DuplicateActiveBooking, @@ -200,6 +200,7 @@ pub fn evaluate_exact_time_admission( if request.party.recipients == 0 || request.party.recipients > exact.max_recipients { return Err(AdmissionRefusal::PartyCapacityInadequate); } + check_capabilities(offering, request)?; check_prerequisites(offering, request)?; check_duplicate(offering, request, snapshot, exclude, *now)?; @@ -355,6 +356,7 @@ pub fn evaluate_window_admission( if required > window.units { return Err(AdmissionRefusal::PartyCapacityInadequate); } + check_capabilities(offering, request)?; check_prerequisites(offering, request)?; check_duplicate(offering, request, snapshot, exclude, *now)?; @@ -452,6 +454,21 @@ fn check_prerequisites( } } +fn check_capabilities( + offering: &OfferingPolicy, + request: &AdmissionRequest, +) -> Result<(), AdmissionRefusal> { + if offering + .requires_capabilities + .iter() + .all(|wanted| request.capabilities.contains(wanted)) + { + Ok(()) + } else { + Err(AdmissionRefusal::CapabilityUnmatched) + } +} + fn check_duplicate( offering: &OfferingPolicy, request: &AdmissionRequest, @@ -467,7 +484,7 @@ fn check_duplicate( // key: skipping the check would let the same party hold two active // bookings by omission. None => Err(AdmissionRefusal::DuplicateKeyRequired), - Some(key) if snapshot.duplicate_active(key, exclude, now) => { + Some(key) if snapshot.duplicate_active(&offering.id, key, exclude, now) => { Err(AdmissionRefusal::DuplicateActiveBooking) } Some(_) => Ok(()), @@ -606,6 +623,7 @@ mod tests { ) -> LedgerClaim { LedgerClaim { id: id.to_owned(), + offering: "registry-update-30".to_owned(), supply_id: supply.to_owned(), kind: LedgerKind::Booking, channel: None, @@ -620,6 +638,7 @@ mod tests { fn window_claim(id: &str, units: u32, channel: &str) -> LedgerClaim { LedgerClaim { id: id.to_owned(), + offering: "household-renewal".to_owned(), supply_id: "household-morning-window".to_owned(), kind: LedgerKind::Booking, channel: Some(channel.to_owned()), @@ -907,6 +926,7 @@ mod tests { let now = utc(4, 4, 0); let expired_hold = LedgerClaim { id: "hold-1".to_owned(), + offering: "registry-update-30".to_owned(), supply_id: "station-1".to_owned(), kind: LedgerKind::Hold, channel: None, @@ -938,6 +958,7 @@ mod tests { let now = utc(4, 4, 0); let live_hold = LedgerClaim { id: "hold-1".to_owned(), + offering: "registry-update-30".to_owned(), supply_id: "station-1".to_owned(), kind: LedgerKind::Hold, channel: None, @@ -1060,8 +1081,27 @@ mod tests { }]; let wanting_context = exact_context(&wanting, &exact_wanting, &capable_members, &snapshot, now); + let capable_request = AdmissionRequest { + capabilities: vec!["interpreter".to_owned()], + ..exact_request(utc(5, 2, 30)) + }; + assert_eq!( + evaluate_exact_time_admission(&wanting_context, &capable_request, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::CapabilityUnmatched) + ); + + // The request must carry the offering capability too; a capable + // backing member does not make up for a party that lacks it. + let available = vec![PoolMember { + resource_id: "station-1".to_owned(), + capabilities: vec!["interpreter".to_owned()], + available: true, + }]; + let available_context = exact_context(&wanting, &exact_wanting, &available, &snapshot, now); assert_eq!( - evaluate_exact_time_admission(&wanting_context, &exact_request(utc(5, 2, 30)), None) + evaluate_exact_time_admission(&available_context, &exact_request(utc(5, 2, 30)), None,) .err() .map(|refusal| refusal.public_code()), Some(ProblemCode::CapabilityUnmatched) @@ -1076,12 +1116,8 @@ mod tests { }]; let unavailable_context = exact_context(&wanting, &exact_wanting, &unavailable, &snapshot, now); - let refused = evaluate_exact_time_admission( - &unavailable_context, - &exact_request(utc(5, 2, 30)), - None, - ) - .expect_err("refused"); + let refused = evaluate_exact_time_admission(&unavailable_context, &capable_request, None) + .expect_err("refused"); assert_eq!(refused.public_code(), ProblemCode::CapacityExhausted); assert_eq!(refused.detailed_code(), ProblemCode::ResourceUnavailable); } @@ -1248,6 +1284,35 @@ mod tests { assert_eq!(single.map(|admission| admission.units), Ok(1)); } + #[test] + fn an_arrival_window_requires_the_partys_capabilities() { + let mut offering = arrival_offering(); + offering.requires_capabilities = vec!["interpreter".to_owned()]; + let window = window(); + let snapshot = LedgerSnapshot::default(); + let now = utc(4, 9, 0); + let context = WindowContext { + offering: &offering, + window: &window, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now, + }; + let missing = evaluate_window_admission(&context, &window_request(1, 1), None); + assert_eq!( + missing.err().map(|refusal| refusal.public_code()), + Some(ProblemCode::CapabilityUnmatched) + ); + let held = AdmissionRequest { + capabilities: vec!["interpreter".to_owned()], + ..window_request(1, 1) + }; + assert!(evaluate_window_admission(&context, &held, None).is_ok()); + } + #[test] fn a_party_the_published_units_could_never_carry_is_inadequate_not_exhausted() { let offering = arrival_offering(); diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs index d1b02dd328..ae4d5a918f 100644 --- a/crates/registry-scheduling-core/src/fixture.rs +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -536,6 +536,7 @@ fn record_admission( } snapshot.claims.push(LedgerClaim { id: format!("case:{case}"), + offering: request.offering.clone(), supply_id: supply_id.to_owned(), kind: LedgerKind::Booking, channel: request.channel.clone(), @@ -722,6 +723,7 @@ facts: available: true initial: - id: booking-9 + offering: registry-update-30 supplyId: station-1 kind: booking start: 2026-10-05T02:00:00Z diff --git a/crates/registry-scheduling-core/src/model.rs b/crates/registry-scheduling-core/src/model.rs index 48d0c743a3..f0819bbecb 100644 --- a/crates/registry-scheduling-core/src/model.rs +++ b/crates/registry-scheduling-core/src/model.rs @@ -71,6 +71,8 @@ pub enum LedgerKind { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct LedgerClaim { pub id: String, + /// The offering whose duplicate-active policy owns this claim. + pub offering: String, /// The pool member resource (exact time) or the window (arrival) this /// claim occupies. pub supply_id: String, @@ -158,12 +160,14 @@ impl LedgerSnapshot { /// ignoring `exclude`. pub fn duplicate_active( &self, + offering: &str, duplicate_key: &str, exclude: Option<&str>, now: DateTime, ) -> bool { self.consuming(now).any(|claim| { - claim.duplicate_key.as_deref() == Some(duplicate_key) + claim.offering == offering + && claim.duplicate_key.as_deref() == Some(duplicate_key) && Some(claim.id.as_str()) != exclude }) } @@ -398,6 +402,7 @@ mod tests { let now = utc(4, 0); let booking = LedgerClaim { id: "claim-1".to_owned(), + offering: "offering-a".to_owned(), supply_id: "station-1".to_owned(), kind: LedgerKind::Booking, channel: None, @@ -409,6 +414,7 @@ mod tests { }; let live_hold = LedgerClaim { id: "claim-2".to_owned(), + offering: "offering-a".to_owned(), supply_id: "station-2".to_owned(), kind: LedgerKind::Hold, channel: None, @@ -420,6 +426,7 @@ mod tests { }; let expired_hold = LedgerClaim { id: "claim-3".to_owned(), + offering: "offering-a".to_owned(), supply_id: "station-3".to_owned(), kind: LedgerKind::Hold, channel: None, @@ -450,6 +457,7 @@ mod tests { let now = utc(4, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), + offering: "offering-a".to_owned(), supply_id: "station-1".to_owned(), kind: LedgerKind::Booking, channel: None, @@ -477,6 +485,7 @@ mod tests { let now = utc(4, 0); let claim = |id: &str, channel: Option<&str>, units: u32| LedgerClaim { id: id.to_owned(), + offering: "offering-a".to_owned(), supply_id: "morning-window".to_owned(), kind: LedgerKind::Booking, channel: channel.map(str::to_owned), @@ -516,6 +525,7 @@ mod tests { let now = utc(4, 0); let claim = |id: &str, units: u32| LedgerClaim { id: id.to_owned(), + offering: "offering-a".to_owned(), supply_id: "morning-window".to_owned(), kind: LedgerKind::Booking, channel: None, @@ -541,6 +551,7 @@ mod tests { let now = utc(4, 0); let expired_hold = LedgerClaim { id: "claim-1".to_owned(), + offering: "offering-a".to_owned(), supply_id: "morning-window".to_owned(), kind: LedgerKind::Hold, channel: None, @@ -550,10 +561,18 @@ mod tests { duplicate_key: Some("subject:one".to_owned()), expires_at: Some(utc(3, 0)), }; + let other_offering = LedgerClaim { + id: "claim-2".to_owned(), + offering: "offering-b".to_owned(), + kind: LedgerKind::Booking, + expires_at: None, + ..expired_hold.clone() + }; let snapshot = LedgerSnapshot { - claims: vec![expired_hold], + claims: vec![expired_hold, other_offering], }; - assert!(!snapshot.duplicate_active("subject:one", None, now)); + assert!(!snapshot.duplicate_active("offering-a", "subject:one", None, now)); + assert!(snapshot.duplicate_active("offering-b", "subject:one", None, now)); } #[test] @@ -638,7 +657,8 @@ mod tests { #[test] fn ledger_and_request_shapes_refuse_unknown_fields() { - let claim_yaml = "id: claim-1\nsupplyId: station-1\nkind: booking\n\ + let claim_yaml = + "id: claim-1\noffering: registry-update-30\nsupplyId: station-1\nkind: booking\n\ start: 2026-10-05T02:00:00Z\nend: 2026-10-05T03:00:00Z\nunits: 1\n"; assert!(serde_norway::from_str::(claim_yaml).is_ok()); assert!( diff --git a/crates/registry-scheduling-core/src/naming.rs b/crates/registry-scheduling-core/src/naming.rs index 243a14a0c0..4883c5091c 100644 --- a/crates/registry-scheduling-core/src/naming.rs +++ b/crates/registry-scheduling-core/src/naming.rs @@ -33,10 +33,6 @@ pub const SCHEDULING_RUNTIME_SCHEMA_ID: &str = pub const SCHEDULING_PROBLEM_TYPE_BASE: &str = "https://id.registrystack.org/problems/registry-scheduling/"; -/// The one reserved hook ABI value. This milestone has no hook engine: the -/// value may appear on the hook policy type, and nowhere else. -pub const SCHEDULING_HOOK_ABI: &str = "registry.scheduling-hook/v1"; - /// apiVersion of an offline replay fixture. pub const SCHEDULING_FIXTURE_API_VERSION: &str = "registry.registrystack.org/scheduling-fixture/v1alpha1"; @@ -116,7 +112,6 @@ mod tests { SCHEDULING_PROBLEM_TYPE_BASE, "https://id.registrystack.org/problems/registry-scheduling/" ); - assert_eq!(SCHEDULING_HOOK_ABI, "registry.scheduling-hook/v1"); assert_eq!( SCHEDULING_FIXTURE_API_VERSION, "registry.registrystack.org/scheduling-fixture/v1alpha1" diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 6cd4223f5c..d6ff3034da 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -1776,6 +1776,7 @@ holdPolicy: let now = utc(6, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), supply_id: "household-morning-window".to_owned(), kind: LedgerKind::Booking, channel: Some("public".to_owned()), @@ -1827,6 +1828,7 @@ holdPolicy: let now = utc(6, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), supply_id: "household-morning-window".to_owned(), kind: LedgerKind::Booking, channel: Some("public".to_owned()), @@ -1888,6 +1890,7 @@ holdPolicy: let now = utc(6, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), supply_id: "household-morning-window".to_owned(), kind: LedgerKind::Booking, channel: Some("public".to_owned()), @@ -1941,6 +1944,7 @@ holdPolicy: let now = utc(6, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), supply_id: "household-morning-window".to_owned(), kind: LedgerKind::Booking, channel: Some("public".to_owned()), @@ -1974,6 +1978,7 @@ holdPolicy: // the morning claim still meets the window, the late one does not. let late = LedgerClaim { id: "claim-2".to_owned(), + offering: "household-renewal".to_owned(), supply_id: "household-morning-window".to_owned(), kind: LedgerKind::Booking, channel: Some("assisted".to_owned()), diff --git a/crates/registry-scheduling-core/src/problem.rs b/crates/registry-scheduling-core/src/problem.rs index d56290e71e..1b05e820fb 100644 --- a/crates/registry-scheduling-core/src/problem.rs +++ b/crates/registry-scheduling-core/src/problem.rs @@ -272,7 +272,7 @@ impl ProblemCode { "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled." } Self::CapabilityUnmatched => { - "No backing member carries every capability this offering requires." + "The party or backing supply does not carry every capability this offering requires." } Self::CapacityExhausted => { "The supply is fully committed for the requested interval. Choose another time." diff --git a/crates/registry-scheduling/migrations/0004_policy_document.sql b/crates/registry-scheduling/migrations/0004_policy_document.sql new file mode 100644 index 0000000000..d1c71284c0 --- /dev/null +++ b/crates/registry-scheduling/migrations/0004_policy_document.sql @@ -0,0 +1,2 @@ +ALTER TABLE scheduling_policy_revisions + ADD COLUMN IF NOT EXISTS policy_document jsonb; diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index 0e4b29749c..93efb9e611 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -1223,7 +1223,7 @@ holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} let package = root.path().join("package"); write_policy(&package); let hooked = format!( - "{POLICY}hooks:\n - {{id: h, abi: registry.scheduling-hook/v1, because: test}}\n" + "{POLICY}hooks:\n - id: appointment-observer\n phase: after\n trigger: appointment.confirmed\n projection: []\n handler:\n kind: url\n destinationId: appointment-events\n" ); std::fs::write(package.join(AUTHORED_POLICY_FILE), hooked).unwrap(); let mut document = operator_value(&package, "development-loopback"); diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index f0b58a32ec..9a6c475e98 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -273,7 +273,13 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> let pool_ids = offering_pool_ids(&policy); let window_ids = offering_window_ids(&policy); let policy_revision = store - .apply_policy(&scheduling_id, &policy_digest, &pool_ids, &window_ids) + .apply_policy( + &scheduling_id, + &policy_digest, + &pool_ids, + &window_ids, + &policy, + ) .await .map_err(database_step("policy publication"))?; diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index ef17f9bdf0..c82e74ab6e 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -816,6 +816,9 @@ impl SchedulingService { let grant = self .require_permission(caller, offering, APPOINTMENT_RESCHEDULE_ACTION) .await?; + if request.admission.offering != appointment.offering { + return Err(ServiceError::Problem(ProblemCode::RequestUnprocessable)); + } let actor = caller.actor_pseudonym(&self.hasher, &self.scheduling_id)?; let (supply, facts_revision) = self.supply(offering).await?; let request_hash = canonical_hash(&json!({ @@ -1283,7 +1286,7 @@ impl SchedulingService { ); match receipt { Ok(commitment) => { - if let Err(failure) = self + match self .store .record_refused_attempt( &commitment, @@ -1293,7 +1296,21 @@ impl SchedulingService { ) .await { - tracing::error!(%failure, "the refused attempt receipt could not be recorded"); + Ok(Some((status_code, receipt))) => { + return Ok(CommitmentAnswer::Replay { + status_code, + receipt, + }); + } + Ok(None) => {} + Err( + failure @ (CommitError::KeyReused | CommitError::KeyExpired), + ) => { + return Err(ServiceError::Problem(problem_of(&failure))); + } + Err(failure) => { + tracing::error!(%failure, "the refused attempt receipt could not be recorded"); + } } } Err(refused) => { @@ -1975,6 +1992,7 @@ mod tests { ) -> registry_scheduling_core::LedgerClaim { registry_scheduling_core::LedgerClaim { id: id.to_owned(), + offering: "registry-update-30".to_owned(), supply_id: supply_id.to_owned(), kind: LedgerKind::Booking, channel: None, diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 069a25a3ac..43856da54b 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -32,6 +32,7 @@ //! dispatch lease: a claimed intent is not claimable again until its lease //! passes, and an outcome write only lands for the attempt that owns it. +use std::collections::HashMap; use std::str::FromStr; use std::time::Duration; @@ -40,10 +41,10 @@ use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime}; use registry_platform_calendar::CalendarInterval; use registry_platform_config::SecretResolver; use registry_scheduling_core::{ - evaluate_exact_time_admission, evaluate_hold_state, evaluate_window_admission, - AdmissionRefusal, ExactTimeContext, LedgerClaim, LedgerKind, LedgerSnapshot, PoolMember, - SchedulingFacts, APPOINTMENT_CANCELLED_TRIGGER, APPOINTMENT_CONFIRMED_TRIGGER, - APPOINTMENT_RESCHEDULED_TRIGGER, + assess_publication_impact, evaluate_exact_time_admission, evaluate_hold_state, + evaluate_window_admission, AdmissionRefusal, ExactTimeContext, LedgerClaim, LedgerKind, + LedgerSnapshot, PoolMember, SchedulingFacts, SchedulingPolicy, APPOINTMENT_CANCELLED_TRIGGER, + APPOINTMENT_CONFIRMED_TRIGGER, APPOINTMENT_RESCHEDULED_TRIGGER, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -58,10 +59,17 @@ const SCHEDULING_MIGRATION: &str = include_str!("../migrations/0001_scheduling.s const FACTS_REVISION_MIGRATION: &str = include_str!("../migrations/0002_facts_revision_and_suppressed.sql"); const HOOK_DELIVERY_MIGRATION_VERSION: i64 = 3; +const POLICY_DOCUMENT_MIGRATION: &str = include_str!("../migrations/0004_policy_document.sql"); +const POLICY_DOCUMENT_MIGRATION_VERSION: i64 = 4; /// Every schema version in ledger order. const MIGRATIONS: [(i64, &str); 2] = [(1, SCHEDULING_MIGRATION), (2, FACTS_REVISION_MIGRATION)]; -const SCHEMA_VERSIONS: [i64; 3] = [1, 2, HOOK_DELIVERY_MIGRATION_VERSION]; +const SCHEMA_VERSIONS: [i64; 4] = [ + 1, + 2, + HOOK_DELIVERY_MIGRATION_VERSION, + POLICY_DOCUMENT_MIGRATION_VERSION, +]; /// Serializes operator-run migrations on one session lock. A second migrator /// waits here instead of racing the ledger primary key. The key spells the @@ -102,8 +110,12 @@ pub enum StoreError { /// The environment records would retire resources that live appointments /// or holds still occupy. The swap is refused whole, so the operator /// either keeps the resource or closes what stands on it first. - #[error("the environment records retire {0}, which live appointments or holds still occupy")] + #[error( + "the environment records retire or move {0}, which live appointments or holds still occupy" + )] FactsInUse(String), + #[error("the proposed policy would strand standing commitments: {0}")] + PolicyInUse(String), // Both carry the driver's own account of what went wrong. Neither the // pool nor the driver repeats the connection string in its message, so // naming the cause costs no credential. @@ -552,6 +564,25 @@ impl PostgresStore { .await?; } transaction.commit().await?; + + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&POLICY_DOCUMENT_MIGRATION_VERSION], + ) + .await? + .get(0); + if !applied { + transaction.batch_execute(POLICY_DOCUMENT_MIGRATION).await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&POLICY_DOCUMENT_MIGRATION_VERSION], + ) + .await?; + } + transaction.commit().await?; Ok(()) } @@ -623,28 +654,35 @@ impl PostgresStore { )) } - /// Publish a policy: bump the revision when the digest changed, and make - /// sure every supply anchor the policy needs exists. Re-applying the - /// same policy is a no-op that still verifies the anchors. + /// Publish a policy after proving it does not strand standing window + /// commitments. Re-applying the same digest backfills its retained policy + /// document and remains a no-op for the revision. /// - /// Lock order, load-bearing: this method takes `scheduling_meta` - /// exclusively and then only inserts `scheduling_supply` rows with - /// `ON CONFLICT DO NOTHING`, which never waits on a row lock a capacity - /// transaction holds on that table. The capacity transactions take a - /// supply anchor first and `scheduling_meta` for share after, and the - /// records swap takes every anchor before `scheduling_meta`. Those three - /// orders cannot cycle only while this method keeps to inserts here: - /// an update or a locking read of `scheduling_supply` under the meta - /// lock would close a cycle. + /// Lock order, load-bearing: policy publication takes every existing + /// supply anchor in order before `scheduling_meta`, the same order as the + /// records swap. Capacity transactions take one anchor and then the meta + /// row, so an older runtime cannot add a commitment between this method's + /// impact assessment and publication. pub async fn apply_policy( &self, scheduling_id: &str, policy_digest: &str, pool_ids: &[String], window_ids: &[String], + policy: &SchedulingPolicy, ) -> Result { + if policy.policy_digest() != policy_digest { + return Err(StoreError::Corrupt); + } + let policy_document = serde_json::to_value(policy).map_err(|_| StoreError::Corrupt)?; let mut client = self.client().await?; let transaction = client.transaction().await?; + transaction + .execute( + "SELECT supply_id FROM scheduling_supply ORDER BY supply_id FOR UPDATE", + &[], + ) + .await?; let row = transaction .query_one( "SELECT scheduling_id, policy_revision, policy_digest FROM scheduling_meta WHERE singleton FOR UPDATE", @@ -655,13 +693,66 @@ impl PostgresStore { return Err(StoreError::Corrupt); } let revision; - if row.get::<_, String>(2) != policy_digest { + let stored_revision = row.get::<_, i64>(1); + let stored_digest = row.get::<_, String>(2); + if stored_digest != policy_digest { + if !stored_digest.is_empty() { + let current_document: Option = transaction + .query_opt( + "SELECT policy_document FROM scheduling_policy_revisions \ + WHERE policy_revision=$1 AND policy_digest=$2", + &[&stored_revision, &stored_digest], + ) + .await? + .and_then(|stored| stored.get(0)); + let Some(current_document) = current_document else { + return Err(StoreError::PolicyInUse( + "the current policy document is unavailable; reapply the current policy before publishing a change" + .to_owned(), + )); + }; + let current: SchedulingPolicy = + serde_json::from_value(current_document).map_err(|_| StoreError::Corrupt)?; + let now = self.observed_now(); + let rows = transaction + .query( + "SELECT c.claim_id, c.offering, c.supply_id, c.kind, c.channel, \ + c.occupied_start, c.occupied_end, c.units, c.duplicate_key, \ + c.hold_expires_at FROM scheduling_claims AS c \ + JOIN scheduling_supply AS s ON s.supply_id=c.supply_id \ + WHERE s.kind='window' AND c.state='active' \ + AND (c.kind='booking' OR (c.kind='hold' AND c.hold_expires_at > $1)) \ + ORDER BY c.occupied_start", + &[&now], + ) + .await?; + let reductions = + assess_publication_impact(¤t, policy, &snapshot_from_rows(rows), now); + if !reductions.is_empty() { + let details = reductions + .iter() + .map(|reduction| { + format!( + "{}:{} has {} committed units but proposes {}", + reduction.window, + reduction + .channel + .map_or("total", |channel| channel.as_str()), + reduction.committed_units, + reduction.proposed_units + ) + }) + .collect::>() + .join(", "); + return Err(StoreError::PolicyInUse(details)); + } + } revision = row.get::<_, i64>(1) + 1; transaction .execute( - "INSERT INTO scheduling_policy_revisions(policy_revision, policy_digest) \ - VALUES($1,$2)", - &[&revision, &policy_digest], + "INSERT INTO scheduling_policy_revisions(\ + policy_revision, policy_digest, policy_document) VALUES($1,$2,$3)", + &[&revision, &policy_digest, &policy_document], ) .await?; transaction @@ -672,7 +763,14 @@ impl PostgresStore { ) .await?; } else { - revision = row.get(1); + revision = stored_revision; + transaction + .execute( + "UPDATE scheduling_policy_revisions SET policy_document=$2 \ + WHERE policy_revision=$1 AND policy_document IS NULL", + &[&revision, &policy_document], + ) + .await?; } for (id, kind) in pool_ids .iter() @@ -940,7 +1038,7 @@ impl PostgresStore { let client = self.client().await?; let rows = client .query( - "SELECT claim_id, supply_id, kind, channel, occupied_start, occupied_end, \ + "SELECT claim_id, offering, supply_id, kind, channel, occupied_start, occupied_end, \ units, duplicate_key, hold_expires_at \ FROM scheduling_claims \ WHERE state='active' \ @@ -1552,16 +1650,6 @@ impl PostgresStore { if appointment.actor != commitment.actor { return Err(CommitError::Unauthorized); } - let current_policy: i64 = transaction - .query_one( - "SELECT policy_revision FROM scheduling_meta WHERE singleton FOR SHARE", - &[], - ) - .await? - .get(0); - if current_policy != commitment.policy_revision { - return Err(AdmissionRefusal::PolicyChanged.into()); - } if let Some(cutoff) = cancellation_cutoff_minutes { let earliest_cancel_end = appointment .displayed_start @@ -1891,9 +1979,10 @@ impl PostgresStore { scope: &str, status_code: u16, receipt: Value, - ) -> Result<(), StoreError> { - let client = self.client().await?; - client + ) -> Result, CommitError> { + let mut client = self.client().await?; + let transaction = client.transaction().await?; + let written = transaction .execute( "INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ idempotency_key, request_hash, state, status_code, receipt, expires_at) \ @@ -1911,7 +2000,21 @@ impl PostgresStore { ], ) .await?; - Ok(()) + if written == 1 { + transaction.commit().await?; + return Ok(None); + } + match replay_stored_attempt(&transaction, commitment, scope).await? { + Some(ReplayOutcome::Replay { + status_code, + receipt, + }) => { + transaction.commit().await?; + Ok(Some((status_code, receipt))) + } + Some(ReplayOutcome::Refused(error)) => Err(error), + None => Err(StoreError::Corrupt.into()), + } } /// Record an authorization refusal in the audit journal. The capacity @@ -1998,7 +2101,8 @@ const SELECT_CLAIM: &str = "SELECT claim_id, kind, state, offering, supply_id, c /// is the sentence the whole capacity contract turns on: an expired hold /// stops consuming capacity at its expiry, whether or not any worker has /// touched it. -const CONSUMING_CLAUSES: &str = "SELECT claim_id, supply_id, kind, channel, occupied_start, \ +const CONSUMING_CLAUSES: &str = + "SELECT claim_id, offering, supply_id, kind, channel, occupied_start, \ occupied_end, units, duplicate_key, hold_expires_at \ FROM scheduling_claims \ WHERE state='active' \ @@ -2046,17 +2150,18 @@ fn snapshot_from_rows(rows: Vec) -> LedgerSnapshot { .iter() .map(|row| LedgerClaim { id: row.get::<_, Uuid>(0).to_string(), - supply_id: row.get(1), - kind: match row.get::<_, String>(2).as_str() { + offering: row.get(1), + supply_id: row.get(2), + kind: match row.get::<_, String>(3).as_str() { "booking" => LedgerKind::Booking, _ => LedgerKind::Hold, }, - channel: row.get(3), - start: row.get(4), - end: row.get(5), - units: u32::try_from(row.get::<_, i32>(6)).unwrap_or(u32::MAX), - duplicate_key: row.get(7), - expires_at: row.get(8), + channel: row.get(4), + start: row.get(5), + end: row.get(6), + units: u32::try_from(row.get::<_, i32>(7)).unwrap_or(u32::MAX), + duplicate_key: row.get(8), + expires_at: row.get(9), }) .collect(), } @@ -2106,6 +2211,16 @@ async fn lock_and_snapshot( .map(|interval| interval.end) .max() .unwrap_or(now); + let buffer = TimeDelta::minutes( + i64::from(exact.buffer_before_minutes) + i64::from(exact.buffer_after_minutes), + ); + let duration = TimeDelta::minutes(i64::from(exact.duration_minutes)); + let earliest = earliest + .checked_sub_signed(buffer) + .ok_or(StoreError::Corrupt)?; + let latest = latest + .checked_add_signed(duration + buffer) + .ok_or(StoreError::Corrupt)?; let rows = transaction .query(CONSUMING_CLAUSES, &[&member_ids, &earliest, &latest, &now]) .await?; @@ -2117,7 +2232,7 @@ async fn lock_and_snapshot( .await?; let rows = transaction .query( - "SELECT claim_id, supply_id, kind, channel, occupied_start, occupied_end, \ + "SELECT claim_id, offering, supply_id, kind, channel, occupied_start, occupied_end, \ units, duplicate_key, hold_expires_at \ FROM scheduling_claims \ WHERE state='active' \ @@ -2212,6 +2327,7 @@ fn evaluate( fn hold_ledger_claim(hold: &ClaimRow) -> LedgerClaim { LedgerClaim { id: hold.claim_id.to_string(), + offering: hold.offering.clone(), supply_id: hold.supply_id.clone(), kind: hold.kind, channel: hold.channel.clone(), @@ -2374,7 +2490,7 @@ pub(crate) async fn replace_facts_in_transaction( &[], ) .await?; - let occupied = occupied_resources_retired_by(transaction, facts).await?; + let occupied = occupied_resources_changed_by(transaction, facts).await?; if !occupied.is_empty() { return Err(StoreError::FactsInUse(occupied.join(", "))); } @@ -2463,7 +2579,7 @@ pub(crate) async fn replace_facts_in_transaction( Ok(()) } -/// The resources live claims occupy that the incoming records do not carry. +/// The resources live claims occupy that the incoming records retire or move. /// /// An exact-time claim names its member in `supply_id`, so a swap that drops /// a member out from under a live booking would leave that booking pointing @@ -2471,29 +2587,43 @@ pub(crate) async fn replace_facts_in_transaction( /// snapshot, counted against nothing, and still promised to its caller. A /// window claim names the window instead, which records never carry, so the /// window anchors are excluded rather than reported as missing members. -async fn occupied_resources_retired_by( +async fn occupied_resources_changed_by( transaction: &deadpool_postgres::Transaction<'_>, facts: &SchedulingFacts, ) -> Result, StoreError> { - let incoming: Vec = facts + let incoming: HashMap<&str, &str> = facts .pools .iter() - .flat_map(|pool| pool.members.iter()) - .map(|member| member.resource_id.clone()) + .flat_map(|pool| { + pool.members + .iter() + .map(move |member| (member.resource_id.as_str(), pool.id.as_str())) + }) .collect(); let rows = transaction .query( - "SELECT DISTINCT supply_id FROM scheduling_claims \ - WHERE state='active' \ - AND (kind='booking' OR (kind='hold' AND hold_expires_at > now())) \ - AND supply_id <> ALL($1::text[]) \ - AND supply_id NOT IN \ - (SELECT supply_id FROM scheduling_supply WHERE kind='window') \ - ORDER BY supply_id", - &[&incoming], + "SELECT DISTINCT c.supply_id, m.pool_id FROM scheduling_claims AS c \ + JOIN scheduling_pool_members AS m ON m.resource_id = c.supply_id \ + WHERE c.state='active' \ + AND (c.kind='booking' OR (c.kind='hold' AND c.hold_expires_at > now())) \ + ORDER BY c.supply_id", + &[], ) .await?; - Ok(rows.iter().map(|row| row.get(0)).collect()) + Ok(rows + .iter() + .filter_map(|row| { + let resource: String = row.get(0); + let current_pool: String = row.get(1); + match incoming.get(resource.as_str()) { + Some(incoming_pool) if *incoming_pool == current_pool => None, + Some(incoming_pool) => { + Some(format!("{resource} from {current_pool} to {incoming_pool}")) + } + None => Some(resource), + } + }) + .collect()) } /// A claim about to be written. diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index 43c27b8d8b..ef0e627d82 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -360,7 +360,7 @@ async fn fixture_publishing_with_hook_url( let policy = parse_policy_yaml(policy_yaml).expect("the scheduling test policy"); let digest = policy.policy_digest(); let revision = store - .apply_policy(SCHEDULING_ID, &digest, pool_ids, window_ids) + .apply_policy(SCHEDULING_ID, &digest, pool_ids, window_ids, &policy) .await .expect("publish the scheduling policy"); let keying = AuditHashSecret::new(vec![0x42; 32]).expect("the test audit hash secret"); @@ -1322,6 +1322,89 @@ async fn a_direct_create_books_without_a_hold_and_exhausts_capacity() { assert_eq!(problem["code"], "capacity.exhausted"); } +#[tokio::test] +async fn exact_time_commitments_include_buffers_outside_the_opening_range() { + let buffered = POLICY + .replacen("endTime: \"23:30\"", "endTime: \"23:59\"", 1) + .replacen("bufferBeforeMinutes: 0", "bufferBeforeMinutes: 30", 1) + .replacen("durationMinutes: 60", "durationMinutes: 45", 1); + let fx = fixture_publishing( + &buffered, + &["north-counter".to_owned(), "two-counter".to_owned()], + &[], + ) + .await; + let day = (Utc::now() + TimeDelta::days(2)).date_naive(); + let midnight = DateTime::::from_naive_utc_and_offset( + day.and_hms_opt(0, 0, 0).expect("midnight"), + Utc, + ); + let earlier = midnight - TimeDelta::hours(1); + let (status, first) = fx + .post( + "/v1/appointments", + &fx.agent, + "buffer-boundary-first", + json!({ + "hold": null, + "admission": admission(&fx, OVERLAPPING_OFFERING, earlier), + }), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{first}"); + + let (status, problem) = fx + .post( + "/v1/appointments", + &fx.agent, + "buffer-boundary-second", + json!({"hold": null, "admission": admission(&fx, OFFERING, midnight)}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{problem}"); + assert_eq!(problem["code"], "capacity.exhausted"); +} + +#[tokio::test] +async fn duplicate_active_keys_are_scoped_to_the_offering() { + let keyed = POLICY.replacen( + " requiresCapabilities: []", + " duplicateActiveKey: subject\n requiresCapabilities: []", + 2, + ); + let fx = fixture_publishing( + &keyed, + &["north-counter".to_owned(), "two-counter".to_owned()], + &[], + ) + .await; + let first = first_slot(&fx, OFFERING, 300, 440).await; + let second = first_slot(&fx, OVERLAPPING_OFFERING, 480, 620).await; + let mut first_admission = admission(&fx, OFFERING, first); + first_admission["duplicateKey"] = json!("subject:shared"); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "duplicate-offering-first", + json!({"hold": null, "admission": first_admission}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + let mut second_admission = admission(&fx, OVERLAPPING_OFFERING, second); + second_admission["duplicateKey"] = json!("subject:shared"); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "duplicate-offering-second", + json!({"hold": null, "admission": second_admission}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); +} + #[tokio::test] async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() { let fx = fixture().await; @@ -1371,6 +1454,38 @@ async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() assert_eq!(problem["code"], "revision.mismatch"); } +#[tokio::test] +async fn a_reschedule_refuses_an_admission_for_another_offering() { + let fx = fixture().await; + let from = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "resched-offering-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, from)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + let appointment_id = appointment["appointmentId"].as_str().unwrap(); + let observed = appointment["revision"].as_u64().unwrap(); + let other = first_slot(&fx, SECOND_OFFERING, 480, 620).await; + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "resched-offering-mismatch", + json!({ + "observedRevision": observed, + "admission": admission(&fx, SECOND_OFFERING, other), + }), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{problem}"); + assert_eq!(problem["code"], "request.unprocessable"); +} + /// COR-1. A reschedule must never compete with the appointment it is moving. /// The runtime supplies the appointment's own allocation as the exclusion /// from inside the commitment transaction, so a move that overlaps the @@ -2158,6 +2273,51 @@ async fn a_concurrent_writer_of_one_idempotency_key_is_refused_not_failed() { assert!(slot_offered(&fx, OFFERING, 90, 260, second).await); } +#[tokio::test] +async fn a_concurrent_identical_request_replays_the_winning_receipt() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 260).await; + let body = json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "race-same-seed", + body.clone(), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + fx.admin + .batch_execute( + "BEGIN; \ + INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ + idempotency_key, request_hash, state, status_code, receipt, expires_at) \ + SELECT gen_random_uuid(), actor_issuer, actor_subject, scope, 'race-same', \ + request_hash, state, status_code, receipt, expires_at \ + FROM scheduling_attempts WHERE idempotency_key = 'race-same-seed'", + ) + .await + .expect("hold the winning receipt from another writer"); + + let contender = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + fx.agent.clone(), + Some("race-same".to_owned()), + Some(body), + )); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + fx.admin + .batch_execute("COMMIT") + .await + .expect("release the winning receipt"); + let (status, replayed) = contender.await.expect("the contending request answers"); + assert_eq!(status, StatusCode::CREATED, "{replayed}"); + assert_eq!(replayed["appointmentId"], appointment["appointmentId"]); +} + /// The revision a commitment is admitted under is the one the deployment /// stores, not the one this process read when it started. Operator tooling /// and a rolling deploy both move the stored revision under a running @@ -2181,13 +2341,17 @@ async fn a_policy_applied_under_a_running_process_refuses_the_older_revision() { // Operator tooling publishes a different policy against the same // deployment, exactly as `schedulingctl` does beside a running runtime. + let mut replacement = parse_policy_yaml(POLICY).expect("the replacement policy"); + replacement.scheduling.version += 1; + let replacement_digest = replacement.policy_digest(); let moved = fx .store .apply_policy( SCHEDULING_ID, - "a-policy-this-process-never-loaded", + &replacement_digest, &["north-counter".to_owned(), "two-counter".to_owned()], &[], + &replacement, ) .await .expect("publish a second policy revision"); @@ -2407,6 +2571,37 @@ async fn a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies() ); } +#[tokio::test] +async fn a_records_swap_refuses_to_move_an_occupied_resource_between_pools() { + let fx = fixture().await; + let slot = first_slot(&fx, OFFERING, 90, 200).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "records-move-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, slot)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + let mut moved = records_without(&[]); + let station = moved.pools[0].members.remove(0); + moved.pools[1].members.push(station); + let refusal = fx + .store + .replace_facts(&moved, Uuid::new_v4(), operator_audit()) + .await + .expect_err("an occupied resource cannot move to another pool"); + assert!(refusal.to_string().contains("station-1"), "{refusal}"); + + let (standing, _) = fx.store.facts().await.expect("the records survive"); + assert!(standing.pool("north-counter").is_some_and(|pool| pool + .members + .iter() + .any(|member| member.resource_id == "station-1"))); +} + #[tokio::test] async fn a_records_swap_waits_for_the_capacity_transaction_holding_the_pool() { let fx = fixture().await; @@ -3650,6 +3845,50 @@ async fn window_entry(fx: &Fixture, from_minutes: i64, to_minutes: i64) -> Optio .cloned() } +#[tokio::test] +async fn policy_publication_refuses_to_move_a_window_with_standing_commitments() { + let day = (Utc::now() + TimeDelta::days(2)).date_naive(); + let start = DateTime::::from_naive_utc_and_offset( + day.and_hms_opt(9, 0, 0).expect("a morning instant"), + Utc, + ); + let authored = policy_with_window(start); + let fx = fixture_publishing( + &authored, + &["north-counter".to_owned(), "two-counter".to_owned()], + &[WINDOW_ID.to_owned()], + ) + .await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "window-publication-standing", + arrival(&fx, start, None), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + let current = parse_policy_yaml(&authored).expect("the current policy"); + let mut moved = current.clone(); + moved.scheduling.version += 1; + moved.windows[0].start += TimeDelta::days(1); + moved.windows[0].end += TimeDelta::days(1); + let digest = moved.policy_digest(); + let refusal = fx + .store + .apply_policy( + SCHEDULING_ID, + &digest, + &["north-counter".to_owned(), "two-counter".to_owned()], + &[WINDOW_ID.to_owned()], + &moved, + ) + .await + .expect_err("a moved window cannot strand a standing appointment"); + assert!(refusal.to_string().contains(WINDOW_ID), "{refusal}"); +} + /// An arrival window is a second admission mode with a ledger of its own: its /// claims occupy the window rather than a member of a pool, its capacity is /// counted in units rather than in slots, and a channel subquota is a ceiling @@ -4086,13 +4325,17 @@ async fn every_mutation_rechecks_expiry_after_its_writes() { #[tokio::test] async fn a_stale_process_refuses_even_a_request_under_the_current_revision() { let fx = fixture().await; + let mut replacement = parse_policy_yaml(POLICY).expect("the replacement policy"); + replacement.scheduling.version += 1; + let replacement_digest = replacement.policy_digest(); let moved = fx .store .apply_policy( SCHEDULING_ID, - "a-policy-this-process-never-loaded", + &replacement_digest, &["north-counter".to_owned(), "two-counter".to_owned()], &[], + &replacement, ) .await .expect("publish a second policy revision"); @@ -4112,19 +4355,24 @@ async fn a_stale_process_refuses_even_a_request_under_the_current_revision() { } #[tokio::test] -async fn a_stale_process_cannot_cancel_under_its_cached_cutoff() { +async fn a_stale_process_can_still_release_an_appointment() { let fx = fixture().await; let (id, revision) = booked(&fx, 480, 620, "before-policy-change").await; + let mut replacement = parse_policy_yaml(POLICY).expect("the replacement policy"); + replacement.scheduling.version += 1; + replacement.offerings[0].cancellation_cutoff_minutes += 1; + let replacement_digest = replacement.policy_digest(); fx.store .apply_policy( SCHEDULING_ID, - "policy-with-a-different-cancellation-cutoff", + &replacement_digest, &["north-counter".to_owned(), "two-counter".to_owned()], &[], + &replacement, ) .await .expect("publish the replacement policy"); - let (status, problem) = fx + let (status, cancelled) = fx .post( &format!("/v1/appointments/{id}/cancel"), &fx.agent, @@ -4132,8 +4380,8 @@ async fn a_stale_process_cannot_cancel_under_its_cached_cutoff() { json!({"observedRevision": revision, "reason": null}), ) .await; - assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{problem}"); - assert_eq!(problem["code"], "policy.changed"); + assert_eq!(status, StatusCode::OK, "{cancelled}"); + assert_eq!(cancelled["state"], "cancelled"); let row = fx .admin .query_one( @@ -4141,9 +4389,9 @@ async fn a_stale_process_cannot_cancel_under_its_cached_cutoff() { &[&Uuid::parse_str(&id).expect("appointment id")], ) .await - .expect("read unchanged appointment"); - assert_eq!(row.get::<_, String>(0), "active"); - assert_eq!(row.get::<_, i64>(1), i64::try_from(revision).unwrap()); + .expect("read cancelled appointment"); + assert_eq!(row.get::<_, String>(0), "cancelled"); + assert_eq!(row.get::<_, i64>(1), i64::try_from(revision + 1).unwrap()); } /// A records replacement moves the records revision under the supply diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index 44626ab8b8..aeb5ce59bf 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -34,3 +34,9 @@ - Re-check hold expiry after locking its supply during confirmation, so a delayed confirmation cannot claim capacity that became bookable and was committed elsewhere after the hold expired. +- Close the beta review races and contract gaps: include exact-time buffers in + locked snapshots, scope duplicate keys to their offering, enforce requested + capabilities, reject cross-offering reschedules, keep cancellations + available during rolling policy changes, prevent occupied resources moving + between pools, replay the winning idempotency receipt, and refuse policy + publications that strand standing window commitments. diff --git a/products/scheduling/README.md b/products/scheduling/README.md index c44dd1346e..bf8fbea0c2 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -74,6 +74,12 @@ at the readiness check rather than serving against it, so an upgrade runs an old runtime may still be serving, and its in-flight availability continuation cursors answer `cursor.invalid` once the new binary holds them, so a caller mid-pagination restarts that listing from its first page. +The migration also retains the current policy document beside its digest. On +an upgrade from an earlier schema, start once with the unchanged policy to +backfill that document before publishing a policy change. A later start locks +the standing supply while it checks the proposal and refuses a change that +would move, remove, or reduce a window below its live bookings and holds; the +operator error names the affected window and deficit. `records apply` is the one attributable operator write of a deployment's environment records: the locations with their time zones, the resource pools and their members, and the dated exceptions such as closures. The policy diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index b30a876480..3d0cbe8e7f 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -402,6 +402,21 @@ invariants: negativeTest: path: crates/registry-scheduling/src/hooks.rs name: projections_disclose_only_the_requested_closed_fields + - id: SCHEDULING-SEC-25 + state: enforced + threat: >- + A rolling policy publication drops, moves, or reduces a published window + below the bookings and live holds that already consume it. + enforcementPoint: >- + Policy publication locks every existing supply anchor, loads the retained + current policy and the standing window ledger, and assesses the proposal + before advancing the durable revision. + refusal: >- + Refuse publication with an operator-facing deficit and leave the policy + revision unchanged; an ordinary restart never strands commitments. + negativeTest: + path: crates/registry-scheduling/tests/postgres_commitments.rs + name: policy_publication_refuses_to_move_a_window_with_standing_commitments deferred: - id: SCHEDULING-DEF-01 subject: Full task-grant bounds re-checked inside the capacity transaction diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index 28bd72540b..82f465dc81 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -14,6 +14,8 @@ entries: - {path: crates/registry-scheduling-core/src/model.rs, name: a_confirmed_booking_consumes_but_an_expired_hold_does_not} - {path: crates/registry-scheduling-core/src/model.rs, name: supply_occupancy_is_half_open_and_honors_the_excluded_claim} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_direct_create_books_without_a_hold_and_exhausts_capacity} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: exact_time_commitments_include_buffers_outside_the_opening_range} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_refuses_to_move_an_occupied_resource_between_pools} - id: SCHEDULING-SEC-02 tests: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_commitment_against_an_unanchored_pool_refuses_loudly} @@ -65,6 +67,7 @@ entries: - {path: crates/registry-scheduling-core/src/model.rs, name: the_same_request_always_hashes_identically} - {path: crates/registry-scheduling-core/src/model.rs, name: set_order_never_changes_the_request_hash} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cancellation_respects_the_cutoff_and_replays_its_verdict} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_concurrent_identical_request_replays_the_winning_receipt} - id: SCHEDULING-SEC-10 tests: - {path: crates/registry-scheduling-core/src/admission.rs, name: policy_change_outranks_a_window_revision_mismatch} @@ -73,6 +76,7 @@ entries: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_stale_process_refuses_even_a_request_under_the_current_revision} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: competing_cancellations_commit_exactly_one_transition} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_and_a_cancellation_commit_exactly_one_transition} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_stale_process_can_still_release_an_appointment} - id: SCHEDULING-SEC-11 tests: - {path: crates/registry-scheduling-core/src/admission.rs, name: an_expired_hold_consumes_nothing_and_cannot_be_confirmed} @@ -163,3 +167,9 @@ entries: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: hook_delivery_sends_the_canonical_event_audits_egress_and_refuses_proposals} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: hook_delivery_audit_failure_prevents_egress} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: changed_retained_hook_destination_binding_is_refused} + - id: SCHEDULING-SEC-25 + tests: + - {path: crates/registry-scheduling-core/src/policy.rs, name: a_reduction_below_standing_commitments_is_rejected_with_its_deficit} + - {path: crates/registry-scheduling-core/src/policy.rs, name: a_subquota_reduction_strands_its_channel_even_at_an_unchanged_total} + - {path: crates/registry-scheduling-core/src/policy.rs, name: moving_a_published_window_reports_no_reduction} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: policy_publication_refuses_to_move_a_window_with_standing_commitments} diff --git a/products/scheduling/generated/registry-scheduling.openapi.json b/products/scheduling/generated/registry-scheduling.openapi.json index 91518d8d9e..b97b4b1d97 100644 --- a/products/scheduling/generated/registry-scheduling.openapi.json +++ b/products/scheduling/generated/registry-scheduling.openapi.json @@ -860,7 +860,7 @@ "const": "capability.unmatched" }, "detail": { - "const": "No backing member carries every capability this offering requires." + "const": "The party or backing supply does not carry every capability this offering requires." }, "status": { "const": 422 From cd6dbb8aded7f77dfea0528d2ece12a1c04f547a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 09:08:21 +0700 Subject: [PATCH 107/115] chore(identifiers): refresh scheduling problem catalog Signed-off-by: Jeremi Joslin --- .../identifiers/generated/catalog.v1.json | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 12c0078838..a0499fd141 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -2095,7 +2095,7 @@ "description": "The bearer credential is missing, invalid, or expired. Sign in again.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "authentication.refused", @@ -2114,7 +2114,7 @@ "description": "An active booking already holds this party's duplicate key. Cancel or complete it before booking again.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "booking.duplicate-active", @@ -2133,7 +2133,7 @@ "description": "The cancellation cutoff for this appointment has passed, so it can no longer be cancelled.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "cancellation.cutoff-passed", @@ -2149,10 +2149,10 @@ "compatibilityLine": "v1alpha1", "owner": "scheduling", "title": "Capability unmatched", - "description": "No backing member carries every capability this offering requires.", + "description": "The party or backing supply does not carry every capability this offering requires.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "capability.unmatched", @@ -2171,7 +2171,7 @@ "description": "The supply is fully committed for the requested interval. Choose another time.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "capacity.exhausted", @@ -2190,7 +2190,7 @@ "description": "This cursor has expired. Start again without a cursor and deduplicate entries by id.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "cursor.expired", @@ -2209,7 +2209,7 @@ "description": "The cursor is invalid for this request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "cursor.invalid", @@ -2228,7 +2228,7 @@ "description": "A required eligibility check could not run. Retry; do not treat this as permission.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "eligibility.unavailable", @@ -2247,7 +2247,7 @@ "description": "The hold expired before confirmation. Its capacity is bookable again; start a new request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "hold.expired", @@ -2266,7 +2266,7 @@ "description": "The claim is not an active hold. It may already be confirmed or released.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "hold.released", @@ -2285,7 +2285,7 @@ "description": "A required lifecycle hook could not run, so the request was refused rather than half-applied.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "hook.unavailable", @@ -2304,7 +2304,7 @@ "description": "The requested start is earlier than the lead time allows or further ahead than the horizon allows.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "horizon.outside", @@ -2323,7 +2323,7 @@ "description": "The stored response for this idempotency key has expired. Reconcile the original operation before choosing a new key.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "idempotency.expired", @@ -2342,7 +2342,7 @@ "description": "This idempotency key was used for a different request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "idempotency.key-reused", @@ -2361,7 +2361,7 @@ "description": "A closure covers the requested start. Choose a start outside the closure.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "location.closed", @@ -2380,7 +2380,7 @@ "description": "Your current Scheduling authority does not allow this operation.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "operation.not-authorized", @@ -2399,7 +2399,7 @@ "description": "The party is larger than this offering can ever serve, or its size falls outside the published units.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "party.capacity-inadequate", @@ -2418,7 +2418,7 @@ "description": "The policy revision changed before the request committed. Reload the catalogue and try again with the current revision.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "policy.changed", @@ -2437,7 +2437,7 @@ "description": "The appointment changed since you loaded it. Reload and try again.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "precondition.failed", @@ -2456,7 +2456,7 @@ "description": "This mutation requires the revision you loaded, or the duplicate key this offering keys on.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "precondition.required", @@ -2475,7 +2475,7 @@ "description": "The party is missing a prerequisite this offering requires.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "prerequisite.missing", @@ -2494,7 +2494,7 @@ "description": "The selected Scheduling profile does not authorize this request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "profile.not-authorized", @@ -2513,7 +2513,7 @@ "description": "The request body exceeds the accepted size.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "request.body-too-large", @@ -2532,7 +2532,7 @@ "description": "The request could not be read as a Scheduling request.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "request.invalid", @@ -2551,7 +2551,7 @@ "description": "The route exists but not for this method.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "request.method-not-allowed", @@ -2570,7 +2570,7 @@ "description": "The requested route does not exist.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "request.not-found", @@ -2589,7 +2589,7 @@ "description": "The request body could not be processed.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "request.unprocessable", @@ -2608,7 +2608,7 @@ "description": "The request body is not JSON.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "request.unsupported-media-type", @@ -2627,7 +2627,7 @@ "description": "Every capable member is unavailable for this interval.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "resource.unavailable", @@ -2646,7 +2646,7 @@ "description": "The window revision changed before the request committed. Reload the catalogue and try again.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "revision.mismatch", @@ -2665,7 +2665,7 @@ "description": "No published schedule serves that start. Choose a start on the published grid.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "schedule.unpublished", @@ -2684,7 +2684,7 @@ "description": "Scheduling storage is unavailable. Try again after the service recovers.", "source": { "path": "crates/registry-scheduling-core/src/problem.rs", - "sha256": "113c0ca83ae857bd2e21ee799367b43e9df71937d76457f27adeedf0520e02e5" + "sha256": "8e72874fb179d87c3d8f158faff5c7e78f48b72c9a59adf3f7697f70606c58fc" }, "problem": { "code": "service.unavailable", From 5e3c79540be1dd9ffd6d06253423794cc5d0ae39 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 09:58:19 +0700 Subject: [PATCH 108/115] fix(scheduling): close beta review gaps Signed-off-by: Jeremi Joslin --- .../registry-platform-calendar/src/weekly.rs | 63 +++--- .../registry-scheduling-core/src/resolve.rs | 64 +++++- crates/registry-scheduling/src/service.rs | 203 +++++++++++++----- crates/registry-scheduling/src/store.rs | 124 +++++++++-- .../tests/postgres_commitments.rs | 118 +++++++++- crates/registry-schedulingctl/src/records.rs | 11 +- .../tests/records_apply_postgres.rs | 131 ++++++++++- products/scheduling/CHANGELOG.md | 5 +- .../contracts/security-test-traceability.yaml | 4 + 9 files changed, 609 insertions(+), 114 deletions(-) diff --git a/crates/registry-platform-calendar/src/weekly.rs b/crates/registry-platform-calendar/src/weekly.rs index 40fbb06a5f..f9c247ab46 100644 --- a/crates/registry-platform-calendar/src/weekly.rs +++ b/crates/registry-platform-calendar/src/weekly.rs @@ -133,9 +133,6 @@ pub fn expand_weekly_openings( }) .collect::>()?; - let exceptions_by_id = parse_exceptions(exceptions, timezone)?; - validate_layers(&exceptions_by_id)?; - let weekdays: BTreeSet = pattern .weekdays .iter() @@ -152,43 +149,54 @@ pub fn expand_weekly_openings( .ok_or(CalendarEvaluationError::Overflow)?; } - // Closures remove pattern time first; authorized reopenings then add time - // back, so a reopening can never be subtracted by a later closure. + apply_exceptions_to_openings(openings, pattern.timezone, exceptions) +} + +/// Layer dated closures and authorized reopenings over one location's merged +/// published openings. +/// +/// Callers with several weekly patterns expand and merge those patterns +/// first, then call this function once. A reopening is therefore checked +/// against the location's complete schedule rather than being rejected by an +/// unrelated pattern that does not cover its date or time. +pub fn apply_exceptions_to_openings( + openings: Vec, + timezone: &str, + exceptions: &[CalendarException<'_>], +) -> Result, CalendarEvaluationError> { + let timezone: Tz = timezone + .parse() + .map_err(|_| CalendarEvaluationError::InvalidTimezone)?; + let exceptions_by_id = parse_exceptions(exceptions, timezone)?; + validate_layers(&exceptions_by_id)?; + let published = merge_intervals(openings); + let mut effective = published.clone(); + + // Closures remove published time first; authorized reopenings then add + // time back, so a reopening can never be subtracted by a later closure. for exception in exceptions_by_id.values() { if exception.kind == CalendarExceptionKind::Closure { - subtract_interval(&mut openings, exception.interval); + subtract_interval(&mut effective, exception.interval); } } for (id, exception) in &exceptions_by_id { if exception.kind == CalendarExceptionKind::Opening { - // A reopening restores pattern time its closure removed; it may - // not extend hours past what the pattern publishes. Outside the - // effective range, on a holiday, or on a non-pattern weekday - // there is no pattern time to restore. - let pattern_day = exception.date >= effective_from - && exception.date <= effective_until - && weekdays.contains(&exception.date.weekday().num_days_from_monday()) - && !holidays.contains(&exception.date); - if pattern_day { - let pattern_interval = - day_interval(exception.date, start_time, end_time, timezone)?; - if exception.interval.start >= pattern_interval.start - && exception.interval.end <= pattern_interval.end - { - openings.push(exception.interval); - continue; - } + let covered = published.iter().any(|interval| { + interval.start <= exception.interval.start && exception.interval.end <= interval.end + }); + if covered { + effective.push(exception.interval); + continue; } return Err(CalendarEvaluationError::ReopenOutsidePattern { id: id.clone() }); } } - Ok(merge_intervals(openings)) + Ok(merge_intervals(effective)) } struct ParsedException<'a> { kind: CalendarExceptionKind, interval: CalendarInterval, - date: NaiveDate, reopens: Option<&'a str>, authority: Option<&'a str>, } @@ -203,17 +211,12 @@ fn parse_exceptions<'e>( return Err(CalendarEvaluationError::EmptyExceptionId); } let interval = parse_exception_times(exception, timezone)?; - let date = - parse_date(exception.date).ok_or(CalendarEvaluationError::InvalidExceptionDate { - id: exception.id.to_owned(), - })?; if parsed .insert( exception.id.to_owned(), ParsedException { kind: exception.kind, interval, - date, reopens: exception.reopens, authority: exception.authority, }, diff --git a/crates/registry-scheduling-core/src/resolve.rs b/crates/registry-scheduling-core/src/resolve.rs index 23451acae9..e4f6c5c141 100644 --- a/crates/registry-scheduling-core/src/resolve.rs +++ b/crates/registry-scheduling-core/src/resolve.rs @@ -11,8 +11,8 @@ use chrono::{DateTime, Utc}; use registry_platform_calendar::{ - expand_weekly_openings, local_interval, CalendarEvaluationError, CalendarInterval, - HolidaySetRevision, WeeklyOpeningPattern, + apply_exceptions_to_openings, expand_weekly_openings, local_interval, CalendarEvaluationError, + CalendarInterval, HolidaySetRevision, WeeklyOpeningPattern, }; use crate::model::{ExceptionRecordKind, SchedulingFacts}; @@ -79,9 +79,9 @@ pub fn location_open_intervals( revision: holiday_set.revision, dates: &dates, }; - all.extend(expand_weekly_openings(&pattern, exceptions, &revision)?); + all.extend(expand_weekly_openings(&pattern, &[], &revision)?); } - Ok(merge_intervals(all)) + apply_exceptions_to_openings(merge_intervals(all), timezone, exceptions).map_err(Into::into) } /// Sort intervals and join every pair that overlaps or meets into one. @@ -155,6 +155,7 @@ pub fn location_closure_intervals( mod tests { use super::*; use crate::policy::parse_policy_yaml; + use registry_platform_calendar::{CalendarException, CalendarExceptionKind}; fn opening(id: &str, start: &str, end: &str) -> String { format!( @@ -305,4 +306,59 @@ holdPolicy: assert_eq!(open[0].end, instant("2026-10-05T05:00:00Z")); assert_eq!(open[1].start, instant("2026-10-05T06:00:00Z")); } + + /// Exceptions apply to the location's combined published hours. A + /// reopening inside the morning pattern must not be rejected merely + /// because the same location also has a disjoint afternoon pattern. + #[test] + fn reopening_inside_one_of_two_disjoint_openings_resolves_the_combined_union() { + let policy = policy_with(&format!( + "{}{}", + opening("morning", "09:00", "12:00"), + opening("afternoon", "13:00", "17:00") + )); + let exceptions = [ + CalendarException { + id: "morning-closure", + kind: CalendarExceptionKind::Closure, + date: "2026-10-05", + start_time: "09:30", + end_time: "11:30", + reopens: None, + authority: None, + }, + CalendarException { + id: "morning-reopening", + kind: CalendarExceptionKind::Opening, + date: "2026-10-05", + start_time: "10:00", + end_time: "10:30", + reopens: Some("morning-closure"), + authority: Some("office-manager"), + }, + ]; + + assert_eq!( + location_open_intervals(&policy, "north-counter", "Asia/Bangkok", &exceptions) + .expect("the combined location schedule accepts the morning reopening"), + vec![ + CalendarInterval { + start: instant("2026-10-05T02:00:00Z"), + end: instant("2026-10-05T02:30:00Z"), + }, + CalendarInterval { + start: instant("2026-10-05T03:00:00Z"), + end: instant("2026-10-05T03:30:00Z"), + }, + CalendarInterval { + start: instant("2026-10-05T04:30:00Z"), + end: instant("2026-10-05T05:00:00Z"), + }, + CalendarInterval { + start: instant("2026-10-05T06:00:00Z"), + end: instant("2026-10-05T10:00:00Z"), + }, + ] + ); + } } diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index c82e74ab6e..90cce2f644 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -436,8 +436,10 @@ impl SchedulingService { /// The separately authorized explanation of one start: the public and the /// detailed code of the refusal a booking would receive, or no codes at /// all when the start admits as things stand. The probe is a minimal - /// party, so the codes explain the calendar and the capacity, never - /// another caller's booking. + /// party that already meets the offering's declared input requirements, + /// so the codes explain the calendar and capacity rather than repeating + /// requirements the operator already knows this synthetic probe must + /// carry. It never names another caller's booking. pub async fn explain( &self, offering_id: &str, @@ -456,8 +458,8 @@ impl SchedulingService { duplicate_key: None, policy_revision: self.revision(), window_revision: None, - capabilities: Vec::new(), - prerequisites: Vec::new(), + capabilities: offering.requires_capabilities.clone(), + prerequisites: offering.prerequisites.clone(), }; let refusal = match self.supply(offering).await?.0 { ResolvedSupply::ExactTime { @@ -593,8 +595,8 @@ impl SchedulingService { facts_revision, ) .await?; - Ok(claim_or_replay(answer, |claim| { - hold_document(claim, &self.policy, self.revision()) + Ok(claim_or_replay(answer, |claim, receipt| { + hold_document(claim, &self.policy, self.revision(), receipt) })) } @@ -681,8 +683,8 @@ impl SchedulingService { .await } }?; - Ok(claim_or_replay(answer, |claim| { - appointment_document(claim, &self.policy, self.revision()) + Ok(claim_or_replay(answer, |claim, receipt| { + appointment_document(claim, &self.policy, self.revision(), receipt) })) } @@ -792,7 +794,12 @@ impl SchedulingService { .owned_booking(caller, appointment_id) .await? .ok_or(ServiceError::Problem(ProblemCode::OperationNotAuthorized))?; - Ok(appointment_document(&claim, &self.policy, self.revision())) + Ok(appointment_document( + &claim, + &self.policy, + self.revision(), + None, + )) } pub async fn reschedule_appointment( @@ -860,8 +867,8 @@ impl SchedulingService { facts_revision, ) .await?; - Ok(claim_or_replay(answer, |claim| { - appointment_document(claim, &self.policy, self.revision()) + Ok(claim_or_replay(answer, |claim, receipt| { + appointment_document(claim, &self.policy, self.revision(), receipt) })) } @@ -906,6 +913,7 @@ impl SchedulingService { appointment_id, request.observed_revision, Some(offering.cancellation_cutoff_minutes), + offering.exact_time.is_some(), request.reason.as_deref(), commitment, ) @@ -924,8 +932,8 @@ impl SchedulingService { 0, ) .await?; - Ok(claim_or_replay(answer, |claim| { - appointment_document(claim, &self.policy, self.revision()) + Ok(claim_or_replay(answer, |claim, receipt| { + appointment_document(claim, &self.policy, self.revision(), receipt) })) } @@ -1419,10 +1427,10 @@ impl FromMinted for () { /// refusal receipt carries no claim and flows to the edge untouched. fn claim_or_replay( answer: CommitmentAnswer, - project: impl FnOnce(&ClaimRow) -> T, + project: impl FnOnce(&ClaimRow, Option<&Value>) -> T, ) -> CommitmentAnswer { match answer { - CommitmentAnswer::Minted(claim) => CommitmentAnswer::Minted(project(&claim)), + CommitmentAnswer::Minted(claim) => CommitmentAnswer::Minted(project(&claim, None)), CommitmentAnswer::Replay { status_code, receipt, @@ -1432,7 +1440,7 @@ fn claim_or_replay( .cloned() .and_then(|value| serde_json::from_value::(value).ok()) { - CommitmentAnswer::Minted(project(&claim)) + CommitmentAnswer::Minted(project(&claim, Some(&receipt))) } else { CommitmentAnswer::Replay { status_code, @@ -1576,36 +1584,42 @@ fn exact_time_slots( let latest = now + TimeDelta::days(i64::from(exact.horizon_days)); let mut entries = Vec::new(); for interval in open { - // Slots anchor on each published opening's own start and step the - // authored grid; a start that leaves the opening is not offered. - let mut slot_start = interval.start; - while slot_start + duration <= interval.end { + // Slots anchor on each published opening's own start. Jump directly + // to the first grid point in the requested/horizon intersection so a + // short page never walks years of an otherwise valid pattern. + let lower = interval.start.max(from).max(earliest); + let delta_seconds = (lower - interval.start).num_seconds().max(0); + let increment_seconds = increment.num_seconds(); + let steps = (delta_seconds + increment_seconds - 1) / increment_seconds; + let Some(mut slot_start) = interval + .start + .checked_add_signed(TimeDelta::seconds(steps.saturating_mul(increment_seconds))) + else { + continue; + }; + while slot_start + duration <= interval.end && slot_start < to && slot_start <= latest { let slot_end = slot_start + duration; let occupied_start = slot_start - TimeDelta::minutes(i64::from(exact.buffer_before_minutes)); let occupied_end = slot_end + TimeDelta::minutes(i64::from(exact.buffer_after_minutes)); - let in_range = slot_start >= from && slot_start < to; - let in_horizon = slot_start >= earliest && slot_start <= latest; - if in_range && in_horizon { - let free = members - .iter() - .filter(|member| member.available) - .filter(|member| member.serves(requires_capabilities)) - .filter(|member| { - snapshot.claims.iter().all(|claim| { - claim.supply_id != member.resource_id - || claim.end <= occupied_start - || occupied_end <= claim.start - }) + let free = members + .iter() + .filter(|member| member.available) + .filter(|member| member.serves(requires_capabilities)) + .filter(|member| { + snapshot.claims.iter().all(|claim| { + claim.supply_id != member.resource_id + || claim.end <= occupied_start + || occupied_end <= claim.start }) - .count(); - if free > 0 { - entries.push(AvailabilityEntry::Slot { - start: slot_start, - end: slot_end, - free: u32::try_from(free).unwrap_or(u32::MAX), - }); - } + }) + .count(); + if free > 0 { + entries.push(AvailabilityEntry::Slot { + start: slot_start, + end: slot_end, + free: u32::try_from(free).unwrap_or(u32::MAX), + }); } slot_start += increment; if slot_start >= interval.end { @@ -1760,18 +1774,17 @@ fn hold_document( claim: &ClaimRow, policy: &SchedulingPolicy, policy_revision: u64, + receipt: Option<&Value>, ) -> registry_scheduling_core::HoldDocument { registry_scheduling_core::HoldDocument { hold_id: claim.claim_id.to_string(), offering: claim.offering.clone(), start: claim.displayed_start, end: claim.displayed_end, - resource: claim - .supply_id_is_member(policy) - .then(|| claim.supply_id.clone()), + resource: projected_resource(claim, policy, receipt), units: u32::try_from(claim.units).unwrap_or(u32::MAX), expires_at: claim.hold_expires_at.unwrap_or(claim.created_at), - policy_revision, + policy_revision: projected_policy_revision(policy_revision, receipt), } } @@ -1779,15 +1792,14 @@ fn appointment_document( claim: &ClaimRow, policy: &SchedulingPolicy, policy_revision: u64, + receipt: Option<&Value>, ) -> AppointmentDocument { AppointmentDocument { appointment_id: claim.claim_id.to_string(), offering: claim.offering.clone(), start: claim.displayed_start, end: claim.displayed_end, - resource: claim - .supply_id_is_member(policy) - .then(|| claim.supply_id.clone()), + resource: projected_resource(claim, policy, receipt), units: u32::try_from(claim.units).unwrap_or(u32::MAX), channel: claim.channel.clone(), revision: u64::try_from(claim.revision).unwrap_or(u64::MAX), @@ -1796,12 +1808,34 @@ fn appointment_document( } else { AppointmentStateDocument::Confirmed }, - policy_revision, + policy_revision: projected_policy_revision(policy_revision, receipt), created_at: claim.created_at, cancelled_at: claim.closed_at, } } +fn projected_policy_revision(current: u64, receipt: Option<&Value>) -> u64 { + receipt + .and_then(|value| value.get("policyRevision")) + .and_then(Value::as_i64) + .and_then(|revision| u64::try_from(revision).ok()) + .unwrap_or(current) +} + +fn projected_resource( + claim: &ClaimRow, + policy: &SchedulingPolicy, + receipt: Option<&Value>, +) -> Option { + match receipt.and_then(|value| value.get("resource")) { + Some(Value::String(resource)) => Some(resource.clone()), + Some(Value::Null) => None, + _ => claim + .supply_id_is_member(policy) + .then(|| claim.supply_id.clone()), + } +} + impl ClaimRow { /// Whether the claim's supply id names a pool member (an exact-time /// booking) rather than a window. @@ -2191,6 +2225,32 @@ mod tests { ); } + #[test] + fn slots_jump_to_the_requested_part_of_a_long_opening() { + let opening = CalendarInterval { + start: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + end: Utc.with_ymd_and_hms(2035, 12, 31, 0, 0, 0).unwrap(), + }; + let now = Utc.with_ymd_and_hms(2035, 12, 1, 0, 0, 0).unwrap(); + let from = Utc.with_ymd_and_hms(2035, 12, 2, 10, 7, 0).unwrap(); + let to = Utc.with_ymd_and_hms(2035, 12, 2, 11, 0, 0).unwrap(); + let entries = exact_time_slots( + &exact(), + &members(1), + &[], + &[opening], + &LedgerSnapshot { claims: Vec::new() }, + from, + to, + now, + ); + + assert_eq!( + entries.iter().map(entry_instant).collect::>(), + vec![Utc.with_ymd_and_hms(2035, 12, 2, 10, 30, 0).unwrap()] + ); + } + #[test] fn page_limits_clamp_to_the_published_bound() { assert_eq!(page_limit(None), DEFAULT_PAGE_LIMIT); @@ -2262,23 +2322,60 @@ mod tests { created_at: at(0, 0), closed_at: None, }; - let receipt = json!({"kind": "booking", "claim": claim.clone()}); + let receipt = json!({ + "kind": "booking", + "claim": claim.clone(), + "resource": "station-1", + "policyRevision": 4 + }); let answer: CommitmentAnswer = CommitmentAnswer::Replay { status_code: 201, receipt, }; - let replayed = claim_or_replay(answer, |replayed| replayed.clone()); + let replayed = claim_or_replay(answer, |replayed, receipt| { + ( + replayed.clone(), + receipt + .and_then(|value| value["resource"].as_str()) + .map(str::to_owned), + ) + }); match replayed { - CommitmentAnswer::Minted(replayed) => assert_eq!(replayed, claim), + CommitmentAnswer::Minted((replayed, resource)) => { + assert_eq!(replayed, claim); + assert_eq!(resource.as_deref(), Some("station-1")); + } CommitmentAnswer::Replay { .. } => panic!("a claim receipt is a minted answer"), } + let current_policy: SchedulingPolicy = serde_json::from_value(json!({ + "apiVersion": "registry.registrystack.org/scheduling-policy-package/v1alpha1", + "kind": "SchedulingPolicyPackage", + "scheduling": {"id": "test", "version": 99}, + "services": [], + "offerings": [], + "holidaySets": [], + "openings": [], + "windows": [], + "holdPolicy": {"ttlMinutes": 5, "maxPerCaller": 1, "because": "test"} + })) + .expect("a policy used only for replay projection"); + let replay_receipt = json!({ + "kind": "booking", + "claim": claim.clone(), + "resource": "station-1", + "policyRevision": 4 + }); + let document = appointment_document(&claim, ¤t_policy, 99, Some(&replay_receipt)); + assert_eq!(document.resource.as_deref(), Some("station-1")); + assert_eq!(document.policy_revision, 4); + let refused: CommitmentAnswer = CommitmentAnswer::Replay { status_code: 409, receipt: problem_receipt(ProblemCode::CapacityExhausted), }; assert!(matches!( - claim_or_replay(refused, |claim| claim.clone()), + claim_or_replay(refused, |claim, _| claim.clone()), CommitmentAnswer::Replay { status_code: 409, .. diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 43856da54b..32dfc718fb 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -107,6 +107,8 @@ pub enum StoreError { SecretConfiguration(String), #[error("the Scheduling database is not in the state this runtime expects")] Corrupt, + #[error("the Scheduling database belongs to another deployment")] + DeploymentIdentity, /// The environment records would retire resources that live appointments /// or holds still occupy. The swap is refused whole, so the operator /// either keeps the resource or closes what stands on it first. @@ -654,9 +656,10 @@ impl PostgresStore { )) } - /// Publish a policy after proving it does not strand standing window - /// commitments. Re-applying the same digest backfills its retained policy - /// document and remains a no-op for the revision. + /// Publish a policy after proving it does not strand standing commitments + /// or change the lifecycle terms they still need. Re-applying the same + /// digest backfills its retained policy document and remains a no-op for + /// the revision. /// /// Lock order, load-bearing: policy publication takes every existing /// supply anchor in order before `scheduling_meta`, the same order as the @@ -714,6 +717,35 @@ impl PostgresStore { let current: SchedulingPolicy = serde_json::from_value(current_document).map_err(|_| StoreError::Corrupt)?; let now = self.observed_now(); + let active_offerings = transaction + .query( + "SELECT DISTINCT offering FROM scheduling_claims \ + WHERE state='active' \ + AND (kind='booking' OR (kind='hold' AND hold_expires_at > $1)) \ + ORDER BY offering", + &[&now], + ) + .await?; + for active in active_offerings { + let offering_id: String = active.get(0); + let Some(current_offering) = current.offering(&offering_id) else { + return Err(StoreError::PolicyInUse(format!( + "offering {offering_id} has active commitments but is absent from the retained current policy" + ))); + }; + let retained = policy.offering(&offering_id).is_some_and(|proposed| { + proposed.service == current_offering.service + && proposed.location == current_offering.location + && proposed.mode == current_offering.mode + && proposed.cancellation_cutoff_minutes + == current_offering.cancellation_cutoff_minutes + }); + if !retained { + return Err(StoreError::PolicyInUse(format!( + "offering {offering_id} has active commitments and must retain its service, location, mode, and cancellation cutoff" + ))); + } + } let rows = transaction .query( "SELECT c.claim_id, c.offering, c.supply_id, c.kind, c.channel, \ @@ -879,13 +911,21 @@ impl PostgresStore { /// that swaps locations, pools, members, and exceptions atomically. pub async fn replace_facts( &self, + scheduling_id: &str, facts: &SchedulingFacts, audit_event: Uuid, audit_record: Value, ) -> Result<(), StoreError> { let mut client = self.client().await?; let transaction = client.transaction().await?; - replace_facts_in_transaction(&transaction, facts, audit_event, audit_record).await?; + replace_facts_in_transaction( + &transaction, + scheduling_id, + facts, + audit_event, + audit_record, + ) + .await?; transaction.commit().await?; Ok(()) } @@ -1144,7 +1184,12 @@ impl PostgresStore { "hold:create", AttemptState::Completed, 201, - json!({"kind": "hold", "claim": claim}), + claim_receipt( + "hold", + &claim, + matches!(supply, SupplyContext::ExactTime { .. }), + commitment.policy_revision, + ), ) .await?; self.recheck_grant(&commitment)?; @@ -1242,7 +1287,12 @@ impl PostgresStore { "appointment:create", AttemptState::Completed, 201, - json!({"kind": "booking", "claim": claim}), + claim_receipt( + "booking", + &claim, + matches!(supply, SupplyContext::ExactTime { .. }), + commitment.policy_revision, + ), ) .await?; self.recheck_grant(&commitment)?; @@ -1386,7 +1436,12 @@ impl PostgresStore { &scope, AttemptState::Completed, 201, - json!({"kind": "booking", "claim": claim}), + claim_receipt( + "booking", + &claim, + matches!(supply, SupplyContext::ExactTime { .. }), + commitment.policy_revision, + ), ) .await?; self.recheck_grant(&commitment)?; @@ -1507,12 +1562,12 @@ impl PostgresStore { { return Err(AdmissionRefusal::PolicyChanged.into()); } - if u64::try_from(appointment.revision) != Ok(observed_revision) { - return Err(CommitError::RevisionMismatch); - } if appointment.actor != commitment.actor { return Err(CommitError::Unauthorized); } + if u64::try_from(appointment.revision) != Ok(observed_revision) { + return Err(CommitError::RevisionMismatch); + } // The appointment's own allocation is the exclusion: a reschedule // never competes with the booking it moves. It is read from the row // this transaction locked, never from the request. @@ -1599,7 +1654,12 @@ impl PostgresStore { &scope, AttemptState::Completed, 200, - json!({"kind": "booking", "claim": moved}), + claim_receipt( + "booking", + &moved, + matches!(supply, SupplyContext::ExactTime { .. }), + commitment.policy_revision, + ), ) .await?; self.recheck_grant(&commitment)?; @@ -1616,6 +1676,7 @@ impl PostgresStore { appointment_id: Uuid, observed_revision: u64, cancellation_cutoff_minutes: Option, + resource_is_member: bool, reason: Option<&str>, commitment: Commitment<'_>, ) -> Result { @@ -1644,12 +1705,12 @@ impl PostgresStore { if appointment.kind != LedgerKind::Booking || appointment.state != ClaimState::Active { return Err(AdmissionRefusal::HoldReleased.into()); } - if u64::try_from(appointment.revision) != Ok(observed_revision) { - return Err(CommitError::RevisionMismatch); - } if appointment.actor != commitment.actor { return Err(CommitError::Unauthorized); } + if u64::try_from(appointment.revision) != Ok(observed_revision) { + return Err(CommitError::RevisionMismatch); + } if let Some(cutoff) = cancellation_cutoff_minutes { let earliest_cancel_end = appointment .displayed_start @@ -1722,7 +1783,12 @@ impl PostgresStore { &scope, AttemptState::Completed, 200, - json!({"kind": "booking", "claim": cancelled.clone()}), + claim_receipt( + "booking", + &cancelled, + resource_is_member, + commitment.policy_revision, + ), ) .await?; self.recheck_grant(&commitment)?; @@ -2471,8 +2537,26 @@ fn confirmation_payload(claim: &ClaimRow, policy_revision: i64) -> Value { }) } +/// The durable success receipt retains every value the public projection +/// cannot recover from a claim alone. In particular, whether `supply_id` was +/// a pool member must survive a later policy mode change or removal. +fn claim_receipt( + kind: &str, + claim: &ClaimRow, + resource_is_member: bool, + policy_revision: i64, +) -> Value { + json!({ + "kind": kind, + "claim": claim, + "resource": resource_is_member.then(|| claim.supply_id.clone()), + "policyRevision": policy_revision, + }) +} + pub(crate) async fn replace_facts_in_transaction( transaction: &deadpool_postgres::Transaction<'_>, + scheduling_id: &str, facts: &SchedulingFacts, audit_event: Uuid, audit_record: Value, @@ -2490,6 +2574,16 @@ pub(crate) async fn replace_facts_in_transaction( &[], ) .await?; + let stored_id: String = transaction + .query_one( + "SELECT scheduling_id FROM scheduling_meta WHERE singleton FOR UPDATE", + &[], + ) + .await? + .get(0); + if stored_id != scheduling_id { + return Err(StoreError::DeploymentIdentity); + } let occupied = occupied_resources_changed_by(transaction, facts).await?; if !occupied.is_empty() { return Err(StoreError::FactsInUse(occupied.join(", "))); diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index ef0e627d82..e39c602b44 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -1454,6 +1454,39 @@ async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() assert_eq!(problem["code"], "revision.mismatch"); } +#[tokio::test] +async fn a_foreign_actor_does_not_learn_the_current_revision() { + let fx = fixture().await; + let (appointment_id, _) = booked(&fx, 300, 440, "owner-revision-create").await; + let stranger = agent_token_for("principal-stranger"); + let other = first_slot(&fx, OFFERING, 480, 620).await; + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &stranger, + "foreign-reschedule-revision", + json!({ + "observedRevision": 0, + "admission": admission(&fx, OFFERING, other), + }), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{problem}"); + assert_eq!(problem["code"], "operation.not-authorized"); + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/cancel"), + &stranger, + "foreign-cancel-revision", + json!({"observedRevision": 0, "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{problem}"); + assert_eq!(problem["code"], "operation.not-authorized"); +} + #[tokio::test] async fn a_reschedule_refuses_an_admission_for_another_offering() { let fx = fixture().await; @@ -2073,6 +2106,40 @@ async fn the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations() { assert_eq!(explained["detailedCode"], "capacity.exhausted"); } +#[tokio::test] +async fn explain_assumes_the_offerings_declared_inputs_are_present() { + let authored = POLICY.replacen( + " prerequisites: []", + " prerequisites: [registry-update-permit]", + 1, + ); + let policy = parse_policy_yaml(&authored).expect("the policy with a required input"); + let mut pool_ids: Vec = policy + .offerings + .iter() + .filter_map(|offering| offering.exact_time.as_ref().map(|exact| exact.pool.clone())) + .collect(); + pool_ids.sort(); + pool_ids.dedup(); + let fx = fixture_publishing(&authored, &pool_ids, &[]).await; + let slot = first_slot(&fx, OFFERING, 300, 440).await; + + let (status, explanation) = fx + .get( + &format!( + "/v1/availability/explain?offering={OFFERING}&start={}", + stamp(slot) + ), + &fx.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "{explanation}"); + assert!( + explanation["publicCode"].is_null(), + "the synthetic probe satisfies declared inputs: {explanation}" + ); +} + #[tokio::test] async fn a_commitment_against_an_unanchored_pool_refuses_loudly() { // Publish the policy with only one of its two pools anchored: reads @@ -2528,6 +2595,7 @@ async fn a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies() let refusal = fx .store .replace_facts( + SCHEDULING_ID, &records_without(&["station-1"]), Uuid::new_v4(), operator_audit(), @@ -2557,6 +2625,7 @@ async fn a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies() // Retiring an idle station is exactly what the command is for. fx.store .replace_facts( + SCHEDULING_ID, &records_without(&["station-2"]), Uuid::new_v4(), operator_audit(), @@ -2590,7 +2659,7 @@ async fn a_records_swap_refuses_to_move_an_occupied_resource_between_pools() { moved.pools[1].members.push(station); let refusal = fx .store - .replace_facts(&moved, Uuid::new_v4(), operator_audit()) + .replace_facts(SCHEDULING_ID, &moved, Uuid::new_v4(), operator_audit()) .await .expect_err("an occupied resource cannot move to another pool"); assert!(refusal.to_string().contains("station-1"), "{refusal}"); @@ -2624,7 +2693,12 @@ async fn a_records_swap_waits_for_the_capacity_transaction_holding_the_pool() { let store = fx.store.clone(); let swap = tokio::spawn(async move { store - .replace_facts(&records_without(&[]), Uuid::new_v4(), operator_audit()) + .replace_facts( + SCHEDULING_ID, + &records_without(&[]), + Uuid::new_v4(), + operator_audit(), + ) .await }); tokio::time::sleep(std::time::Duration::from_millis(300)).await; @@ -3889,6 +3963,44 @@ async fn policy_publication_refuses_to_move_a_window_with_standing_commitments() assert!(refusal.to_string().contains(WINDOW_ID), "{refusal}"); } +#[tokio::test] +async fn policy_publication_refuses_to_remove_an_offering_with_a_live_appointment() { + let fx = fixture().await; + let (appointment_id, revision) = booked(&fx, 300, 440, "retained-offering-create").await; + let current = parse_policy_yaml(POLICY).expect("the current policy"); + let mut replacement = current.clone(); + replacement.scheduling.version += 1; + replacement + .offerings + .retain(|offering| offering.id != OFFERING); + let digest = replacement.policy_digest(); + let mut pool_ids: Vec = replacement + .offerings + .iter() + .filter_map(|offering| offering.exact_time.as_ref().map(|exact| exact.pool.clone())) + .collect(); + pool_ids.sort(); + pool_ids.dedup(); + + let refusal = fx + .store + .apply_policy(SCHEDULING_ID, &digest, &pool_ids, &[], &replacement) + .await + .expect_err("a live appointment keeps its offering operable"); + assert!(refusal.to_string().contains(OFFERING), "{refusal}"); + + let (status, cancelled) = fx + .post( + &format!("/v1/appointments/{appointment_id}/cancel"), + &fx.agent, + "retained-offering-cancel", + json!({"observedRevision": revision, "reason": null}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{cancelled}"); + assert_eq!(cancelled["state"], "cancelled"); +} + /// An arrival window is a second admission mode with a ledger of its own: its /// claims occupy the window rather than a member of a pool, its capacity is /// counted in units rather than in slots, and a channel subquota is a ceiling @@ -4435,7 +4547,7 @@ async fn a_records_swap_refuses_a_commitment_resolving_the_old_facts() { // it, so the swap commits. let swapped = records_without(&["station-1"]); fx.store - .replace_facts(&swapped, Uuid::new_v4(), operator_audit()) + .replace_facts(SCHEDULING_ID, &swapped, Uuid::new_v4(), operator_audit()) .await .expect("the records swap commits"); let supply = SupplyContext::ExactTime { diff --git a/crates/registry-schedulingctl/src/records.rs b/crates/registry-schedulingctl/src/records.rs index 293e682705..5c2bc8abe5 100644 --- a/crates/registry-schedulingctl/src/records.rs +++ b/crates/registry-schedulingctl/src/records.rs @@ -28,6 +28,9 @@ pub fn apply(config_path: &Path, records_path: &Path) -> Result { fs::canonicalize(records_path).context("resolving the Scheduling environment records")?; let config = RuntimeConfig::load(&config_path) .with_context(|| format!("loading {}", config_path.display()))?; + let policy = config + .load_policy() + .with_context(|| format!("loading {}", config.policy_path().display()))?; let text = crate::project::read_authoring_input(&records_path)?; let facts = parse_records(&text)?; validate(&facts)?; @@ -40,8 +43,10 @@ pub fn apply(config_path: &Path, records_path: &Path) -> Result { .build() .context("starting the Scheduling operator runtime")?; runtime - .block_on(store.migrate()) - .context("applying the Scheduling schema")?; + .block_on(store.ready()) + .context( + "checking the Scheduling schema; run `scheduling --runtime-config migrate` first", + )?; let audit_event = Uuid::new_v4(); let audit_record = json!({ "actorKind": "operator", @@ -51,7 +56,7 @@ pub fn apply(config_path: &Path, records_path: &Path) -> Result { "counts": counts, }); runtime - .block_on(store.replace_facts(&facts, audit_event, audit_record)) + .block_on(store.replace_facts(&policy.scheduling.id, &facts, audit_event, audit_record)) .context("replacing the environment records")?; Ok(json!({ "ok": true, diff --git a/crates/registry-schedulingctl/tests/records_apply_postgres.rs b/crates/registry-schedulingctl/tests/records_apply_postgres.rs index f71dfde534..8301076eff 100644 --- a/crates/registry-schedulingctl/tests/records_apply_postgres.rs +++ b/crates/registry-schedulingctl/tests/records_apply_postgres.rs @@ -117,6 +117,18 @@ fn apply(config: &Path, records_path: &Path) -> serde_json::Value { .expect("records apply succeeds") } +fn apply_error(config: &Path, records_path: &Path) -> String { + let config = config.to_path_buf(); + let records_path = records_path.to_path_buf(); + std::thread::spawn(move || { + let error = records::apply(&config, &records_path) + .expect_err("records apply refuses a different deployment identity"); + format!("{error:#}") + }) + .join() + .expect("records apply does not panic") +} + #[tokio::test] async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") @@ -171,6 +183,16 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { "0123456789abcdef0123456789abcdef", ); + let config = RuntimeConfig::load(&config_path).expect("the runtime configuration loads"); + let resolver = SecretResolver::new([SecretProvider::Environment], "") + .expect("the environment secret provider configures"); + let store = PostgresStore::connect_migration(&config.database, &resolver).unwrap(); + store.migrate().await.expect("the schema migrates"); + store + .adopt("registry-updates") + .await + .expect("the policy identity adopts the database"); + let report = apply(&config_path, &root.path().join("first.yaml")); assert_eq!(report["command"], "records-apply"); assert_eq!( @@ -178,11 +200,6 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { json!({"locations": 1, "pools": 1, "members": 2, "exceptions": 1}) ); - let config = RuntimeConfig::load(&config_path).expect("the runtime configuration loads"); - let resolver = SecretResolver::new([SecretProvider::Environment], "") - .expect("the environment secret provider configures"); - let store = PostgresStore::connect_runtime(&config.database, &resolver).unwrap(); - let (facts, _) = store.facts().await.unwrap(); assert_eq!(facts.locations.len(), 1); assert_eq!(facts.locations[0].id, "bangkok-counter"); @@ -241,3 +258,107 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { .await .expect("the disposable schema is dropped"); } + +#[tokio::test] +async fn records_apply_rejects_a_different_deployment_identity_without_writing() { + let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") + .expect("SCHEDULING_TEST_DATABASE_URL names a disposable PostgreSQL test server"); + let schema = format!("records_identity_{}", Uuid::new_v4().simple()); + let (admin, connection) = tokio_postgres::connect(&base, tokio_postgres::NoTls) + .await + .expect("the test server accepts an administrative connection"); + tokio::spawn(async move { + connection + .await + .expect("the administrative connection stays up") + }); + admin + .batch_execute(&format!("CREATE SCHEMA {schema}")) + .await + .expect("a disposable schema is created"); + + let root = tempfile::tempdir().unwrap(); + let adopted_project = root.path().join("adopted-project"); + let other_project = root.path().join("other-project"); + std::fs::create_dir_all(&adopted_project).unwrap(); + std::fs::create_dir_all(&other_project).unwrap(); + std::fs::write(adopted_project.join("scheduling.yaml"), POLICY).unwrap(); + std::fs::write( + other_project.join("scheduling.yaml"), + POLICY.replacen(" id: registry-updates\n", " id: permit-renewals\n", 1), + ) + .unwrap(); + std::fs::write(root.path().join("first.yaml"), FIRST_RECORDS).unwrap(); + std::fs::write(root.path().join("second.yaml"), SECOND_RECORDS).unwrap(); + + let runtime = |project: &Path, audit: &Path| { + format!( + "apiVersion: registry.registrystack.org/scheduling-runtime/v1alpha1\n\ + kind: SchedulingRuntimeConfig\n\ + package:\n root: {project}\n\ + listener:\n bind: 127.0.0.1:8105\n tlsTermination: development-loopback\n\ + secretProviders:\n environment: {{}}\n\ + authentication:\n oidc:\n issuer: https://identity.example.test\n\ + \x20 audience: urn:example:scheduling\n\ + database:\n runtimeUrlRef: secret:env/SCHEDULING_RECORDS_IDENTITY_DATABASE\n\ + \x20 migrationUrlRef: secret:env/SCHEDULING_RECORDS_IDENTITY_DATABASE\n\ + \x20 testOnlyPlaintext: true\n\ + audit:\n path: {audit}\n hashKeyRef: secret:env/SCHEDULING_RECORDS_IDENTITY_AUDIT\n\ + retention:\n attemptReceiptDays: 2\n", + project = project.display(), + audit = audit.display(), + ) + }; + let adopted_config = root.path().join("adopted-runtime.yaml"); + let other_config = root.path().join("other-runtime.yaml"); + std::fs::write( + &adopted_config, + runtime(&adopted_project, &root.path().join("adopted-audit.ndjson")), + ) + .unwrap(); + std::fs::write( + &other_config, + runtime(&other_project, &root.path().join("other-audit.ndjson")), + ) + .unwrap(); + std::env::set_var( + "SCHEDULING_RECORDS_IDENTITY_DATABASE", + scoped_url(&base, &schema), + ); + std::env::set_var( + "SCHEDULING_RECORDS_IDENTITY_AUDIT", + "0123456789abcdef0123456789abcdef", + ); + + let config = + RuntimeConfig::load(&adopted_config).expect("the adopted runtime configuration loads"); + let resolver = SecretResolver::new([SecretProvider::Environment], "") + .expect("the environment secret provider configures"); + let store = PostgresStore::connect_migration(&config.database, &resolver).unwrap(); + store.migrate().await.expect("the schema migrates"); + store + .adopt("registry-updates") + .await + .expect("the first policy identity adopts the database"); + apply(&adopted_config, &root.path().join("first.yaml")); + let (before, _) = store.facts().await.expect("the first records landed"); + + let error = apply_error(&other_config, &root.path().join("second.yaml")); + assert_eq!( + error, + "replacing the environment records: the Scheduling database belongs to another deployment" + ); + let (after, _) = store + .facts() + .await + .expect("the existing records remain readable"); + assert_eq!( + after, before, + "the refused apply changed the existing facts" + ); + + admin + .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) + .await + .expect("the disposable schema is dropped"); +} diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index aeb5ce59bf..09ea2470f6 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -39,4 +39,7 @@ capabilities, reject cross-offering reschedules, keep cancellations available during rolling policy changes, prevent occupied resources moving between pools, replay the winning idempotency receipt, and refuse policy - publications that strand standing window commitments. + publications that strand standing commitments. Keep explanation probes, + multi-pattern reopenings, long-range availability, deployment-bound records + updates, ownership checks, and replayed response fields aligned with those + same runtime contracts. diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index 82f465dc81..ad8a8411cd 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -23,6 +23,7 @@ entries: - id: SCHEDULING-SEC-03 tests: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_grant_that_lapses_before_the_commit_never_books} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_foreign_actor_does_not_learn_the_current_revision} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations} - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: expiry_uses_earlier_token_or_grant_deadline_and_refuses_exact_deadline} - id: SCHEDULING-SEC-04 @@ -50,6 +51,7 @@ entries: - {path: crates/registry-scheduling/src/service.rs, name: the_detailed_code_stays_behind_the_explain_path} - {path: crates/registry-scheduling/src/service.rs, name: commit_errors_project_to_their_public_codes} - {path: crates/registry-scheduling/src/service.rs, name: a_fully_occupied_slot_is_not_availability} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: explain_assumes_the_offerings_declared_inputs_are_present} - {path: crates/registry-scheduling-core/src/admission.rs, name: refusals_carry_their_distinguished_public_codes} - {path: crates/registry-scheduling-core/src/problem.rs, name: exactly_one_code_is_detailed_only} - id: SCHEDULING-SEC-08 @@ -115,6 +117,7 @@ entries: - {path: crates/registry-scheduling/src/config.rs, name: a_refused_authored_policy_names_the_member_and_the_cause} - {path: crates/registry-scheduling-core/src/policy.rs, name: the_policy_digest_is_stable_and_moves_with_content} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: adoption_binds_one_identity_and_refuses_a_second} + - {path: crates/registry-schedulingctl/tests/records_apply_postgres.rs, name: records_apply_rejects_a_different_deployment_identity_without_writing, features: [postgres-test]} - id: SCHEDULING-SEC-16 tests: - {path: crates/registry-scheduling/src/config.rs, name: static_jwks_requires_unique_named_asymmetric_keys} @@ -173,3 +176,4 @@ entries: - {path: crates/registry-scheduling-core/src/policy.rs, name: a_subquota_reduction_strands_its_channel_even_at_an_unchanged_total} - {path: crates/registry-scheduling-core/src/policy.rs, name: moving_a_published_window_reports_no_reduction} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: policy_publication_refuses_to_move_a_window_with_standing_commitments} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: policy_publication_refuses_to_remove_an_offering_with_a_live_appointment} From 9bb83095f8cfa1e651ee5cd1e9f5be4140e3f6a4 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 15:42:16 +0700 Subject: [PATCH 109/115] fix(scheduling): anchor arrival window records Signed-off-by: Jeremi Joslin --- .../registry-scheduling-client/src/client.rs | 24 +- .../tests/http_boundary.rs | 45 ++- .../registry-scheduling-core/src/admission.rs | 121 ++++++ .../registry-scheduling-core/src/fixture.rs | 118 +++--- crates/registry-scheduling-core/src/model.rs | 13 +- crates/registry-scheduling-core/src/policy.rs | 346 +++++++++++------- .../registry-scheduling-core/src/resolve.rs | 3 +- .../migrations/0005_window_records.sql | 10 + crates/registry-scheduling/src/config.rs | 1 - crates/registry-scheduling/src/runtime.rs | 30 +- crates/registry-scheduling/src/service.rs | 86 +++-- crates/registry-scheduling/src/store.rs | 203 +++++++--- .../tests/postgres_commitments.rs | 272 +++++++++----- crates/registry-schedulingctl/src/project.rs | 42 ++- crates/registry-schedulingctl/src/records.rs | 137 ++++++- .../registry-schedulingctl/src/templates.rs | 158 +++++--- .../tests/intents_postgres.rs | 1 - .../tests/records_apply_postgres.rs | 50 ++- products/scheduling/CHANGELOG.md | 4 + products/scheduling/README.md | 21 +- .../contracts/recorded-decisions.yaml | 30 +- .../contracts/security-invariant-matrix.yaml | 16 +- .../contracts/security-test-traceability.yaml | 7 +- .../fixtures/household-afternoon.yaml | 23 ++ .../fixtures/household-morning.yaml | 22 ++ .../standalone-arrival-window/records.yaml | 44 +++ .../standalone-arrival-window/scheduling.yaml | 44 --- .../standalone-exact-time/records.yaml | 1 + .../standalone-exact-time/scheduling.yaml | 1 - .../scheduling/scripts/check-checkpoint.sh | 15 +- 30 files changed, 1328 insertions(+), 560 deletions(-) create mode 100644 crates/registry-scheduling/migrations/0005_window_records.sql diff --git a/crates/registry-scheduling-client/src/client.rs b/crates/registry-scheduling-client/src/client.rs index e439030141..b131ddbcfa 100644 --- a/crates/registry-scheduling-client/src/client.rs +++ b/crates/registry-scheduling-client/src/client.rs @@ -566,8 +566,8 @@ fn validate_identifier( fn validate_availability( offering: &str, - start: Option>, - end: Option>, + _start: Option>, + _end: Option>, cursor: Option<&str>, limit: Option, ) -> Result<(), SchedulingClientError> { @@ -582,13 +582,6 @@ fn validate_availability( "the page size is outside the accepted range", )); } - if let (Some(start), Some(end)) = (start, end) { - if end <= start { - return Err(SchedulingClientError::invalid_request( - "the availability interval ends before it starts", - )); - } - } Ok(()) } @@ -653,9 +646,18 @@ mod tests { assert!(validate_availability("", None, None, None, None).is_err()); assert!(validate_availability("registry-update-30", None, None, Some(""), None).is_err()); assert!(validate_availability("registry-update-30", None, None, None, Some(0)).is_err()); + } + + #[test] + fn availability_ranges_preserve_runtime_normalization() { + let start = Utc.with_ymd_and_hms(2026, 10, 5, 2, 0, 0).unwrap(); + let end = Utc.with_ymd_and_hms(2026, 10, 5, 2, 30, 0).unwrap(); + assert!( + validate_availability("registry-update-30", Some(start), Some(start), None, None) + .is_ok() + ); assert!( - validate_availability("registry-update-30", Some(end), Some(start), None, None) - .is_err() + validate_availability("registry-update-30", Some(end), Some(start), None, None).is_ok() ); } diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs index 67d7775022..d4ea8a5fd5 100644 --- a/crates/registry-scheduling-client/tests/http_boundary.rs +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -368,22 +368,31 @@ async fn availability_forwards_the_exact_query_and_validates_its_selectors_local .await, Err(SchedulingClientError::InvalidRequest { .. }) )); - assert!(matches!( - client - .availability( - auth(&token), - "registry-update-30", - Some(end), - Some(start), - None, - None - ) - .await, - Err(SchedulingClientError::InvalidRequest { .. }) - )); + client + .availability( + auth(&token), + "registry-update-30", + Some(start), + Some(start), + None, + None, + ) + .await + .expect("equal bounds reach the runtime for normalization"); + client + .availability( + auth(&token), + "registry-update-30", + Some(end), + Some(start), + None, + None, + ) + .await + .expect("reversed bounds reach the runtime for normalization"); let observations = observations.lock().expect("observations"); - assert_eq!(observations.len(), 2); + assert_eq!(observations.len(), 4); assert_eq!( observations[0].uri, "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z&end=2026-10-05T02%3A30%3A00Z&limit=25" @@ -392,6 +401,14 @@ async fn availability_forwards_the_exact_query_and_validates_its_selectors_local observations[1].uri, "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z&end=2026-10-05T02%3A30%3A00Z&cursor=next&limit=25" ); + assert_eq!( + observations[2].uri, + "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A00%3A00Z&end=2026-10-05T02%3A00%3A00Z" + ); + assert_eq!( + observations[3].uri, + "/v1/availability?offering=registry-update-30&start=2026-10-05T02%3A30%3A00Z&end=2026-10-05T02%3A00%3A00Z" + ); server.abort(); } diff --git a/crates/registry-scheduling-core/src/admission.rs b/crates/registry-scheduling-core/src/admission.rs index bb493f74ed..12857f7041 100644 --- a/crates/registry-scheduling-core/src/admission.rs +++ b/crates/registry-scheduling-core/src/admission.rs @@ -143,6 +143,12 @@ pub struct ExactTimeContext<'a> { pub struct WindowContext<'a> { pub offering: &'a OfferingPolicy, pub window: &'a PublishedWindow, + /// Effective published openings for the window's location, after dated + /// closures and authorized reopenings have been layered. + pub open: &'a [CalendarInterval], + /// Raw blocking closures, used only to distinguish `location.closed` + /// from a start outside every published opening. + pub closures: &'a [CalendarInterval], /// The arrival block's lead time and horizon, resolved from the offering. pub lead_time_minutes: u32, pub horizon_days: u32, @@ -324,6 +330,8 @@ pub fn evaluate_window_admission( let WindowContext { offering, window, + open, + closures, lead_time_minutes, horizon_days, snapshot, @@ -341,6 +349,22 @@ pub fn evaluate_window_admission( }); } check_horizon(window.start, *now, *lead_time_minutes, *horizon_days)?; + if request.start != window.start { + return Err(AdmissionRefusal::ScheduleUnpublished); + } + let covered = open + .iter() + .any(|interval| interval.start <= window.start && window.end <= interval.end); + if !covered { + let overlaps_closure = closures + .iter() + .any(|closure| window.start < closure.end && closure.start < window.end); + return Err(if overlaps_closure { + AdmissionRefusal::LocationClosed + } else { + AdmissionRefusal::ScheduleUnpublished + }); + } if request.party.recipients == 0 { return Err(AdmissionRefusal::PartyCapacityInadequate); @@ -1225,6 +1249,13 @@ mod tests { } } + fn published_window_interval(window: &PublishedWindow) -> [CalendarInterval; 1] { + [CalendarInterval { + start: window.start, + end: window.end, + }] + } + /// AT-03: three attendees with two recipients consume two units, never /// three. #[test] @@ -1233,9 +1264,12 @@ mod tests { let window = window(); let snapshot = LedgerSnapshot::default(); let now = utc(4, 9, 0); + let open = published_window_interval(&window); let context = WindowContext { offering: &offering, window: &window, + open: &open, + closures: &[], lead_time_minutes: 1, horizon_days: 60, snapshot: &snapshot, @@ -1258,9 +1292,12 @@ mod tests { claims: vec![window_claim("claim-1", 2, "public")], }; let now = utc(4, 9, 0); + let open = published_window_interval(&window); let context = WindowContext { offering: &offering, window: &window, + open: &open, + closures: &[], lead_time_minutes: 1, horizon_days: 60, snapshot: &snapshot, @@ -1291,9 +1328,12 @@ mod tests { let window = window(); let snapshot = LedgerSnapshot::default(); let now = utc(4, 9, 0); + let open = published_window_interval(&window); let context = WindowContext { offering: &offering, window: &window, + open: &open, + closures: &[], lead_time_minutes: 1, horizon_days: 60, snapshot: &snapshot, @@ -1330,9 +1370,12 @@ mod tests { }; let snapshot = LedgerSnapshot::default(); let now = utc(4, 9, 0); + let open = published_window_interval(&window); let context = WindowContext { offering: &offering, window: &window, + open: &open, + closures: &[], lead_time_minutes: 1, horizon_days: 60, snapshot: &snapshot, @@ -1361,9 +1404,12 @@ mod tests { claims: vec![window_claim("claim-1", 2, "public")], }; let now = utc(4, 9, 0); + let open = published_window_interval(&window); let context = WindowContext { offering: &offering, window: &window, + open: &open, + closures: &[], lead_time_minutes: 1, horizon_days: 60, snapshot: &snapshot, @@ -1392,9 +1438,12 @@ mod tests { claims: vec![window_claim("claim-1", 2, "public")], }; let now = utc(4, 9, 0); + let open = published_window_interval(&window); let context = WindowContext { offering: &offering, window: &window, + open: &open, + closures: &[], lead_time_minutes: 1, horizon_days: 60, snapshot: &snapshot, @@ -1448,9 +1497,12 @@ mod tests { let window = window(); let snapshot = LedgerSnapshot::default(); let now = utc(4, 9, 0); + let open = published_window_interval(&window); let context = WindowContext { offering: &offering, window: &window, + open: &open, + closures: &[], lead_time_minutes: 1, horizon_days: 60, snapshot: &snapshot, @@ -1477,6 +1529,75 @@ mod tests { ); } + #[test] + fn window_request_start_must_match_the_published_window() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot::default(); + let open = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &open, + closures: &[], + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now: utc(4, 9, 0), + }; + let request = AdmissionRequest { + start: utc(4, 10, 30), + ..window_request(1, 1) + }; + + assert_eq!( + evaluate_window_admission(&context, &request, None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + } + + #[test] + fn window_outside_openings_distinguishes_closure_from_unpublished_schedule() { + let offering = arrival_offering(); + let window = window(); + let snapshot = LedgerSnapshot::default(); + let closure = published_window_interval(&window); + let context = WindowContext { + offering: &offering, + window: &window, + open: &[], + closures: &closure, + lead_time_minutes: 1, + horizon_days: 60, + snapshot: &snapshot, + policy_revision: 1, + channels: &[], + now: utc(4, 9, 0), + }; + + assert_eq!( + evaluate_window_admission(&context, &window_request(1, 1), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::LocationClosed) + ); + + let uncovered = WindowContext { + closures: &[], + ..context + }; + assert_eq!( + evaluate_window_admission(&uncovered, &window_request(1, 1), None) + .err() + .map(|refusal| refusal.public_code()), + Some(ProblemCode::ScheduleUnpublished) + ); + } + /// AT-19: opening expansion across a daylight-saving fold feeds admission /// with real elapsed intervals, and the repeated wall-clock hour yields two /// distinct bookable instants, never an invisible duplicate. diff --git a/crates/registry-scheduling-core/src/fixture.rs b/crates/registry-scheduling-core/src/fixture.rs index ae4d5a918f..2a1e43b203 100644 --- a/crates/registry-scheduling-core/src/fixture.rs +++ b/crates/registry-scheduling-core/src/fixture.rs @@ -174,7 +174,7 @@ pub enum ReplayError { EmptyPool { case: String, pool: String }, #[error("case {case} needs location {location}, which the facts do not declare")] UnknownLocation { case: String, location: String }, - #[error("case {case} needs window {window}, which the policy does not publish")] + #[error("case {case} needs window {window}, which the facts do not declare")] UnknownWindow { case: String, window: String }, #[error("case name {case} is used more than once; case names identify claims")] DuplicateCaseName { case: String }, @@ -292,7 +292,7 @@ impl SchedulingFixture { }); }; let window_id = arrival.window.clone(); - let Some(window) = policy.window(&window_id) else { + let Some(window) = self.facts.window(&window_id) else { return Err(ReplayError::UnknownWindow { case: case.name.clone(), window: window_id, @@ -307,19 +307,11 @@ impl SchedulingFixture { .expect("the location was resolved above") .timezone .clone(); - let exceptions: Vec<_> = self - .facts - .exceptions - .iter() - .filter(|exception| exception.location == offering.location) - .map(|exception| exception.borrowed()) - .collect(); - let open = location_open_intervals( - policy, - &offering.location, - &timezone, - &exceptions, - )?; + // Structural validation uses the authored schedule before + // dated exceptions. A closure is a valid fixture fact + // whose case must reach admission and refuse as + // `location.closed`, not an invalid fixture definition. + let open = location_open_intervals(policy, &offering.location, &timezone, &[])?; if !covers_span(&open, window.start, window.end) { return Err(ReplayError::WindowOutsideOpenings { window: window_id }); } @@ -496,15 +488,35 @@ fn replay_case( offering: offering.id.clone(), }, ))?; - let window = policy.window(&arrival.window).ok_or(CaseStoppage::Replay( - ReplayError::UnknownWindow { + let window = fixture + .facts + .window(&arrival.window) + .ok_or(CaseStoppage::Replay(ReplayError::UnknownWindow { case: case.name.clone(), window: arrival.window.clone(), - }, - ))?; + }))?; + let exceptions: Vec<_> = fixture + .facts + .exceptions + .iter() + .filter(|exception| exception.location == offering.location) + .map(|exception| exception.borrowed()) + .collect(); + let open = location_open_intervals( + policy, + &offering.location, + &location.timezone, + &exceptions, + ) + .map_err(|error| CaseStoppage::Replay(error.into()))?; + let closures = + location_closure_intervals(&fixture.facts, &offering.location, &location.timezone) + .map_err(|error| CaseStoppage::Replay(error.into()))?; let context = WindowContext { offering, window, + open: &open, + closures: &closures, lead_time_minutes: arrival.lead_time_minutes, horizon_days: arrival.horizon_days, snapshot, @@ -568,7 +580,7 @@ impl From for ReplayError { #[cfg(test)] mod tests { use super::*; - use crate::model::{LocationRecord, PartyCounts}; + use crate::model::{CalendarExceptionRecord, ExceptionRecordKind, LocationRecord, PartyCounts}; use registry_platform_calendar::CalendarInterval; fn exact_time_fixture_yaml() -> &'static str { @@ -669,7 +681,6 @@ openings: effectiveFrom: "2026-10-05" effectiveUntil: "2026-10-05" because: Counter opening hours reviewed by the office manager. -windows: [] holdPolicy: ttlMinutes: 5 maxPerCaller: 3 @@ -912,6 +923,7 @@ cases: timezone: "Asia/Bangkok".to_owned(), }], pools: vec![], + windows: vec![], exceptions: vec![crate::model::CalendarExceptionRecord { id: "systems-training".to_owned(), location: "north-counter".to_owned(), @@ -948,6 +960,28 @@ facts: - id: civic-hall timezone: Asia/Bangkok pools: [] + windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block. initial: [] cases: - name: first-household @@ -1035,28 +1069,6 @@ openings: effectiveFrom: "2026-10-01" effectiveUntil: "2026-12-31" because: The hall opens on Saturday mornings. -windows: - - id: household-morning-window - revision: 2 - offering: household-morning - location: civic-hall - start: 2026-10-10T01:00:00Z - end: 2026-10-10T03:00:00Z - units: 3 - unitsPolicy: - kind: perRecipient - perRecipient: 1 - because: Each recipient consumes one serving slot. - subquotas: - - id: public-quota - channel: public - units: 2 - because: Most households book the public channel. - - id: assisted-quota - channel: assisted - units: 1 - because: Assisted bookings hold a protected unit. - because: The Saturday morning household block. holdPolicy: ttlMinutes: 10 maxPerCaller: 2 @@ -1074,6 +1086,26 @@ holdPolicy: .all(|outcome| outcome.status == CaseStatus::Pass), "{outcomes:?}" ); + + let mut closed = parse_fixture_yaml(yaml).expect("parses a closed-window fixture"); + closed.facts.exceptions.push(CalendarExceptionRecord { + id: "hall-closure".to_owned(), + location: "civic-hall".to_owned(), + kind: ExceptionRecordKind::Closure, + date: "2026-10-10".to_owned(), + start_time: "08:00".to_owned(), + end_time: "12:00".to_owned(), + reopens: None, + authority: None, + }); + closed.cases.truncate(1); + closed.cases[0].expect = FixtureExpectation::Refused { + code: "location.closed".to_owned(), + }; + let outcomes = closed + .replay(&policy) + .expect("the closure reaches admission"); + assert_eq!(outcomes[0].status, CaseStatus::Pass, "{outcomes:?}"); } #[test] diff --git a/crates/registry-scheduling-core/src/model.rs b/crates/registry-scheduling-core/src/model.rs index f0819bbecb..4b3b07a497 100644 --- a/crates/registry-scheduling-core/src/model.rs +++ b/crates/registry-scheduling-core/src/model.rs @@ -270,8 +270,8 @@ impl CalendarExceptionRecord { } } -/// The environment records an admission runs against: locations, pools, and -/// dated exceptions. In production these are runtime records; in an offline +/// The environment records an admission runs against: locations, pools, +/// published windows, and dated exceptions. In production these are runtime records; in an offline /// replay they are fixture facts. The evaluator never reads or mutates them /// directly; its caller resolves them into the context the evaluator takes. /// A fixture may omit an empty collection; replay fails fast when a case @@ -283,6 +283,10 @@ pub struct SchedulingFacts { pub locations: Vec, #[serde(default)] pub pools: Vec, + /// Published arrival-window supply owned by this deployment's operator + /// records. The policy references these records by identifier. + #[serde(default)] + pub windows: Vec, #[serde(default)] pub exceptions: Vec, } @@ -297,6 +301,11 @@ impl SchedulingFacts { pub fn pool(&self, id: &str) -> Option<&ResourcePool> { self.pools.iter().find(|pool| pool.id == id) } + + #[must_use] + pub fn window(&self, id: &str) -> Option<&crate::policy::PublishedWindow> { + self.windows.iter().find(|window| window.id == id) + } } /// A request for one admission decision. The evaluator is pure: it resolves diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index d6ff3034da..99f79bfc41 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -1,19 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 //! The authored scheduling policy: services, offerings, opening patterns, -//! holiday sets, published arrival windows, and the hold policy, with the -//! validators a publication gate runs. +//! holiday sets, and the hold policy, with the validators a publication gate +//! runs. Published arrival-window supply is an operator record referenced by +//! identifier from an arrival offering. //! //! The policy is layer one of the configuration model. It declares what a -//! deployment publishes and why; locations, resource pools, and dated -//! exceptions are runtime records the policy references by identifier but -//! never embeds, so the same published policy governs every environment that -//! resolves those identifiers. +//! deployment publishes and why; locations, resource pools, arrival windows, +//! and dated exceptions are runtime records the policy references by +//! identifier but never embeds, so the same published policy governs every +//! environment that resolves those identifiers. use chrono::{DateTime, Utc}; use registry_platform_hooks::{validate_hooks, HookHandlerSource, HookPhase, HookValidationError}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; use crate::diagnostics::{PolicyCheckReason, SchedulingDiagnostic}; use crate::naming::SCHEDULING_POLICY_API_VERSION; @@ -324,7 +326,6 @@ pub struct SchedulingPolicy { pub offerings: Vec, pub holiday_sets: Vec, pub openings: Vec, - pub windows: Vec, /// The channels this deployment declares it serves. The vocabulary is /// closed by the `Channel` type itself; a declaration narrows it to the /// served subset, every subquota must draw on a declared channel, and a @@ -351,11 +352,6 @@ impl SchedulingPolicy { self.offerings.iter().find(|offering| offering.id == id) } - #[must_use] - pub fn window(&self, id: &str) -> Option<&PublishedWindow> { - self.windows.iter().find(|window| window.id == id) - } - #[must_use] pub fn holiday_set(&self, id: &str) -> Option<&HolidaySetPolicy> { self.holiday_sets.iter().find(|set| set.id == id) @@ -411,7 +407,6 @@ impl SchedulingPolicy { check_collection_bound(&self.offerings, "offerings", &mut findings); check_collection_bound(&self.holiday_sets, "holidaySets", &mut findings); check_collection_bound(&self.openings, "openings", &mut findings); - check_collection_bound(&self.windows, "windows", &mut findings); // A declared channel set is a closed list of the channels this // deployment serves; naming one twice declares it once. @@ -550,10 +545,6 @@ impl SchedulingPolicy { self.check_offering(offering, index, &mut findings); } - for (index, window) in self.windows.iter().enumerate() { - self.check_window(window, index, &mut findings); - } - check_because( &self.hold_policy.because, "holdPolicy.because", @@ -653,12 +644,7 @@ impl SchedulingPolicy { } if let Some(arrival) = &offering.arrival { let block_path = format!("{path}.arrival"); - if self.window(&arrival.window).is_none() { - findings.push(SchedulingDiagnostic::new( - format!("{block_path}.window"), - PolicyCheckReason::UnknownWindow, - )); - } + check_identifier(&arrival.window, &format!("{block_path}.window"), findings); if arrival.lead_time_minutes == 0 || arrival.horizon_days == 0 { findings.push(SchedulingDiagnostic::new( block_path.clone(), @@ -723,9 +709,33 @@ impl SchedulingPolicy { } } + /// Validate the published windows in one environment-records document + /// against this policy. Policy authoring can validate the identifier + /// reference alone; existence and the window's capacity contract belong + /// to the operator records that carry the supply. + pub fn check_window_records(&self, windows: &[PublishedWindow]) -> Vec { + let mut findings = Vec::new(); + check_collection_bound(windows, "windows", &mut findings); + for (index, offering) in self.offerings.iter().enumerate() { + if let Some(arrival) = &offering.arrival { + if !windows.iter().any(|window| window.id == arrival.window) { + findings.push(SchedulingDiagnostic::new( + format!("offerings[{index}].arrival.window"), + PolicyCheckReason::UnknownWindow, + )); + } + } + } + for (index, window) in windows.iter().enumerate() { + self.check_window(window, windows, index, &mut findings); + } + findings + } + fn check_window( &self, window: &PublishedWindow, + windows: &[PublishedWindow], index: usize, findings: &mut Vec, ) { @@ -738,7 +748,12 @@ impl SchedulingPolicy { )); } check_because(&window.because, &format!("{path}.because"), findings); - self.check_unique_id(&window.id, &path, "windows", findings); + if windows.iter().filter(|other| other.id == window.id).count() > 1 { + findings.push(SchedulingDiagnostic::new( + format!("{path}.id"), + PolicyCheckReason::DuplicateIdentifier, + )); + } if window.units == 0 { findings.push(SchedulingDiagnostic::new( format!("{path}.units"), @@ -851,7 +866,7 @@ impl SchedulingPolicy { // two overlapping windows each claiming the whole pool are // double-counting the same staffing. if staffing.reserved_members.is_none() { - let doubly_claimed = self.windows.iter().any(|other| { + let doubly_claimed = windows.iter().any(|other| { other.id != window.id && other.staffing.as_ref().is_some_and(|other_staffing| { other_staffing.pool == staffing.pool @@ -885,7 +900,6 @@ impl SchedulingPolicy { .count() > 1 } - "windows" => self.windows.iter().filter(|window| window.id == id).count() > 1, "openings" => { self.openings .iter() @@ -986,7 +1000,7 @@ pub struct CapacityReduction { pub proposed_units: u32, } -/// Assess a proposed publication against the commitments already standing in +/// Assess proposed window records against the commitments already standing in /// a ledger snapshot. /// /// A normal capacity reduction that would leave confirmed bookings and live @@ -1001,32 +1015,37 @@ pub struct CapacityReduction { /// zero, and no longer capacity the moved window must cover. An emergency /// reduction is a different operation entirely, recorded as an incident with /// its affected bookings, and does not pass through this assessment. -pub fn assess_publication_impact( - current: &SchedulingPolicy, - proposed: &SchedulingPolicy, +pub fn assess_window_record_impact( + current: &[PublishedWindow], + proposed: &[PublishedWindow], snapshot: &crate::model::LedgerSnapshot, now: DateTime, ) -> Vec { let mut reductions = Vec::new(); - for window in ¤t.windows { - let proposed_window = proposed.window(&window.id); + let window_ids: BTreeSet<&str> = current + .iter() + .map(|window| window.id.as_str()) + .chain( + snapshot + .consuming(now) + .map(|claim| claim.supply_id.as_str()), + ) + .collect(); + for window_id in window_ids { + let current_window = current.iter().find(|candidate| candidate.id == window_id); + let proposed_window = proposed.iter().find(|candidate| candidate.id == window_id); let proposed_units = proposed_window.map_or(0, |proposed| proposed.units); // A window the proposal drops has no interval left to meet, so every // claim counts against the zero it proposes. let committed = match proposed_window { - Some(proposed) => units_committed_within( - snapshot, - &window.id, - None, - proposed.start, - proposed.end, - now, - ), - None => snapshot.window_units_allocated(&window.id, None, None, now), + Some(proposed) => { + units_committed_within(snapshot, window_id, None, proposed.start, proposed.end, now) + } + None => snapshot.window_units_allocated(window_id, None, None, now), }; - if proposed_units < window.units && committed > proposed_units { + if committed > proposed_units { reductions.push(CapacityReduction { - window: window.id.clone(), + window: window_id.to_owned(), channel: None, committed_units: committed, proposed_units, @@ -1037,10 +1056,10 @@ pub fn assess_publication_impact( // stranded: the proposal publishes no capacity where they stand, // which is a reduction to zero for them. let stranded = - units_committed_outside(snapshot, &window.id, proposed.start, proposed.end, now); + units_committed_outside(snapshot, window_id, proposed.start, proposed.end, now); if stranded > 0 { reductions.push(CapacityReduction { - window: window.id.clone(), + window: window_id.to_owned(), channel: None, committed_units: stranded, proposed_units: 0, @@ -1048,7 +1067,10 @@ pub fn assess_publication_impact( } } // A subquota dropped from the proposal proposes zero for its channel. - for subquota in &window.subquotas { + for subquota in current_window + .map(|window| window.subquotas.as_slice()) + .unwrap_or_default() + { let proposed_subquota = proposed_window .and_then(|proposed| { proposed @@ -1063,14 +1085,14 @@ pub fn assess_publication_impact( let committed_channel = match proposed_window { Some(proposed) => units_committed_within( snapshot, - &window.id, + window_id, Some(subquota.channel.as_str()), proposed.start, proposed.end, now, ), None => snapshot.window_units_allocated( - &window.id, + window_id, Some(subquota.channel.as_str()), None, now, @@ -1078,7 +1100,7 @@ pub fn assess_publication_impact( }; if committed_channel > proposed_subquota { reductions.push(CapacityReduction { - window: window.id.clone(), + window: window_id.to_owned(), channel: Some(subquota.channel), committed_units: committed_channel, proposed_units: proposed_subquota, @@ -1091,16 +1113,18 @@ pub fn assess_publication_impact( // reduction of an existing slice would. if let Some(proposed) = proposed_window { for subquota in &proposed.subquotas { - let already_published = window - .subquotas - .iter() - .any(|current| current.channel == subquota.channel); + let already_published = current_window.is_some_and(|window| { + window + .subquotas + .iter() + .any(|current| current.channel == subquota.channel) + }); if already_published { continue; } let committed_channel = units_committed_within( snapshot, - &window.id, + window_id, Some(subquota.channel.as_str()), proposed.start, proposed.end, @@ -1108,7 +1132,7 @@ pub fn assess_publication_impact( ); if committed_channel > subquota.units { reductions.push(CapacityReduction { - window: window.id.clone(), + window: window_id.to_owned(), channel: Some(subquota.channel), committed_units: committed_channel, proposed_units: subquota.units, @@ -1121,9 +1145,9 @@ pub fn assess_publication_impact( } /// Recipient units standing against a window's supply at `now`, counting only -/// claims whose occupied interval meets the half-open interval -/// `[start, end)`, so a proposal that moves or shortens the window is assessed -/// over the interval it now publishes. +/// claims whose occupied interval is fully contained in `[start, end)`, so a +/// proposal that moves or shortens the window cannot count a partly stranded +/// commitment against the capacity it still publishes. fn units_committed_within( snapshot: &crate::model::LedgerSnapshot, window_id: &str, @@ -1137,8 +1161,8 @@ fn units_committed_within( .filter(|claim| { claim.supply_id == window_id && channel.is_none_or(|wanted| claim.channel.as_deref() == Some(wanted)) - && claim.start < end - && start < claim.end + && start <= claim.start + && claim.end <= end }) .map(|claim| claim.units) // Saturating, for the same reason the snapshot's own sum saturates: @@ -1147,8 +1171,8 @@ fn units_committed_within( } /// Recipient units standing against a window's supply at `now` whose occupied -/// interval meets nothing of the half-open interval `[start, end)`: the -/// commitments a proposal publishing that interval no longer covers. +/// interval is not fully contained in `[start, end)`: the commitments a +/// proposal publishing that interval no longer covers. fn units_committed_outside( snapshot: &crate::model::LedgerSnapshot, window_id: &str, @@ -1158,7 +1182,7 @@ fn units_committed_outside( ) -> u32 { snapshot .consuming(now) - .filter(|claim| claim.supply_id == window_id && !(claim.start < end && start < claim.end)) + .filter(|claim| claim.supply_id == window_id && !(start <= claim.start && claim.end <= end)) .map(|claim| claim.units) .fold(0, u32::saturating_add) } @@ -1350,7 +1374,6 @@ openings: effectiveFrom: "2026-10-01" effectiveUntil: "2026-12-31" because: Counter opening hours reviewed by the office manager. -windows: [] holdPolicy: ttlMinutes: 5 maxPerCaller: 3 @@ -1398,6 +1421,17 @@ openings: effectiveFrom: "2026-10-01" effectiveUntil: "2026-12-31" because: The hall opens on Saturday mornings. +holdPolicy: + ttlMinutes: 10 + maxPerCaller: 2 + because: Households need a few minutes to gather documents. +"#; + serde_norway::from_str(yaml).expect("the household policy parses") + } + + fn household_windows() -> Vec { + let records: crate::model::SchedulingFacts = serde_norway::from_str( + r#" windows: - id: household-morning-window revision: 2 @@ -1420,12 +1454,10 @@ windows: units: 1 because: Assisted bookings hold a protected unit. because: The Saturday morning household block, sized for two officers. -holdPolicy: - ttlMinutes: 10 - maxPerCaller: 2 - because: Households need a few minutes to gather documents. -"#; - serde_norway::from_str(yaml).expect("the household policy parses") +"#, + ) + .expect("the household window records parse"); + records.windows } #[test] @@ -1580,18 +1612,20 @@ holdPolicy: #[test] fn subquota_slices_may_not_overdraw_the_window() { - let mut policy = household_window_policy(); - policy.windows[0].subquotas[0].units = 3; - let findings = policy.check(); + let policy = household_window_policy(); + let mut windows = household_windows(); + windows[0].subquotas[0].units = 3; + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!(rendered.contains(&"windows[0].subquotas: subquota-overdrawn".to_owned())); } #[test] fn one_channel_may_not_hold_two_slices_of_one_window() { - let mut policy = household_window_policy(); - policy.windows[0].subquotas[1].channel = Channel::Public; - let findings = policy.check(); + let policy = household_window_policy(); + let mut windows = household_windows(); + windows[0].subquotas[1].channel = Channel::Public; + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!( rendered.contains(&"windows[0].subquotas[1].channel: duplicate-identifier".to_owned()) @@ -1605,10 +1639,11 @@ holdPolicy: #[test] fn a_subquota_may_not_draw_on_an_undeclared_channel() { let mut policy = household_window_policy(); + let windows = household_windows(); // The window serves public and assisted; the deployment declares // only assisted. policy.channels = vec![Channel::Assisted]; - let findings = policy.check(); + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!( rendered.contains(&"windows[0].subquotas[0].channel: unknown-channel".to_owned()), @@ -1621,7 +1656,7 @@ holdPolicy: // With no declaration, the vocabulary alone governs and the same // subquotas check clean. policy.channels = Vec::new(); - let findings = policy.check(); + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!( rendered @@ -1652,7 +1687,8 @@ holdPolicy: #[test] fn an_unpartitioned_shared_staffing_block_is_rejected() { let mut policy = household_window_policy(); - policy.windows[0].staffing = Some(WindowStaffing { + let mut windows = household_windows(); + windows[0].staffing = Some(WindowStaffing { pool: "officer-pool".to_owned(), reserved_members: None, because: "The morning block is staffed by the duty officers.".to_owned(), @@ -1681,7 +1717,7 @@ holdPolicy: requires_capabilities: Vec::new(), prerequisites: Vec::new(), }); - let findings = policy.check(); + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!( rendered.contains(&"windows[0].staffing.pool: shared-supply-unpartitioned".to_owned()) @@ -1689,12 +1725,12 @@ holdPolicy: // A partition does not attribute the supply across modes, so the mix // is refused with the partition declared. - policy.windows[0].staffing = Some(WindowStaffing { + windows[0].staffing = Some(WindowStaffing { pool: "officer-pool".to_owned(), reserved_members: Some(2), because: "Two of the four duty officers staff the block.".to_owned(), }); - let findings = policy.check(); + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!( rendered.contains(&"windows[0].staffing.pool: shared-supply-unpartitioned".to_owned()) @@ -1702,29 +1738,34 @@ holdPolicy: // A partitioned staffing block over a pool no exact-time offering // sells makes the share explicit and passes. - policy.windows[0].staffing = Some(WindowStaffing { + windows[0].staffing = Some(WindowStaffing { pool: "hall-officers".to_owned(), reserved_members: Some(2), because: "Two of the four duty officers staff the block.".to_owned(), }); - assert!(policy.check().is_empty(), "{:?}", policy.check()); + assert!( + policy.check_window_records(&windows).is_empty(), + "{:?}", + policy.check_window_records(&windows) + ); // A window with no staffing block draws on no pool at all, so it // carries no edge an exact-time pool could share: the mode mix a // staffing block would create cannot exist without one. - policy.windows[0].staffing = None; - assert!(policy.check().is_empty(), "{:?}", policy.check()); + windows[0].staffing = None; + assert!(policy.check_window_records(&windows).is_empty()); } #[test] fn two_overlapping_whole_pool_claims_are_double_counting() { - let mut policy = household_window_policy(); - policy.windows[0].staffing = Some(WindowStaffing { + let policy = household_window_policy(); + let mut windows = household_windows(); + windows[0].staffing = Some(WindowStaffing { pool: "officer-pool".to_owned(), reserved_members: None, because: "The morning block is staffed by the duty officers.".to_owned(), }); - policy.windows.push(PublishedWindow { + windows.push(PublishedWindow { id: "household-noon-window".to_owned(), revision: 1, offering: "household-morning".to_owned(), @@ -1747,7 +1788,7 @@ holdPolicy: }); // The second window's offering linkage is wrong on purpose in this // fixture; only the supply finding is asserted here. - let findings = policy.check(); + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!(rendered .iter() @@ -1757,10 +1798,10 @@ holdPolicy: let disjoint = PublishedWindow { start: Utc.with_ymd_and_hms(2026, 10, 10, 13, 0, 0).unwrap(), end: Utc.with_ymd_and_hms(2026, 10, 10, 15, 0, 0).unwrap(), - ..policy.windows[1].clone() + ..windows[1].clone() }; - policy.windows[1] = disjoint; - let findings = policy.check(); + windows[1] = disjoint; + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!(!rendered .iter() @@ -1772,7 +1813,7 @@ holdPolicy: /// covered passes. #[test] fn a_reduction_below_standing_commitments_is_rejected_with_its_deficit() { - let current = household_window_policy(); + let current = household_windows(); let now = utc(6, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), @@ -1791,9 +1832,9 @@ holdPolicy: }; let mut reduced = current.clone(); - reduced.windows[0].units = 1; + reduced[0].units = 1; assert_eq!( - assess_publication_impact(¤t, &reduced, &snapshot, now), + assess_window_record_impact(¤t, &reduced, &snapshot, now), vec![CapacityReduction { window: "household-morning-window".to_owned(), channel: None, @@ -1805,18 +1846,18 @@ holdPolicy: // Dropping the window entirely reduces both the total and the public // slice to zero; the total reduction is reported first. let mut removed = current.clone(); - removed.windows.clear(); - let reductions = assess_publication_impact(¤t, &removed, &snapshot, now); + removed.clear(); + let reductions = assess_window_record_impact(¤t, &removed, &snapshot, now); assert_eq!(reductions[0].proposed_units, 0); assert_eq!(reductions[0].channel, None); // A reduction that still covers commitments, and an increase, pass. let mut adequate = current.clone(); - adequate.windows[0].units = 2; - assert!(assess_publication_impact(¤t, &adequate, &snapshot, now).is_empty()); + adequate[0].units = 2; + assert!(assess_window_record_impact(¤t, &adequate, &snapshot, now).is_empty()); let mut increased = current.clone(); - increased.windows[0].units = 9; - assert!(assess_publication_impact(¤t, &increased, &snapshot, now).is_empty()); + increased[0].units = 9; + assert!(assess_window_record_impact(¤t, &increased, &snapshot, now).is_empty()); } /// AT-10, channel half: a subquota reduced below its channel's standing @@ -1824,7 +1865,7 @@ holdPolicy: /// dropped subquota proposes zero for its channel. #[test] fn a_subquota_reduction_strands_its_channel_even_at_an_unchanged_total() { - let current = household_window_policy(); + let current = household_windows(); let now = utc(6, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), @@ -1845,9 +1886,9 @@ holdPolicy: // The total stays at 3 while the public slice shrinks below the 2 // units that channel already holds. let mut narrowed = current.clone(); - narrowed.windows[0].subquotas[0].units = 1; + narrowed[0].subquotas[0].units = 1; assert_eq!( - assess_publication_impact(¤t, &narrowed, &snapshot, now), + assess_window_record_impact(¤t, &narrowed, &snapshot, now), vec![CapacityReduction { window: "household-morning-window".to_owned(), channel: Some(Channel::Public), @@ -1859,9 +1900,9 @@ holdPolicy: // Dropping the subquota entirely is a reduction of that channel to // zero; only the channel strand is reported, the total still covers. let mut dropped = current.clone(); - dropped.windows[0].subquotas.remove(0); + dropped[0].subquotas.remove(0); assert_eq!( - assess_publication_impact(¤t, &dropped, &snapshot, now), + assess_window_record_impact(¤t, &dropped, &snapshot, now), vec![CapacityReduction { window: "household-morning-window".to_owned(), channel: Some(Channel::Public), @@ -1873,11 +1914,13 @@ holdPolicy: // A channel nothing has committed against never strands, and an // unchanged slice passes. let mut assisted_dropped = current.clone(); - assisted_dropped.windows[0].subquotas.remove(1); - assert!(assess_publication_impact(¤t, &assisted_dropped, &snapshot, now).is_empty()); + assisted_dropped[0].subquotas.remove(1); + assert!( + assess_window_record_impact(¤t, &assisted_dropped, &snapshot, now).is_empty() + ); let mut widened = current.clone(); - widened.windows[0].subquotas[0].units = 2; - assert!(assess_publication_impact(¤t, &widened, &snapshot, now).is_empty()); + widened[0].subquotas[0].units = 2; + assert!(assess_window_record_impact(¤t, &widened, &snapshot, now).is_empty()); } /// BL-2: a subquota that exists only in the proposal was never assessed, @@ -1885,8 +1928,8 @@ holdPolicy: /// already holds and strand its commitments silently. #[test] fn an_added_subquota_never_strands_its_channel_at_publication() { - let mut current = household_window_policy(); - current.windows[0].subquotas.clear(); + let mut current = household_windows(); + current[0].subquotas.clear(); let now = utc(6, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), @@ -1907,14 +1950,14 @@ holdPolicy: // Two public units already stand; the proposal publishes a public // slice of one for the first time. let mut proposal = current.clone(); - proposal.windows[0].subquotas = vec![WindowSubquota { + proposal[0].subquotas = vec![WindowSubquota { id: "public-quota".to_owned(), channel: Channel::Public, units: 1, because: "One public unit in the revised block.".to_owned(), }]; assert_eq!( - assess_publication_impact(¤t, &proposal, &snapshot, now), + assess_window_record_impact(¤t, &proposal, &snapshot, now), vec![CapacityReduction { window: "household-morning-window".to_owned(), channel: Some(Channel::Public), @@ -1925,13 +1968,13 @@ holdPolicy: // A slice that still covers what the channel holds passes. let mut covered = current.clone(); - covered.windows[0].subquotas = vec![WindowSubquota { + covered[0].subquotas = vec![WindowSubquota { id: "public-quota".to_owned(), channel: Channel::Public, units: 2, because: "Two public units in the revised block.".to_owned(), }]; - assert!(assess_publication_impact(¤t, &covered, &snapshot, now).is_empty()); + assert!(assess_window_record_impact(¤t, &covered, &snapshot, now).is_empty()); } /// BL-3: a window moved under the same id was assessed by supply id @@ -1939,8 +1982,8 @@ holdPolicy: /// window and pass as safe. Each standing claim's interval is now /// compared against the proposed window's interval. #[test] - fn moving_a_published_window_reports_no_reduction() { - let current = household_window_policy(); + fn moving_a_published_window_reports_the_stranded_commitments() { + let current = household_windows(); let now = utc(6, 0); let claim = LedgerClaim { id: "claim-1".to_owned(), @@ -1962,10 +2005,10 @@ holdPolicy: // units fall outside the published interval and are stranded against // the zero capacity that proposal leaves where they stand. let mut moved = current.clone(); - moved.windows[0].start = Utc.with_ymd_and_hms(2026, 12, 24, 8, 0, 0).unwrap(); - moved.windows[0].end = Utc.with_ymd_and_hms(2026, 12, 24, 10, 0, 0).unwrap(); + moved[0].start = Utc.with_ymd_and_hms(2026, 12, 24, 8, 0, 0).unwrap(); + moved[0].end = Utc.with_ymd_and_hms(2026, 12, 24, 10, 0, 0).unwrap(); assert_eq!( - assess_publication_impact(¤t, &moved, &snapshot, now), + assess_window_record_impact(¤t, &moved, &snapshot, now), vec![CapacityReduction { window: "household-morning-window".to_owned(), channel: None, @@ -1975,7 +2018,12 @@ holdPolicy: ); // A shortened interval strands only the claims it no longer covers: - // the morning claim still meets the window, the late one does not. + // the morning claim remains fully inside, while the late one only + // partly overlaps and therefore cannot be treated as covered. + let covered = LedgerClaim { + end: utc(9, 0), + ..claim + }; let late = LedgerClaim { id: "claim-2".to_owned(), offering: "household-renewal".to_owned(), @@ -1989,12 +2037,12 @@ holdPolicy: expires_at: None, }; let snapshot = crate::model::LedgerSnapshot { - claims: vec![claim, late], + claims: vec![covered, late], }; let mut shortened = current.clone(); - shortened.windows[0].end = utc(9, 0); + shortened[0].end = utc(9, 45); assert_eq!( - assess_publication_impact(¤t, &shortened, &snapshot, now), + assess_window_record_impact(¤t, &shortened, &snapshot, now), vec![CapacityReduction { window: "household-morning-window".to_owned(), channel: None, @@ -2004,7 +2052,41 @@ holdPolicy: ); // A window that stays put reports nothing: its claims still meet it. - assert!(assess_publication_impact(¤t, ¤t.clone(), &snapshot, now).is_empty()); + assert!(assess_window_record_impact(¤t, ¤t.clone(), &snapshot, now).is_empty()); + } + + #[test] + fn standing_claims_are_guarded_when_the_current_window_record_is_missing() { + let proposed = household_windows(); + let now = utc(6, 0); + let snapshot = crate::model::LedgerSnapshot { + claims: vec![LedgerClaim { + id: "claim-1".to_owned(), + offering: "household-renewal".to_owned(), + supply_id: "household-morning-window".to_owned(), + kind: LedgerKind::Booking, + channel: Some("public".to_owned()), + start: utc(8, 0), + end: utc(10, 0), + units: 2, + duplicate_key: None, + expires_at: None, + }], + }; + + assert!(assess_window_record_impact(&[], &proposed, &snapshot, now).is_empty()); + + let mut inadequate = proposed.clone(); + inadequate[0].units = 1; + assert_eq!( + assess_window_record_impact(&[], &inadequate, &snapshot, now), + vec![CapacityReduction { + window: "household-morning-window".to_owned(), + channel: None, + committed_units: 2, + proposed_units: 1, + }] + ); } #[test] @@ -2141,11 +2223,12 @@ holdPolicy: #[test] fn a_declared_leftover_policy_is_refused_because_nothing_reads_it() { - let mut policy = household_window_policy(); - assert!(policy.check().is_empty()); + let policy = household_window_policy(); + let mut windows = household_windows(); + assert!(policy.check_window_records(&windows).is_empty()); - policy.windows[0].leftover = Some(LeftoverCapacityPolicy::BecomesWalkIn); - let findings = policy.check(); + windows[0].leftover = Some(LeftoverCapacityPolicy::BecomesWalkIn); + let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); assert!(rendered.contains(&"windows[0].leftover: leftover-unsupported".to_owned())); } @@ -2256,7 +2339,6 @@ services: [] offerings: [] holidaySets: [] openings: [] -windows: [] holdPolicy: ttlMinutes: 5 maxPerCaller: 3 diff --git a/crates/registry-scheduling-core/src/resolve.rs b/crates/registry-scheduling-core/src/resolve.rs index e4f6c5c141..08b6d3d17b 100644 --- a/crates/registry-scheduling-core/src/resolve.rs +++ b/crates/registry-scheduling-core/src/resolve.rs @@ -199,8 +199,7 @@ holidaySets: because: Public holidays observed by the registry office. dates: [] openings: -{openings}windows: [] -holdPolicy: +{openings}holdPolicy: ttlMinutes: 5 maxPerCaller: 3 because: Holds are short because counter capacity is scarce. diff --git a/crates/registry-scheduling/migrations/0005_window_records.sql b/crates/registry-scheduling/migrations/0005_window_records.sql new file mode 100644 index 0000000000..f4bd8d5a25 --- /dev/null +++ b/crates/registry-scheduling/migrations/0005_window_records.sql @@ -0,0 +1,10 @@ +-- Published arrival windows are deployment facts applied by the operator, +-- beside locations, pools, members, and dated exceptions. The strict Rust +-- record model validates this JSON before the atomic replacement writes it; +-- the identifier remains a typed column for deterministic replacement and +-- lookup. +CREATE TABLE IF NOT EXISTS scheduling_windows ( + window_id text PRIMARY KEY, + window_record jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/crates/registry-scheduling/src/config.rs b/crates/registry-scheduling/src/config.rs index 93efb9e611..38b1a732e0 100644 --- a/crates/registry-scheduling/src/config.rs +++ b/crates/registry-scheduling/src/config.rs @@ -1073,7 +1073,6 @@ offerings: maxRecipients: 1 requiresCapabilities: [] prerequisites: [] -windows: [] holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} "#; diff --git a/crates/registry-scheduling/src/runtime.rs b/crates/registry-scheduling/src/runtime.rs index 9a6c475e98..336004d7b6 100644 --- a/crates/registry-scheduling/src/runtime.rs +++ b/crates/registry-scheduling/src/runtime.rs @@ -271,15 +271,8 @@ pub async fn serve_from_path(path: impl AsRef) -> Result<(), RuntimeError> // the digest changed; the anchors the policy names must exist for any // offering to resolve supply. let pool_ids = offering_pool_ids(&policy); - let window_ids = offering_window_ids(&policy); let policy_revision = store - .apply_policy( - &scheduling_id, - &policy_digest, - &pool_ids, - &window_ids, - &policy, - ) + .apply_policy(&scheduling_id, &policy_digest, &pool_ids, &policy) .await .map_err(database_step("policy publication"))?; @@ -587,23 +580,6 @@ fn offering_pool_ids(policy: ®istry_scheduling_core::SchedulingPolicy) -> Vec ids } -/// The published windows the policy's arrival-window offerings serve in. -fn offering_window_ids(policy: ®istry_scheduling_core::SchedulingPolicy) -> Vec { - let mut ids: Vec = policy - .offerings - .iter() - .filter_map(|offering| { - offering - .arrival - .as_ref() - .map(|arrival| arrival.window.clone()) - }) - .collect(); - ids.sort(); - ids.dedup(); - ids -} - // --------------------------------------------------------------------------- // Reminder dispatch // --------------------------------------------------------------------------- @@ -1090,7 +1066,7 @@ mod tests { } #[test] - fn the_offering_anchors_are_collected_without_duplicates() { + fn the_offering_pool_anchors_are_collected_without_duplicates() { use registry_scheduling_core::{ ArrivalOffering, ExactTimeOffering, HoldPolicy, OfferingPolicy, PolicyIdentity, SchedulingMode, SchedulingPolicy, ServicePolicy, @@ -1156,7 +1132,6 @@ mod tests { ], holiday_sets: Vec::new(), openings: Vec::new(), - windows: Vec::new(), channels: Vec::new(), hold_policy: HoldPolicy { ttl_minutes: 10, @@ -1166,7 +1141,6 @@ mod tests { hooks: Vec::new(), }; assert_eq!(offering_pool_ids(&policy), vec!["north".to_owned()]); - assert_eq!(offering_window_ids(&policy), vec!["w-morning".to_owned()]); } #[test] diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index 90cce2f644..0e337aac99 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -229,12 +229,13 @@ impl SchedulingService { ) -> Result, ServiceError> { let limit = page_limit(limit); let position = self.resolve_position(cursor, "offerings", now).await?; + let (facts, _) = self.store.facts().await?; let mut offerings: Vec<_> = self .policy .offerings .iter() - .map(|offering| self.offering_document(offering)) - .collect(); + .map(|offering| self.offering_document(offering, &facts)) + .collect::>()?; offerings.sort_by(|a, b| a.id.cmp(&b.id)); page_of(self, offerings, position, limit, "offerings", now).await } @@ -396,6 +397,7 @@ impl SchedulingService { window, lead_time_minutes, horizon_days, + open, .. } => { let snapshot = self.store.window_snapshot(&window.id, now).await?; @@ -407,6 +409,7 @@ impl SchedulingService { now, lead_time_minutes, horizon_days, + &open, ) } }; @@ -505,6 +508,8 @@ impl SchedulingService { lead_time_minutes, horizon_days, channels, + open, + closures, } => { // The probe carries the window's current revision, the way a // booking request would: window admission checks capacity @@ -518,6 +523,8 @@ impl SchedulingService { &WindowContext { offering, window: &window, + open: &open, + closures: &closures, lead_time_minutes, horizon_days, snapshot: &snapshot, @@ -1016,7 +1023,11 @@ impl SchedulingService { Ok(Some(claim)) } - fn offering_document(&self, offering: &OfferingPolicy) -> OfferingDocument { + fn offering_document( + &self, + offering: &OfferingPolicy, + facts: &SchedulingFacts, + ) -> Result { let mode = match offering.mode { SchedulingMode::ExactTime => SchedulingModeDocument::ExactTime, SchedulingMode::ArrivalWindow => SchedulingModeDocument::ArrivalWindow, @@ -1032,25 +1043,33 @@ impl SchedulingService { ), None => (None, None, None, None, None), }; - let window = offering.arrival.as_ref().map(|arrival| { - let window = self - .policy - .window(&arrival.window) - .expect("a published offering names a declared window"); - WindowDocument { - id: window.id.clone(), - revision: window.revision, - start: window.start, - end: window.end, - units: window.units, - } - }); + let window = offering + .arrival + .as_ref() + .map(|arrival| { + let window = facts.window(&arrival.window).ok_or_else(|| { + tracing::error!( + offering = %offering.id, + reference = %arrival.window, + "the offering names a window the records do not carry" + ); + ServiceError::Problem(ProblemCode::ServiceUnavailable) + })?; + Ok::(WindowDocument { + id: window.id.clone(), + revision: window.revision, + start: window.start, + end: window.end, + units: window.units, + }) + }) + .transpose()?; let (lead_time_minutes, horizon_days) = match (&offering.exact_time, &offering.arrival) { (Some(exact), None) => (exact.lead_time_minutes, exact.horizon_days), (None, Some(arrival)) => (arrival.lead_time_minutes, arrival.horizon_days), _ => (0, 0), }; - OfferingDocument { + Ok(OfferingDocument { id: offering.id.clone(), service: offering.service.clone(), label: offering.label.clone(), @@ -1074,7 +1093,7 @@ impl SchedulingService { .collect(), requires_capabilities: offering.requires_capabilities.clone(), prerequisites: offering.prerequisites.clone(), - } + }) } /// The offering the deployed policy publishes under `offering_id`. @@ -1461,10 +1480,12 @@ enum ResolvedSupply { closures: Vec, }, Window { - window: PublishedWindow, + window: Box, lead_time_minutes: u32, horizon_days: u32, channels: Vec, + open: Vec, + closures: Vec, }, } @@ -1516,14 +1537,25 @@ impl ResolvedSupply { }) } (None, Some(arrival)) => { - let window = policy + let window = facts .window(&arrival.window) .ok_or_else(|| operator_gap(&arrival.window))?; Ok(Self::Window { - window: window.clone(), + window: Box::new(window.clone()), lead_time_minutes: arrival.lead_time_minutes, horizon_days: arrival.horizon_days, channels: policy.channels.clone(), + open: location_open_intervals( + policy, + &offering.location, + &location.timezone, + &exceptions, + )?, + closures: location_closure_intervals( + facts, + &offering.location, + &location.timezone, + )?, }) } _ => Err(operator_gap("one scheduling mode")), @@ -1548,11 +1580,15 @@ impl ResolvedSupply { lead_time_minutes, horizon_days, channels, + open, + closures, } => SupplyContext::Window { window, lead_time_minutes: *lead_time_minutes, horizon_days: *horizon_days, channels: channels.as_slice(), + open, + closures, }, } } @@ -1634,6 +1670,7 @@ fn exact_time_slots( /// The arrival-window availability walk: one entry per published window in /// range with units left, after lead time and inside the horizon. +#[allow(clippy::too_many_arguments)] fn window_entries( window: &PublishedWindow, snapshot: &LedgerSnapshot, @@ -1642,6 +1679,7 @@ fn window_entries( now: DateTime, lead_time_minutes: u32, horizon_days: u32, + open: &[CalendarInterval], ) -> Vec { let consumed: u32 = snapshot.claims.iter().map(|claim| claim.units).sum(); let remaining = window.units.saturating_sub(consumed); @@ -1649,7 +1687,10 @@ fn window_entries( let latest = now + TimeDelta::days(i64::from(horizon_days)); let in_range = window.start >= from && window.start < to; let in_horizon = window.start >= earliest && window.start <= latest; - if in_range && in_horizon && remaining > 0 { + let is_open = open + .iter() + .any(|interval| interval.start <= window.start && window.end <= interval.end); + if in_range && in_horizon && is_open && remaining > 0 { vec![AvailabilityEntry::Window { window: window.id.clone(), start: window.start, @@ -2356,7 +2397,6 @@ mod tests { "offerings": [], "holidaySets": [], "openings": [], - "windows": [], "holdPolicy": {"ttlMinutes": 5, "maxPerCaller": 1, "because": "test"} })) .expect("a policy used only for replay projection"); diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 32dfc718fb..198dbf4df1 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -41,7 +41,7 @@ use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime}; use registry_platform_calendar::CalendarInterval; use registry_platform_config::SecretResolver; use registry_scheduling_core::{ - assess_publication_impact, evaluate_exact_time_admission, evaluate_hold_state, + assess_window_record_impact, evaluate_exact_time_admission, evaluate_hold_state, evaluate_window_admission, AdmissionRefusal, ExactTimeContext, LedgerClaim, LedgerKind, LedgerSnapshot, PoolMember, SchedulingFacts, SchedulingPolicy, APPOINTMENT_CANCELLED_TRIGGER, APPOINTMENT_CONFIRMED_TRIGGER, APPOINTMENT_RESCHEDULED_TRIGGER, @@ -61,14 +61,17 @@ const FACTS_REVISION_MIGRATION: &str = const HOOK_DELIVERY_MIGRATION_VERSION: i64 = 3; const POLICY_DOCUMENT_MIGRATION: &str = include_str!("../migrations/0004_policy_document.sql"); const POLICY_DOCUMENT_MIGRATION_VERSION: i64 = 4; +const WINDOW_RECORDS_MIGRATION: &str = include_str!("../migrations/0005_window_records.sql"); +const WINDOW_RECORDS_MIGRATION_VERSION: i64 = 5; /// Every schema version in ledger order. const MIGRATIONS: [(i64, &str); 2] = [(1, SCHEDULING_MIGRATION), (2, FACTS_REVISION_MIGRATION)]; -const SCHEMA_VERSIONS: [i64; 4] = [ +const SCHEMA_VERSIONS: [i64; 5] = [ 1, 2, HOOK_DELIVERY_MIGRATION_VERSION, POLICY_DOCUMENT_MIGRATION_VERSION, + WINDOW_RECORDS_MIGRATION_VERSION, ]; /// Serializes operator-run migrations on one session lock. A second migrator @@ -109,11 +112,11 @@ pub enum StoreError { Corrupt, #[error("the Scheduling database belongs to another deployment")] DeploymentIdentity, - /// The environment records would retire resources that live appointments - /// or holds still occupy. The swap is refused whole, so the operator - /// either keeps the resource or closes what stands on it first. + /// The environment records would retire or reduce supply that live + /// appointments or holds still occupy. The swap is refused whole, so the + /// operator either keeps the supply or closes what stands on it first. #[error( - "the environment records retire or move {0}, which live appointments or holds still occupy" + "the environment records retire, move, or reduce {0}, which live appointments or holds still occupy" )] FactsInUse(String), #[error("the proposed policy would strand standing commitments: {0}")] @@ -291,6 +294,8 @@ pub enum SupplyContext<'p> { lead_time_minutes: u32, horizon_days: u32, channels: &'p [registry_scheduling_core::Channel], + open: &'p [CalendarInterval], + closures: &'p [CalendarInterval], }, } @@ -585,6 +590,25 @@ impl PostgresStore { .await?; } transaction.commit().await?; + + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&WINDOW_RECORDS_MIGRATION_VERSION], + ) + .await? + .get(0); + if !applied { + transaction.batch_execute(WINDOW_RECORDS_MIGRATION).await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&WINDOW_RECORDS_MIGRATION_VERSION], + ) + .await?; + } + transaction.commit().await?; Ok(()) } @@ -657,9 +681,9 @@ impl PostgresStore { } /// Publish a policy after proving it does not strand standing commitments - /// or change the lifecycle terms they still need. Re-applying the same - /// digest backfills its retained policy document and remains a no-op for - /// the revision. + /// or move their offering to different supply. Re-applying the same digest + /// backfills its retained policy document and remains a no-op for the + /// revision. /// /// Lock order, load-bearing: policy publication takes every existing /// supply anchor in order before `scheduling_meta`, the same order as the @@ -671,7 +695,6 @@ impl PostgresStore { scheduling_id: &str, policy_digest: &str, pool_ids: &[String], - window_ids: &[String], policy: &SchedulingPolicy, ) -> Result { if policy.policy_digest() != policy_digest { @@ -708,12 +731,19 @@ impl PostgresStore { ) .await? .and_then(|stored| stored.get(0)); - let Some(current_document) = current_document else { + let Some(mut current_document) = current_document else { return Err(StoreError::PolicyInUse( "the current policy document is unavailable; reapply the current policy before publishing a change" .to_owned(), )); }; + // Earlier branch builds retained mutable window records inside + // the policy document. Preserve that historical document and + // digest, but ignore its legacy member while comparing the + // lifecycle terms governed by the current policy shape. + if let Some(document) = current_document.as_object_mut() { + document.remove("windows"); + } let current: SchedulingPolicy = serde_json::from_value(current_document).map_err(|_| StoreError::Corrupt)?; let now = self.observed_now(); @@ -734,50 +764,29 @@ impl PostgresStore { ))); }; let retained = policy.offering(&offering_id).is_some_and(|proposed| { + let same_supply = match ( + ¤t_offering.exact_time, + &proposed.exact_time, + ¤t_offering.arrival, + &proposed.arrival, + ) { + (Some(current), Some(next), None, None) => current.pool == next.pool, + (None, None, Some(current), Some(next)) => { + current.window == next.window + } + _ => false, + }; proposed.service == current_offering.service && proposed.location == current_offering.location && proposed.mode == current_offering.mode - && proposed.cancellation_cutoff_minutes - == current_offering.cancellation_cutoff_minutes + && same_supply }); if !retained { return Err(StoreError::PolicyInUse(format!( - "offering {offering_id} has active commitments and must retain its service, location, mode, and cancellation cutoff" + "offering {offering_id} has active commitments and must retain its service, location, mode, and supply" ))); } } - let rows = transaction - .query( - "SELECT c.claim_id, c.offering, c.supply_id, c.kind, c.channel, \ - c.occupied_start, c.occupied_end, c.units, c.duplicate_key, \ - c.hold_expires_at FROM scheduling_claims AS c \ - JOIN scheduling_supply AS s ON s.supply_id=c.supply_id \ - WHERE s.kind='window' AND c.state='active' \ - AND (c.kind='booking' OR (c.kind='hold' AND c.hold_expires_at > $1)) \ - ORDER BY c.occupied_start", - &[&now], - ) - .await?; - let reductions = - assess_publication_impact(¤t, policy, &snapshot_from_rows(rows), now); - if !reductions.is_empty() { - let details = reductions - .iter() - .map(|reduction| { - format!( - "{}:{} has {} committed units but proposes {}", - reduction.window, - reduction - .channel - .map_or("total", |channel| channel.as_str()), - reduction.committed_units, - reduction.proposed_units - ) - }) - .collect::>() - .join(", "); - return Err(StoreError::PolicyInUse(details)); - } } revision = row.get::<_, i64>(1) + 1; transaction @@ -804,16 +813,12 @@ impl PostgresStore { ) .await?; } - for (id, kind) in pool_ids - .iter() - .map(|id| (id, "pool")) - .chain(window_ids.iter().map(|id| (id, "window"))) - { + for id in pool_ids { transaction .execute( "INSERT INTO scheduling_supply(supply_id, kind) VALUES($1,$2) \ ON CONFLICT(supply_id) DO NOTHING", - &[id, &kind], + &[id, &"pool"], ) .await?; } @@ -856,6 +861,12 @@ impl PostgresStore { let pools = transaction .query("SELECT pool_id FROM scheduling_pools ORDER BY pool_id", &[]) .await?; + let windows = transaction + .query( + "SELECT window_record FROM scheduling_windows ORDER BY window_id", + &[], + ) + .await?; let exceptions = transaction .query( "SELECT exception_id, location, kind, date::text, start_time, end_time, \ @@ -863,6 +874,13 @@ impl PostgresStore { &[], ) .await?; + let windows = windows + .iter() + .map(|row| { + serde_json::from_value::(row.get(0)) + .map_err(|_| StoreError::Corrupt) + }) + .collect::, _>>()?; let facts = SchedulingFacts { locations: locations .iter() @@ -886,6 +904,7 @@ impl PostgresStore { .collect(), }) .collect(), + windows, exceptions: exceptions .iter() .map(|row| registry_scheduling_core::CalendarExceptionRecord { @@ -908,7 +927,7 @@ impl PostgresStore { /// Replace the environment records wholesale. This is the operator /// tooling path (`schedulingctl records apply`): one attributable write - /// that swaps locations, pools, members, and exceptions atomically. + /// that swaps locations, pools, members, windows, and exceptions atomically. pub async fn replace_facts( &self, scheduling_id: &str, @@ -2373,10 +2392,14 @@ fn evaluate( lead_time_minutes, horizon_days, channels, + open, + closures, } => evaluate_window_admission( ®istry_scheduling_core::WindowContext { offering, window, + open, + closures, lead_time_minutes: *lead_time_minutes, horizon_days: *horizon_days, snapshot, @@ -2588,6 +2611,55 @@ pub(crate) async fn replace_facts_in_transaction( if !occupied.is_empty() { return Err(StoreError::FactsInUse(occupied.join(", "))); } + let current_windows = transaction + .query( + "SELECT window_record FROM scheduling_windows ORDER BY window_id", + &[], + ) + .await? + .iter() + .map(|row| { + serde_json::from_value::(row.get(0)) + .map_err(|_| StoreError::Corrupt) + }) + .collect::, _>>()?; + let now = Utc::now(); + let rows = transaction + .query( + "SELECT c.claim_id, c.offering, c.supply_id, c.kind, c.channel, \ + c.occupied_start, c.occupied_end, c.units, c.duplicate_key, c.hold_expires_at \ + FROM scheduling_claims AS c \ + JOIN scheduling_supply AS s ON s.supply_id=c.supply_id \ + WHERE s.kind='window' AND c.state='active' \ + AND (c.kind='booking' OR (c.kind='hold' AND c.hold_expires_at > $1)) \ + ORDER BY c.occupied_start", + &[&now], + ) + .await?; + let reductions = assess_window_record_impact( + ¤t_windows, + &facts.windows, + &snapshot_from_rows(rows), + now, + ); + if !reductions.is_empty() { + let details = reductions + .iter() + .map(|reduction| { + format!( + "{}:{} has {} committed units but proposes {}", + reduction.window, + reduction + .channel + .map_or("total", |channel| channel.as_str()), + reduction.committed_units, + reduction.proposed_units + ) + }) + .collect::>() + .join(", "); + return Err(StoreError::FactsInUse(details)); + } transaction .execute("DELETE FROM scheduling_pool_members", &[]) .await?; @@ -2600,6 +2672,12 @@ pub(crate) async fn replace_facts_in_transaction( transaction .execute("DELETE FROM scheduling_exceptions", &[]) .await?; + transaction + .execute("DELETE FROM scheduling_windows", &[]) + .await?; + transaction + .execute("DELETE FROM scheduling_supply WHERE kind='window'", &[]) + .await?; for location in &facts.locations { transaction .execute( @@ -2630,6 +2708,21 @@ pub(crate) async fn replace_facts_in_transaction( .await?; } } + for window in &facts.windows { + let record = serde_json::to_value(window).map_err(|_| StoreError::Corrupt)?; + transaction + .execute( + "INSERT INTO scheduling_windows(window_id, window_record) VALUES($1,$2)", + &[&window.id, &record], + ) + .await?; + transaction + .execute( + "INSERT INTO scheduling_supply(supply_id, kind) VALUES($1,'window')", + &[&window.id], + ) + .await?; + } for exception in &facts.exceptions { let kind = match exception.kind { registry_scheduling_core::ExceptionRecordKind::Closure => "closure", @@ -2679,8 +2772,8 @@ pub(crate) async fn replace_facts_in_transaction( /// a member out from under a live booking would leave that booking pointing /// at a resource the deployment no longer has: invisible to every pool /// snapshot, counted against nothing, and still promised to its caller. A -/// window claim names the window instead, which records never carry, so the -/// window anchors are excluded rather than reported as missing members. +/// Window claims are assessed separately against the incoming window records, +/// so this helper reports only moved or retired pool members. async fn occupied_resources_changed_by( transaction: &deadpool_postgres::Transaction<'_>, facts: &SchedulingFacts, diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index e39c602b44..e6e1bb5ff4 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -16,7 +16,7 @@ use axum::body::{to_bytes, Body}; use axum::http::{Request, StatusCode}; use axum::Router; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; +use chrono::{DateTime, SecondsFormat, TimeDelta, Timelike, Utc}; use jsonwebtoken::{Algorithm, EncodingKey, Header}; use registry_platform_audit::{ AuditEnvelope, AuditHashSecret, AuditKeyHasher, AuditProfile, JsonlFileSink, @@ -40,8 +40,9 @@ use registry_scheduling::store::{ }; use registry_scheduling_core::{ location_closure_intervals, location_open_intervals, parse_policy_yaml, AdmissionRefusal, - AdmissionRequest, LocationRecord, OfferingPolicy, PartyCounts, PoolMember, ResourcePool, - SchedulingFacts, SchedulingPolicy, + AdmissionRequest, CalendarExceptionRecord, Channel, ExceptionRecordKind, LocationRecord, + OfferingPolicy, PartyCounts, PoolMember, PublishedWindow, RequiredUnitsPolicy, ResourcePool, + SchedulingFacts, SchedulingPolicy, WindowSubquota, }; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -153,7 +154,6 @@ offerings: maxRecipients: 1 requiresCapabilities: [] prerequisites: [] -windows: [] holdPolicy: {ttlMinutes: 10, maxPerCaller: 2, because: test} "#; @@ -263,31 +263,19 @@ async fn fixture() -> Fixture { /// A fresh deployment whose publication anchored exactly `pool_ids`, so a /// test can pin what happens when a policy names a pool no anchor covers. async fn fixture_anchoring(pool_ids: &[String]) -> Fixture { - fixture_publishing(POLICY, pool_ids, &[]).await + fixture_publishing(POLICY, pool_ids).await } -/// A fresh deployment publishing `policy_yaml`, anchoring exactly `pool_ids` -/// and `window_ids`. A published window is an anchor of its own: its claims -/// occupy the window rather than a pool member, so the supply the capacity -/// transaction locks is the window itself. -async fn fixture_publishing( - policy_yaml: &str, - pool_ids: &[String], - window_ids: &[String], -) -> Fixture { - fixture_publishing_with_hook_url( - policy_yaml, - pool_ids, - window_ids, - "http://127.0.0.1:9/scheduling-hooks", - ) - .await +/// A fresh deployment publishing `policy_yaml` and anchoring exactly +/// `pool_ids`. Window anchors are owned by the records replacement path. +async fn fixture_publishing(policy_yaml: &str, pool_ids: &[String]) -> Fixture { + fixture_publishing_with_hook_url(policy_yaml, pool_ids, "http://127.0.0.1:9/scheduling-hooks") + .await } async fn fixture_publishing_with_hook_url( policy_yaml: &str, pool_ids: &[String], - window_ids: &[String], hook_url: &str, ) -> Fixture { let base = std::env::var("SCHEDULING_TEST_DATABASE_URL") @@ -360,7 +348,7 @@ async fn fixture_publishing_with_hook_url( let policy = parse_policy_yaml(policy_yaml).expect("the scheduling test policy"); let digest = policy.policy_digest(); let revision = store - .apply_policy(SCHEDULING_ID, &digest, pool_ids, window_ids, &policy) + .apply_policy(SCHEDULING_ID, &digest, pool_ids, &policy) .await .expect("publish the scheduling policy"); let keying = AuditHashSecret::new(vec![0x42; 32]).expect("the test audit hash secret"); @@ -735,7 +723,6 @@ async fn hook_fixture_at(hook_url: &str) -> Fixture { fixture_publishing_with_hook_url( &policy, &["north-counter".to_owned(), "two-counter".to_owned()], - &[], hook_url, ) .await @@ -1331,7 +1318,6 @@ async fn exact_time_commitments_include_buffers_outside_the_opening_range() { let fx = fixture_publishing( &buffered, &["north-counter".to_owned(), "two-counter".to_owned()], - &[], ) .await; let day = (Utc::now() + TimeDelta::days(2)).date_naive(); @@ -1375,7 +1361,6 @@ async fn duplicate_active_keys_are_scoped_to_the_offering() { let fx = fixture_publishing( &keyed, &["north-counter".to_owned(), "two-counter".to_owned()], - &[], ) .await; let first = first_slot(&fx, OFFERING, 300, 440).await; @@ -2121,7 +2106,7 @@ async fn explain_assumes_the_offerings_declared_inputs_are_present() { .collect(); pool_ids.sort(); pool_ids.dedup(); - let fx = fixture_publishing(&authored, &pool_ids, &[]).await; + let fx = fixture_publishing(&authored, &pool_ids).await; let slot = first_slot(&fx, OFFERING, 300, 440).await; let (status, explanation) = fx @@ -2173,6 +2158,35 @@ async fn a_commitment_against_an_unanchored_pool_refuses_loudly() { assert_eq!(problem["code"], "service.unavailable"); } +#[tokio::test] +async fn an_arrival_offering_without_its_window_record_is_an_operator_gap() { + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_publishing( + &policy_with_window(), + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + + let (status, unavailable) = fx + .get(&availability_uri(WINDOW_OFFERING, 60, 300), &fx.reader) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{unavailable}"); + assert_eq!(unavailable["code"], "service.unavailable"); + + let (status, unavailable) = fx + .post( + "/v1/appointments", + &fx.agent, + "missing-window-record", + arrival(&fx, start, None), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{unavailable}"); + assert_eq!(unavailable["code"], "service.unavailable"); +} + /// Adoption binds one deployment identity: claiming from empty is the /// fixture's own provisioning line, the same id again is a no-op, and a /// different id is refused, so two deployments never share one database @@ -2417,7 +2431,6 @@ async fn a_policy_applied_under_a_running_process_refuses_the_older_revision() { SCHEDULING_ID, &replacement_digest, &["north-counter".to_owned(), "two-counter".to_owned()], - &[], &replacement, ) .await @@ -2565,6 +2578,7 @@ fn records_without(retired: &[&str]) -> SchedulingFacts { ..pool }) .collect(), + windows: Vec::new(), exceptions: Vec::new(), } } @@ -3844,17 +3858,11 @@ const WINDOW_ID: &str = "morning-arrivals"; /// accepted by coincidence. const WINDOW_REVISION: u64 = 3; -/// The test policy with one arrival window in it, opening at `start` and -/// running two hours, with two units in total and one of them reserved to the -/// assisted channel. -/// -/// A published window carries absolute instants, so its interval is built -/// against the clock the test runs on rather than frozen into the constant -/// policy beside it. -fn policy_with_window(start: DateTime) -> String { - let end = start + TimeDelta::hours(2); +/// The test policy with one arrival offering that references the window ID +/// owned by the runtime records. +fn policy_with_window() -> String { POLICY.replace( - "windows: []", + "holdPolicy:", &format!( " - id: {WINDOW_OFFERING}\n\ \x20 service: registry-update\n\ @@ -3869,23 +3877,57 @@ fn policy_with_window(start: DateTime) -> String { \x20 horizonDays: 60\n\ \x20 requiresCapabilities: []\n\ \x20 prerequisites: []\n\ - windows:\n\ - \x20 - id: {WINDOW_ID}\n\ - \x20 revision: {WINDOW_REVISION}\n\ - \x20 offering: {WINDOW_OFFERING}\n\ - \x20 location: north-counter\n\ - \x20 start: {}\n\ - \x20 end: {}\n\ - \x20 units: 2\n\ - \x20 unitsPolicy: {{kind: fixed, units: 1, because: test}}\n\ - \x20 subquotas: [{{id: assisted-quota, channel: assisted, units: 1, because: test}}]\n\ - \x20 because: test", - stamp(start), - stamp(end), + holdPolicy:", ), ) } +/// The operator records that publish one concrete arrival window. +fn records_with_window(start: DateTime) -> SchedulingFacts { + let mut facts = records_without(&[]); + facts.windows.push(PublishedWindow { + id: WINDOW_ID.to_owned(), + revision: WINDOW_REVISION, + offering: WINDOW_OFFERING.to_owned(), + location: "north-counter".to_owned(), + start, + end: start + TimeDelta::hours(2), + units: 2, + units_policy: RequiredUnitsPolicy::Fixed { + units: 1, + because: "test".to_owned(), + }, + subquotas: vec![WindowSubquota { + id: "assisted-quota".to_owned(), + channel: Channel::Assisted, + units: 1, + because: "test".to_owned(), + }], + leftover: None, + staffing: None, + because: "test".to_owned(), + }); + facts +} + +async fn fixture_with_window(start: DateTime) -> Fixture { + let fx = fixture_publishing( + &policy_with_window(), + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + fx.store + .replace_facts( + SCHEDULING_ID, + &records_with_window(start), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("publish the window records"); + fx +} + /// One direct-create body admitting an arrival on the window, on `channel` /// when it names one. fn arrival(fx: &Fixture, start: DateTime, channel: Option<&str>) -> Value { @@ -3920,19 +3962,13 @@ async fn window_entry(fx: &Fixture, from_minutes: i64, to_minutes: i64) -> Optio } #[tokio::test] -async fn policy_publication_refuses_to_move_a_window_with_standing_commitments() { +async fn records_replacement_refuses_to_move_a_window_with_standing_commitments() { let day = (Utc::now() + TimeDelta::days(2)).date_naive(); let start = DateTime::::from_naive_utc_and_offset( day.and_hms_opt(9, 0, 0).expect("a morning instant"), Utc, ); - let authored = policy_with_window(start); - let fx = fixture_publishing( - &authored, - &["north-counter".to_owned(), "two-counter".to_owned()], - &[WINDOW_ID.to_owned()], - ) - .await; + let fx = fixture_with_window(start).await; let (status, appointment) = fx .post( "/v1/appointments", @@ -3943,26 +3979,57 @@ async fn policy_publication_refuses_to_move_a_window_with_standing_commitments() .await; assert_eq!(status, StatusCode::CREATED, "{appointment}"); - let current = parse_policy_yaml(&authored).expect("the current policy"); - let mut moved = current.clone(); - moved.scheduling.version += 1; + let mut moved = records_with_window(start); moved.windows[0].start += TimeDelta::days(1); moved.windows[0].end += TimeDelta::days(1); - let digest = moved.policy_digest(); let refusal = fx .store - .apply_policy( - SCHEDULING_ID, - &digest, - &["north-counter".to_owned(), "two-counter".to_owned()], - &[WINDOW_ID.to_owned()], - &moved, - ) + .replace_facts(SCHEDULING_ID, &moved, Uuid::new_v4(), operator_audit()) .await .expect_err("a moved window cannot strand a standing appointment"); assert!(refusal.to_string().contains(WINDOW_ID), "{refusal}"); } +#[tokio::test] +async fn a_location_closure_hides_and_refuses_an_arrival_window() { + let day = (Utc::now() + TimeDelta::days(2)).date_naive(); + let start = DateTime::::from_naive_utc_and_offset( + day.and_hms_opt(9, 0, 0).expect("a morning instant"), + Utc, + ); + let fx = fixture_with_window(start).await; + let mut facts = records_with_window(start); + facts.exceptions.push(CalendarExceptionRecord { + id: "counter-closure".to_owned(), + location: "north-counter".to_owned(), + kind: ExceptionRecordKind::Closure, + date: day.to_string(), + start_time: "08:00".to_owned(), + end_time: "12:00".to_owned(), + reopens: None, + authority: None, + }); + fx.store + .replace_facts(SCHEDULING_ID, &facts, Uuid::new_v4(), operator_audit()) + .await + .expect("apply the location closure"); + + assert!( + window_entry(&fx, 60, 4_000).await.is_none(), + "a closed window is not published as availability" + ); + let (status, refused) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-closed", + arrival(&fx, start, None), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{refused}"); + assert_eq!(refused["code"], "location.closed"); +} + #[tokio::test] async fn policy_publication_refuses_to_remove_an_offering_with_a_live_appointment() { let fx = fixture().await; @@ -3984,7 +4051,7 @@ async fn policy_publication_refuses_to_remove_an_offering_with_a_live_appointmen let refusal = fx .store - .apply_policy(SCHEDULING_ID, &digest, &pool_ids, &[], &replacement) + .apply_policy(SCHEDULING_ID, &digest, &pool_ids, &replacement) .await .expect_err("a live appointment keeps its offering operable"); assert!(refusal.to_string().contains(OFFERING), "{refusal}"); @@ -4001,6 +4068,34 @@ async fn policy_publication_refuses_to_remove_an_offering_with_a_live_appointmen assert_eq!(cancelled["state"], "cancelled"); } +#[tokio::test] +async fn policy_publication_refuses_to_move_an_active_offering_between_pools() { + let fx = fixture().await; + booked(&fx, 300, 440, "retained-offering-pool-create").await; + let mut replacement = parse_policy_yaml(POLICY).expect("the current policy"); + replacement.scheduling.version += 1; + replacement + .offerings + .iter_mut() + .find(|offering| offering.id == OFFERING) + .and_then(|offering| offering.exact_time.as_mut()) + .expect("the exact-time offering") + .pool = "two-counter".to_owned(); + let digest = replacement.policy_digest(); + + let refusal = fx + .store + .apply_policy( + SCHEDULING_ID, + &digest, + &["north-counter".to_owned(), "two-counter".to_owned()], + &replacement, + ) + .await + .expect_err("an active offering cannot move to different supply"); + assert!(refusal.to_string().contains("supply"), "{refusal}"); +} + /// An arrival window is a second admission mode with a ledger of its own: its /// claims occupy the window rather than a member of a pool, its capacity is /// counted in units rather than in slots, and a channel subquota is a ceiling @@ -4015,13 +4110,10 @@ async fn policy_publication_refuses_to_remove_an_offering_with_a_live_appointmen async fn an_arrival_window_allocates_its_units_and_holds_its_channel_ceiling() { // The published interval is stamped to the second, which is the precision // every comparison below reads it back at. - let start = Utc::now() + TimeDelta::hours(3); - let fx = fixture_publishing( - &policy_with_window(start), - &["north-counter".to_owned(), "two-counter".to_owned()], - &[WINDOW_ID.to_owned()], - ) - .await; + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_with_window(start).await; let offered = window_entry(&fx, 60, 300) .await @@ -4030,6 +4122,17 @@ async fn an_arrival_window_allocates_its_units_and_holds_its_channel_ceiling() { assert_eq!(offered["start"], stamp(start)); assert_eq!(offered["remaining"], 2, "the whole window is unallocated"); + let (status, mismatch) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-wrong-start", + arrival(&fx, start + TimeDelta::minutes(15), None), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{mismatch}"); + assert_eq!(mismatch["code"], "schedule.unpublished"); + // The assisted channel holds one of the two units. let (status, booked) = fx .post( @@ -4446,7 +4549,6 @@ async fn a_stale_process_refuses_even_a_request_under_the_current_revision() { SCHEDULING_ID, &replacement_digest, &["north-counter".to_owned(), "two-counter".to_owned()], - &[], &replacement, ) .await @@ -4479,7 +4581,6 @@ async fn a_stale_process_can_still_release_an_appointment() { SCHEDULING_ID, &replacement_digest, &["north-counter".to_owned(), "two-counter".to_owned()], - &[], &replacement, ) .await @@ -4796,13 +4897,10 @@ async fn a_suppressed_reminder_is_skipped_and_keeps_its_accounting() { /// full one as the public capacity refusal, never as a revision mismatch. #[tokio::test] async fn explain_answers_a_window_offering_rather_than_a_revision_mismatch() { - let start = Utc::now() + TimeDelta::hours(3); - let fx = fixture_publishing( - &policy_with_window(start), - &["north-counter".to_owned(), "two-counter".to_owned()], - &[WINDOW_ID.to_owned()], - ) - .await; + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_with_window(start).await; let uri = format!( "/v1/availability/explain?offering={WINDOW_OFFERING}&start={}", stamp(start) diff --git a/crates/registry-schedulingctl/src/project.rs b/crates/registry-schedulingctl/src/project.rs index cf6a0f162d..572952a691 100644 --- a/crates/registry-schedulingctl/src/project.rs +++ b/crates/registry-schedulingctl/src/project.rs @@ -14,8 +14,8 @@ use anyhow::{anyhow, bail, Context, Result}; use registry_scheduling::config::{verify_policy_package, PolicyPackageManifest}; use registry_scheduling_core::{ parse_fixture_yaml, parse_policy_yaml, CaseStatus, FixtureExpectation, ReplayError, - SchedulingDiagnostic, SchedulingFixture, SchedulingPolicy, AUTHORED_POLICY_FILE, - SCHEDULING_PACKAGE_MANIFEST_FILE, + SchedulingDiagnostic, SchedulingFacts, SchedulingFixture, SchedulingPolicy, + AUTHORED_POLICY_FILE, SCHEDULING_PACKAGE_MANIFEST_FILE, }; use serde_json::{json, Value}; @@ -70,7 +70,12 @@ pub(super) fn init(project: &Path, template: &str) -> Result { pub(super) fn check(project: &Path) -> Result { let policy = load_policy(project)?; - let findings = policy.check(); + let facts = load_records(project)?; + let mut findings = policy.check(); + if authoring_status(&findings) != "invalid" { + crate::records::validate(&facts, &policy)?; + } + findings.extend(policy.check_window_records(&facts.windows)); let status = authoring_status(&findings); Ok(json!({ "ok": true, @@ -78,7 +83,7 @@ pub(super) fn check(project: &Path) -> Result { "status": status, "project": project, "findings": findings_json(&findings), - "effective": effective(&policy), + "effective": effective(&policy, &facts), "networkAccess": false, "databaseAccess": false, })) @@ -104,7 +109,12 @@ fn authoring_status(findings: &[SchedulingDiagnostic]) -> &'static str { pub(super) fn test(project: &Path) -> Result { let policy = load_policy(project)?; - let findings = policy.check(); + let facts = load_records(project)?; + let mut findings = policy.check(); + if authoring_status(&findings) != "invalid" { + crate::records::validate(&facts, &policy)?; + } + findings.extend(policy.check_window_records(&facts.windows)); let authoring_status = authoring_status(&findings); if authoring_status == "invalid" { // A malformed value has no business running fixtures against it: the @@ -163,7 +173,12 @@ pub(super) fn test(project: &Path) -> Result { pub(super) fn explain(project: &Path) -> Result { let policy = load_policy(project)?; - let findings = policy.check(); + let facts = load_records(project)?; + let mut findings = policy.check(); + if authoring_status(&findings) != "invalid" { + crate::records::validate(&facts, &policy)?; + } + findings.extend(policy.check_window_records(&facts.windows)); if !findings.is_empty() { bail!( "explain requires a policy that passes its check; run schedulingctl check first. Findings: {}", @@ -193,7 +208,7 @@ pub(super) fn explain(project: &Path) -> Result { Ok(report) }) .collect::>>()?; - let windows = policy + let windows = facts .windows .iter() .map(|window| { @@ -369,6 +384,11 @@ fn load_policy(project: &Path) -> Result { parse_policy_yaml(&bytes).with_context(|| format!("parsing {AUTHORED_POLICY_FILE}")) } +fn load_records(project: &Path) -> Result { + let bytes = read_authoring_input(&project.join("records.yaml"))?; + crate::records::parse_records(&bytes).context("parsing records.yaml") +} + fn load_fixture(path: &Path) -> Result { let bytes = read_authoring_input(path)?; parse_fixture_yaml(&bytes).with_context(|| format!("parsing fixture {}", path.display())) @@ -392,7 +412,7 @@ fn findings_json(findings: &[SchedulingDiagnostic]) -> Vec { /// The effective policy a check saw: identity, digest, collection sizes and /// identifiers, and the hold policy. -fn effective(policy: &SchedulingPolicy) -> Value { +fn effective(policy: &SchedulingPolicy, facts: &SchedulingFacts) -> Value { fn summary<'a>(ids: impl Iterator) -> Value { let all: Vec<&str> = ids.map(String::as_str).collect(); json!({"count": all.len(), "ids": all}) @@ -404,7 +424,7 @@ fn effective(policy: &SchedulingPolicy) -> Value { "services": summary(policy.services.iter().map(|service| &service.id)), "offerings": summary(policy.offerings.iter().map(|offering| &offering.id)), "openings": summary(policy.openings.iter().map(|opening| &opening.id)), - "windows": summary(policy.windows.iter().map(|window| &window.id)), + "windows": summary(facts.windows.iter().map(|window| &window.id)), "holdPolicy": { "ttlMinutes": policy.hold_policy.ttl_minutes, "maxPerCaller": policy.hold_policy.max_per_caller, @@ -765,8 +785,8 @@ mod tests { init(&project, "standalone-exact-time").unwrap(); let policy_path = project.join(AUTHORED_POLICY_FILE); let broken = std::fs::read_to_string(&policy_path).unwrap().replacen( - "windows: []\n", - "windows: []\nsurprise: true\n", + "holdPolicy:\n", + "surprise: true\nholdPolicy:\n", 1, ); std::fs::write(&policy_path, broken).unwrap(); diff --git a/crates/registry-schedulingctl/src/records.rs b/crates/registry-schedulingctl/src/records.rs index 5c2bc8abe5..90b7c161e0 100644 --- a/crates/registry-schedulingctl/src/records.rs +++ b/crates/registry-schedulingctl/src/records.rs @@ -3,7 +3,8 @@ //! The live environment-records command. //! //! `records apply` is the one attributable operator write that swaps a -//! deployment's locations, pools, members, and exceptions wholesale. The +//! deployment's locations, pools, members, published windows, and exceptions +//! wholesale. The //! records document is parsed and validated offline first, nothing reaches //! the database until the whole document holds together, and the swap itself //! lands through the store's single replace transaction, which writes its own @@ -17,7 +18,7 @@ use anyhow::{anyhow, bail, Context, Result}; use registry_platform_config::{SecretProvider, SecretResolver}; use registry_scheduling::config::RuntimeConfig; use registry_scheduling::store::PostgresStore; -use registry_scheduling_core::SchedulingFacts; +use registry_scheduling_core::{location_open_intervals, SchedulingFacts}; use serde_json::{json, Value}; use uuid::Uuid; @@ -33,7 +34,7 @@ pub fn apply(config_path: &Path, records_path: &Path) -> Result { .with_context(|| format!("loading {}", config.policy_path().display()))?; let text = crate::project::read_authoring_input(&records_path)?; let facts = parse_records(&text)?; - validate(&facts)?; + validate(&facts, &policy)?; let counts = counts(&facts); let resolver = secret_resolver(&config)?; let store = PostgresStore::connect_migration(&config.database, &resolver) @@ -69,7 +70,7 @@ pub fn apply(config_path: &Path, records_path: &Path) -> Result { /// Read the environment records, refusing a document the model does not /// carry and naming the path of the first offending field. -fn parse_records(text: &str) -> Result { +pub(crate) fn parse_records(text: &str) -> Result { let deserializer = serde_norway::Deserializer::from_str(text); serde_path_to_error::deserialize(deserializer).map_err(|error| { let path = error.path().to_string(); @@ -88,7 +89,10 @@ fn parse_records(text: &str) -> Result { /// and typed columns, so an inconsistent document would fail mid-swap with a /// database error naming none of the author's vocabulary. Every rule here /// refuses before the transaction opens, in the records document's own terms. -fn validate(facts: &SchedulingFacts) -> Result<()> { +pub(crate) fn validate( + facts: &SchedulingFacts, + policy: ®istry_scheduling_core::SchedulingPolicy, +) -> Result<()> { let mut locations = BTreeSet::new(); for location in &facts.locations { if location.id.is_empty() { @@ -129,10 +133,45 @@ fn validate(facts: &SchedulingFacts) -> Result<()> { } } } + if let Some(finding) = policy + .check_window_records(&facts.windows) + .into_iter() + .next() + { + bail!("invalid window record at {finding}"); + } + for window in &facts.windows { + if !locations.contains(&window.location) { + bail!( + "window {} names location {} the records do not declare", + window.id, + window.location + ); + } + if pools.contains(&window.id) { + bail!( + "window {} collides with a resource-pool supply identifier", + window.id + ); + } + if let Some(staffing) = &window.staffing { + if !pools.contains(&staffing.pool) { + bail!( + "window {} names staffing pool {} the records do not declare", + window.id, + staffing.pool + ); + } + } + } + let mut exception_ids = BTreeSet::new(); for exception in &facts.exceptions { if exception.id.is_empty() { bail!("an exception record carries an empty id"); } + if !exception_ids.insert(&exception.id) { + bail!("exception {} is declared more than once", exception.id); + } if !locations.contains(&exception.location) { bail!( "exception {} names location {} the records do not declare", @@ -157,6 +196,16 @@ fn validate(facts: &SchedulingFacts) -> Result<()> { } } } + for location in &facts.locations { + let exceptions: Vec<_> = facts + .exceptions + .iter() + .filter(|exception| exception.location == location.id) + .map(|exception| exception.borrowed()) + .collect(); + location_open_intervals(policy, &location.id, &location.timezone, &exceptions) + .with_context(|| format!("validating calendar records for location {}", location.id))?; + } Ok(()) } @@ -189,6 +238,7 @@ fn counts(facts: &SchedulingFacts) -> Value { "locations": facts.locations.len(), "pools": facts.pools.len(), "members": facts.pools.iter().map(|pool| pool.members.len()).sum::(), + "windows": facts.windows.len(), "exceptions": facts.exceptions.len(), }) } @@ -216,6 +266,16 @@ pub(crate) fn secret_resolver(config: &RuntimeConfig) -> Result mod tests { use super::*; + fn policy() -> registry_scheduling_core::SchedulingPolicy { + let files = crate::templates::template_files("standalone-exact-time").unwrap(); + let text = files + .iter() + .find(|(path, _)| *path == "scheduling.yaml") + .unwrap() + .1; + registry_scheduling_core::parse_policy_yaml(text).unwrap() + } + fn facts_from(text: &str) -> SchedulingFacts { serde_norway::from_str(text).unwrap() } @@ -229,10 +289,10 @@ mod tests { exceptions:\n - id: training\n location: north-counter\n kind: closure\n\ \x20 date: '2026-10-07'\n startTime: '09:00'\n endTime: '12:30'\n", ); - assert!(validate(&facts).is_ok()); + assert!(validate(&facts, &policy()).is_ok()); assert_eq!( counts(&facts), - json!({"locations": 1, "pools": 1, "members": 1, "exceptions": 1}) + json!({"locations": 1, "pools": 1, "members": 1, "windows": 0, "exceptions": 1}) ); } @@ -275,7 +335,7 @@ mod tests { ]; for (document, expected) in cases { let facts = facts_from(document); - let refusal = validate(&facts).expect_err("the document is refused"); + let refusal = validate(&facts, &policy()).expect_err("the document is refused"); assert!( refusal.to_string().contains(expected), "{refusal} does not name {expected}" @@ -283,6 +343,67 @@ mod tests { } } + #[test] + fn exception_layer_semantics_are_refused_before_apply() { + let cases = [ + ( + "duplicate id", + " - id: repeated\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '09:00'\n endTime: '10:00'\n\ + \x20 - id: repeated\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '10:00'\n endTime: '11:00'\n", + "exception repeated is declared more than once", + ), + ( + "opening without a named closure", + " - id: reopening\n location: bangkok-counter\n kind: opening\n date: '2026-10-07'\n startTime: '09:30'\n endTime: '10:00'\n", + "exceptional reopening reopening does not name an existing closure", + ), + ( + "opening without authority", + " - id: closure\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '09:00'\n endTime: '11:00'\n\ + \x20 - id: reopening\n location: bangkok-counter\n kind: opening\n date: '2026-10-07'\n startTime: '09:30'\n endTime: '10:00'\n reopens: closure\n", + "exceptional reopening reopening has no explicit authority", + ), + ( + "opening names an unknown closure", + " - id: reopening\n location: bangkok-counter\n kind: opening\n date: '2026-10-07'\n startTime: '09:30'\n endTime: '10:00'\n reopens: absent\n authority: office-manager\n", + "exceptional reopening reopening does not name an existing closure", + ), + ( + "closure carries reopening fields", + " - id: closure\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '09:00'\n endTime: '10:00'\n reopens: another\n authority: office-manager\n", + "blocking closure closure carries reopening fields", + ), + ( + "invalid interval", + " - id: backwards\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '11:00'\n endTime: '10:00'\n", + "exception times must be valid HH:MM local values with start before end", + ), + ( + "reopening outside the pattern", + " - id: early-closure\n location: bangkok-counter\n kind: closure\n date: '2026-10-07'\n startTime: '08:00'\n endTime: '10:00'\n\ + \x20 - id: early-reopening\n location: bangkok-counter\n kind: opening\n date: '2026-10-07'\n startTime: '08:00'\n endTime: '08:30'\n reopens: early-closure\n authority: office-manager\n", + "reopening early-reopening is not inside the opening pattern's time", + ), + ]; + + for (name, exceptions, expected) in cases { + let facts = facts_from(&format!( + "locations:\n - id: bangkok-counter\n timezone: Asia/Bangkok\nexceptions:\n{exceptions}" + )); + let refusal = validate(&facts, &policy()).expect_err(name); + let message = format!("{refusal:#}"); + assert!( + message.contains("validating calendar records for location bangkok-counter") + || expected.contains("declared more than once"), + "{name}: {message} does not name the affected location" + ); + assert!( + message.contains(expected), + "{name}: {message} does not name {expected}" + ); + } + } + #[test] fn a_document_the_model_does_not_carry_is_refused_with_its_path() { let refusal = parse_records( diff --git a/crates/registry-schedulingctl/src/templates.rs b/crates/registry-schedulingctl/src/templates.rs index 0683e423b5..dae1c1b92a 100644 --- a/crates/registry-schedulingctl/src/templates.rs +++ b/crates/registry-schedulingctl/src/templates.rs @@ -84,7 +84,6 @@ openings: effectiveFrom: "2026-11-01" effectiveUntil: "2026-11-01" because: Fold-day hours whose endpoints sit outside the repeated local hour. -windows: [] holdPolicy: ttlMinutes: 5 maxPerCaller: 3 @@ -284,6 +283,7 @@ pools: - resourceId: hall-station-1 capabilities: [] available: true +windows: [] exceptions: - id: hall-prep location: new-york-hall @@ -356,50 +356,6 @@ openings: effectiveFrom: "2026-10-01" effectiveUntil: "2026-12-31" because: The hall reopens on Saturday afternoons for banded household visits. -windows: - - id: household-morning-window - revision: 2 - offering: household-morning - location: civic-hall - start: 2026-10-10T01:00:00Z - end: 2026-10-10T03:00:00Z - units: 3 - unitsPolicy: - kind: perRecipient - perRecipient: 1 - because: Each recipient consumes one serving slot. - subquotas: - - id: public-quota - channel: public - units: 2 - because: Most households book the public channel. - - id: assisted-quota - channel: assisted - units: 1 - because: Assisted bookings hold a protected unit. - because: The Saturday morning household block, sized for two officers. - - id: household-afternoon-window - revision: 1 - offering: household-afternoon - location: civic-hall - start: 2026-10-10T07:00:00Z - end: 2026-10-10T09:00:00Z - units: 3 - unitsPolicy: - kind: bandedTable - input: serviceRecipientCount - bands: - - upTo: 4 - units: 1 - because: A household of up to four shares one officers' block. - - upTo: 8 - units: 2 - because: A larger household of up to eight needs two officers' blocks. - aboveHighestBand: - policy: refuse - because: A household above eight needs a scheduled outreach visit instead of a walk-in block. - subquotas: [] - because: The Saturday afternoon household block, sized by party rather than a flat headcount. holdPolicy: ttlMinutes: 10 maxPerCaller: 2 @@ -416,6 +372,28 @@ facts: locations: - id: civic-hall timezone: Asia/Bangkok + windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. initial: [] cases: - name: first-household-takes-the-public-quota @@ -476,6 +454,29 @@ facts: locations: - id: civic-hall timezone: Asia/Bangkok + windows: + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. initial: [] cases: - name: a-small-household-fits-the-first-band @@ -522,12 +523,56 @@ cases: units: 2 "#; -/// Live environment records for the arrival-window starter. Published windows -/// carry their own supply, so this project needs only its referenced location. +/// Live environment records for the arrival-window starter. The policy names +/// each window by id; this operator document owns its concrete supply. const ARRIVAL_WINDOW_RECORDS: &str = r#"locations: - id: civic-hall timezone: Asia/Bangkok pools: [] +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. exceptions: [] "#; @@ -794,8 +839,23 @@ mod tests { #[test] fn the_arrival_window_template_demonstrates_a_banded_table_refusing_above_its_highest_band() { let files = template_files("standalone-arrival-window").unwrap(); - let policy = parse_policy_yaml(files[0].1).expect("the template policy parses"); - let window = policy + let policy = parse_policy_yaml( + files + .iter() + .find(|(path, _)| *path == "scheduling.yaml") + .expect("the policy file exists") + .1, + ) + .expect("the template policy parses"); + let records: registry_scheduling_core::SchedulingFacts = serde_norway::from_str( + files + .iter() + .find(|(path, _)| *path == "records.yaml") + .expect("the records file exists") + .1, + ) + .expect("the template records parse"); + let window = records .windows .iter() .find(|window| window.id == "household-afternoon-window") diff --git a/crates/registry-schedulingctl/tests/intents_postgres.rs b/crates/registry-schedulingctl/tests/intents_postgres.rs index 0e2bccb7ff..57f3b397f8 100644 --- a/crates/registry-schedulingctl/tests/intents_postgres.rs +++ b/crates/registry-schedulingctl/tests/intents_postgres.rs @@ -65,7 +65,6 @@ openings: effectiveFrom: "2026-10-01" effectiveUntil: "2026-12-31" because: Counter opening hours reviewed by the office manager. -windows: [] holdPolicy: ttlMinutes: 5 maxPerCaller: 3 diff --git a/crates/registry-schedulingctl/tests/records_apply_postgres.rs b/crates/registry-schedulingctl/tests/records_apply_postgres.rs index 8301076eff..c1c2bf1e75 100644 --- a/crates/registry-schedulingctl/tests/records_apply_postgres.rs +++ b/crates/registry-schedulingctl/tests/records_apply_postgres.rs @@ -45,6 +45,19 @@ offerings: cancellationCutoffMinutes: 240 requiresCapabilities: [] prerequisites: [] + - id: registry-arrivals + service: registry-update + label: Counter arrivals + mode: arrival-window + location: bangkok-counter + because: Arrivals join an operator-published window. + arrival: + window: morning-arrivals + leadTimeMinutes: 60 + horizonDays: 45 + cancellationCutoffMinutes: 240 + requiresCapabilities: [] + prerequisites: [] holidaySets: - id: office-holidays revision: 1 @@ -60,7 +73,6 @@ openings: effectiveFrom: "2026-10-01" effectiveUntil: "2026-12-31" because: Counter opening hours reviewed by the office manager. -windows: [] holdPolicy: ttlMinutes: 5 maxPerCaller: 3 @@ -79,6 +91,17 @@ pools: - resourceId: station-2 capabilities: [] available: true +windows: + - id: morning-arrivals + revision: 1 + offering: registry-arrivals + location: bangkok-counter + start: 2026-10-08T02:00:00Z + end: 2026-10-08T04:00:00Z + units: 10 + unitsPolicy: {kind: fixed, units: 1, because: Each arrival consumes one unit.} + subquotas: [] + because: The operator published the morning arrival block. exceptions: - id: staff-training location: bangkok-counter @@ -99,6 +122,17 @@ pools: - resourceId: station-1 capabilities: [] available: true +windows: + - id: morning-arrivals + revision: 2 + offering: registry-arrivals + location: bangkok-counter + start: 2026-10-08T03:00:00Z + end: 2026-10-08T05:00:00Z + units: 8 + unitsPolicy: {kind: fixed, units: 1, because: Each arrival consumes one unit.} + subquotas: [] + because: The operator republished the arrival block. "#; fn scoped_url(base: &str, schema: &str) -> String { @@ -143,7 +177,9 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { .expect("the administrative connection stays up") }); admin - .batch_execute(&format!("CREATE SCHEMA {schema}")) + .batch_execute(&format!( + "CREATE SCHEMA {schema}; SET search_path TO {schema}" + )) .await .expect("a disposable schema is created"); @@ -197,7 +233,7 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { assert_eq!(report["command"], "records-apply"); assert_eq!( report["applied"], - json!({"locations": 1, "pools": 1, "members": 2, "exceptions": 1}) + json!({"locations": 1, "pools": 1, "members": 2, "windows": 1, "exceptions": 1}) ); let (facts, _) = store.facts().await.unwrap(); @@ -206,6 +242,9 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { assert_eq!(facts.locations[0].timezone, "Asia/Bangkok"); assert_eq!(facts.pools.len(), 1); assert_eq!(facts.pools[0].id, "update-stations"); + assert_eq!(facts.windows.len(), 1); + assert_eq!(facts.windows[0].id, "morning-arrivals"); + assert_eq!(facts.windows[0].revision, 1); let member_ids: Vec<&str> = facts.pools[0] .members .iter() @@ -225,13 +264,14 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { assert_eq!(applies[0].1["outcome"], "allowed"); assert_eq!(applies[0].1["reason"], "authorization.allowed"); assert_eq!(applies[0].1["counts"]["exceptions"], 1); + assert_eq!(applies[0].1["counts"]["windows"], 1); // The second document replaces the first: the second location lands, the // retired station is gone, and the closure does not survive. let report = apply(&config_path, &root.path().join("second.yaml")); assert_eq!( report["applied"], - json!({"locations": 2, "pools": 1, "members": 1, "exceptions": 0}) + json!({"locations": 2, "pools": 1, "members": 1, "windows": 1, "exceptions": 0}) ); let (facts, _) = store.facts().await.unwrap(); assert_eq!(facts.locations.len(), 2); @@ -242,6 +282,8 @@ async fn records_apply_replaces_facts_wholesale_and_audits_each_write() { .any(|member| member.resource_id == "station-2"); assert!(!retired_station, "a replace is not an append"); assert!(facts.exceptions.is_empty(), "the closure did not survive"); + assert_eq!(facts.windows.len(), 1); + assert_eq!(facts.windows[0].revision, 2, "the window was replaced"); let audit = store.pending_audit(10).await.unwrap(); let applies: Vec<_> = audit diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index 09ea2470f6..563f21ae85 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -34,6 +34,10 @@ - Re-check hold expiry after locking its supply during confirmation, so a delayed confirmation cannot claim capacity that became bookable and was committed elsewhere after the hold expired. +- Keep arrival offerings as policy references while operators publish their + concrete window capacity through atomic runtime records. Apply location + openings and closures to live window availability and admission, and refuse + a request whose start differs from the selected window. - Close the beta review races and contract gaps: include exact-time buffers in locked snapshots, scope duplicate keys to their offering, enforce requested capabilities, reject cross-offering reschedules, keep cancellations diff --git a/products/scheduling/README.md b/products/scheduling/README.md index bf8fbea0c2..565ce1f9ae 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -50,12 +50,13 @@ repeats that authoring status and replays the project's bounded synthetic fixtures offline; a passing report carries the `offline_synthetic` proof boundary and `productionClosure: false`, so it does not establish source reachability or deployment readiness. Explain publishes what the runtime would -serve: identity, policy digest, offerings, windows with their subquotas, and -the hold policy. +serve: identity, policy digest, offerings, the operator-published windows with +their subquotas, and the hold policy. `schedulingctl init` writes `runtime.example.yaml` and `records.yaml` beside -the policy. The records document contains every location and resource pool the -selected starter needs. Copy the runtime example to `runtime.yaml`, set its +the policy. The records document contains every location, resource pool, and +arrival window the selected starter needs. Copy the runtime example to +`runtime.yaml`, set its absolute paths and secret references, and read [RUNTIME-CONFIG.md](RUNTIME-CONFIG.md) for every block, field, and default. Then apply the live environment records and start: @@ -76,14 +77,14 @@ continuation cursors answer `cursor.invalid` once the new binary holds them, so a caller mid-pagination restarts that listing from its first page. The migration also retains the current policy document beside its digest. On an upgrade from an earlier schema, start once with the unchanged policy to -backfill that document before publishing a policy change. A later start locks -the standing supply while it checks the proposal and refuses a change that -would move, remove, or reduce a window below its live bookings and holds; the -operator error names the affected window and deficit. +backfill that document before publishing a policy change. `records apply` is the one attributable operator write of a deployment's environment records: the locations with their time zones, the resource pools -and their members, and the dated exceptions such as closures. The policy -references that supply by identifier; it does not embed it. Database +and their members, the published arrival windows, and the dated exceptions +such as closures. The policy references that supply by identifier; it does +not embed it. A records replacement locks standing supply and refuses to move, +remove, or reduce a window below its live bookings and holds; the operator +error names the affected window and deficit. Database transport security is not configurable in production: the runtime requires TLS on both connections, and the plaintext escape is a `postgres-test` build switch documented in [RUNTIME-CONFIG.md](RUNTIME-CONFIG.md). diff --git a/products/scheduling/contracts/recorded-decisions.yaml b/products/scheduling/contracts/recorded-decisions.yaml index 1d041c47c7..ea6d267a70 100644 --- a/products/scheduling/contracts/recorded-decisions.yaml +++ b/products/scheduling/contracts/recorded-decisions.yaml @@ -156,16 +156,15 @@ decisions: unconditionally. The swap takes every supply anchor in the anchor's own order before it reads a claim, and refuses the whole apply when any active booking, or any hold whose lifetime is still ahead, occupies a - pool member the incoming records do not carry. The operator either keeps - the member or closes what stands on it first; nothing partial is - written. + pool member the incoming records remove or move, or when incoming window + records remove, move, or reduce capacity below standing commitments. + The operator keeps the supply or closes what stands on it first; nothing + partial is written. reasoning: >- - The ledger's claims name their supply by id and the facts are what give - that id a member, so deleting a member under a live claim leaves the - claim pointing at supply the deployment no longer has. Nothing else in - the runtime would notice: availability would stop offering the member - while the appointment kept its seat, and the caller would arrive to a - station that no longer exists. Taking the anchors first is what makes + The ledger's claims name their supply by id and the records give that id + its pool membership or published window capacity. Changing those records + under a live claim can strand promised capacity that availability no + longer counts. Taking the anchors first is what makes the guard honest rather than advisory: a capacity transaction holds its own anchor from its snapshot until it commits, so holding all of them means no commitment is mid-evaluation against a member about to be @@ -173,17 +172,16 @@ decisions: than stranded behind it. A capacity transaction takes exactly one anchor and the swap takes them in order, so the two cannot cycle. alsoTrue: >- - A window supply anchor is excluded from the guard: a window claim - occupies the window, which the published policy carries, not the - environment records this verb replaces. An expired hold occupies + Window facts and their lock anchors are replaced in the same transaction + as locations, pools, members, and exceptions. An expired hold occupies nothing, matching the live-predicate rule the rest of the ledger uses, so it never blocks a swap. residual: >- - The guard is keyed on a member disappearing, not on a member moving. - Moving a member between pools while it carries active claims still - applies, because the claim's supply id survives the swap. The claim then - sits in a pool its offering may not serve. Phase 2 work. + Emergency capacity reduction remains a separate incident operation and + does not pass through ordinary records replacement. evidence: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_refuses_to_strand_a_resource_an_active_claim_occupies} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_refuses_to_move_an_occupied_resource_between_pools} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: records_replacement_refuses_to_move_a_window_with_standing_commitments} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_waits_for_the_capacity_transaction_holding_the_pool} - {path: crates/registry-schedulingctl/tests/records_apply_postgres.rs, name: records_apply_replaces_facts_wholesale_and_audits_each_write} diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index 3d0cbe8e7f..623d3e3705 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -42,7 +42,7 @@ invariants: unanchored reference in the operator's diagnostics, never to the caller. negativeTest: path: crates/registry-scheduling/tests/postgres_commitments.rs - name: a_commitment_against_an_unanchored_pool_refuses_loudly + name: an_arrival_offering_without_its_window_record_is_an_operator_gap - id: SCHEDULING-SEC-03 state: partial threat: >- @@ -405,18 +405,18 @@ invariants: - id: SCHEDULING-SEC-25 state: enforced threat: >- - A rolling policy publication drops, moves, or reduces a published window + An operator records replacement drops, moves, or reduces a published window below the bookings and live holds that already consume it. enforcementPoint: >- - Policy publication locks every existing supply anchor, loads the retained - current policy and the standing window ledger, and assesses the proposal - before advancing the durable revision. + Records replacement locks every existing supply anchor, loads the current + window records and standing window ledger, and assesses the proposal + before atomically advancing the durable facts revision. refusal: >- - Refuse publication with an operator-facing deficit and leave the policy - revision unchanged; an ordinary restart never strands commitments. + Refuse replacement with an operator-facing deficit and leave all records + and their revision unchanged. negativeTest: path: crates/registry-scheduling/tests/postgres_commitments.rs - name: policy_publication_refuses_to_move_a_window_with_standing_commitments + name: records_replacement_refuses_to_move_a_window_with_standing_commitments deferred: - id: SCHEDULING-DEF-01 subject: Full task-grant bounds re-checked inside the capacity transaction diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index ad8a8411cd..e31da6143c 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -19,7 +19,8 @@ entries: - id: SCHEDULING-SEC-02 tests: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_commitment_against_an_unanchored_pool_refuses_loudly} - - {path: crates/registry-scheduling/src/runtime.rs, name: the_offering_anchors_are_collected_without_duplicates} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_arrival_offering_without_its_window_record_is_an_operator_gap} + - {path: crates/registry-scheduling/src/runtime.rs, name: the_offering_pool_anchors_are_collected_without_duplicates} - id: SCHEDULING-SEC-03 tests: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_grant_that_lapses_before_the_commit_never_books} @@ -174,6 +175,6 @@ entries: tests: - {path: crates/registry-scheduling-core/src/policy.rs, name: a_reduction_below_standing_commitments_is_rejected_with_its_deficit} - {path: crates/registry-scheduling-core/src/policy.rs, name: a_subquota_reduction_strands_its_channel_even_at_an_unchanged_total} - - {path: crates/registry-scheduling-core/src/policy.rs, name: moving_a_published_window_reports_no_reduction} - - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: policy_publication_refuses_to_move_a_window_with_standing_commitments} + - {path: crates/registry-scheduling-core/src/policy.rs, name: moving_a_published_window_reports_the_stranded_commitments} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: records_replacement_refuses_to_move_a_window_with_standing_commitments} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: policy_publication_refuses_to_remove_an_offering_with_a_live_appointment} diff --git a/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml b/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml index f5001c987b..9b6747de18 100644 --- a/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml +++ b/products/scheduling/examples/standalone-arrival-window/fixtures/household-afternoon.yaml @@ -6,6 +6,29 @@ facts: locations: - id: civic-hall timezone: Asia/Bangkok + windows: + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. initial: [] cases: - name: a-small-household-fits-the-first-band diff --git a/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml b/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml index 7e511f9e7f..dc1d65e92d 100644 --- a/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml +++ b/products/scheduling/examples/standalone-arrival-window/fixtures/household-morning.yaml @@ -6,6 +6,28 @@ facts: locations: - id: civic-hall timezone: Asia/Bangkok + windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. initial: [] cases: - name: first-household-takes-the-public-quota diff --git a/products/scheduling/examples/standalone-arrival-window/records.yaml b/products/scheduling/examples/standalone-arrival-window/records.yaml index 555f4b0b3e..686d61354f 100644 --- a/products/scheduling/examples/standalone-arrival-window/records.yaml +++ b/products/scheduling/examples/standalone-arrival-window/records.yaml @@ -2,4 +2,48 @@ locations: - id: civic-hall timezone: Asia/Bangkok pools: [] +windows: + - id: household-morning-window + revision: 2 + offering: household-morning + location: civic-hall + start: 2026-10-10T01:00:00Z + end: 2026-10-10T03:00:00Z + units: 3 + unitsPolicy: + kind: perRecipient + perRecipient: 1 + because: Each recipient consumes one serving slot. + subquotas: + - id: public-quota + channel: public + units: 2 + because: Most households book the public channel. + - id: assisted-quota + channel: assisted + units: 1 + because: Assisted bookings hold a protected unit. + because: The Saturday morning household block, sized for two officers. + - id: household-afternoon-window + revision: 1 + offering: household-afternoon + location: civic-hall + start: 2026-10-10T07:00:00Z + end: 2026-10-10T09:00:00Z + units: 3 + unitsPolicy: + kind: bandedTable + input: serviceRecipientCount + bands: + - upTo: 4 + units: 1 + because: A household of up to four shares one officers' block. + - upTo: 8 + units: 2 + because: A larger household of up to eight needs two officers' blocks. + aboveHighestBand: + policy: refuse + because: A household above eight needs a scheduled outreach visit instead of a walk-in block. + subquotas: [] + because: The Saturday afternoon household block, sized by party rather than a flat headcount. exceptions: [] diff --git a/products/scheduling/examples/standalone-arrival-window/scheduling.yaml b/products/scheduling/examples/standalone-arrival-window/scheduling.yaml index 0fa0b219ef..a39be7e198 100644 --- a/products/scheduling/examples/standalone-arrival-window/scheduling.yaml +++ b/products/scheduling/examples/standalone-arrival-window/scheduling.yaml @@ -57,50 +57,6 @@ openings: effectiveFrom: "2026-10-01" effectiveUntil: "2026-12-31" because: The hall reopens on Saturday afternoons for banded household visits. -windows: - - id: household-morning-window - revision: 2 - offering: household-morning - location: civic-hall - start: 2026-10-10T01:00:00Z - end: 2026-10-10T03:00:00Z - units: 3 - unitsPolicy: - kind: perRecipient - perRecipient: 1 - because: Each recipient consumes one serving slot. - subquotas: - - id: public-quota - channel: public - units: 2 - because: Most households book the public channel. - - id: assisted-quota - channel: assisted - units: 1 - because: Assisted bookings hold a protected unit. - because: The Saturday morning household block, sized for two officers. - - id: household-afternoon-window - revision: 1 - offering: household-afternoon - location: civic-hall - start: 2026-10-10T07:00:00Z - end: 2026-10-10T09:00:00Z - units: 3 - unitsPolicy: - kind: bandedTable - input: serviceRecipientCount - bands: - - upTo: 4 - units: 1 - because: A household of up to four shares one officers' block. - - upTo: 8 - units: 2 - because: A larger household of up to eight needs two officers' blocks. - aboveHighestBand: - policy: refuse - because: A household above eight needs a scheduled outreach visit instead of a walk-in block. - subquotas: [] - because: The Saturday afternoon household block, sized by party rather than a flat headcount. holdPolicy: ttlMinutes: 10 maxPerCaller: 2 diff --git a/products/scheduling/examples/standalone-exact-time/records.yaml b/products/scheduling/examples/standalone-exact-time/records.yaml index 546d942434..7b9199f212 100644 --- a/products/scheduling/examples/standalone-exact-time/records.yaml +++ b/products/scheduling/examples/standalone-exact-time/records.yaml @@ -17,6 +17,7 @@ pools: - resourceId: hall-station-1 capabilities: [] available: true +windows: [] exceptions: - id: hall-prep location: new-york-hall diff --git a/products/scheduling/examples/standalone-exact-time/scheduling.yaml b/products/scheduling/examples/standalone-exact-time/scheduling.yaml index 61a73db8b3..1418e7ccc9 100644 --- a/products/scheduling/examples/standalone-exact-time/scheduling.yaml +++ b/products/scheduling/examples/standalone-exact-time/scheduling.yaml @@ -73,7 +73,6 @@ openings: effectiveFrom: "2026-11-01" effectiveUntil: "2026-11-01" because: Fold-day hours whose endpoints sit outside the repeated local hour. -windows: [] holdPolicy: ttlMinutes: 5 maxPerCaller: 3 diff --git a/products/scheduling/scripts/check-checkpoint.sh b/products/scheduling/scripts/check-checkpoint.sh index 099adf2367..dde869dbe0 100755 --- a/products/scheduling/scripts/check-checkpoint.sh +++ b/products/scheduling/scripts/check-checkpoint.sh @@ -72,12 +72,12 @@ fi # The banded units table is reachable from the authoring journey, not only # from the core's unit tests: swapping the arrival template's per-recipient -# policy for a banded table must still check clean, and must change the -# policy digest, which proves the swap took and was read. +# record for a banded table must still check clean, and must change explain's +# window projection, which proves the records swap was read. banded="$work/banded" "$schedulingctl_bin" init "$banded" --template standalone-arrival-window >/dev/null -plain_digest=$("$schedulingctl_bin" check "$banded" | grep -o '"policyDigest":"[^"]*"') -python3 - "$banded/scheduling.yaml" <<'PY' +plain_explain=$("$schedulingctl_bin" explain "$banded") +python3 - "$banded/records.yaml" <<'PY' import sys from pathlib import Path @@ -98,9 +98,10 @@ for window in document["windows"]: } path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") PY -banded_digest=$("$schedulingctl_bin" check "$banded" | grep -o '"policyDigest":"[^"]*"') -if [ "$plain_digest" = "$banded_digest" ]; then - echo 'the banded units table did not change the policy the check read' >&2 +"$schedulingctl_bin" check "$banded" >/dev/null +banded_explain=$("$schedulingctl_bin" explain "$banded") +if [ "$plain_explain" = "$banded_explain" ]; then + echo 'the banded units table did not change the window records explain read' >&2 exit 1 fi From 5dab704da59c22953cbc8fdf4cc1d2ee411262f9 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 16:37:54 +0700 Subject: [PATCH 110/115] fix(scheduling): preserve window supply invariants Signed-off-by: Jeremi Joslin --- .../src/diagnostics.rs | 7 +- crates/registry-scheduling-core/src/policy.rs | 114 +++++--- .../migrations/0005_window_records.sql | 9 + .../migrations/0006_window_revision_heads.sql | 35 +++ crates/registry-scheduling/src/service.rs | 105 +++++++- crates/registry-scheduling/src/store.rs | 78 +++++- .../tests/postgres_commitments.rs | 251 +++++++++++++++++- .../contracts/recorded-decisions.yaml | 22 +- .../contracts/security-invariant-matrix.yaml | 28 +- .../contracts/security-test-traceability.yaml | 2 + 10 files changed, 587 insertions(+), 64 deletions(-) create mode 100644 crates/registry-scheduling/migrations/0006_window_revision_heads.sql diff --git a/crates/registry-scheduling-core/src/diagnostics.rs b/crates/registry-scheduling-core/src/diagnostics.rs index 7acded8730..629ee9926f 100644 --- a/crates/registry-scheduling-core/src/diagnostics.rs +++ b/crates/registry-scheduling-core/src/diagnostics.rs @@ -29,6 +29,8 @@ pub enum PolicyCheckReason { UnknownOffering, /// A referenced window does not exist. UnknownWindow, + /// A reference resolves to a record owned by another declaration. + MismatchedReference, /// A referenced holiday set does not exist. UnknownHolidaySet, /// A subquota names a channel outside the package's declared channel @@ -51,8 +53,8 @@ pub enum PolicyCheckReason { InvalidBands, /// Channel subquotas overlap or sum above the window's published units. SubquotaOverdrawn, - /// Two declarations draw on the same supply without an attributable - /// partition of it. + /// Two declarations draw on the same supply without a partition the + /// ledger can enforce. SharedSupplyUnpartitioned, /// A local hook omits or changes the shared handler ABI. UnsupportedHookAbi, @@ -88,6 +90,7 @@ impl PolicyCheckReason { Self::UnknownService => "unknown-service", Self::UnknownOffering => "unknown-offering", Self::UnknownWindow => "unknown-window", + Self::MismatchedReference => "mismatched-reference", Self::UnknownHolidaySet => "unknown-holiday-set", Self::UnknownChannel => "unknown-channel", Self::MissingModeField => "missing-mode-field", diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index 99f79bfc41..bdae09464b 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -251,10 +251,10 @@ pub enum LeftoverCapacityPolicy { /// staffing in a ledger that cannot see the conflict. That mix is refused at /// publication whatever this block declares. /// -/// `reserved_members` is the partition among windows: how many pool members -/// the window's supply is attributed to. A window that names a pool without a -/// partition claims the whole block, so two overlapping windows each claiming -/// the whole pool are double-counting and are rejected at publication. +/// `reserved_members` records how many pool members the window's supply is +/// intended to use. The current ledger does not enforce that partition between +/// windows, so two windows backed by the same pool may not overlap, whatever +/// either staffing block declares. Disjoint windows may reuse the pool. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WindowStaffing { @@ -718,11 +718,21 @@ impl SchedulingPolicy { check_collection_bound(windows, "windows", &mut findings); for (index, offering) in self.offerings.iter().enumerate() { if let Some(arrival) = &offering.arrival { - if !windows.iter().any(|window| window.id == arrival.window) { - findings.push(SchedulingDiagnostic::new( + match windows.iter().find(|window| window.id == arrival.window) { + None => findings.push(SchedulingDiagnostic::new( format!("offerings[{index}].arrival.window"), PolicyCheckReason::UnknownWindow, - )); + )), + Some(window) + if window.offering != offering.id + || window.location != offering.location => + { + findings.push(SchedulingDiagnostic::new( + format!("offerings[{index}].arrival.window"), + PolicyCheckReason::MismatchedReference, + )); + } + Some(_) => {} } } } @@ -862,25 +872,23 @@ impl SchedulingPolicy { PolicyCheckReason::SharedSupplyUnpartitioned, )); } - // An unpartitioned staffing claim owns the whole pool block, so - // two overlapping windows each claiming the whole pool are - // double-counting the same staffing. - if staffing.reserved_members.is_none() { - let doubly_claimed = windows.iter().any(|other| { - other.id != window.id - && other.staffing.as_ref().is_some_and(|other_staffing| { - other_staffing.pool == staffing.pool - && other_staffing.reserved_members.is_none() - && other.start < window.end - && window.start < other.end - }) - }); - if doubly_claimed { - findings.push(SchedulingDiagnostic::new( - format!("{path}.staffing.reservedMembers"), - PolicyCheckReason::SharedSupplyUnpartitioned, - )); - } + // Window claims occupy their window ids, so the ledger cannot + // enforce an authored staffing partition between two windows. + // Refuse every overlapping use of one pool; disjoint windows may + // reuse it because their staffing cannot be booked twice at once. + let doubly_claimed = windows.iter().any(|other| { + other.id != window.id + && other.staffing.as_ref().is_some_and(|other_staffing| { + other_staffing.pool == staffing.pool + && other.start < window.end + && window.start < other.end + }) + }); + if doubly_claimed { + findings.push(SchedulingDiagnostic::new( + format!("{path}.staffing.pool"), + PolicyCheckReason::SharedSupplyUnpartitioned, + )); } } } @@ -1532,6 +1540,19 @@ windows: assert!(rendered.contains(&"openings[0].holidaySet: unknown-holiday-set".to_owned())); } + #[test] + fn every_arrival_offering_must_own_its_referenced_window() { + let mut policy = household_window_policy(); + let windows = household_windows(); + let mut neighbour = policy.offerings[0].clone(); + neighbour.id = "household-afternoon".to_owned(); + policy.offerings.push(neighbour); + + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(ToString::to_string).collect(); + assert!(rendered.contains(&"offerings[1].arrival.window: mismatched-reference".to_owned())); + } + /// The weekly expansion serves at most MAXIMUM_PATTERN_SPAN_DAYS days, and /// the authoring check refuses a wider span at exactly that boundary, so a /// checked policy never deploys into a runtime that cannot expand it. @@ -1757,7 +1778,7 @@ windows: } #[test] - fn two_overlapping_whole_pool_claims_are_double_counting() { + fn overlapping_windows_may_not_share_a_staffing_pool() { let policy = household_window_policy(); let mut windows = household_windows(); windows[0].staffing = Some(WindowStaffing { @@ -1790,11 +1811,42 @@ windows: // fixture; only the supply finding is asserted here. let findings = policy.check_window_records(&windows); let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); - assert!(rendered - .iter() - .any(|f| f.ends_with("shared-supply-unpartitioned"))); + assert_eq!( + rendered + .iter() + .filter(|finding| finding.ends_with("shared-supply-unpartitioned")) + .count(), + 2 + ); + + // A whole-pool claim and an attributed subset still have independent + // window ledgers, so the authored subset cannot make the overlap safe. + windows[1].staffing.as_mut().unwrap().reserved_members = Some(1); + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert_eq!( + rendered + .iter() + .filter(|finding| finding.ends_with("shared-supply-unpartitioned")) + .count(), + 2 + ); + + // Two attributed subsets are equally unenforceable: their claims lock + // and consume separate window ids rather than a shared staffing slice. + windows[0].staffing.as_mut().unwrap().reserved_members = Some(1); + let findings = policy.check_window_records(&windows); + let rendered: Vec = findings.iter().map(|f| f.to_string()).collect(); + assert_eq!( + rendered + .iter() + .filter(|finding| finding.ends_with("shared-supply-unpartitioned")) + .count(), + 2 + ); - // Disjoint windows may each claim the whole pool. + // Disjoint windows may reuse the pool regardless of their advisory + // attributed member counts. let disjoint = PublishedWindow { start: Utc.with_ymd_and_hms(2026, 10, 10, 13, 0, 0).unwrap(), end: Utc.with_ymd_and_hms(2026, 10, 10, 15, 0, 0).unwrap(), diff --git a/crates/registry-scheduling/migrations/0005_window_records.sql b/crates/registry-scheduling/migrations/0005_window_records.sql index f4bd8d5a25..02860c7e73 100644 --- a/crates/registry-scheduling/migrations/0005_window_records.sql +++ b/crates/registry-scheduling/migrations/0005_window_records.sql @@ -8,3 +8,12 @@ CREATE TABLE IF NOT EXISTS scheduling_windows ( window_record jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now() ); + +-- The public revision is the caller's agreement with a window's terms. Keep +-- its last accepted record even while the active window is absent, so a later +-- replacement cannot reuse an observed revision for different terms. +CREATE TABLE IF NOT EXISTS scheduling_window_revision_heads ( + window_id text PRIMARY KEY, + window_record jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/crates/registry-scheduling/migrations/0006_window_revision_heads.sql b/crates/registry-scheduling/migrations/0006_window_revision_heads.sql new file mode 100644 index 0000000000..858fa977fc --- /dev/null +++ b/crates/registry-scheduling/migrations/0006_window_revision_heads.sql @@ -0,0 +1,35 @@ +-- Keep the last accepted record for every published-window identifier, even +-- while that window is absent from the active environment records. A later +-- records replacement can therefore distinguish an exact re-publication from +-- changed terms attempting to reuse an observed public revision. +CREATE TABLE IF NOT EXISTS scheduling_window_revision_heads ( + window_id text PRIMARY KEY, + window_record jsonb NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Schema version 5 was never released, but a developer may have exercised it +-- from the review branch. Refuse its formerly accepted overlapping staffing +-- shape during upgrade rather than carrying double-bookable records forward. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM scheduling_windows AS earlier + JOIN scheduling_windows AS later ON earlier.window_id < later.window_id + WHERE earlier.window_record #>> '{staffing,pool}' = + later.window_record #>> '{staffing,pool}' + AND (earlier.window_record ->> 'start')::timestamptz < + (later.window_record ->> 'end')::timestamptz + AND (later.window_record ->> 'start')::timestamptz < + (earlier.window_record ->> 'end')::timestamptz + ) THEN + RAISE EXCEPTION 'overlapping published windows share one staffing pool'; + END IF; +END +$$; + +INSERT INTO scheduling_window_revision_heads(window_id, window_record) +SELECT window_id, window_record +FROM scheduling_windows +ON CONFLICT(window_id) DO NOTHING; diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index 0e337aac99..eb1ed5c49a 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -1047,14 +1047,7 @@ impl SchedulingService { .arrival .as_ref() .map(|arrival| { - let window = facts.window(&arrival.window).ok_or_else(|| { - tracing::error!( - offering = %offering.id, - reference = %arrival.window, - "the offering names a window the records do not carry" - ); - ServiceError::Problem(ProblemCode::ServiceUnavailable) - })?; + let window = bound_window(facts, offering, &arrival.window)?; Ok::(WindowDocument { id: window.id.clone(), revision: window.revision, @@ -1537,9 +1530,7 @@ impl ResolvedSupply { }) } (None, Some(arrival)) => { - let window = facts - .window(&arrival.window) - .ok_or_else(|| operator_gap(&arrival.window))?; + let window = bound_window(facts, offering, &arrival.window)?; Ok(Self::Window { window: Box::new(window.clone()), lead_time_minutes: arrival.lead_time_minutes, @@ -1594,6 +1585,33 @@ impl ResolvedSupply { } } +/// Resolve a published window only when its operator record belongs to the +/// offering and location whose authority the service checked. A malformed or +/// stale records set is an operator gap, never capacity a caller may consume. +fn bound_window<'a>( + facts: &'a SchedulingFacts, + offering: &OfferingPolicy, + reference: &str, +) -> Result<&'a PublishedWindow, ServiceError> { + let window = facts.window(reference).ok_or_else(|| { + tracing::error!( + offering = %offering.id, + reference, + "the offering names a window the records do not carry" + ); + ServiceError::Problem(ProblemCode::ServiceUnavailable) + })?; + if window.offering != offering.id || window.location != offering.location { + tracing::error!( + offering = %offering.id, + reference, + "the window record is not bound to the offering and location" + ); + return Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)); + } + Ok(window) +} + /// The exact-time availability walk: one entry per grid slot that lies inside /// the effective openings, inside the booking horizon, and that at least one /// available, capable member can still serve. @@ -2079,6 +2097,71 @@ mod tests { } } + fn window_offering() -> OfferingPolicy { + OfferingPolicy { + id: "review-arrivals".to_owned(), + service: "registry-review".to_owned(), + label: "Review arrivals".to_owned(), + mode: SchedulingMode::ArrivalWindow, + location: "north-counter".to_owned(), + because: "test".to_owned(), + exact_time: None, + arrival: Some(registry_scheduling_core::ArrivalOffering { + window: "morning-arrivals".to_owned(), + lead_time_minutes: 60, + horizon_days: 30, + }), + cancellation_cutoff_minutes: 240, + reminders: Vec::new(), + duplicate_active_key: None, + requires_capabilities: Vec::new(), + prerequisites: Vec::new(), + } + } + + fn window_facts() -> SchedulingFacts { + SchedulingFacts { + windows: vec![PublishedWindow { + id: "morning-arrivals".to_owned(), + revision: 1, + offering: "review-arrivals".to_owned(), + location: "north-counter".to_owned(), + start: at(2, 0), + end: at(4, 0), + units: 2, + units_policy: registry_scheduling_core::RequiredUnitsPolicy::Fixed { + units: 1, + because: "test".to_owned(), + }, + subquotas: Vec::new(), + leftover: None, + staffing: None, + because: "test".to_owned(), + }], + ..SchedulingFacts::default() + } + } + + #[test] + fn a_window_resolves_only_for_its_own_offering_and_location() { + let offering = window_offering(); + let mut facts = window_facts(); + assert!(bound_window(&facts, &offering, "morning-arrivals").is_ok()); + + facts.windows[0].offering = "other-arrivals".to_owned(); + assert!(matches!( + bound_window(&facts, &offering, "morning-arrivals"), + Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)) + )); + + facts.windows[0].offering = offering.id.clone(); + facts.windows[0].location = "south-counter".to_owned(); + assert!(matches!( + bound_window(&facts, &offering, "morning-arrivals"), + Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)) + )); + } + #[test] fn slots_walk_the_grid_inside_the_opening() { let now = at(0, 0); diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 198dbf4df1..6eb0e5d489 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -63,15 +63,19 @@ const POLICY_DOCUMENT_MIGRATION: &str = include_str!("../migrations/0004_policy_ const POLICY_DOCUMENT_MIGRATION_VERSION: i64 = 4; const WINDOW_RECORDS_MIGRATION: &str = include_str!("../migrations/0005_window_records.sql"); const WINDOW_RECORDS_MIGRATION_VERSION: i64 = 5; +const WINDOW_REVISION_HEADS_MIGRATION: &str = + include_str!("../migrations/0006_window_revision_heads.sql"); +const WINDOW_REVISION_HEADS_MIGRATION_VERSION: i64 = 6; /// Every schema version in ledger order. const MIGRATIONS: [(i64, &str); 2] = [(1, SCHEDULING_MIGRATION), (2, FACTS_REVISION_MIGRATION)]; -const SCHEMA_VERSIONS: [i64; 5] = [ +const SCHEMA_VERSIONS: [i64; 6] = [ 1, 2, HOOK_DELIVERY_MIGRATION_VERSION, POLICY_DOCUMENT_MIGRATION_VERSION, WINDOW_RECORDS_MIGRATION_VERSION, + WINDOW_REVISION_HEADS_MIGRATION_VERSION, ]; /// Serializes operator-run migrations on one session lock. A second migrator @@ -119,6 +123,14 @@ pub enum StoreError { "the environment records retire, move, or reduce {0}, which live appointments or holds still occupy" )] FactsInUse(String), + #[error( + "window {window} changed without advancing its revision beyond {current}; proposed revision is {proposed}" + )] + WindowRevision { + window: String, + current: u64, + proposed: u64, + }, #[error("the proposed policy would strand standing commitments: {0}")] PolicyInUse(String), // Both carry the driver's own account of what went wrong. Neither the @@ -609,6 +621,27 @@ impl PostgresStore { .await?; } transaction.commit().await?; + + let transaction = client.transaction().await?; + let applied: bool = transaction + .query_one( + "SELECT EXISTS(SELECT 1 FROM scheduling_schema_migrations WHERE version=$1)", + &[&WINDOW_REVISION_HEADS_MIGRATION_VERSION], + ) + .await? + .get(0); + if !applied { + transaction + .batch_execute(WINDOW_REVISION_HEADS_MIGRATION) + .await?; + transaction + .execute( + "INSERT INTO scheduling_schema_migrations(version,applied_at) VALUES($1,now()) ON CONFLICT(version) DO NOTHING", + &[&WINDOW_REVISION_HEADS_MIGRATION_VERSION], + ) + .await?; + } + transaction.commit().await?; Ok(()) } @@ -2623,6 +2656,40 @@ pub(crate) async fn replace_facts_in_transaction( .map_err(|_| StoreError::Corrupt) }) .collect::, _>>()?; + let revision_heads = transaction + .query( + "SELECT window_id, window_record FROM scheduling_window_revision_heads ORDER BY window_id", + &[], + ) + .await? + .iter() + .map(|row| { + let window_id: String = row.get(0); + let window = serde_json::from_value::( + row.get(1), + ) + .map_err(|_| StoreError::Corrupt)?; + if window.id != window_id { + return Err(StoreError::Corrupt); + } + Ok(window) + }) + .collect::, _>>()?; + for proposed in &facts.windows { + let Some(current) = revision_heads + .iter() + .find(|current| current.id == proposed.id) + else { + continue; + }; + if proposed != current && proposed.revision <= current.revision { + return Err(StoreError::WindowRevision { + window: proposed.id.clone(), + current: current.revision, + proposed: proposed.revision, + }); + } + } let now = Utc::now(); let rows = transaction .query( @@ -2722,6 +2789,15 @@ pub(crate) async fn replace_facts_in_transaction( &[&window.id], ) .await?; + transaction + .execute( + "INSERT INTO scheduling_window_revision_heads(window_id, window_record, updated_at) \ + VALUES($1,$2,now()) \ + ON CONFLICT(window_id) DO UPDATE SET window_record=EXCLUDED.window_record, \ + updated_at=EXCLUDED.updated_at", + &[&window.id, &record], + ) + .await?; } for exception in &facts.exceptions { let kind = match exception.kind { diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index e6e1bb5ff4..cfdf913ce4 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -36,7 +36,7 @@ use registry_scheduling::runtime::{ }; use registry_scheduling::service::SchedulingService; use registry_scheduling::store::{ - CommitError, CommitOutcome, Commitment, PostgresStore, SupplyContext, + CommitError, CommitOutcome, Commitment, PostgresStore, StoreError, SupplyContext, }; use registry_scheduling_core::{ location_closure_intervals, location_open_intervals, parse_policy_yaml, AdmissionRefusal, @@ -563,6 +563,32 @@ fn agent_token_for(subject: &str) -> String { token(claims) } +fn agent_token_for_service(service: &str) -> String { + let mut grant = grant_claims(); + grant["registry_grant_bounds"]["permissions"] = json!([{ + "service": service, + "location": "north-counter", + "actions": ["appointment.create"], + }]); + let mut claims = json!({ + "sub": format!("principal-{service}"), + "azp": CLIENT, + "registry_scopes": "scheduling-read", + "registry_actor_kind": "service", + }); + claims + .as_object_mut() + .expect("the claims are an object") + .extend( + grant + .as_object() + .expect("the grant claims are an object") + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); + token(claims) +} + /// A complete admission request for `offering` at `slot`. The wire shape /// carries no `rescheduleOf`: the exclusion is the runtime's to supply from /// inside the reschedule transaction, and a caller who sends one is refused @@ -3882,6 +3908,30 @@ fn policy_with_window() -> String { ) } +const FOREIGN_WINDOW_OFFERING: &str = "registry-review-arrivals"; + +fn policy_with_shared_window() -> String { + policy_with_window().replace( + "holdPolicy:", + &format!( + " - id: {FOREIGN_WINDOW_OFFERING}\n\ + \x20 service: registry-review\n\ + \x20 label: Review arrivals\n\ + \x20 mode: arrival-window\n\ + \x20 location: north-counter\n\ + \x20 because: test\n\ + \x20 cancellationCutoffMinutes: 240\n\ + \x20 arrival:\n\ + \x20 window: {WINDOW_ID}\n\ + \x20 leadTimeMinutes: 60\n\ + \x20 horizonDays: 60\n\ + \x20 requiresCapabilities: []\n\ + \x20 prerequisites: []\n\ + holdPolicy:" + ), + ) +} + /// The operator records that publish one concrete arrival window. fn records_with_window(start: DateTime) -> SchedulingFacts { let mut facts = records_without(&[]); @@ -3990,6 +4040,205 @@ async fn records_replacement_refuses_to_move_a_window_with_standing_commitments( assert!(refusal.to_string().contains(WINDOW_ID), "{refusal}"); } +#[tokio::test] +async fn changed_window_records_must_advance_the_revision_even_after_removal() { + let start = (Utc::now() + TimeDelta::days(2)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_with_window(start).await; + let (initial, facts_revision) = fx.store.facts().await.expect("the initial records"); + let audit_rows = fx + .store + .pending_audit(100) + .await + .expect("the initial audit rows") + .len(); + + let mut changed = records_with_window(start); + changed.windows[0].end += TimeDelta::minutes(30); + for proposed_revision in [WINDOW_REVISION, WINDOW_REVISION - 1] { + changed.windows[0].revision = proposed_revision; + let refusal = fx + .store + .replace_facts(SCHEDULING_ID, &changed, Uuid::new_v4(), operator_audit()) + .await + .expect_err("changed terms must advance their public revision"); + assert!(matches!( + refusal, + StoreError::WindowRevision { + current: WINDOW_REVISION, + proposed, + .. + } if proposed == proposed_revision + )); + } + let (after_refusals, after_refusal_revision) = fx + .store + .facts() + .await + .expect("the refused records roll back"); + assert_eq!(after_refusals, initial); + assert_eq!(after_refusal_revision, facts_revision); + assert_eq!( + fx.store + .pending_audit(100) + .await + .expect("the audit rows after refusal") + .len(), + audit_rows, + "a refused replacement writes no allowed audit row" + ); + + changed.windows[0].revision = WINDOW_REVISION + 1; + fx.store + .replace_facts(SCHEDULING_ID, &changed, Uuid::new_v4(), operator_audit()) + .await + .expect("an advanced revision accepts changed terms"); + fx.store + .replace_facts( + SCHEDULING_ID, + &records_without(&[]), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("the idle window may be removed"); + let (_, removed_revision) = fx.store.facts().await.expect("the removed records"); + let removed_audit_rows = fx + .store + .pending_audit(100) + .await + .expect("the audit rows after removal") + .len(); + + let mut republished = changed.clone(); + republished.windows[0].end += TimeDelta::minutes(30); + for proposed_revision in [WINDOW_REVISION + 1, WINDOW_REVISION] { + republished.windows[0].revision = proposed_revision; + let refusal = fx + .store + .replace_facts( + SCHEDULING_ID, + &republished, + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect_err("removal does not erase the revision high-water mark"); + assert!(matches!( + refusal, + StoreError::WindowRevision { + current, + proposed, + .. + } if current == WINDOW_REVISION + 1 && proposed == proposed_revision + )); + } + let (still_removed, after_republish_refusal) = fx + .store + .facts() + .await + .expect("the refused republication rolls back"); + assert!(still_removed.windows.is_empty()); + assert_eq!(after_republish_refusal, removed_revision); + assert_eq!( + fx.store + .pending_audit(100) + .await + .expect("the audit rows after republication refusal") + .len(), + removed_audit_rows + ); + + republished.windows[0].revision = WINDOW_REVISION + 2; + fx.store + .replace_facts( + SCHEDULING_ID, + &republished, + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("an advanced revision may republish the identifier"); + fx.store + .replace_facts( + SCHEDULING_ID, + &republished, + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("an exact unchanged reapply remains accepted"); +} + +#[tokio::test] +async fn a_window_record_must_belong_to_the_authorized_offering_and_location() { + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_publishing( + &policy_with_shared_window(), + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + fx.store + .replace_facts( + SCHEDULING_ID, + &records_with_window(start), + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("seed the intentionally misbound operator record"); + let caller = agent_token_for_service("registry-review"); + let mut request = arrival(&fx, start, None); + request["admission"]["offering"] = json!(FOREIGN_WINDOW_OFFERING); + + let (status, problem) = fx + .post( + "/v1/appointments", + &caller, + "foreign-window-offering", + request.clone(), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}"); + assert_eq!(problem["code"], "service.unavailable"); + + let mut wrong_location = records_with_window(start); + wrong_location.windows[0].revision = WINDOW_REVISION + 1; + wrong_location.windows[0].offering = FOREIGN_WINDOW_OFFERING.to_owned(); + wrong_location.windows[0].location = "two-counter".to_owned(); + fx.store + .replace_facts( + SCHEDULING_ID, + &wrong_location, + Uuid::new_v4(), + operator_audit(), + ) + .await + .expect("seed the intentionally wrong-location operator record"); + request["admission"]["windowRevision"] = json!(WINDOW_REVISION + 1); + let (status, problem) = fx + .post( + "/v1/appointments", + &caller, + "foreign-window-location", + request, + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}"); + assert_eq!(problem["code"], "service.unavailable"); + + let claims: i64 = fx + .admin + .query_one("SELECT count(*) FROM scheduling_claims", &[]) + .await + .expect("count the claims") + .get(0); + assert_eq!(claims, 0, "misbound records never reach a commitment"); +} + #[tokio::test] async fn a_location_closure_hides_and_refuses_an_arrival_window() { let day = (Utc::now() + TimeDelta::days(2)).date_naive(); diff --git a/products/scheduling/contracts/recorded-decisions.yaml b/products/scheduling/contracts/recorded-decisions.yaml index ea6d267a70..e318579772 100644 --- a/products/scheduling/contracts/recorded-decisions.yaml +++ b/products/scheduling/contracts/recorded-decisions.yaml @@ -100,19 +100,25 @@ decisions: - {path: crates/registry-scheduling-core/src/admission.rs, name: subquota_channels_are_checked_before_the_total} - {path: crates/registry-scheduling-core/src/policy.rs, name: subquota_slices_may_not_overdraw_the_window} - id: SCHEDULING-DEC-05 - subject: A pool serving both an exact-time offering and a window is refused + subject: One staffing pool cannot back independently counted overlapping supply decision: >- The authoring check refuses a resource pool that serves an exact-time - offering and a published window at the same time. That is not a supported - configuration in this milestone. + offering and a published window at the same time. It also refuses two + overlapping windows backed by the same pool, whatever reservedMembers + either window declares. Those are not supported configurations in this + milestone; disjoint windows may reuse the pool. reasoning: >- - The two modes count the same staffing differently, so a shared pool would - need ledger-level partition enforcement to stay honest. Refusing the - configuration at authoring time is a promise the product can keep now; - enforcing the partition in the ledger is later work, and until it exists - a deployment must not be able to author its way into the unsound shape. + Exact-time commitments count pool members while arrival commitments count + window units, and two windows count separate window ids. Each shared shape + therefore needs ledger-level staffing partition enforcement to stay + honest. reservedMembers records operator intent but does not create that + enforcement. Refusing the configuration at authoring time is a promise + the product can keep now; enforcing partitions in the ledger is later + work, and until it exists a deployment must not be able to author its way + into the unsound shape. evidence: - {path: crates/registry-scheduling-core/src/policy.rs, name: an_unpartitioned_shared_staffing_block_is_rejected} + - {path: crates/registry-scheduling-core/src/policy.rs, name: overlapping_windows_may_not_share_a_staffing_pool} - id: SCHEDULING-DEC-06 subject: Listing cursors carry no actor binding field decision: >- diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index 623d3e3705..06b0a48311 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -90,10 +90,14 @@ invariants: enforcementPoint: >- require_permission in service.rs, matching one permission whose service and location both equal the offering's own and whose action list contains - the requested action. + the requested action, followed by bound_window requiring the operator + record to name that same offering and location before any window capacity + is exposed or consumed. refusal: >- - Answer operation.not-authorized; a broader service or location never - covers a narrower one and no action is inferred from another. + Answer operation.not-authorized when the grant does not match, or fail + closed as service.unavailable when the operator record is misbound. A + broader service or location never covers a narrower one and no action is + inferred from another. negativeTest: path: crates/registry-scheduling/src/auth.rs name: a_grant_naming_another_resource_is_a_profile_refusal @@ -163,16 +167,20 @@ invariants: - id: SCHEDULING-SEC-10 state: enforced threat: >- - A caller commits against a policy or an appointment that changed under - it, so the booking rests on supply or terms the operator has withdrawn. + A caller commits against a policy, published window, or appointment that + changed under it, so the booking rests on supply or terms the operator + has withdrawn. enforcementPoint: >- - The policy revision the request names, compared against the revision this - process serves, and the observed appointment revision carried in the - request body; both are fenced inside the capacity transaction, after the - supply anchor is locked and before the claim is written. + The policy revision the request names, the published-window revision for + arrival commitments, and the observed appointment revision carried in + the request body. Records replacement requires changed window terms to + advance that public revision, and the request revisions are fenced inside + the capacity transaction after the supply anchor is locked and before the + claim is written. refusal: >- Refuse a stale policy revision with policy.changed ahead of any narrower - mismatch, and refuse a stale appointment revision with revision.mismatch. + mismatch, and refuse a stale window or appointment revision with + revision.mismatch. scopeNote: >- Cancellation is deliberately outside this row: it releases capacity and is guarded by the observed appointment revision and the cutoff, never by diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index e31da6143c..4717f8c2b8 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -40,6 +40,7 @@ entries: tests: - {path: crates/registry-scheduling/src/auth.rs, name: a_grant_naming_another_resource_is_a_profile_refusal} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_edge_refuses_unauthenticated_callers_and_unauthorized_mutations} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_window_record_must_belong_to_the_authorized_offering_and_location} - {path: crates/registry-platform-oidc/src/authorization_claims.rs, name: scheduling_permission_cardinality_bounds_are_enforced} - id: SCHEDULING-SEC-06 tests: @@ -77,6 +78,7 @@ entries: - {path: crates/registry-scheduling-core/src/admission.rs, name: window_revisions_must_match_and_policy_change_wins} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_stale_process_refuses_even_a_request_under_the_current_revision} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: changed_window_records_must_advance_the_revision_even_after_removal} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: competing_cancellations_commit_exactly_one_transition} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_reschedule_and_a_cancellation_commit_exactly_one_transition} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_stale_process_can_still_release_an_appointment} From 4212df0af8187c28a1656e3d68da3069ec53763a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 16:46:05 +0700 Subject: [PATCH 111/115] docs(cli): refresh combined command review Signed-off-by: Jeremi Joslin --- docs/site/src/data/cli-reference.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index 710c083e91..91f3a4d516 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -8,5 +8,5 @@ schema_version: registry.cli-reference-review/v3 status: current last_reviewed: 2026-09-20 reviewed_source_version: "0.32.0" -reviewed_catalog_sha256: 111d61e084559498479d608c0a4e7ca7e9b7f4dfcc5f6776ffe8415899fd90a5 -reviewed_content_sha256: f2d1b8cc05af0ff192aa406e447557f456c027393b66d80fdafce6ffb87562bc +reviewed_catalog_sha256: ae90e68819adace77306956f6c3c10d8368a929d540d3f64aa79fcef68dbed5d +reviewed_content_sha256: cff8286cb8781658ee4508dcc272bb834fc2c986db34a7a3d9980d823ca68e49 From 00ef3b6064494154e6a3616cdd3f2ce3a0b7268f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 20 Sep 2026 18:02:20 +0700 Subject: [PATCH 112/115] fix(scheduling): preserve commitment accounting Signed-off-by: Jeremi Joslin --- crates/registry-scheduling-core/src/policy.rs | 27 +- crates/registry-scheduling/src/service.rs | 8 +- crates/registry-scheduling/src/store.rs | 87 +++++- .../tests/postgres_commitments.rs | 277 +++++++++++++++++- products/scheduling/README.md | 7 +- .../contracts/security-invariant-matrix.yaml | 12 +- .../contracts/security-test-traceability.yaml | 3 + 7 files changed, 389 insertions(+), 32 deletions(-) diff --git a/crates/registry-scheduling-core/src/policy.rs b/crates/registry-scheduling-core/src/policy.rs index bdae09464b..b70b745213 100644 --- a/crates/registry-scheduling-core/src/policy.rs +++ b/crates/registry-scheduling-core/src/policy.rs @@ -181,7 +181,7 @@ pub struct HolidaySetPolicy { pub dates: Vec, } -/// The authenticated channel a subquota reserves capacity for. +/// The authenticated channel a subquota limits. #[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum Channel { @@ -218,7 +218,7 @@ impl Channel { } } -/// A non-overlapping reserved slice of a window's published capacity. +/// A per-channel ceiling within a window's published capacity. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct WindowSubquota { @@ -764,7 +764,7 @@ impl SchedulingPolicy { PolicyCheckReason::DuplicateIdentifier, )); } - if window.units == 0 { + if window.units == 0 || window.units > i32::MAX as u32 { findings.push(SchedulingDiagnostic::new( format!("{path}.units"), PolicyCheckReason::InvalidBound, @@ -830,7 +830,7 @@ impl SchedulingPolicy { PolicyCheckReason::DuplicateIdentifier, )); } - // A declared set is closed: a subquota may not reserve capacity + // A declared set is closed: a subquota may not limit capacity // for a channel the deployment does not say it serves. if !self.channels.is_empty() && !self.channels.contains(&subquota.channel) { findings.push(SchedulingDiagnostic::new( @@ -1641,6 +1641,25 @@ windows: assert!(rendered.contains(&"windows[0].subquotas: subquota-overdrawn".to_owned())); } + #[test] + fn window_units_must_fit_the_ledger_column() { + let policy = household_window_policy(); + let mut windows = household_windows(); + windows[0].units = i32::MAX as u32; + assert!( + policy.check_window_records(&windows).is_empty(), + "the largest ledger allocation must remain valid" + ); + + windows[0].units = i32::MAX as u32 + 1; + let rendered: Vec = policy + .check_window_records(&windows) + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!(rendered, vec!["windows[0].units: invalid-bound".to_owned()]); + } + #[test] fn one_channel_may_not_hold_two_slices_of_one_window() { let policy = household_window_policy(); diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index eb1ed5c49a..9ef48d4b3c 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -1291,9 +1291,11 @@ impl SchedulingService { return Err(ServiceError::Problem(ProblemCode::ServiceUnavailable)); } let problem = problem_of(&error); - // Key misuse keeps the stored attempt as the answer and - // records nothing new. - if !matches!(error, CommitError::KeyReused | CommitError::KeyExpired) { + // An expired receipt cannot be recreated. A reused key still + // passes through the insert-or-replay path: a concurrent + // identical winner is replayed, while a different request + // hash remains key-reused. + if !matches!(error, CommitError::KeyExpired) { let receipt = self.commitment( caller, actor, diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 6eb0e5d489..13dd552977 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -1170,7 +1170,14 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; - let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + let mut snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + include_active_duplicate( + &transaction, + &mut snapshot, + offering, + request.duplicate_key.as_deref(), + ) + .await?; // The caller lock is taken after the supply lock, never before: every // hold transaction acquires the two in that one order, so no pair of // them can hold what the other is waiting for. @@ -1207,7 +1214,7 @@ impl PostgresStore { displayed_end: admission.end, occupied_start: admission.occupied_start, occupied_end: admission.occupied_end, - units: i32::try_from(admission.units).unwrap_or(i32::MAX), + units: i32::try_from(admission.units).map_err(|_| StoreError::Corrupt)?, duplicate_key: request.duplicate_key.as_deref(), hold_expires_at: Some(expires_at), revision: 1, @@ -1275,7 +1282,14 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; - let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + let mut snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + include_active_duplicate( + &transaction, + &mut snapshot, + offering, + request.duplicate_key.as_deref(), + ) + .await?; guard_revisions(&transaction, &commitment).await?; let admission = evaluate(offering, supply, request, &snapshot, &commitment, None)?; self.recheck_grant(&commitment)?; @@ -1295,7 +1309,7 @@ impl PostgresStore { displayed_end: admission.end, occupied_start: admission.occupied_start, occupied_end: admission.occupied_end, - units: i32::try_from(admission.units).unwrap_or(i32::MAX), + units: i32::try_from(admission.units).map_err(|_| StoreError::Corrupt)?, duplicate_key: request.duplicate_key.as_deref(), hold_expires_at: None, revision: 1, @@ -1599,7 +1613,14 @@ impl PostgresStore { None => {} } check_grant_current(&commitment)?; - let snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + let mut snapshot = lock_and_snapshot(&transaction, supply, commitment.now).await?; + include_active_duplicate( + &transaction, + &mut snapshot, + offering, + request.duplicate_key.as_deref(), + ) + .await?; let appointment = transaction .claim_in_transaction(appointment_id) .await? @@ -1634,6 +1655,7 @@ impl PostgresStore { )?; self.recheck_grant(&commitment)?; let next_revision = appointment.revision + 1; + let next_units = i32::try_from(admission.units).map_err(|_| StoreError::Corrupt)?; if !transaction .move_claim( &ClaimMove { @@ -1646,6 +1668,8 @@ impl PostgresStore { displayed_end: admission.end, occupied_start: admission.occupied_start, occupied_end: admission.occupied_end, + channel: request.channel.as_deref(), + units: next_units, policy_revision: commitment.policy_revision, next_revision, }, @@ -1677,6 +1701,8 @@ impl PostgresStore { occupied_start: admission.occupied_start, occupied_end: admission.occupied_end, supply_id: admission.resource.unwrap_or(appointment.supply_id.clone()), + channel: request.channel.clone(), + units: next_units, revision: next_revision, policy_revision: commitment.policy_revision, ..appointment.clone() @@ -2365,6 +2391,43 @@ async fn lock_and_snapshot( } } +/// Add the offering-wide duplicate fact that a time-bounded capacity snapshot +/// may omit. The existing supply lock serializes this read with every writer +/// for the offering's frozen supply; the supporting partial index keeps the +/// lookup independent of the age or span of its openings. +async fn include_active_duplicate( + transaction: &deadpool_postgres::Transaction<'_>, + snapshot: &mut LedgerSnapshot, + offering: ®istry_scheduling_core::OfferingPolicy, + duplicate_key: Option<&str>, +) -> Result<(), StoreError> { + if offering.duplicate_active_key.is_none() { + return Ok(()); + } + let Some(duplicate_key) = duplicate_key else { + return Ok(()); + }; + let rows = transaction + .query( + "SELECT claim_id, offering, supply_id, kind, channel, occupied_start, \ + occupied_end, units, duplicate_key, hold_expires_at \ + FROM scheduling_claims \ + WHERE state='active' AND kind='booking' AND offering=$1 AND duplicate_key=$2", + &[&offering.id, &duplicate_key], + ) + .await?; + for claim in snapshot_from_rows(rows).claims { + if !snapshot + .claims + .iter() + .any(|existing| existing.id == claim.id) + { + snapshot.claims.push(claim); + } + } + Ok(()) +} + /// The task-grant re-check at the door of the transaction: a request whose /// grant already lapsed fails before any lock is taken. fn check_grant_current(commitment: &Commitment<'_>) -> Result<(), CommitError> { @@ -2918,6 +2981,8 @@ struct ClaimMove<'c> { displayed_end: DateTime, occupied_start: DateTime, occupied_end: DateTime, + channel: Option<&'c str>, + units: i32, policy_revision: i64, next_revision: i64, } @@ -3093,8 +3158,9 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { let moved = self .execute( "UPDATE scheduling_claims SET supply_id=$2, displayed_start=$3, displayed_end=$4, \ - occupied_start=$5, occupied_end=$6, policy_revision=$7, revision=$8, \ - changed_at=now() WHERE claim_id=$1 AND state='active' AND revision=$9", + occupied_start=$5, occupied_end=$6, channel=$7, units=$8, policy_revision=$9, \ + revision=$10, changed_at=now() \ + WHERE claim_id=$1 AND state='active' AND revision=$11", &[ &movement.claim_id, &movement.supply_id, @@ -3102,6 +3168,8 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { &movement.displayed_end, &movement.occupied_start, &movement.occupied_end, + &movement.channel, + &movement.units, &movement.policy_revision, &movement.next_revision, &observed_revision, @@ -3206,9 +3274,8 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { if written == 0 { // The replay read at the head of this transaction saw no stored // attempt because the writer that owns the key had not committed - // yet. The key is not this caller's to answer under, and the - // whole transaction rolls back behind the refusal, so nothing was - // decided. + // yet. Roll back this transaction's tentative effects; the + // service then reconciles against the winning receipt. return Err(CommitError::KeyReused); } Ok(()) diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index cfdf913ce4..ced16568ac 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -1416,6 +1416,104 @@ async fn duplicate_active_keys_are_scoped_to_the_offering() { assert_eq!(status, StatusCode::CREATED, "{appointment}"); } +/// A policy may move an exact-time offering's opening dates while retaining +/// its offering and pool. The standing appointment then falls outside the new +/// opening span, but its offering-scoped duplicate key remains active and must +/// still prevent a second booking. +#[tokio::test] +async fn a_duplicate_active_key_survives_an_opening_shift_on_the_same_pool() { + let keyed = POLICY.replacen( + " requiresCapabilities: []", + " duplicateActiveKey: subject\n requiresCapabilities: []", + 1, + ); + let mut fx = fixture_publishing( + &keyed, + &["north-counter".to_owned(), "two-counter".to_owned()], + ) + .await; + let first = first_slot(&fx, OFFERING, 300, 440).await; + let mut first_admission = admission(&fx, OFFERING, first); + first_admission["duplicateKey"] = json!("subject:opening-shift"); + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "duplicate-before-opening-shift", + json!({"hold": null, "admission": first_admission}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + + let next_date = first + .date_naive() + .succ_opt() + .expect("the next opening date is representable"); + let mut replacement = parse_policy_yaml(&keyed).expect("the replacement policy"); + replacement.scheduling.version += 1; + replacement.openings[0].effective_from = next_date.format("%Y-%m-%d").to_string(); + let replacement_digest = replacement.policy_digest(); + let revision = fx + .store + .apply_policy( + SCHEDULING_ID, + &replacement_digest, + &["north-counter".to_owned(), "two-counter".to_owned()], + &replacement, + ) + .await + .expect("shift the opening while retaining the offering and pool"); + + let keying = AuditHashSecret::new(vec![0x42; 32]).expect("the test audit hash secret"); + let service = Arc::new(SchedulingService::new( + fx.store.clone(), + replacement, + SCHEDULING_ID.to_owned(), + revision, + replacement_digest, + AuditKeyHasher::Keyed(keying), + 7, + )); + fx.http = router(HttpState { + service, + authenticator: Arc::new(authenticator()), + store: fx.store.clone(), + }); + fx.revision = u64::try_from(revision).expect("a bounded policy revision"); + + let second = DateTime::::from_naive_utc_and_offset( + next_date.and_hms_opt(2, 0, 0).expect("a morning slot"), + Utc, + ); + let mut second_admission = admission(&fx, OFFERING, second); + second_admission["duplicateKey"] = json!("subject:opening-shift"); + let (status, refusal) = fx + .post( + "/v1/appointments", + &fx.agent, + "duplicate-after-opening-shift", + json!({"hold": null, "admission": second_admission}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{refusal}"); + assert_eq!(refusal["code"], "booking.duplicate-active"); + + let active = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims \ + WHERE offering=$1 AND kind='booking' AND state='active'", + &[&OFFERING], + ) + .await + .expect("count active bookings after the refusal"); + assert_eq!( + active.get::<_, i64>(0), + 1, + "the duplicate refusal leaves the first booking as the only active one" + ); +} + #[tokio::test] async fn a_reschedule_moves_under_the_observed_revision_and_refuses_stale_ones() { let fx = fixture().await; @@ -2425,6 +2523,83 @@ async fn a_concurrent_identical_request_replays_the_winning_receipt() { assert_eq!(replayed["appointmentId"], appointment["appointmentId"]); } +/// A success-side idempotency race differs from the exhausted-capacity race +/// above: the contender still has capacity to admit when it reaches the +/// completed-attempt insert. The winner's uncommitted key is invisible at the +/// initial replay read, then wins the insert conflict. The contender must roll +/// back its tentative claim and replay that receipt. +#[tokio::test] +async fn concurrent_identical_admissible_requests_replay_one_winning_success() { + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let fx = fixture_with_window(start).await; + let body = arrival(&fx, start, Some("public")); + let (status, winner) = fx + .post( + "/v1/appointments", + &fx.agent, + "same-admissible-seed", + body.clone(), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{winner}"); + + // Stage the same completed attempt under the contested key without + // committing it. The two-unit window still has one unit free, so the + // contender evaluates successfully and reaches this row conflict. + fx.admin + .batch_execute( + "BEGIN; \ + INSERT INTO scheduling_attempts(attempt_id, actor_issuer, actor_subject, scope, \ + idempotency_key, request_hash, state, status_code, receipt, expires_at) \ + SELECT gen_random_uuid(), actor_issuer, actor_subject, scope, \ + 'same-admissible-request', request_hash, state, status_code, receipt, expires_at \ + FROM scheduling_attempts WHERE idempotency_key = 'same-admissible-seed'", + ) + .await + .expect("hold the winning success from another writer"); + + let contender = tokio::spawn(send( + fx.http.clone(), + "POST".to_owned(), + "/v1/appointments".to_owned(), + fx.agent.clone(), + Some("same-admissible-request".to_owned()), + Some(body), + )); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!( + !contender.is_finished(), + "the contender waits on the completed-attempt conflict" + ); + + fx.admin + .batch_execute("COMMIT") + .await + .expect("release the winning success"); + let replayed = contender.await.expect("the contender answers"); + assert_eq!(replayed.0, StatusCode::CREATED, "{replayed:?}"); + assert_eq!( + replayed.1["appointmentId"], winner["appointmentId"], + "the contender receives the winning receipt" + ); + + let claims = fx + .admin + .query_one( + "SELECT count(*) FROM scheduling_claims WHERE offering=$1 AND kind='booking'", + &[&WINDOW_OFFERING], + ) + .await + .expect("count committed window claims"); + assert_eq!( + claims.get::<_, i64>(0), + 1, + "the losing transaction rolls back its tentative claim" + ); +} + /// The revision a commitment is admitted under is the one the deployment /// stores, not the one this process read when it started. Operator tooling /// and a rolling deploy both move the stored revision under a running @@ -3961,18 +4136,17 @@ fn records_with_window(start: DateTime) -> SchedulingFacts { } async fn fixture_with_window(start: DateTime) -> Fixture { + fixture_with_window_records(records_with_window(start)).await +} + +async fn fixture_with_window_records(facts: SchedulingFacts) -> Fixture { let fx = fixture_publishing( &policy_with_window(), &["north-counter".to_owned(), "two-counter".to_owned()], ) .await; fx.store - .replace_facts( - SCHEDULING_ID, - &records_with_window(start), - Uuid::new_v4(), - operator_audit(), - ) + .replace_facts(SCHEDULING_ID, &facts, Uuid::new_v4(), operator_audit()) .await .expect("publish the window records"); fx @@ -4482,6 +4656,97 @@ async fn an_arrival_window_allocates_its_units_and_holds_its_channel_ceiling() { assert_eq!(mismatch["code"], "revision.mismatch"); } +#[tokio::test] +async fn an_arrival_reschedule_persists_its_new_units_and_channel() { + let start = (Utc::now() + TimeDelta::hours(3)) + .with_nanosecond(0) + .expect("second precision"); + let mut facts = records_with_window(start); + facts.windows[0].units = 6; + facts.windows[0].units_policy = RequiredUnitsPolicy::PerRecipient { + per_recipient: 1, + because: "test".to_owned(), + }; + let fx = fixture_with_window_records(facts).await; + + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-resize-create", + arrival(&fx, start, Some("assisted")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{appointment}"); + assert_eq!(appointment["units"], 1); + assert_eq!(appointment["channel"], "assisted"); + let appointment_id = appointment["appointmentId"] + .as_str() + .expect("an appointment identifier"); + let observed_revision = appointment["revision"] + .as_u64() + .expect("an appointment revision"); + + let mut moved_admission = arrival(&fx, start, Some("public"))["admission"].clone(); + moved_admission["party"] = json!({"recipients": 5, "attendees": 5}); + let (status, moved) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "arrival-resize-move", + json!({ + "observedRevision": observed_revision, + "admission": moved_admission, + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{moved}"); + assert_eq!(moved["units"], 5); + assert_eq!(moved["channel"], "public"); + + let stored = fx + .admin + .query_one( + "SELECT units, channel FROM scheduling_claims WHERE claim_id=$1", + &[&Uuid::parse_str(appointment_id).expect("a stored appointment identifier")], + ) + .await + .expect("read the rescheduled allocation"); + assert_eq!(stored.get::<_, i32>(0), 5); + assert_eq!( + stored.get::<_, Option>(1).as_deref(), + Some("public") + ); + + // Moving out of the assisted channel frees that subquota, while the five + // recalculated units leave exactly one unit in the window's total. + let (status, last) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-resize-last-unit", + arrival(&fx, start, Some("assisted")), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{last}"); + assert_eq!(last["channel"], "assisted"); + assert!( + window_entry(&fx, 60, 300).await.is_none(), + "the resized appointment and final assisted unit exhaust the window" + ); + + let (status, refusal) = fx + .post( + "/v1/appointments", + &fx.agent, + "arrival-resize-over-capacity", + arrival(&fx, start, Some("public")), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{refusal}"); + assert_eq!(refusal["code"], "capacity.exhausted"); +} + // --------------------------------------------------------------------------- // Concurrency and ownership regressions // --------------------------------------------------------------------------- diff --git a/products/scheduling/README.md b/products/scheduling/README.md index 565ce1f9ae..bb7a4e00ad 100644 --- a/products/scheduling/README.md +++ b/products/scheduling/README.md @@ -71,10 +71,9 @@ scheduling --runtime-config "$PWD/runtime.yaml" serve scheduling id, which every later start verifies before it writes anything. A `serve` that finds a schema older than the one its binary carries refuses at the readiness check rather than serving against it, so an upgrade runs -`migrate` before it restarts the new runtime; the few minutes in between, -an old runtime may still be serving, and its in-flight availability -continuation cursors answer `cursor.invalid` once the new binary holds -them, so a caller mid-pagination restarts that listing from its first page. +`migrate` before it restarts the new runtime. Availability cursors last at +most 15 minutes; callers should deduplicate entries by their start when a +policy, records, or runtime change overlaps an in-flight listing. The migration also retains the current policy document beside its digest. On an upgrade from an earlier schema, start once with the unchanged policy to backfill that document before publishing a policy change. diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index 06b0a48311..17783418bd 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -456,12 +456,14 @@ deferred: subject: Database backstop under the duplicate-key guard state: deferred reason: >- - The duplicate guard is evaluated in the admission path against the active - claims it reads. The supporting index is partial rather than unique, so - the guard rests on the transaction rather than on a database constraint. + The duplicate guard is evaluated in the admission path against an + offering-wide indexed read of active bookings. The supporting index is + partial rather than unique, so the guard rests on the transaction rather + than on a database constraint. compensatingControl: >- - The guard runs inside the same capacity transaction as the claim it - protects, so a concurrent pair cannot straddle it. + The offering's supply lock serializes the guard and the claim it protects, + so a concurrent pair cannot straddle it even when their booking intervals + lie under different published opening revisions. recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md - id: SCHEDULING-DEF-06 subject: Appointment, history, outbox, and audit retention diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index 4717f8c2b8..c2526bc759 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -16,6 +16,8 @@ entries: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_direct_create_books_without_a_hold_and_exhausts_capacity} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: exact_time_commitments_include_buffers_outside_the_opening_range} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_records_swap_refuses_to_move_an_occupied_resource_between_pools} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: an_arrival_reschedule_persists_its_new_units_and_channel} + - {path: crates/registry-scheduling-core/src/policy.rs, name: window_units_must_fit_the_ledger_column} - id: SCHEDULING-SEC-02 tests: - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_commitment_against_an_unanchored_pool_refuses_loudly} @@ -72,6 +74,7 @@ entries: - {path: crates/registry-scheduling-core/src/model.rs, name: set_order_never_changes_the_request_hash} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: cancellation_respects_the_cutoff_and_replays_its_verdict} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_concurrent_identical_request_replays_the_winning_receipt} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: concurrent_identical_admissible_requests_replay_one_winning_success} - id: SCHEDULING-SEC-10 tests: - {path: crates/registry-scheduling-core/src/admission.rs, name: policy_change_outranks_a_window_revision_mismatch} From 07aeed714b244fd136f627a6f6308a3eca37bb8b Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 21 Sep 2026 17:39:47 +0700 Subject: [PATCH 113/115] fix(scheduling): audit every refusal the capacity transaction decides SCHEDULING-SEC-14 is recorded as enforced and promises an authorization audit record for every refusal the ledger decides. The guard selecting which outcomes to record matched only an admission refusal, the hold ceiling, and a lapsed grant, so a stale observed revision and a cancellation past its cutoff were answered as refusals with nothing written behind them. Both are decisions about a named appointment under a grant the service had already matched, which is exactly what the invariant exists to attribute. Replace the wildcard match with an exhaustive one, so a new CommitError variant has to state whether it is a decision or a fault instead of inheriting silence. Faults decide nothing and stay unaudited; an idempotency key refusal stays unaudited because the attempt receipt already carries it and the key says nothing about what a grant reaches. Narrow the invariant's enforcement point to name the outcomes on each side, so the contract asserts what the code performs rather than a looser claim it did not meet. Security review notes: this changes audit integrity. It only adds records; no existing record changes shape, reason, or ordering, and the hash chain is untouched. The added records carry the same pseudonymized principal and grant as the refusals already audited beside them, so no caller identifier reaches the journal in the clear. The new test asserts both the added rows and the absence of the raw principal. Signed-off-by: Jeremi Joslin --- crates/registry-scheduling/src/service.rs | 31 +++-- .../tests/postgres_commitments.rs | 111 ++++++++++++++++++ products/scheduling/CHANGELOG.md | 7 ++ .../contracts/security-invariant-matrix.yaml | 14 ++- .../contracts/security-test-traceability.yaml | 1 + 5 files changed, 152 insertions(+), 12 deletions(-) diff --git a/crates/registry-scheduling/src/service.rs b/crates/registry-scheduling/src/service.rs index 9ef48d4b3c..b456830511 100644 --- a/crates/registry-scheduling/src/service.rs +++ b/crates/registry-scheduling/src/service.rs @@ -1340,14 +1340,29 @@ impl SchedulingService { } } } - if matches!( - error, - CommitError::Refused(_) | CommitError::HoldCeiling | CommitError::Unauthorized - ) { - let reason = match error { - CommitError::Unauthorized => "authorization.refused", - _ => "authorization.profile", - }; + // Every commitment the ledger decides is attributable, refused + // as much as allowed. The match is exhaustive on purpose: a + // new variant must state which side it falls on rather than + // inherit silence from a wildcard. + let audited = match error { + // Nothing was decided. The transaction failed, or the + // environment moved and the caller retries against the + // current records, so there is no verdict to attribute. + CommitError::Store(_) + | CommitError::Query(_) + | CommitError::Hooks(_) + | CommitError::FactsStale => None, + // The idempotency layer refused the key, not the + // commitment. The attempt receipt above already records + // it, and the key says nothing about what a grant reaches. + CommitError::KeyReused | CommitError::KeyExpired => None, + CommitError::Unauthorized => Some("authorization.refused"), + CommitError::Refused(_) + | CommitError::HoldCeiling + | CommitError::RevisionMismatch + | CommitError::CutoffPassed => Some("authorization.profile"), + }; + if let Some(reason) = audited { self.record_refusal(audit_record( &self.hasher, &self.scheduling_id, diff --git a/crates/registry-scheduling/tests/postgres_commitments.rs b/crates/registry-scheduling/tests/postgres_commitments.rs index ced16568ac..66a973d9f1 100644 --- a/crates/registry-scheduling/tests/postgres_commitments.rs +++ b/crates/registry-scheduling/tests/postgres_commitments.rs @@ -3171,6 +3171,117 @@ async fn a_permission_refused_before_the_transaction_writes_its_audit_row() { } } +/// Every denied record in the pending journal as an (operation, reason) pair, +/// in the order the journal wrote them. +async fn denied_audit_entries(fx: &Fixture) -> Vec<(String, String)> { + fx.store + .pending_audit(100) + .await + .expect("the pending audit journal") + .into_iter() + .map(|(_, record)| record) + .filter(|record| record["outcome"] == "denied") + .map(|record| { + let field = |name: &str| { + record[name] + .as_str() + .unwrap_or_else(|| panic!("an audit record names its {name}")) + .to_owned() + }; + (field("operation"), field("reason")) + }) + .collect() +} + +/// SCHEDULING-SEC-14: a commitment the ledger refuses inside the capacity +/// transaction is as attributable as one it allows. A stale observed revision +/// and a cancellation past its cutoff are each a decision about a named +/// appointment under a grant the service already matched, so each leaves a +/// denied record behind. Without them the journal shows the booking and not +/// the refusals that followed it. +#[tokio::test] +async fn refusals_decided_inside_the_capacity_transaction_write_their_audit_rows() { + let fx = fixture().await; + + // A stale observed revision, refused after the reschedule moved it on. + let from = first_slot(&fx, OFFERING, 300, 440).await; + let (status, appointment) = fx + .post( + "/v1/appointments", + &fx.agent, + "audited-create", + json!({"hold": null, "admission": admission(&fx, OFFERING, from)}), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let appointment_id = appointment["appointmentId"].as_str().unwrap().to_owned(); + let observed = appointment["revision"].as_u64().unwrap(); + + let other = first_slot(&fx, OFFERING, 480, 620).await; + let (status, _) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "audited-resched-1", + json!({"observedRevision": observed, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let (status, problem) = fx + .post( + &format!("/v1/appointments/{appointment_id}/reschedule"), + &fx.agent, + "audited-resched-2", + json!({"observedRevision": observed, "admission": admission(&fx, OFFERING, other)}), + ) + .await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED); + assert_eq!(problem["code"], "revision.mismatch"); + + // A cancellation inside the four-hour cutoff, refused by the same ledger. + let (near_id, near_revision) = booked(&fx, 90, 200, "audited-cancel-near").await; + let (status, problem) = fx + .post( + &format!("/v1/appointments/{near_id}/cancel"), + &fx.agent, + "audited-cancel-key", + json!({"observedRevision": near_revision, "reason": "caller cancelled"}), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(problem["code"], "cancellation.cutoff-passed"); + + let denied = denied_audit_entries(&fx).await; + assert_eq!( + denied, + vec![ + ( + "appointment.reschedule".to_owned(), + "authorization.profile".to_owned() + ), + ( + "appointment.cancel".to_owned(), + "authorization.profile".to_owned() + ), + ], + "a refusal the capacity transaction decides is attributable afterwards" + ); + + // The record attributes the decision without repeating the caller. + for (_, record) in fx + .store + .pending_audit(100) + .await + .expect("the pending audit journal") + { + assert!( + !record.to_string().contains("principal-agent"), + "the audit record repeats the caller's raw identity" + ); + } +} + /// The most octets the claim ledger stores for a caller-chosen duplicate key. /// It mirrors the CHECK in migration 0001; the edge refuses the same size /// first, so a caller who exceeds it is answered rather than told the service diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index 563f21ae85..c7efda4560 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -14,6 +14,13 @@ the offering's service, its location, and the action, bounded to 64 permissions of 32 actions with no wildcard. Only the grant's expiry is re-checked inside the capacity transaction. +- Write one pseudonymized authorization audit record for every commitment + decision: the allowed case, the permission mismatches refused before the + capacity transaction opens, and the commitments that transaction refuses, + including an admission refusal, the hold ceiling, a lapsed grant, a stale + observed revision, and a cancellation past its cutoff. A failed transaction + and a replaced environment decided nothing and are not audited; an + idempotency key refusal is carried by the attempt receipt instead. - Document the maintained Casework approval and stock ThunderID exchange path, and feed its exact exchanged bearer through Scheduling's real authenticator before the adopter uses it for an appointment. diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index 17783418bd..ba0ad3a4f3 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -246,10 +246,16 @@ invariants: enforcementPoint: >- An authorization audit record built with a pseudonymized principal and stamped with its identity exactly once, written for the allowed case, - for every refusal the ledger decides, and for every permission mismatch - the service refuses before the capacity transaction opens. The journal - is published in the order it was written, and the hash chain continues - across a restart from its verified tail. + for every commitment the capacity transaction refuses, and for every + permission mismatch the service refuses before that transaction opens. + A refused commitment is one the ledger decided: an admission refusal, + the hold ceiling, a lapsed grant, a stale observed revision, or a + cancellation past its cutoff. A failed transaction and a replaced + environment decided nothing and are not audited, and an idempotency key + refusal is carried by the attempt receipt instead. The match that + selects between them is exhaustive, so a new outcome states its side. + The journal is published in the order it was written, and the hash + chain continues across a restart from its verified tail. refusal: >- Record authorization.allowed or authorization.refused as an audit reason, never as a problem code, and never carry the caller's raw identifier. diff --git a/products/scheduling/contracts/security-test-traceability.yaml b/products/scheduling/contracts/security-test-traceability.yaml index c2526bc759..60889c3fdc 100644 --- a/products/scheduling/contracts/security-test-traceability.yaml +++ b/products/scheduling/contracts/security-test-traceability.yaml @@ -115,6 +115,7 @@ entries: - {path: crates/registry-scheduling/src/runtime.rs, name: a_pending_audit_record_is_stamped_with_its_identity_exactly_once} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_hold_confirms_into_an_appointment_with_attributable_history} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: a_permission_refused_before_the_transaction_writes_its_audit_row} + - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: refusals_decided_inside_the_capacity_transaction_write_their_audit_rows} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_audit_journal_is_read_in_the_order_it_was_written} - {path: crates/registry-scheduling/tests/postgres_commitments.rs, name: the_audit_chain_continues_across_a_restart} - id: SCHEDULING-SEC-15 From 84c301bbbc7cb481e82d4350d12f33484eb49288 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 21 Sep 2026 17:39:48 +0700 Subject: [PATCH 114/115] docs: state the scheduling artifacts the release actually publishes The install block advertised scheduling-install.sh and a SCHEDULING_INSTALL_DIR override, but the release candidate's payload inventory carries no scheduling binary and no scheduling installer, and that inventory is checked for exact equality. Both links would answer 404 on the next release, and adding the assets to a manifest without first extending the inventory would be refused as unexpected payloads. Say what is true today: Scheduling publishes a container image, which the release model does carry, and has no released binary or installer yet. Wiring the binaries and the installer into the release pipeline touches release provenance and belongs in its own reviewed change. Move the CLI publication review date to the day the combined command content was last reviewed against this branch. Signed-off-by: Jeremi Joslin --- README.md | 18 ++++++++---------- docs/site/src/data/cli-reference.yaml | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 297c2e6d17..e6bd5ee483 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,6 @@ curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/downl # Registry Casework: casework and caseworkctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/casework-install.sh | bash -# Registry Scheduling: scheduling and schedulingctl -curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/scheduling-install.sh | bash - # Registry Relay: relay and relayctl curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/relay-install.sh | bash @@ -100,15 +97,16 @@ curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/downl Each installer verifies the binaries against the published `SHA256SUMS` before writing them to `$HOME/.local/bin`, or to the directory `BREG_INSTALL_DIR`, -`CASEWORK_INSTALL_DIR`, `SCHEDULING_INSTALL_DIR`, `RELAY_INSTALL_DIR`, or -`EVIDENCECTL_INSTALL_DIR` names. Registry Discovery and Registry Manifest -publish a binary and no installer: download `discovery--linux-amd64` or +`CASEWORK_INSTALL_DIR`, `RELAY_INSTALL_DIR`, or `EVIDENCECTL_INSTALL_DIR` +names. Registry Discovery and Registry Manifest publish a binary and no +installer: download `discovery--linux-amd64` or `registry-manifest--linux-amd64` from the [release page](https://github.com/registrystack/registry-stack/releases) and -check it against the release checksum chain. Container images for `breg`, -`casework`, `scheduling`, `relay`, `evidence`, and `discovery` are published as -`ghcr.io/registrystack/:`. Which platforms each artifact supports, -and what is not supported, is recorded in +check it against the release checksum chain. Registry Scheduling publishes a +container image only; it has no released binary or installer yet. Container +images for `breg`, `casework`, `scheduling`, `relay`, `evidence`, and +`discovery` are published as `ghcr.io/registrystack/:`. Which +platforms each artifact supports, and what is not supported, is recorded in [known limitations](https://docs.registrystack.org/dev/explanation/known-limitations/#platform-support). ```mermaid diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index 91f3a4d516..46c3ab314f 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -6,7 +6,7 @@ # while this record remains a draft. schema_version: registry.cli-reference-review/v3 status: current -last_reviewed: 2026-09-20 +last_reviewed: 2026-09-21 reviewed_source_version: "0.32.0" reviewed_catalog_sha256: ae90e68819adace77306956f6c3c10d8368a929d540d3f64aa79fcef68dbed5d reviewed_content_sha256: cff8286cb8781658ee4508dcc272bb834fc2c986db34a7a3d9980d823ca68e49 From e807da43f8a330bce704b271f63c9b5dfa7728ca Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Mon, 21 Sep 2026 18:41:09 +0700 Subject: [PATCH 115/115] docs(scheduling): record the unchecked combined policy and records invariants The canonical validation of a policy against the published window records it governs runs only in the authoring tooling. Neither database write repeats it: policy publication fences offerings carrying active claims but never reads the window records, and records replacement never reads the deployed policy. An operator bypassing the authoring tooling can back an exact-time offering with a pool that already staffs a published window, and the two modes then draw the same staffing through separate anchor rows. No matrix row claimed this was enforced, so the security contract was accurate but silent. Record it as SCHEDULING-DEF-07 with the authoring refusal as its compensating control, restate it in the review notes the row points at, and name the limit in the changelog beside the ledger promise it qualifies. Signed-off-by: Jeremi Joslin --- products/scheduling/CHANGELOG.md | 5 +++++ products/scheduling/SECURITY-REVIEW-NOTES.md | 21 +++++++++++++++++-- .../contracts/security-invariant-matrix.yaml | 18 ++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/products/scheduling/CHANGELOG.md b/products/scheduling/CHANGELOG.md index c7efda4560..b25bd9cda1 100644 --- a/products/scheduling/CHANGELOG.md +++ b/products/scheduling/CHANGELOG.md @@ -10,6 +10,11 @@ appointment is created, moved, or released only inside that transaction, and no other product may write the ledger; eligibility stays with the source system. +- Validate a policy against the published window records it governs in the + authoring tooling, which refuses a window whose staffing pool also backs an + exact-time offering. Policy publication and records replacement do not + repeat that combined check at the database, so a deployment that bypasses + the authoring tooling can still publish the combination it refuses. - Authorize every commitment with a task grant whose scheduling bounds name the offering's service, its location, and the action, bounded to 64 permissions of 32 actions with no wildcard. Only the grant's expiry is diff --git a/products/scheduling/SECURITY-REVIEW-NOTES.md b/products/scheduling/SECURITY-REVIEW-NOTES.md index b6f49d6f6f..5e549bf1fd 100644 --- a/products/scheduling/SECURITY-REVIEW-NOTES.md +++ b/products/scheduling/SECURITY-REVIEW-NOTES.md @@ -261,8 +261,8 @@ request shapes stay strict. *Tests:* ## Known deferrals -The matrix records four deferrals with their compensating controls. -SCHEDULING-DEF-01 is stated in threat 2 above; the other three are +The matrix records five deferrals with their compensating controls. +SCHEDULING-DEF-01 is stated in threat 2 above; the other four are restated here as the index the matrix's `recordedIn` points at: - **SCHEDULING-DEF-04, the channel is not bound to the verified caller.** @@ -277,6 +277,23 @@ restated here as the index the matrix's `recordedIn` points at: supporting index is partial rather than unique. - **SCHEDULING-DEF-06, retention scope.** Recorded in `RUNTIME-CONFIG.md`, which states plainly what the sweeps cover. +- **SCHEDULING-DEF-07, the combined policy and records invariants are + not re-checked at the database.** `SchedulingPolicy::check_window_records` + validates a policy against the published window records it governs, + and among other rules refuses a window whose staffing pool also backs + an exact-time offering: the two modes count that staffing differently, + and an exact-time claim locks the pool anchor while an arrival claim + locks the window anchor, so the ledger has no row on which to observe + the conflict. That check runs only in `schedulingctl`, against the + policy file on disk. `apply_policy` locks every supply anchor and + fences offerings carrying active claims, but never reads + `scheduling_windows`; `replace_facts` swaps the window records without + reading the deployed policy. An operator who edits the policy and + restarts the runtime can therefore deploy the combination the + authoring check refuses. It is an operator path, not a caller-reachable + one, and the authoring refusal is the compensating control; closing it + means re-checking both directions inside the two transactions that + already hold the anchors locked. A change that closes one of these promotes the matrix entry in the same commit and rewrites this section with it. diff --git a/products/scheduling/contracts/security-invariant-matrix.yaml b/products/scheduling/contracts/security-invariant-matrix.yaml index ba0ad3a4f3..0002a6beee 100644 --- a/products/scheduling/contracts/security-invariant-matrix.yaml +++ b/products/scheduling/contracts/security-invariant-matrix.yaml @@ -482,3 +482,21 @@ deferred: The configuration reference says so explicitly rather than implying a sweep that does not run. recordedIn: products/scheduling/RUNTIME-CONFIG.md + - id: SCHEDULING-DEF-07 + subject: Combined policy and records invariants re-checked at the database + state: deferred + reason: >- + The canonical validation of a policy against the published window records + it governs runs only in the authoring tooling, against the policy file on + the operator's disk. Neither database write repeats it: policy + publication fences offerings carrying active claims but never reads the + window records, and records replacement never reads the deployed policy. + An operator who publishes a policy their own tooling would refuse can + therefore back an exact-time offering with a pool that already staffs a + published window, and the two modes then draw on the same staffing + through separate anchor rows the capacity transaction cannot compare. + compensatingControl: >- + Every authored path runs the combined check before either document + reaches a deployment, and names the offending field in the operator's + own vocabulary. Reaching the gap means bypassing that tooling. + recordedIn: products/scheduling/SECURITY-REVIEW-NOTES.md