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.
Found during review of #1092.
The refusal names an operation the product does not have
check_duplicateincrates/registry-scheduling-core/src/admission.rsrefuses asecond booking whose party carries a duplicate key already held by an active one.
The published detail for
booking.duplicate-activeis:There is no completion transition. The router in
crates/registry-scheduling/src/http.rspublishes exactly these appointment operations: create, get, reschedule, cancel,
history.
schedulingctlhas no claim lifecycle command either: its subcommands areinit,check,test,explain,package,records apply, andintents.The active set has no upper time bound
include_active_duplicateincrates/registry-scheduling/src/store.rsreads:No interval bound. A confirmed booking stays
state='active'for as long as the rowexists, so the query keeps finding it after the appointment has ended.
Cancellation stops being available before the appointment starts
The cancellation path in
store.rsrefuses once the cutoff is reached:cancellation_cutoff_minutesis a plainu32onOfferingPolicy, not an option, andcancel_appointmentalways passes it. So the cutoff always applies. Even at0theappointment becomes uncancellable the moment it starts.
Result
For any offering carrying
duplicateActiveKey, once a party's appointment start haspassed, 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 thehash-chained audit journal that every other state change goes through.
Options
it, which is the honest fix and the largest.
include_active_duplicatebyoccupied_end > now, so the guard means "concurrentlyactive" rather than "ever booked".
expire_due_holdssweeps 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-activedetail should stop promisingcompleteuntil that operation exists.