Schedule Locks
Overview
A Schedule Lock is a tournament director's declaration that a matchUp's placement is deliberate and must not be moved by bulk or automated scheduling. The marquee match promised centre court at 19:00 survives a "Clear this day", a re-schedule of everything around it, or a committed schedule scenario.
Key characteristics:
- Persisted first-class on
matchUp.schedule.lock— no legacytimeItemmirror, noLEGACY/DUAL/NATIVEbranching. - Guards placement only.
startTime,stopTime,resumeTimeandendTimerecord actual play and are never guarded — a pinned match must still be startable, suspendable and completable. - Skipped, never fatal, in bulk paths. One pinned matchUp cannot abort the clear of the other two hundred; the skipped ids come back as
lockedMatchUpIds. - Inert rather than removed once the matchUp completes, or while it has no placement to guard. Nothing is ever unwritten.
- Invisible to published views — a lock is an internal operations annotation, and
lock.reasonis the director's own note. - Unrelated to
mutationLocks, which gate whole methods at tournament grain. Different mechanism, different scope; only the word is shared.
Data Structure
type ScheduleLock = {
attributes?: ScheduleLockAttribute[]; // absent ⇒ the whole placement is pinned
lockedAt?: string; // caller-provided ISO string (factory does not stamp wall-clock)
lockedBy?: string;
reason?: string; // 'featured' | 'broadcast' | free text
};
type ScheduleLockAttribute =
'allocatedCourts' | 'courtId' | 'courtOrder' | 'scheduledDate' | 'scheduledTime' | 'venueId';
The presence of the object is the lock. {} pins the entire placement; { attributes: ['scheduledTime'] } pins the time and leaves the court free to move.
Setting and clearing
// pin the whole placement
engine.setMatchUpScheduleLock({
lock: { reason: 'featured', lockedBy: userId, lockedAt: new Date().toISOString() },
matchUpId,
drawId,
});
// pin only the clock time
engine.setMatchUpScheduleLock({ lock: { attributes: ['scheduledTime'] }, matchUpId, drawId });
// unlock
engine.setMatchUpScheduleLock({ lock: null, matchUpId, drawId });
Asking whether a matchUp is locked
// ids — drawId resolves to drawDefinition through the engine
engine.isScheduleLocked({ matchUpId, drawId });
// → { success: true, scheduleLocked: true, lock: { reason: 'featured', … } }
// a matchUp already in hand — the cheap form; a table of hundreds of rows
// should not resolve each one by id
engine.isScheduleLocked({ matchUp });
The lock object comes back alongside the verdict so a caller can show why a matchUp is pinned without a second lookup. scheduleLocked is false — with the lock still returned — when a lock exists but is inert (the matchUp completed, or there is no placement to guard).
scheduleGovernor.matchUpScheduleLocked({ matchUp }) is the bare predicate behind it, returning a boolean with no result envelope. Enforcement inside the factory uses that; consumers should prefer isScheduleLocked.
What a lock stops
The predicate is enforced at every mutation that writes or wipes placement, so there is no path that quietly bypasses it:
| Mutation | Behaviour on a locked matchUp |
|---|---|
addMatchUpScheduleItems | Returns SCHEDULE_LOCKED; info names the locked attributes |
bulkScheduleMatchUps / bulkScheduleTournamentMatchUps | Skips the matchUp, continues with the rest, returns lockedMatchUpIds |
clearScheduledMatchUps | Skips the matchUp, returns lockedMatchUpIds |
clearMatchUpSchedule | Returns SCHEDULE_LOCKED |
applyScheduleScenario | Skips (hand-off to bulkScheduleMatchUps), returns lockedMatchUpIds |
Every one of them accepts overrideScheduleLock: true for callers that have confirmed the intent with the operator — the TMX grid, for example, confirms a drag of a pinned matchUp and then passes the override. An override moves the placement but does not remove the lock: only setMatchUpScheduleLock does that.
Two paths deliberately ignore locks:
deleteCourt/deleteVenue. A lock cannot survive the deletion of the court it pins to — keeping the assignment would leave a danglingcourtId.resetDrawDefinition. A draw reset discards the schedule wholesale, lock included.
What a lock does not stop
Automated scheduling never needed guarding: proAutoSchedule only fills empty grid cells, and processAlreadyScheduledMatchUps already counts existing placements against court capacity. A locked matchUp therefore reserves its own slot in every scheduler run for free. The one scheduler path that could have moved it — clearScheduleDates, which routes through clearScheduledMatchUps — is covered by the table above.
Annotations on a placement stay editable while pinned: courtAnnotation, timeModifiers, and the official / scorekeeper / timekeeper assignments. Assigning an official is never blocked by a lock, and an officiating conflict-of-interest refusal is never masked by one — the two gates live in different mutations (addMatchUpOfficial vs addMatchUpScheduleItems) and are asserted independently, so a director cannot unlock, retry, and meet a second refusal they were never warned about.
A write that changes nothing is also permitted: re-writing the same values does not trip a lock. That includes court allocations, which are compared by court identity rather than structurally — allocatedCourts is written as bare courtIds but stored as hydrated court objects, and re-ordering the same courts is not a move.
Release on completion
A lock is inert — not deleted — once the matchUp reaches a status in completedMatchUpStatuses:
isScheduleLocked({ matchUp }); // false once matchUpStatus is COMPLETED / RETIRED / WALKOVER / …
This is deliberate. Completed matchUps are already skipped by every bulk scheduling path unless scheduleCompletedMatchUps is passed, so a lock has nothing left to guard; releasing it lazily keeps schedule writes off the scoring path entirely and leaves nothing to undo if a score is later removed.
The same inertness applies when a matchUp has no placement. A lock left behind by an overridden clear must not make the matchUp silently unschedulable — a failure that would present as "auto-schedule keeps ignoring this match" with nothing in the record to explain it.
Write-mode parity
LEGACY and DUAL records keep placement in timeItems[]; NATIVE keeps it in first-class matchUp.schedule.*. The lock predicate reads both surfaces, so a lock behaves identically in every schemaWriteMode. A first-class-only read would have made locks silently inert in LEGACY — the same divergence that once made unscheduling a no-op in NATIVE.