Skip to main content

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).

flagprecedenceabsent both
allowChangePropagationpolicy ?? param ?? undefinednot allowed
propagateExitStatuspolicy ?? param ?? undefinedno
propagateRetirementAsExitpolicy ?? param ?? falseno

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.

#codeconditionwhere
1ERR_MISSING_MATCHUP_IDno matchUpIdsetMatchUpStatus
2ERR_MISSING_DRAWDEFno draw could be resolved from drawId / drawDefinitionsetMatchUpStatus
3ERR_INVALID_WINNING_SIDEwinningSide present and not 1 or 2; 0 is refused (CA, 2026-10-01: there is never a winningSide: 0); a set's own winningSide likewisesetMatchUpStatus
4ERR_UNRECOGNIZED_MATCHUP_FORMATthe call's matchUpFormat does not parsecheckMatchUpFormatApplication
5ERR_INVALID_VALUESmatchUpStatus is one of CANCELLED, INCOMPLETE, ABANDONED, TO_BE_PLAYED with a winningSide; or a TEAM dual with AWAITING_RESULTvalidateMatchUpStateInputs, setMatchUpState
6ERR_INVALID_MATCHUP_STATUSmatchUpStatus 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
7ERR_MATCHUP_STATUS_OUT_OF_SCOPEthe status exists but means nothing in this draw type (CHALLENGED outside a LADDER)getMatchUpStatusScopeViolation
8ERR_NOT_FOUND_MATCHUPmatchUpId is not in the drawresolveMatchUpAndContext
9ERR_INCOMPATIBLE_MATCHUP_STATUSBYE 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 statusresolveMatchUpAndContext, checkCompletedRevertGuard, checkImpliedCompletionGuard, checkDownstreamCompatibility
20ERR_UNCHANGED_CANNOT_CHANGE_OUTCOMEa 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 numbersrewritesCarriedExit
21ERR_INVALID_VALUESa 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 itpositionTargets, getActiveDownstream
10ERR_PROPAGATED_EXITS_DOWNSTREAMa clear (TO_BE_PLAYED, empty strings, no winner) that would leave a propagated exit standing downstreamhasPropagatedExitDownstream
11ERR_INVALID_SCOREthe 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 disableScoreValidationvalidateScore
12ERR_CANNOT_CHANGE_FEED_ELIGIBILITYthe loser of the linked round has already been directed under the previous outcomefeedEligibilityChange
13ERR_UNCHANGED_CANNOT_CHANGE_OUTCOMEa line of a dual that holds a double exit, with the dual's downstream active; or changing a double exit that has propagatedresolveAndApplyOutcome, winningSideWithDownstreamDependencies
14ERR_UNCHANGED_CANNOT_CHANGE_WINNING_SIDEchanging the winner of a matchUp whose winner has been played on, without allowChangePropagationwinningSideWithDownstreamDependencies
15ERR_NO_VALID_ACTIONSdownstream is active, the matchUp has propagated, the call names no winner and no directing status, and auto-calc is onresolveAndApplyOutcome
16ERR_INVALID_TIMEthe schedule carries a time that does not parse (validated before the write, applied after)addMatchUpScheduleItems with validateOnly
17ERR_ACTIVE_DRAW_POSITIONa direction would move a participant off a position already played onreconcileFedLoserEligibility, BYE and qualifier assignment
18ERR_MUTATION_LOCKEDthe tournament holds a mutation lock whose scope covers the methodengine, before the pipeline
19ERR_FORCEDOPEN: 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". propagateExitStatus decides only whether the exit is then carried into the loser's next matchUp. matchUpActions offers it as EXIT.

3. The routes​

attemptToSetMatchUpStatus chooses exactly one route, in this order:

routewhendoes
unrecognizedstatus is neither directing nor non-directingrefuse ERR_UNRECOGNIZED_MATCHUP_STATUS
already in the requested double exitsame double exit requested, no winnersuccess, no-op (§ 6)
only modify scorea TEAM line; or a winner exists and the status is directing and not a double exitwrite the score
completed → double exita winner exists and a double exit is requestedremove the directed participants, then advance the double exit
existing winnera winner existsremove the directed participants, re-evaluate
non-directingstatus is non-directingclear the score (CANCELLED and WALKOVER remove it)
BYEstatus is BYEthe BYE path
not directingrefuse ERR_UNRECOGNIZED_MATCHUP_STATUS
double exitclear the score, then advance the double exit
team round robina dual in a round-robin containerwrite the score
propagatingpropagateExitStatuswrite the score
fallthroughrefuse 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.

  1. A walkover, or a removal, blanks the result with the toBePlayed fixture: matchUpStatus: TO_BE_PLAYED, score with empty scoreStringSide1 / scoreStringSide2 and sets: undefined, winningSide: undefined, matchUpStatusCodes: [], and matchUpFormat: undefined, though since 7.5.0 a matchUp-level matchUpFormat is 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 in sideExitProvenance goes.
  2. Otherwise the given score replaces the old one, then matchUpStatus, matchUpFormat (only if given) and matchUpStatusCodes are written.
  3. Status codes are split at this boundary: the positional array a client sends (['', 'DM']) becomes sideStatusCodes keyed by side, with the exiting side taken from winningSide, not from the index.
  4. scoredTime is stamped on schedule the 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.
  5. 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:

  1. 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 where ERR_ACTIVE_DRAW_POSITION can arise.
  2. 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. progressExitStatus iterates 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).
  3. Round robin tally is recomputed when the matchUp is in a group (updateTallyIfNeeded).
  4. 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.
  5. 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.
  6. Notifications (MODIFY_MATCHUP and the draw's topics) are emitted; the factory extension's timeStamp is 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 under v1.
  • differential: v2 plans the whole call before v1 runs, then checks v1's result against it, and a disagreement throws OutcomePipelineDivergence naming 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 recorded50,559 (recorded tests, matrix, census, route flips, fixtures)
refusal codes observed18 (§ 2), none declared in the entry file
setMatchUpStateexported 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
bulkMatchUpStatusUpdate1 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/undo7 of 11 probed fixtures restore the draw projection; 4 do not (§ 4)
authored scenarios13 (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)​

  1. 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.)
  2. Where ERR_FORCED comes from on this path is still untraced. (§ 2, row 19)
  3. setMatchUpState leaves the governor; its internal callers keep it. (§ 8; deprecated in 7.5.0, removed at the next major)
  4. The flags: the policy governs, both ways. (§ 1, landed with this revision)
  • exit propagation: what propagates, provenance, the guarantees on cascades.
  • drawPositions: the rules direction relies on.
  • corpus/INVARIANTS.md and corpus/README.md: the properties and the scenarios that pin this page.