The outcome pipeline
Every result a matchUp receives travels one path: setMatchUpStatus validates and orchestrates,
setMatchUpState decides, attemptToSetMatchUpStatus routes, and modifyMatchUpScore is the one
place matchUp.score and matchUp.matchUpStatus are written. What that write causes, direction of
the winner and loser and propagation of an exit, is the rest of the pipeline. This page states the
rules a second implementation has to reproduce: the inputs, every refusal by its error code, the
routes, what a write does, the side effects in order, and the guarantees. Where the engine's
behaviour is a question rather than a decision it is marked OPEN.
First version, 2026-10-01, written from the source and from the golden corpus's measurements. The
pipeline is 6,548 lines across twelve files; 281 test files touch it; the corpus records 50,559
setMatchUpStatus steps and has observed eighteen distinct refusal codes from it.
setMatchUpStatus validate params, resolve the draw, read policy, derive score strings
└─ setMatchUpState guards, participants, downstream dependencies, dispatch
├─ noDownstreamDependencies ──┐
├─ winningSideWithDownstream ─┤─▶ attemptToSetMatchUpStatus / attemptToModifyScore
└─ applyMatchUpValues ────────┘ └─▶ modifyMatchUpScore (the only score write)
└─▶ direction, exit propagation, tally
└─ progressExitStatus (≤10 levels) · reconcileStaleExitOrigins · settleDraw
1. The inputs
An outcome carries at most score, matchUpStatus, winningSide and matchUpStatusCodes, and
optionally a matchUpFormat. The call also takes matchUpId, a draw (drawId or drawDefinition),
a schedule to apply once accepted, notes, and the flags below.
Score strings are derived, never accepted. When score.sets is present, empty sets are dropped
and scoreStringSide1 / scoreStringSide2 are regenerated from the sets under the matchUp's
effective format. A caller's strings are discarded. score.sets is the single source of truth.
Three flags, one precedence rule: the policy governs, both ways (CA, 2026-10-01).
| flag | precedence | absent both |
|---|---|---|
allowChangePropagation | policy ?? param ?? undefined | not allowed |
propagateExitStatus | policy ?? param ?? undefined | no |
propagateRetirementAsExit | policy ?? param ?? false | no |
An applied scoring policy that speaks on a flag, true or false, wins over anything on the
call; the call decides only where the policy is silent. So under a policy that propagates
retirement as an exit, a tournament director passing false is overruled, and under a policy that
forbids it, a director passing true is overruled too: "if a governance policy is someone who
retires can no longer continue playing, a tournament director under that policy shouldn't be able to
allow a participant to continue in the draw." POLICY_SCORING_DEFAULT is silent on all three, so a
provider that attaches it leaves the decision to the call. Until 2026-10-01 the call won on
propagateRetirementAsExit and a truthy call won on the other two (||), so a caller could
override its federation's rule; the three authored scenarios authored/outcome-pipeline/flags-*
pin the new contract from both directions.
A matchUpFormat on the call is validated before anything runs and persisted only once the outcome
is accepted. It used to be written first, so a refused outcome left a new format behind.
2. The refusals, in the order they are checked
A refused call returns one ErrorType with a code and changes nothing (§ 6); its context, when
present, is an object, and a sentence for a person goes in info (two row-9 refusals returned the
sentence AS context until the authored scenarios refused to record them, 2026-10-01). The checks run in
this order; the first that fails is the answer. Two corrections from S2a (2026-10-01), measured by
running v2 differentially against v1: the TEAM clause of row 5 (AWAITING_RESULT) is asked after
row 10, and the participants clause of row 6 (§ 2.1) after row 11; and row 9's second condition
judges the stored score under the stored format while its third judges the incoming score under
the format the call would apply, so a call that carries a new matchUpFormat can be refused under
the old one.
| # | code | condition | where |
|---|---|---|---|
| 1 | ERR_MISSING_MATCHUP_ID | no matchUpId | setMatchUpStatus |
| 2 | ERR_MISSING_DRAWDEF | no draw could be resolved from drawId / drawDefinition | setMatchUpStatus |
| 3 | ERR_INVALID_WINNING_SIDE | winningSide present and not 1 or 2; 0 is refused (CA, 2026-10-01: there is never a winningSide: 0); a set's own winningSide likewise | setMatchUpStatus |
| 4 | ERR_UNRECOGNIZED_MATCHUP_FORMAT | the call's matchUpFormat does not parse | checkMatchUpFormatApplication |
| 5 | ERR_INVALID_VALUES | matchUpStatus is one of CANCELLED, INCOMPLETE, ABANDONED, TO_BE_PLAYED with a winningSide; or a TEAM dual with AWAITING_RESULT | validateMatchUpStateInputs, setMatchUpState |
| 6 | ERR_INVALID_MATCHUP_STATUS | matchUpStatus is not a known status; or a directing outcome on a matchUp without two participants (§ 2.1); or no route accepts it (§ 3) | validateMatchUpStateInputs, checkParticipants, attemptToSetMatchUpStatus |
| 7 | ERR_MATCHUP_STATUS_OUT_OF_SCOPE | the status exists but means nothing in this draw type (CHALLENGED outside a LADDER) | getMatchUpStatusScopeViolation |
| 8 | ERR_NOT_FOUND_MATCHUP | matchUpId is not in the draw | resolveMatchUpAndContext |
| 9 | ERR_INCOMPATIBLE_MATCHUP_STATUS | BYE with a winningSide (existing or requested); a live status (IN_PROGRESS, SUSPENDED) on a COMPLETED matchUp whose score is a valid win and the call carries no new result; a live status with a winningSide or a score that decides a winner under the incoming format; a non-directing or double-exit status without winningSide while downstream is active; a winningSide equal to the current one with a non-directing status | resolveMatchUpAndContext, checkCompletedRevertGuard, checkImpliedCompletionGuard, checkDownstreamCompatibility |
| 20 | ERR_UNCHANGED_CANNOT_CHANGE_OUTCOME | a direct write that would change a matchUp holding a carried or produced exit: a re-score, another winner, a relabel to a played result, or a clear (CA, 2026-10-03). The correction is made at the exit's origin. Checked here, between rows 9 and 10; numbered 20 so the existing rows keep their numbers | rewritesCarriedExit |
| 21 | ERR_INVALID_VALUES | a WINNER or LOSER link that directs the matchUp has no source.roundNumber, a malformed draw (CA, 2026-10-06: an error, not a crash). Checked between rows 20 and 10, for the matchUp's own targets and any structure downstream of it | positionTargets, getActiveDownstream |
| 10 | ERR_PROPAGATED_EXITS_DOWNSTREAM | a clear (TO_BE_PLAYED, empty strings, no winner) that would leave a propagated exit standing downstream | hasPropagatedExitDownstream |
| 11 | ERR_INVALID_SCORE | the score is not valid under the format (sets, tiebreaks, a 7-5 after 6-5 is valid, a 7-6 needs a tiebreak); skipped for TEAM duals and with disableScoreValidation | validateScore |
| 12 | ERR_CANNOT_CHANGE_FEED_ELIGIBILITY | the loser of the linked round has already been directed under the previous outcome | feedEligibilityChange |
| 13 | ERR_UNCHANGED_CANNOT_CHANGE_OUTCOME | a line of a dual that holds a double exit, with the dual's downstream active; or changing a double exit that has propagated | resolveAndApplyOutcome, winningSideWithDownstreamDependencies |
| 14 | ERR_UNCHANGED_CANNOT_CHANGE_WINNING_SIDE | changing the winner of a matchUp whose winner has been played on, without allowChangePropagation | winningSideWithDownstreamDependencies |
| 15 | ERR_NO_VALID_ACTIONS | downstream is active, the matchUp has propagated, the call names no winner and no directing status, and auto-calc is on | resolveAndApplyOutcome |
| 16 | ERR_INVALID_TIME | the schedule carries a time that does not parse (validated before the write, applied after) | addMatchUpScheduleItems with validateOnly |
| 17 | ERR_ACTIVE_DRAW_POSITION | a direction would move a participant off a position already played on | reconcileFedLoserEligibility, BYE and qualifier assignment |
| 18 | ERR_MUTATION_LOCKED | the tournament holds a mutation lock whose scope covers the method | engine, before the pipeline |
| 19 | ERR_FORCED | OPEN: observed by the corpus from a setMatchUpStatus step; its origin is outside the twelve files and not yet traced |
Of the eighteen codes the corpus has observed from setMatchUpStatus, none is declared by
setMatchUpStatus.ts itself: every refusal is raised by a helper. A reader of the entry file alone
sees no refusal at all. That is why the catalogue above is by condition, not by file.
2.1 Who may receive a directing outcome
A directing outcome (a winner, or COMPLETED, RETIRED, WALKOVER, DEFAULTED, a double exit) requires two participants, with one family of exceptions and one rule inside it:
- The waiver. WALKOVER, DEFAULTED, DOUBLE_WALKOVER or DOUBLE_DEFAULT on a matchUp holding ONE
participant is accepted when it is the cascade's own write (
propagatingExit), or when the call names no winner, or when the winner it names is awardable. A DOUBLE exit is waived only as the cascade's own write: entered directly it needs both seats reached, because a double exit is one exit per seat (CA, 2026-10-04). Entered beside a seat nobody has reached, the arrival there would meet a double exit already standing, a third entity in a matchUp that holds two. - Awardable means the winning side is not the participant already present (a walkover over an opponent nobody knows yet would be two winners of one matchUp), is not a BYE, and is not a phantom: a drawPosition whose assignment exists and holds nobody. An unfilled feed slot, no drawPosition at all, is awardable; so is a QUALIFIER placeholder.
- A BYE is never the winning side. The participant advances through the BYE and their carried exit occurs where they land. This was decided in two places before it was written here.
- A policy may switch the requirement off:
requireParticipantsForScoring: false. - No request flag is involved. The waiver is how a director records a walkover or default before
the second opponent arrives (CA, 2026-10-04): "propagateExitStatus shouldn't have anything to do with
this ability".
propagateExitStatusdecides only whether the exit is then carried into the loser's next matchUp.matchUpActionsoffers it asEXIT.
3. The routes
attemptToSetMatchUpStatus chooses exactly one route, in this order:
| route | when | does |
|---|---|---|
| unrecognized | status is neither directing nor non-directing | refuse ERR_UNRECOGNIZED_MATCHUP_STATUS |
| already in the requested double exit | same double exit requested, no winner | success, no-op (§ 6) |
| only modify score | a TEAM line; or a winner exists and the status is directing and not a double exit | write the score |
| completed → double exit | a winner exists and a double exit is requested | remove the directed participants, then advance the double exit |
| existing winner | a winner exists | remove the directed participants, re-evaluate |
| non-directing | status is non-directing | clear the score (CANCELLED and WALKOVER remove it) |
| BYE | status is BYE | the BYE path |
| not directing | refuse ERR_UNRECOGNIZED_MATCHUP_STATUS | |
| double exit | clear the score, then advance the double exit | |
| team round robin | a dual in a round-robin container | write the score |
| propagating | propagateExitStatus | write the score |
| fallthrough | refuse ERR_INVALID_MATCHUP_STATUS |
Before any route, resolveAndApplyOutcome dispatches on two facts: whether downstream is active
(something later depends on this result) and whether this matchUp has propagated (it holds a
winner or a double exit). A matchUp that has never sent anything downstream cannot invalidate
anything there, so a first entry always takes the propagating branch. With both true, a named winner
goes to winningSideWithDownstreamDependencies; a directing status or disabled auto-calc goes to
applyMatchUpValues; anything else is ERR_NO_VALID_ACTIONS.
allowChangePropagation with a different winner on a matchUp that has one swaps winner and
loser everywhere they have gone (swapWinnerLoser), rather than refusing.
4. What a write does
modifyMatchUpScore → applyScoreAndStatus is the only place score and matchUpStatus change.
- A walkover, or a removal, blanks the result with the
toBePlayedfixture:matchUpStatus: TO_BE_PLAYED,scorewith emptyscoreStringSide1/scoreStringSide2andsets: undefined,winningSide: undefined,matchUpStatusCodes: [], andmatchUpFormat: undefined, though since 7.5.0 a matchUp-levelmatchUpFormatis carried across the blank (below). The requested status is then written over the blank. Exit provenance survives the blank when the written status is an exit, and BYE claims survive it on a BYE; everything else insideExitProvenancegoes. - Otherwise the given
scorereplaces the old one, thenmatchUpStatus,matchUpFormat(only if given) andmatchUpStatusCodesare written. - Status codes are split at this boundary: the positional array a client sends (
['', 'DM']) becomessideStatusCodeskeyed by side, with the exiting side taken fromwinningSide, not from the index. scoredTimeis stamped onschedulethe first time the matchUp becomes scored (a score with value, a winner, or a completed status), kept through corrections, and deleted when the result is removed. It is first-class in NATIVE and has no timeItem mirror.- The schedule the call carried is applied after the outcome is accepted, and was validated before it, so a refusal cannot land on a draw this call has just changed.
Decided 2026-10-01 (CA), found by the corpus on real records: rule 1 means a clear
normalises rather than restores. On a record whose matchUp had no matchUpStatus and no
score, a score-then-clear leaves matchUpStatus: "TO_BE_PLAYED" and an explicit score object;
measured on 4 of 11 probed fixtures. That stays: TO_BE_PLAYED and an empty score object are the
canonical cleared state, and a reader never sees an undefined status. A clear keeps a
matchUp-level matchUpFormat: the format is a property of the match, not of its result. The
blank fixture still carries matchUpFormat: undefined, because it is the unwound-matchUp shape, and
applyScoreAndStatus now carries the existing format across it the way it carries exit provenance;
a call that brings a new format still writes the new one. Pinned by
clearKeepsMatchUpFormat.test.ts.
A double exit entered over a completed result (CA, 2026-10-02). A DOUBLE_DEFAULT keeps the score
the matchUp had, finished or not, unless the call brings one: both players can be defaulted after
the last ball (post-match misconduct at the net, for example), and the score is the record of what
was played. The reason belongs in a penalty, addPenalty with each player's participantId and the
matchUpId. A DOUBLE_WALKOVER says the match was not played, so it blanks the score; one entered
over a completed result is taken at its word, and correcting a mistaken one is the director's re-entry.
Neither has a winner. Pinned by authored/outcome-pipeline/double-exit-over-a-completed-result.
5. The side effects, in order
After the write, and only on success:
- Direction. A winner is advanced to the winner target; the loser is directed along the loser
link, if the structure has one. A changed result first removes what the previous one directed
(
removeDirectedParticipants), and that is whereERR_ACTIVE_DRAW_POSITIONcan arise. - Exit propagation. WALKOVER, DEFAULTED and (by policy) RETIRED carry into the consolation the
loser is fed to; a double exit produces exits downstream. The rules are on
exit propagation and are not repeated here.
progressExitStatusiterates through up to ten levels of consolation (COMPASS feeds consolation into consolation); a failsafe, not a limit anyone has reached. A relabel keeps the winner the matchUp already has and changes what it says about the loser (CA, 2026-10-02). Re-entered as a WALKOVER or DEFAULTED, it carries the exit to the matchUp the loser already stands in; re-entered as COMPLETED, it withdraws the exit it carried there. Neither applies where that matchUp already has a result of its own, nor where the winner of the carried exit has played on from it, counting past any BYEs they were advanced through (7.7.0). Both apply whether or not the relabelled matchUp's own winner has played on since. Where the carried exit had converged with another, only this origin's entry is withdrawn and the matchUp re-derives to the kept exit (its carrier directed on, or — a produced exit — won by the relabelled loser, who goes on); and a loser who had won a produced exit on arrival has that award taken back when the origin becomes an exit again, so the exit converges as it would have at first entry (7.7.0; exit propagation). - Round robin tally is recomputed when the matchUp is in a group (
updateTallyIfNeeded). - Stale exit origins are reconciled once removals, directions and propagation have all settled: a carried exit whose origin no longer describes an exit is corrected.
- The draw is settled: held exits are released (
settleHeldExits), and a final that feeds a decider settles whether the decider is needed (reconcileDeciders) when a final's winner changed, or when the cascade moved a decider while the final's winner stayed put (7.8.0). A mutation OF the decider never triggers it, so a decider played anyway keeps its result; in a TEAM draw a line of the decider's dual is a mutation of the decider (7.8.0). See Double Elimination. - Notifications (MODIFY_MATCHUP and the draw's topics) are emitted; the factory extension's
timeStampis written by the engine after the call.
6. Guarantees
- A refusal changes nothing (
ERROR_IMPLIES_NO_MUTATION). The checks in § 2 run above every write; the two that used to run late (participants on a bare winner, feed eligibility) were moved up after each was measured leaving a destroyed previous result behind a refusal. - Asking for the state a matchUp is already in is satisfied, not repeated. A second identical double exit is a no-op success; without this, the cascade read its own earlier work as a second source and escalated a WALKOVER to a DOUBLE_WALKOVER. Scoped to double exits only.
- A first entry always propagates. Dispatching on "downstream active" alone refused a TO_BE_PLAYED Main matchUp above a played consolation and, routed the other way, silently stopped winners advancing in 27 FMLC cells.
- Score strings never disagree with sets, because they are not accepted.
- A completed matchUp with a valid winning score cannot be reverted to a live status without a new result; submit a corrected outcome or clear first.
- The properties the exit-propagation harness checks (
DO_UNDO_IDENTITY,IDEMPOTENT_REAPPLY,MONOTONIC_DECISION) are stated in the corpus vocabulary (corpus/INVARIANTS.md) and hold on generated draws across the 600-cell matrix and the census; on real records, see § 8 (real-record do/undo) and the clear that normalises in § 4.
7. Write mode
In NATIVE mode (the default since 5.0.0) scoredTime is written on matchUp.schedule and nothing
here touches timeItems. In LEGACY and BRIDGE modes the pipeline is unchanged; the schedule
attributes it stamps follow setFirstClassOrTimeItem. The result, status and codes are first-class
in every mode.
7.1 Two implementations (S2)
src/mutate/matchUps/outcome/ is the clean-room re-implementation of this page, written from it and
from the corpus. It works over a read-only view of the draw: refuseOutcome(request, view) is § 2
as one pure function, chooseRoute is § 3, planWrite is § 4 and planDirection is § 5's
direction and exit propagation. v1 is the pipeline as written across setMatchUpState and the
matchUpGovernor routes, and it is still the only one that writes.
The mode is process-wide, set with
engine.outcomePipeline(mode) and read with
engine.getOutcomePipeline(); no argument
restores v1, and an unknown mode is INVALID_VALUES.
v1(the default): nothing is built and nothing is decided by v2.v2: v2 decides the refusals. A refusal it raises is the call's result and v1 is not asked; an outcome it accepts is handed to v1, which routes and writes as underv1.differential: v2 plans the whole call before v1 runs, then checks v1's result against it, and a disagreement throwsOutcomePipelineDivergencenaming the matchUp and both answers. The plan covers the refusal, the route, the write on the matchUp, the winner's direction, the loser's destination (including the propagated BYE for a loser an FMLC feed keeps out), the exit a loser carries, the exit a double exit produces, two exits converging, the swap, a decider's settlement, TEAM auto-calc and the direction of a dual its lines decide, and an exit that meets a BYE (none may be left labelled an exit beside it). A refusal v1 raises at the apply stage, where v2 does not reach (APPLY_STAGE_CODES: an invalid time, an active or assigned drawPosition, a mutation lock,ERR_FORCED, and the status and assignment checks a write makes), is recorded as deferred rather than a divergence; so is a step v2 does not yet plan, such as a relabel whose loser stands past a BYE, or an exit carried into a swap.getDifferentialTally()counts compared and deferred decisions by route.
OUTCOME_PIPELINE=differential vitest run is the gate. It runs in CI for pull requests into master
and on master itself — every checkpoint — and nowhere else, so a test added since the last checkpoint
has never run under it until then: run OUTCOME_PIPELINE=differential TZ=UTC npx vitest run locally
before opening one (a plain green suite predicts nothing about it). Since 7.9.0 the view reads a
BYE-holder occupant's carry where it was written, a round on, so a withdrawn BYE's convergence is
planned as v1 writes it; and the exit-beside-a-BYE invariant holds under either doubleExitPropagateBye
setting.
8. What the corpus pins
setMatchUpStatus steps recorded | 50,559 (recorded tests, matrix, census, route flips, fixtures) |
| refusal codes observed | 18 (§ 2), none declared in the entry file |
setMatchUpState | exported as an engine method, eleven declared codes, no caller in the corpus: an internal that leaked onto the surface. Deprecated on the engine surface in 7.5.0 and removed at the next major; its internal callers keep it, and consumers call setMatchUpStatus |
bulkMatchUpStatusUpdate | 1 recorded step; its two declared codes pinned by authored/outcome-pipeline/bulk-update-refusals: ERR_MISSING_VALUE for no outcomes, ERR_MISSING_TOURNAMENT for an unknown tournamentId |
| real-record do/undo | 7 of 11 probed fixtures restore the draw projection; 4 do not (§ 4) |
| authored scenarios | 13 (pnpm corpus:authored), one per rule above that the recorded sources did not reach: the flag precedence (§ 1, three scenarios), rows 5, 9, 10 and 14 of § 2 with every condition of row 9, the swap (§ 3), a double exit entered over a completed result (§ 4), the FMLC loser kept out and a decider's settlement (§ 5), the double-exit no-op (§ 6), two double exits converging, a TEAM dual handed back to auto-calc, the bulk refusals. Each asserts the result code of every step and, where a flag's effect is the claim, the state the patches rebuild |
9. Open questions — decided 2026-10-01 (CA)
- A clear keeps normalising: TO_BE_PLAYED and an empty score object are the canonical cleared
state. It keeps a matchUp-level
matchUpFormat: the format is a property of the match, not of its result. (§ 4; landed.) - Where
ERR_FORCEDcomes from on this path is still untraced. (§ 2, row 19) setMatchUpStateleaves the governor; its internal callers keep it. (§ 8; deprecated in 7.5.0, removed at the next major)- The flags: the policy governs, both ways. (§ 1, landed with this revision)
Related
- exit propagation: what propagates, provenance, the guarantees on cascades.
- drawPositions: the rules direction relies on.
corpus/INVARIANTS.mdandcorpus/README.md: the properties and the scenarios that pin this page.