Skip to content

scheduling: a duplicate-key offering permanently locks a party out once their appointment has elapsed #1251

Description

@jeremi

Found during review of #1092.

The refusal names an operation the product does not have

check_duplicate in crates/registry-scheduling-core/src/admission.rs refuses a
second booking whose party carries a duplicate key already held by an active one.
The published detail for booking.duplicate-active is:

An active booking already holds this party's duplicate key. Cancel or complete it before booking again.

There is no completion transition. The router in crates/registry-scheduling/src/http.rs
publishes exactly these appointment operations: create, get, reschedule, cancel,
history. schedulingctl has no claim lifecycle command either: its subcommands are
init, check, test, explain, package, records apply, and intents.

The active set has no upper time bound

include_active_duplicate in crates/registry-scheduling/src/store.rs reads:

WHERE state='active' AND kind='booking' AND offering=$1 AND duplicate_key=$2

No interval bound. A confirmed booking stays state='active' for as long as the row
exists, so the query keeps finding it after the appointment has ended.

Cancellation stops being available before the appointment starts

The cancellation path in store.rs refuses once the cutoff is reached:

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);
}

cancellation_cutoff_minutes is a plain u32 on OfferingPolicy, not an option, and
cancel_appointment always passes it. So the cutoff always applies. Even at 0 the
appointment becomes uncancellable the moment it starts.

Result

For any offering carrying duplicateActiveKey, once a party's appointment start has
passed, that party's booking can no longer be cancelled and can never become inactive.
The duplicate guard then refuses every future booking of that offering by that party,
permanently, while instructing them to complete a booking the product cannot complete.
Rescheduling does not help: it moves an active claim and leaves it active.

The only repair is a direct write to scheduling_claims, which bypasses the
hash-chained audit journal that every other state change goes through.

Options

  • Add a terminal transition (completed, attended, no-show) and a surface that reaches
    it, which is the honest fix and the largest.
  • Stop treating elapsed bookings as active for duplicate-key purposes: bound
    include_active_duplicate by occupied_end > now, so the guard means "concurrently
    active" rather than "ever booked".
  • Sweep elapsed bookings to a terminal state the way expire_due_holds sweeps holds,
    which also gives the ledger an accurate active set for every other reader.

The second is the smallest change that removes the permanent lockout, and it matches
what the guard is actually for: one live booking per party per offering. Whichever is
chosen, the booking.duplicate-active detail should stop promising complete
until that operation exists.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions