Provenance
7.0.0 is about a single idea: a competition record should be able to say why it is in the state it is in.
Most of what changed follows from that, and most of it is behaviour you already expected. Two changes ask something of you; the rest is the engine answering questions it previously could not.
The headline feature is unrelated and additive: the LADDER draw type, a continuous
challenge-driven competition with an enforced CHALLENGED status, an attestation gate on
self-reported results, RANK or RATING ordering, and eighteen new engine methods to drive it.
- Upgrading? Start at what you must act on — it is a short list.
- Upgrade mechanics in detail: the 6.x to 7.0.0 migration guide.
- Every commit: CHANGELOG.md.
Why provenance
TODS describes a tournament that has happened. It is a results standard, and an excellent one: it can express who played whom, what the score was, and how a draw was structured, with enough precision to exchange records between systems.
A tournament in progress is a different thing. It is an operation. A director corrects a mistyped score at nine in the evening and that correction has to travel — through a consolation that is already playing, past a BYE that was placed because of the result being corrected, into a slot that was reserved for somebody who is now eligible again. The question that matters operationally is not what is the state but why is it this state, and what happens to it when the reason changes.
TODS has no vocabulary for that, so implementations put it in extensions — untyped bags hanging off
the record, each system inventing its own shape, none of it exchangeable and none of it validated.
CODES is CourtHive's superset of TODS, and 7.0.0 is where the operational half stops being an
extension and becomes part of the standard. Check-in and sign-in become
first-class attestations. Why an exit sits on a side becomes
sideExitProvenance, carrying the identity of the matchUp that produced
it. A BYE placed by a cascade is distinguishable from one placed by draw generation. Scheduling
attributes, delegated outcomes and check-in attribution all move out of bags and into typed fields.
Pressing into the edges
Provenance is not only a storage question. Once a record can say why it is in a state, you can ask whether it is still right — and that is where the bulk of this release went.
Two edges in particular:
Double-exit propagation. A DOUBLE_WALKOVER or DOUBLE_DEFAULT advances nobody, so the engine
must decide what reaches the next round, what reaches the consolation, and what happens where two of
them meet. The rules were mostly right and the edges were not, and the edges are where real
tournaments live.
Undoability. A result can be corrected, and the correction has to leave the draw where it would have been had the correction been the original entry. That sounds obvious and is surprisingly hard: a cascade that writes several matchUps forward must take back exactly what it placed and nothing a different cascade placed. 7.0.0 makes that hold across every draw type the engine generates, with one characterised exception in the compass family, and it is enforced by a sweep rather than by inspection.
Most of the resulting changes are technically breaking and practically invisible: a BYE is never the winning side, a person cannot be on both sides of a matchUp, removing a result takes back the exits it produced. You were entitled to expect all of those already.
How we know
Three things made this release measurable rather than argued.
Probes over assertions. Every defect closed here was reproduced before it was diagnosed — a draw built, driven through a sequence of results, and its state compared against what the same outcomes reach by another route. A defect that cannot be reproduced on demand is not understood well enough to fix.
Confluence sweeps. A 192-cell harness drives every draw type down two routes to the same outcome — entering a result directly, and reaching it through a correction — then compares the draws. Its counts are asserted exactly, in both directions, so an improvement fails the build until the baseline is lowered and a regression cannot hide in the noise.
Integrity detectors that can see the failure. getDrawInconsistencies gained
BYE_ADVANCEMENT_MISSING and PROPAGATED_EXIT_LOST because two whole defect classes were
structurally invisible to it: every existing check started from a winningSide or an exit status,
and a BYE has no winner while an erased exit has neither.
Provenance you can read
Three fields carry the operational "why" that used to live in extensions or in inference.
matchUp.sideExitProvenance — per side, keyed by sideNumber: the upstream status that caused
an exit, the status this side was given, and sourceMatchUpId, the identity of the matchUp whose
exit produced it. Identity is what makes an unwind possible: when a result is corrected, the cascade
can withdraw exactly what it placed and leave what a different cascade placed. It also carries
byeClaims — the matchUps whose double exit claims a BYE on that side, because two of them can claim
the same one and only the survivor's claim should keep it.
positionAssignment.byeFromPropagation — a BYE placed by a cascade, distinguishable from one
placed by draw generation or by hand. It replaced a topology inference (feedRound || roundNumber === 1)
that could not tell the two apart and therefore over-cleared on unwind.
matchUp.matchUpStatusCodes is now typed. It was published as any[]. It carries the scoring
policy's codes, one per side, for display. Which side exited is read from sideExitProvenance, and
which reason belongs to which side from sideStatusCodes. See
§24.
Read provenance, not status, when the question is "did an exit reach here?" — a matchUpStatus
cannot tell you which side it belongs to, and a BYE-held matchUp keeps its BYE status by design.
Presence becomes first-class
Check-in and sign-in were timeItems — an ordered log of untyped items, with the participant id
smuggled into itemValue. They are now PresenceAttestation collections: matchUp.checkIns[] for
presenting at a match, participant.presence[] for arriving at the tournament. Deliberately one
model, because they are the same statement at different scope.
Being first-class buys attribution: who attested, when it happened as distinct from when it was
recorded, and an attestation id. A DECLARED attribution can name somebody who is not in the
record — a minor's parent at the desk — which is also why the attester is never emitted in bulk.
If you read checkedInParticipantIds or allParticipantsCheckedIn, nothing changes; hydration still
attaches both, and the reader folds whichever surface a record holds.
The surface changes in detail
These are the changes that alter a published contract rather than correcting behaviour. Each is covered in full in the migration guide; what follows is why each one moved.
1. Exit propagation is idempotent, atomic, and correct
WALKOVER and DEFAULTED statuses cascade through a draw, placing BYEs and advancing participants ahead of a match that will never be played. 7.0.0 corrects a cluster of defects in that cascade, found by a new test harness and an at-scale sweep. Two of the fixes change behaviour a caller could have been relying on.
Re-applying the same double exit now does nothing. Previously the second call re-ran the cascade, read its own earlier work as a second source exiting into the same target, escalated the produced WALKOVER to a DOUBLE_WALKOVER, and cascaded a round further. Both calls reported success, so a client retry or a double-click corrupted the draw progressively and silently.
A rejected mutation no longer destroys a recorded result. setMatchUpStatus now validates a bare { winningSide } outcome before any removal runs. Previously such an outcome skipped the early participant check — it carries no matchUpStatus — and was caught only after the existing result had already been unwound, so a rejected call could destroy a recorded result.
For the general case, pass rollbackOnError: true and a refused mutation leaves the draw byte-identical, discarding the queued notices with it. That is not new in 7.0.0, and it is worth stating plainly that it is opt-in: without the flag a refusal raised deep in a cascade can still return an error over a partially changed draw. executionQueue snapshots for the whole queue, so TMX and competition-factory-server already have this on every mutation; a consumer calling setMatchUpStatus directly should pass it. See Exit Propagation.
The rest correct wrong answers rather than change a contract: a topology proxy that awarded a walkover to the side holding nobody, a cross-structure winner that fell through silently and never reached the Decider, an unwind that asked a field the cascade had already overwritten, a source-structure drawPosition handed to a target in another structure, and an unguarded participantId === undefined that matched the first empty slot rather than nothing.
A second round of cascade fixes landed after that list and two of them are visible in stored records. DOUBLE_DEFAULT now takes the same propagation branches as DOUBLE_WALKOVER — three sites asked "is this a double exit" by testing === DOUBLE_WALKOVER, so a default took the wrong branch at each. And which double exit a convergence becomes no longer depends on the order the two results were entered: it is derived from both sides' origins, where it used to be read from whichever arrived last. Both are described in migration §11.
The remaining cascade work is a refusal the engine raises mid-cascade for a placement it should not have attempted. Those are being closed one root cause at a time and are tracked by the randomized sweep rather than by a contract change.
→ Exit Propagation, migration §2, migration §3.
2. Time-zone conversions refuse rather than throw or guess
The zoned calendar intent had two implementations — a published timeZone.ts and an internal zonedTime.ts that carried every live conversion in the repo. On valid input they were measured to be numerically identical: 420,480 wall clocks across 12 zones and every day of 2026, plus all 418 IANA zones from 1970 onward, produced zero disagreements. DST was never the difference.
The difference was failure handling, and each was fail-open on a different axis. timeZone.ts threw an uncaught RangeError from functions typed string | { error }, and omitting the zone returned the host machine's offset — so the same published call answered differently on a New York server and a London one. zonedTime.ts never threw, but silently substituted the caller's offset for an unrecognised zone.
zonedDateTime is now the single implementation and timeZone is an adapter over it with no arithmetic of its own. Three inputs get three answers, and never a substituted number:
| Input | Result |
|---|---|
| zone absent | the caller's utcOffsetMinutes, with source: 'offset' |
| zone unrecognised | refused — { error: INVALID_TIME_ZONE } |
| zone recognised | resolved per instant, with source: 'zone' |
getTimeZoneOffsetMinutes also changes shape, from number to number | undefined — the one change in this area that alters a published type, so a TypeScript consumer sees it at compile time rather than at runtime.
→ migration §4, tools.zonedDateTime.
3. Two queries refuse an absent object param instead of answering
checkMatchUpIsComplete takes a matchUp object; getParticipantResults takes an array of in-context matchUps. Neither takes an id — and paramsMiddleware resolves drawId into a drawDefinition but does not resolve matchUpId into a matchUp or gather matchUps. So the engine-idiomatic call supplied nothing, and both answered anyway:
// before 7.0.0 — both wrong, neither says so
tournamentEngine.checkMatchUpIsComplete({ matchUpId, drawId }); // false, for a COMPLETED matchUp
tournamentEngine.getParticipantResults({ drawId }); // {}, for a fully-played draw
Both now return ERR_MISSING_MATCHUP / ERR_MISSING_MATCHUPS. It was worth breaking because checkMatchUpIsComplete returns a boolean a caller branches on — no error to notice, no undefined to guard, and the wrong answer is the safe-looking one.
An empty array is still a valid question with an empty answer; the guard is on the argument being absent, not empty.
getParticipantResults also refuses any stored (non-hydrated) matchUp — one carrying no sides at all. Results are attributed through sides[].participantId, and until 7.0.0 the helper that reads a side returned the literal string 'foo' when sides was absent — so a round robin tallied from stored matchUps returned results keyed foo rather than failing. That sentinel, and the two console.log calls beside it, are gone.
→ migration §5.
4. participantsRequiredMatchUpStatuses — a spelling fix
The exported constant was misspelled particicipantsRequiredMatchUpStatuses (an extra ici) since it was introduced. Rename the import; the value, the type and the semantics are identical. No deprecated alias is provided, and a survey of the CourtHive ecosystem found no consumer importing the old name.
→ migration §1.
The headline feature — the LADDER draw type
A ladder is a continuous, challenge-driven competition. Participants occupy an ordered standing, a participant may challenge someone above them, and the result rearranges the standing. There are no rounds, no bracket, no fixed field, and often no end date.
LADDER shares the AD_HOC structure shape — matchUps carry neither roundPosition nor drawPosition — but not its meaning: an AD_HOC draw's positionAssignments are a roster, while a ladder's are the standing and drawPosition reads as rank, where 1 is the top. isAdHocType('LADDER') is therefore true; ask isLadder wherever the difference matters.
The full walkthrough is the Ladder concepts page. What follows is what is new about it.
Driving a ladder
drawType: LADDER generates a structure with no matchUps and no positionAssignments — every matchUp is created by a challenge, and every position is a rank that addLadderParticipant assigns. The lifecycle is on the engine as eighteen methods, and each resolves the ladder from drawId alone:
tournamentEngine.addLadderParticipant({ participantId, addedAt, drawId });
const { matchUpId } = tournamentEngine.issueChallenge({
challengerParticipantId,
defenderParticipantId,
issuedAt,
drawId,
});
tournamentEngine.acceptChallenge({ matchUpId, respondedAt, drawId });
tournamentEngine.submitResult({ participantId, outcome: { winningSide: 1 }, submittedAt, matchUpId, drawId });
tournamentEngine.confirmResult({ participantId, confirmedAt, matchUpId, drawId });
tournamentEngine.applyLadderMovement({ trigger: 'RESULT', appliedAt, matchUpId, drawId });
const standing = tournamentEngine.getLadderStanding({ drawId });
// [{ position: 1, participantId: 'p9' }, …]
Three internal helpers are deliberately not on the engine. mirrorStandingToScale must stay a side effect of the position mutation, or a caller could move a standing without writing its history; applyLapseConsequence is invoked by declineChallenge after the lapse is counted, so calling it directly would apply a penalty nothing counted.
The challenge is the only fixture a participant creates
Everything else in the factory arrives from a draw. Eligibility is consequently enforced when the challenge is issued rather than when a draw is generated — nothing upstream decided these two would meet. A downward challenge, a self-challenge, a participant not seated on the ladder, a challenge outside the policy's range, and any drawType that is not LADDER are all refused.
Expiry is derived, never stored, so a challenge nobody looked at does not sit in the record claiming to be pending.
CHALLENGED is the first matchUpStatus whose context is enforced
CHALLENGED is valid only in a LADDER drawType; setMatchUpStatus returns ERR_MATCHUP_STATUS_OUT_OF_SCOPE anywhere else. The mechanism is general — matchUpStatusScopes declares where a status may be used and getMatchUpStatusScopeViolation enforces it — rather than an if in one setter. DEAD_RUBBER, meaningful only inside a team tie, is the obvious second adopter and is declared but deliberately left inactive, since enforcing it could reject data that is legal today.
CHALLENGED joins validMatchUpStatuses, participantsRequiredMatchUpStatuses, nonDirectingMatchUpStatuses and upcomingMatchUpStatuses. That last one widens the meaning of "upcoming" from will happen to is expected to happen, since a challenge may be declined or expire — a deliberate trade, because a challenge missing from every upcoming-match view is invisible to exactly the people who must act on it.
An attestation gate on self-reported results
A ladder result is reported by the people who played it, so movement is gated on an attested result in code rather than in a comment. POLICY_LADDER's resultValidation decides what counts: PEER (the opponent confirms), OPERATOR (an official does), or EITHER. A submitted result that has been disputed is blocked from moving the standing.
Declines, silence and no-shows are one mechanism
Declining a challenge, ignoring one until it expires, and accepting then not turning up are treated as a single lapse, not three separate rules — from the challenger's side they are the same offence, being unavailable to the people below you, and clubs already treat them that way. A unified lapsePolicy decides the consequence.
Because consequences are evaluated when a challenge resolves rather than by a periodic sweep, a participant nobody challenges never lapses. An operator removal escape hatch exists for exactly that case.
→ Lapses, Removing someone by hand.
Ordering is RANK or RATING, and direction is read rather than assumed
Under RANK (the default) the standing is positionAssignments, mutated by the movement rules — SWAP or INSERTION, which are genuinely different competitions. Under RATING the standing is derived from each participant's rating, positionAssignments becomes a projection, and movement does nothing because the ordering already is the scale.
ratingsParameters[ratingType].ascending decides which end is the top: WTN, BWF and USAR are lower-is-better, while UTR, ELO, DUPR and PSA are higher-is-better. Hardcoding "higher wins" would invert an entire WTN ladder and raise no error. An unrated participant sorts last — absent is not "best" — and an unknown ratingType returns the stored order with no ratingValue rather than presenting a misconfiguration as a working ladder.
Every rank change is mirrored to a dated ScaleItem as a side effect of the position mutation, never as a separate call a caller might skip, so "where was I in March" is an ordinary scale lookup rather than a second store.
→ Ordering, Movement, History.
What the factory deliberately does not do
It does not fetch ratings. UTR, WTN and DUPR are other people's systems with their own credentials and terms; retrieving values belongs in an operator's ingest adapter, not in a competition engine. A rating-ordered ladder therefore runs either dynamically — the factory computes ratings from ladder results via the same generateDynamicRatings machinery DrawMatic uses, seeded from each participant's published rating — or from an operator's bulk refresh.
Dispute resolution is not built: a disputed result is blocked from moving the standing, but nothing resolves it yet.
Other 7.0.0 additions
SQUASHandBADMINTONjoin the discipline vocabulary —DisciplineUnionaccepts any string, so both values already validated; what they lacked was membership of the curated set that drives autocomplete, normalization and near-match typo defense. They belong there because the matchUpFormat grammar already parses and round-trips both sports' scoring, and a discipline the engine can score should not be a stranger to the vocabulary.- The four calendar intents are named and documented —
tools.plainDate(which calendar day),tools.plainTime(what time on the clock),tools.zonedDateTime(which moment at a venue) and an absolute instant. Only the third depends on a zone, and mixing the first two without an explicit conversion produces a figure wrong by the venue's UTC offset.plainDate,plainTimeandzonedDateTimeare new published exports;tools.dateTimeis supported and unchanged. See Four questions, not one. - The Temporal status is corrected — Temporal reached Stage 4 in March 2026, is part of ES2026, and ships unflagged in Node 26, Chrome/Edge 144, Firefox 139 and Deno 2.7. Safari remains the gap, so it is not yet Baseline. See Temporal API.
PositionAssignment.byeFromPropagation— a new optional boolean recording that a BYE was placed by an exit cascade rather than by draw generation or by hand. It replaces a topology inference (feedRound || roundNumber === 1) with a first-class fact, and is visible in stored records and anything that round-tripspositionAssignments. See BYE provenance.matchUp.sideExitProvenance— a new optional per-side record of WHY an exit sits on a side: the upstream status that caused it, the status this side was given, and the id of the matchUp whose exit produced it. It gives propagation provenance its own home, keyed bysideNumber, instead of leaving it positional insidematchUpStatusCodesbeside two unrelated element shapes. Purely additive;matchUpStatusCodesis still written. See Exit provenance.- Exit codes stop being relabelled as walkovers — a normalization in the propagation path mapped every object-shaped
matchUpStatusCodeselement to the walkover codeWO. Measured over randomized scenarios, 44% of what it rewrote was not a walkover: 13 BYEs and 9 defaults out of 50. Each element now resolves to its own outcome code, and a BYE contributes none rather than becoming a walkover. Values you read frommatchUpStatusCodescan therefore differ — see migration §12. addFinishingRoundsrefuses an absentmatchUpsarray instead of returning[]— which mattered because the natural call shape is an assignment, so an empty return silently replaced the caller's list. It mutates in place and returns the same reference, so valid usage is unchanged. See migration §7.buildDrawHierarchyis removed — a D3-era draw-rendering tree with no consumer anywhere in the ecosystem (measured across 1,809 source files in seven repos) and no documentation. Its implementation and tests are preserved verbatim outside this repository, so restoring it would be a copy rather than a rewrite — open an issue if you need it. See migration §6.- An exit-propagation test harness — a cross-product matrix, relational property suites (do/undo, idempotence, monotonicity), two agreement oracles, and a quarantine registry enforced in both directions so a fixed failure fails the run until its entry is removed. An at-scale randomized sweep with delta-debugging runs on demand. It exists because
isActiveDownstreamwas at 100% branch coverage when it shipped 444 spurious refusals. See Exit Propagation Harness.
What you must act on
Two changes ask something of you. The rest of this list is narrow — several items touch no consumer anywhere in the CourtHive ecosystem, measured across seven repositories.
- A produced exit has no
winningSideuntil an opponent arrives. This is the one most likely to affect you. AWALKOVERpropagated into the next round used to carry awinningSidecomputed from drawPosition ordering — on matchUps holding nobody at all. It no longer does, so a naiveif (matchUp.winningSide)renders nothing where it once rendered a winner. Test the pending state withisAnyExit(matchUp.matchUpStatus) && !matchUp.winningSide, and readsideExitProvenancefor which side exited. See §20. - A PAIR or TEAM is no longer a valid check-in subject.
checkInParticipantandcheckOutParticipantnow returnERR_INVALID_ATTESTATION_SUBJECTwhen given a side rather than one of its members — because a desk checking in the pair and a desk checking in both players stored different state for one physical fact. Reading is unchanged: a side still derives as checked in when all its members are. See §14.2. - Read the migration guide for the full detail behind every item here.
- Rename
particicipantsRequiredMatchUpStatusestoparticipantsRequiredMatchUpStatusesat every import site. - Handle the error return from
wallClockToUTC,utcToWallClockandtoEmbargoUTC, and branch onundefinedfromgetTimeZoneOffsetMinutes. Remove anytry/catchthat was there to catch a throw. - Delete compensating logic that re-read or repaired state after a
setMatchUpStatuserror — a rejected call no longer alters the draw. Note the error code for the bare-{ winningSide }case moved fromERR_MISSING_ASSIGNMENTStoERR_INVALID_MATCHUP_STATUS; match on behaviour rather than on that code. - Stop relying on re-application as repair. Re-sending an identical double exit no longer nudges a draw whose advancement is missing. Detect that state with
getDrawInconsistenciesand repair it deliberately. - Add cases for
CHALLENGEDand forSQUASH/BADMINTONif youswitchexhaustively overMatchUpStatusEnumorDisciplineEnumwith nodefault. - Pass objects, not ids, to
checkMatchUpIsCompleteandgetParticipantResults— resolve withfindMatchUp/allDrawMatchUpsfirst. Both now refuse an absent argument rather than answeringfalse/ empty. - Check any branch on
isAdHocType— it now includesLADDER. UseisLadderwhere the difference matters. Both are newly published ondrawsGovernor(engine.isAdHocType({ drawType }),engine.isLadder({ drawType })); neither was reachable before 7.0.0, so this step is newly followable rather than newly relevant. - Adopt the ladder at your own pace — the draw type and its eighteen engine methods are purely additive; no action is required to keep existing code working.
Where to go from here
| If you want… | Read |
|---|---|
| The full upgrade walkthrough | 6.x to 7.0.0 migration |
| To run a continuous challenge-driven competition | Ladder |
| To understand how a walkover travels through a draw | Exit Propagation |
| To contribute to the propagation code | Exit Propagation Harness |
| To pick the right date or time utility | Four questions, not one |
| Zoned conversion that refuses rather than guesses | tools.zonedDateTime |
| The previous major's feature tour | What's New in 6.0.0 |