Data model
The tournament app keeps all tournament state in plain files under a single data folder. There is no database. This page describes the entities, how they relate, and how they are laid out on disk.
Related: Software architecture · Network architecture · Infrastructure architecture
1. Why files
The format is chosen for the room the software runs in, not for query power. A tournament runs for a day in a sports hall, often on a laptop behind a flaky network. The person responsible for the results is a volunteer with a spreadsheet, not a database administrator.
Inspectability, repairability and diffability matter more here than normalisation:
- Inspectable. An organiser can open the results in Excel or a text editor mid event.
- Repairable. An organiser can correct a wrong cell by hand, and the app picks it up on reload.
- Diffable. The whole tournament state can be committed to version control or copied to
a USB stick as a backup. Two copies can then be compared line by line.
One exception:
tournament.mdholds the admin password in plain text, so strip or change it before sharing a copy. Atournament.mdthat carries anadmin_passwordfield may hold another password in plain text there. The app ignores that field and drops it the next time it writes the file; remove it by hand before sharing a copy that still has it. Refer to Upgrading from an earlier release.
The trade this makes is described honestly in section 6.
2. Tournament and competition structure
A tournament owns competitions; a competition owns everything else. The competition is the consistency boundary: every write is serialised per competition, and no write spans two.
One rule does span them, and it comes from the venue rather than from the data. Competitions run at the same time on a shared set of courts, so a court may hold only one running match in the whole tournament. Starting a match therefore checks every other competition first. That check and the write it guards run under one tournament-wide lock, so two operators in different competitions cannot both claim the same court.
classDiagram
direction LR
class Tournament {
+string Name
+string Date
+string Venue
+string[] Courts
+string Mode
+int DurationDays
+Sponsor[] Sponsors
+Theme Theme
+TournamentContact[] Contacts
}
class Competition {
+string ID
+string Name
+string Kind
+string Format
+CompetitionStatus Status
+int TeamSize
+int PoolSize
+int PoolWinners
+TeamMatchType TeamMatchType
+string NumberPrefix
+string[] Courts
+bool Naginata
+bool Engi
+bool WithZekkenName
}
class Player {
<<one row per entrant: a competitor, or a TEAM when Kind is team>>
+string ID
+string Name
+string DisplayName
+string Dojo
+string[] Metadata
+string Number
+int Seed
+int PoolPosition
+bool CheckedIn
+string Source
}
class Pool {
+string PoolName
+Player[] Players
}
class CompetitorStatus {
+string PlayerID
+bool Eligible
+bool Reinstateable
+string Reason
+string MatchID
+time RecordedAt
}
class TeamLineup {
<<team competitions only>>
+string TeamID
+string CompetitionID
+int Round
+string MatchID
+Map~Position, string~ Positions
+Map~Position, string~ MemberIDs
}
class TeamMember {
<<team competitions only>>
+string ID
+int Index
+string Name
+int64 ModifiedAt
}
class Overrides {
+Map PoolRanks
+Map Winners
}
Tournament "1" o-- "0..*" Competition
Competition "1" o-- "0..*" Player
Competition "1" o-- "0..*" Pool
Competition "1" o-- "0..*" CompetitorStatus
Competition "1" o-- "0..*" TeamLineup
Competition "1" o-- "0..1" Overrides
Pool "1" o-- "1..*" Player : draws from
Player "1" o-- "0..*" TeamMember : members, only when the row is a team
TeamLineup "1" ..> "0..*" TeamMember : positions reference
The competition box lists the settings that shape the model, not every field it holds. Scheduling, display and format-specific settings are left out because nothing else on this page depends on them.
Kind separates individual from team competitions; Format selects knockout, pools plus
knockout, league or Swiss. TeamMatchType selects fixed order or kachinuki for team
competitions. A competition in the team kind treats each Player entry as a team. The people on that team are its members, stored in team-members.yaml under the team's participant
id.
That one setting decides which records exist at all. An individual competition has no
team-members.yaml and no lineups.yaml: its entrants are people, and a match pairs two of them
directly. A team competition has both, and its entrants are teams, so the person who fights
a given bout is named one level further down. The positions a round has are the five FIK
names, senpo, jiho, chuken, fukusho and taisho, when the team size is five, and numbers for
any other size. Everything else, the pools, the eligibility records and
the ranking overrides, is the same for either kind, and reads a Player row without caring
which of the two it is.
Each team member carries a stable id, minted once when the member is added and never reused, and a display index. The id is what a lineup position and a bout row record, so a member can be renamed without any record losing track of who fought. A team's member list is seeded with one member per position the competition's team size defines, plus two reserve positions, so a team has its shape before anyone is named. No member is ever removed, so an index is never freed and no renumbering question arises. Instead, an organiser can clear a name, which empties that position but keeps its id and index. That is possible only until the competition starts. After that, a name can still be corrected, which is what renaming is for: a bout row holds the member's id, so a bout already fought names the same person whatever the name is changed to. Adding a member stays available at any time, including after the start, because a team fields replacements mid tournament.
Each member also records when it was last added, named, renamed or cleared (ModifiedAt,
server time, 0 until the first such write). One member's stamps only grow, so a page that
holds two copies of a member, one from a list it read and one from a write it made, keeps the
newer one whatever order they arrived in, and a list read before a rename can never put the
old name back.
The label an organiser reads is the team's competitor number followed by the member index,
for example T10.1. That label is composed when it is shown and never stored, because a
competitor number can change and the identity underneath it cannot. It is derived the same
way on both sides: the public surfaces receive a team's members on the viewer payload and
compose the label themselves, rather than reading a stored string.
Two rules govern member names. Within one team the names must be unique: a name is how an organiser picks a member, and the winner-stays-on format has to tell two teammates apart. Members of different teams may share a name freely. The member list is not a starting line-up, so it may hold more members than the competition's team size: the extra entries are the replacements an organiser can field, and the team size only fixes how many positions a round has.
A lineup is stored for a match or as a team's starting lineup (its round-0 entry), and the
lineup a team fields in a match is not stored: it is worked out when read, because a team
carries the lineup of its previous match unless one is entered for the match. The lineup in
force at a match is the match's own, else the latest one the team had before it: one saved for
an earlier match of the team, or its starting lineup, which comes before every match.
Matches are ordered by pool-match number first, then by knockout round and position, with the
3rd-place match last. A lineup saved for a match counts as an earlier lineup only while that
match is in the current draw and the team is seated in it by participant id. A team is its
participant id and nothing else: a lineup is the team's only when it is stored under that id,
a lineup stored under a team name is not the team's, and a side with no id has none.
Discarding a draw removes the lineups saved for its matches, because a draw generated again
reuses the match ids, and keeps the starting lineups. One rule in the engine owns this, and
the kachinuki roster, the Kachinuki Detail export and the public lineup-in-force read all
ask it.
A lineup save names the positions it changed, and the server writes only those, under the competition's lock, onto the lineup stored for that match or that starting lineup, or, for a match with no lineup of its own yet, onto the lineup in force there. Two devices that change different positions of one lineup both keep their change, whichever save arrives first, a save sent later from a device that was offline included. Two changes to the same position keep the later arrival.
Releases up to v2.1.1 also saved a lineup for a round, and read the lineup a team fielded at
a match as the match's own, else the round lineup with the highest round at or below the
match's round, else the highest round, so a lineup saved for one match applied to that match
alone. Such lineups are converted into this form rather than read under that rule. A lineup
stored under a team's name is stored under the team's id when exactly one team has that name.
A legacy team is one with a lineup for round 1 or later, or one that, when the app first
settles the competition, holds a lineup saved for a match of the current draw that seats it
by participant id. config.md records that list at the first settlement
(round_lineups_legacy), so a lineup saved for a match afterwards never makes a team legacy.
A legacy team is given, at every match it is seated in by participant id, a lineup of its own
equal to the one v2.1.1 showed there, or an empty one where v2.1.1 showed none (no round
lineup and no lineup for that match), since carrying would show an earlier match's instead. A
match it is seated in later is given its lineup by the write that seats it: inside that
write's transaction when it runs in one, otherwise straight after it. config.md lists, under
each team's id, the matches already given one (round_lineups_given), so a lineup removed
from one of them is not given again. Discarding the draw, or generating a new one, clears that
list, since the next draw reuses the match ids, and drops from the legacy list every team with
no round lineup, whose converted match lineups went with the draw. A legacy team with round
lineups and no starting lineup gets the one v2.1.1 showed before its first saved round, its
highest round's. The round lineups stay, never read, until the competition is completed; then
the next write of the draw, or the next start of the app, removes them and config.md records
round_lineups_converted, which is set as soon as no legacy team waits. A new competition
starts with it set.
3. The match and result model
This is the detailed part of the model, because the rules it encodes are detailed. A match carries its pairing, its score, how it was decided, when and where it is played, and an audit trail for corrections. Every match belongs to exactly one competition: pool, league and Swiss matches are rows in that competition's results file, and knockout matches are nodes in its bracket, so nothing in this section exists outside a competition.
classDiagram
direction TB
class MatchResult {
+string ID
+MatchStatus Status
+string Court
+int Round
+string ScheduledAt
+int QueuePosition (derived, not persisted)
+string Decision
+string DecisionBy
+string DecisionReason
+bool? DecidedByHantei (legacy, read-only, unset = writer said nothing)
+string ResultSource
+string CorrectionReason
+bool ReopenPending
+long ModifiedAt
+map GroupStamps
}
class CompetitorSide {
<<value pair, stored as A and B columns>>
+string Name
+string ParticipantID
+string[] Ippons
+int Hansoku
+int Flags
+string RepPlayer
}
class Outcome {
<<value object>>
+string WinnerName
+string WinnerID
+string WinnerSide
}
class SubMatchResult {
+int Position
+string SideA
+string SideB
+string SideAMemberID
+string SideBMemberID
+string[] IpponsA
+string[] IpponsB
+int HansokuA
+int HansokuB
+string Winner
+string WinnerMemberID
+string Decision
+bool? DecidedByHantei (legacy, read-only, unset = writer said nothing)
}
class EnchoMetadata {
+int PeriodCount
}
class Bracket {
+bool Preview
+BracketMatch[][] Rounds
+BracketMatch ThirdPlaceMatch
+string[] DrawOrder
+bool TimesSettled
}
class BracketMatch {
+string ID
+MatchStatus Status
+string Court
+string ScheduledAt
+string SideA
+string SideB
+string SideAID
+string SideBID
+string Winner
+string WinnerID
+string[] IpponsA
+string[] IpponsB
+int HansokuA
+int HansokuB
+int FlagsA
+int FlagsB
+string ScoreA (legacy, read-only)
+string ScoreB (legacy, read-only)
+int MatchNumber
+int DisplayRound
+string[] Feeders
+string PlaceholderA
+string PlaceholderB
+string PlaceholderWinner
+bool IsOverridden
+bool Hidden
+string Decision
+string DecisionBy
+string DecisionReason
+string ResultSource
+string CorrectionReason
+bool ReopenPending
+bool DecidedByHantei (legacy, read-only)
+long ModifiedAt
+map GroupStamps
}
MatchResult "1" *-- "2" CompetitorSide : side A is aka, side B is shiro
MatchResult "1" *-- "1" Outcome
MatchResult "1" *-- "0..*" SubMatchResult : team bouts
MatchResult "1" *-- "0..1" EnchoMetadata
SubMatchResult "1" *-- "0..1" EnchoMetadata
Bracket "1" *-- "1..*" BracketMatch
BracketMatch "1" *-- "0..*" SubMatchResult
BracketMatch "1" *-- "0..1" EnchoMetadata
The diagram makes three points that a raw field list would hide.
CompetitorSide is a value object that the storage flattens. The name is this page's
own, chosen for clarity: no such type exists in the code. Everything a competitor brings
to a match (name, participant id, struck points, outstanding fouls, flags, and the
representative player for a team tie breaker) exists twice, once per side. In the object
model those are SideA/SideB, IpponsA/IpponsB, HansokuA/HansokuB and so on. They
are one concept with two instances, not twelve independent attributes. Side A is aka and
side B is shiro. The draw fixes that pairing, and it is not a display order: every surface
draws shiro on the left.
A pool bout's id carries its number in the playing order. A pool or league bout is
<pool name>-<n>, and the draw gives n after it has put the pool's bouts in the order they
are played (a league is reordered so nobody fights twice in a row), so Pool A-0 is the
first bout played. Every surface names a bout by this number: the court console's "Match 1
of 6", the server's messages, the scores list and the printed workbook. Moving a bout in a
court's queue changes when it is played, not its number. A draw made by an earlier release
may hold a league whose ids were given before the reorder; its bouts keep their numbers, and
every surface shows the same one.
A team match is an aggregate. SubMatchResult is a full bout in its own right: its own
pairing, score, decision, overtime and judges' decision. A five person team encounter holds
five of them, plus an optional representative bout at position -1. Ranking figures such as
individual victories and points won are DERIVED from these by one function. They are
never entered by hand and never a second source of truth: every read serves them alongside the bouts, every
save of an elimination match recomputes them, and every surface that shows them takes what
the server computed rather than re-deriving its own.
Sides carry both a name and an id, and the id is the one that identifies. Both are kept because a rename must not orphan a recorded result: the name is what a person reads on the sheet, the id is what the app resolves by. Where a record has an id field, it is read by id alone; a name is consulted only for a row old enough to predate the field, which the app repairs when it loads it.
That applies one level further down as well. A team bout carries its two fighters' TEAM MEMBER ids beside their names, and the winner's id beside the winner's name. Without them a bout between two people who happen to share a display name could not be attributed at all, because the name identifies neither of them, and competitors are allowed to share one. The representative bout is the one row whose sides are the TEAM names: it keeps them and carries the two representatives' member ids beside them, the members picked for each side. Each side's id is a change of its own, ordered apart from the other side's and from the bout's score and result: a point struck on the representative bout never alters who was picked, a pick never alters the score, and a pick for one side never alters the other side's pick. So two captains picking at the same moment both land, and so do two boards doing one each. The winner's member id stays with the bout, and after a merge it always names one of the two picked members (or is empty): when a pick changes under it, it is worked out again from the side that won. A pick is a member of the team seated on its side, so when a knockout match is given another team on a side (a winner corrected upstream, a slot taken back to its placeholder, a qualifier repainted), that side's pick is cleared and dated at that moment, later than the pick had been. The write that made the old pick, replayed from a queue, is then kept in the match's history instead of seating the old team's member on the new team. A side whose team does not change (the same winner stored again, or the team only renamed) keeps its pick. A side taken back to its placeholder loses its pick, even when the match that feeds it is finished again with the same winner: the pick is picked again.
Those are two different namespaces, and the field names keep them apart on purpose. A match side carries a PARTICIPANT id, which names a competitor or a whole team; a bout side carries a TEAM MEMBER id, which names one person inside a team. They sit one level apart and are never interchangeable.
The cost of the pattern is this model's sharpest edge. A name and its id are stored as two fields, or in a lineup's case as two maps keyed by the same position, so nothing in the storage forces a writer to set both. A record with a name and no id is therefore representable, and does occur: rows written before the id existed, and rows whose name could not be resolved to exactly one person. The app treats such a row as unrepaired rather than as a fault, repairs what it can when it loads the file, and falls back to the name for that row alone. Making the pair a single value instead of two fields would remove the state entirely, at the cost of rewriting every stored file, which is why it has not been done.
A knockout slot remembers the draw. A bracket match repeats the scheduling and audit fields a pool match carries, so a knockout match is scheduled, corrected and reopened the same way. Two things are its own. For a knockout fed by pools it keeps what the draw wrote in each slot before any pool finished, which is how a qualifier reaches the slot the draw gave it without the draw being recomputed. For a knockout drawn without pools the bracket keeps the order the competitors were drawn in, which is what their competitor numbers count; a competition with pools numbers its entrants at the pool draw instead, so it stores no second order.
Match status and decision
Status moves scheduled to running to completed. A completed match is never sent back
to the queue by a score write; corrections are made through the score editor, which writes a
new completed result with a CorrectionReason.
Decision records how a match ended when it was not simply fought to a score: a draw, a
withdrawal, a no show, a representative bout, or exhaustion in a kachinuki encounter. A
judges' decision (hantei) is recorded separately, as the Ht entry in the winner's ippon
list: the mark occupies a point slot on the score sheet but never counts as a point, and
the winner it sits beside is the winner the referees chose from a level scoreline. The
DecidedByHantei fields in the diagrams are legacy read-only channels: a file that
carries the old flag loads through a conversion that moves it into the mark, and nothing
writes them. The bracket ScoreA/ScoreB strings are the same kind of channel: a file
that rendered each side's scoreline as one string loads through a conversion into the
ippon arrays, and every match record holds its score the same way.
4. On disk layout
Each competition is a folder. The tournament root holds the shared settings and the uploaded images.
classDiagram
direction LR
class tournament_md["tournament.md"] {
<<Markdown, YAML front matter>>
Tournament
}
class config_md["competitions/<id>/config.md"] {
<<Markdown, YAML front matter>>
Competition
}
class participants_csv["participants.csv"] {
<<CSV>>
Player rows
}
class seeds_csv["seeds.csv"] {
<<CSV>>
Rank, Name, Dojo, ID
}
class pools_csv["pools.csv"] {
<<CSV>>
PoolName, Name, Position, DisplayName, Dojo, Seed, Number, ID
}
class pool_matches_csv["pool-matches.csv"] {
<<CSV, one row per match>>
MatchResult rows
}
class bracket_json["bracket.json"] {
<<JSON>>
Bracket
}
class status_yaml["competitor-status.yaml"] {
<<YAML>>
CompetitorStatus list
}
class lineups_yaml["lineups.yaml"] {
<<YAML>>
TeamLineup per match, or the starting one
position to name and member id
a position may hold an id and an empty name
}
class team_members_yaml["team-members.yaml"] {
<<YAML>>
TeamMember list by team
}
class overrides_json["overrides.json"] {
<<JSON>>
Overrides
}
class history_dir["history/"] {
<<JSON lines, one file per match>>
every write that reached the match
applied or held, with held values
}
class wal[".wal/"] {
<<pending transactions>>
replayed on startup
}
class branding_dir["branding/"] {
<<uploaded images>>
tournament logo
}
class sponsors_dir["sponsors/"] {
<<uploaded images>>
sponsor logos
}
tournament_md --> config_md : owns
tournament_md --> branding_dir : owns
tournament_md --> sponsors_dir : owns
tournament_md --> wal : stages transactions in
config_md --> participants_csv
config_md --> seeds_csv
config_md --> pools_csv
config_md --> pool_matches_csv
config_md --> bracket_json
config_md --> status_yaml
config_md --> lineups_yaml
config_md --> team_members_yaml
config_md --> overrides_json
config_md --> history_dir
team_members_yaml --> lineups_yaml : positions reference members
Markdown, CSV, and JSON or YAML each earn their place:
| Format | Used for | Why |
|---|---|---|
| Markdown with YAML front matter | tournament and competition settings | Human readable and editable, and the body can hold notes |
| CSV | participants, seeds, pools, pool and league matches | Opens in a spreadsheet; one row per record diffs cleanly |
| JSON and YAML | bracket, eligibility, lineups, overrides | Tree shaped data that does not fit a row |
A competitor list must exist before a seed can be set: a seed ranks a participant, so a rank with nobody to attach it to is refused, not stored. Clearing a seeding is exempt, since that is how an operator removes one, and there is nothing to attach either way.
The seed list stores the participant's own id alongside the name and dojo, and once a row carries that id it is the one thing a seed is matched to its participant by. The name and dojo are kept for two other jobs. They are what an older reader that has never heard of the id column still matches by. They are also the fallback for a row the id column has not reached yet, matched by name and dojo together, because two competitors may share a name across dojos and a file that stored only the name could not say which of them the rank belonged to. A row without a dojo still matches by name alone when that name is unique in the roster, and a file without the dojo or the id column is completed from the roster on first load.
5. Write guarantees
Every file write is atomic. The bytes go to a temporary file in the same directory, are flushed to disk, and are then renamed over the target, with the directory entry flushed too. A power cut never leaves a half written results file: a reader sees either the previous version or the new one.
Writes to one competition are serialised. Each competition has its own lock. A score write takes it for the whole read, modify and write cycle, and reads from disk rather than from cache while holding it, so two writes to the same competition cannot interleave.
Multi file changes are transactional. Some actions touch several files at once, for example recording a withdrawal writes the match result, the competitor eligibility record and the updated bracket. Those run as a transaction: the intended writes are collected, committed to a write ahead log, and only then applied. If the process stops midway the log is replayed at the next startup, so the group either lands completely or not at all. One write sits outside this: a participant edit lands by atomic rename rather than through the log, so it is crash safe on its own but is not rolled back if the rest of the group fails.
sequenceDiagram
participant H as HTTP handler
participant S as Store
participant W as Write ahead log
participant D as Disk
H->>S: begin transaction (competition id)
S->>S: take the competition lock
S->>W: stage intended writes
H->>S: work completes without error
S->>W: commit the log
W->>D: apply each write atomically
S->>W: remove the log
S->>S: release the lock
Concurrent writes are merged, never dropped. A match result is split into groups of fields that change together: the scoreline, the verdict (status, winner, decision and the notes that describe it), the overtime, the engi flags, the representative players, each side's fighter picked for a representative bout (one group per side), and each bout of a team match on its own. A write names the groups it changes, and each group records the time of the last change applied to it. A change applies only when it was made no earlier than the stored change to the same group, so two operators changing different things (one records the overtime while another enters a point, or two bouts of a team match are scored on two boards) both land, whatever order their writes arrive in. A group a write does not change is never overwritten. When two writes change the same group, the one made later stays, and the other is not discarded: it is kept in the match's history with its values. The times compared are when each change was made, never when it arrived, so a court coming back from an outage cannot overwrite a newer change recorded elsewhere, and its own changes to things nobody touched since still land. A write all of whose changes are outranked is reported as not applied (the operator sees an explicit notice naming that it was kept in the history), and one applied in part says which groups were held. A write that does not name its groups counts as changing every group it carries.
Further rules sit on top. A kiken, fusenpai, or fusensho is one change with the scoreline it writes: it is held whole when any point or bout was entered after it was declared, because points scored later mean the decision no longer describes the match. The other way round, a point entered on a board still scoring the match after one was recorded means it was a mistake: it is cleared (and kept in the history), the competitor it barred can fight again, and the winner is worked out from the points. That clearing rule is for a withdrawal of the match itself. A kiken, fusenpai, or fusensho that has already closed a match, whether a withdrawal or a win awarded because the other side is barred by another match, otherwise stands: a later change to its score, or its overtime, says nothing about that decision, so it is held in the history with the reason. A board that is still scoring a match that has meanwhile been finished never reopens it: its changes made after the finish are applied to the finished result, which stays finished, and the winner is worked out again from the merged points or bouts; its changes made before the finish are held. A pool or league match that this leaves tied becomes a draw. A finished knockout match needs a winner, so a correction that would leave it tied is not applied: the match keeps the result it was finished with (it never goes back to running), the change is kept in the history as held, and the operator is told to correct the result with a winner. The same holds for an engi match, in a pool as in a knockout: it is never a draw, so a recount that decides no winner (an even total) is held and the recorded result stands. A judges' decision (hantei) belongs to the result, so a points change that does not touch the result keeps it.
The order of arrival does not change the outcome. A finish made before a point that arrives later is applied, and the winner is then worked out from the newer point; a finish that arrives after a newer point gets the same winner, worked out from that point, and keeps its judges' decision. If the newer point would leave a finished knockout match tied (or an engi match without a valid count), then in the order the changes were made it came after the finish and could not apply. So a finish that arrives after it is applied on the scoreline it was made on, and the newer point is moved out of the match into the history as held, with the reason. Both orders end in the same match and the same kept change. A finish that does not carry the scoreline it was made on cannot be replayed that way; it is held instead, and the match stays as the point left it.
An out of order write from the same client session (an older save arriving after a newer one) is not applied, and is kept whole in the history as an older revision of that board.
A deliberate correction is subject to the same rule, which matters most when it was made without a connection. Correcting the result you are looking at works normally. Correcting a result that has since been changed by someone else does not overwrite them: the correction's outranked parts are held and reported, exactly as any other out of date write would be. That is the point, because a correction saved during an outage can reach the server hours later, long after the match has moved on, and the operator who wrote it has no way of knowing what happened in between.
Every write is kept in the match's history. Each match has an append only history file in
the competition's history/ folder, one line per write that reached it: when the change was
made, when the server received it, which endpoint it came through, the groups it changed and,
for each, whether it was applied or held, with the held values. A held group that says exactly
what is already stored (for example "no overtime" over a match with none) is no loss, so it is
recorded as unchanged and its value is not kept. When a rule held changes rather than their
times, the line also says why: an older revision of the same board, a late finish kept
together with the scoreline it closed the match on, an engi result whose flags and winner are
kept together, a kiken, fusenpai, or fusensho that already closed the match, a knockout match
that needs a winner, or an engi result that needs a valid flag count. The history is written in the
same transaction as the match, so the two land together or not at all, and a write refused
outright (an invalid payload, the wrong competitors) leaves no line. Reopening, requeueing,
overriding a winner and the other server actions that change a result write their own line.
The history goes with its competition, and with a discarded draw, since a draw generated again
reuses the match ids.
The merge needs the time of each stored change to compare against, so it only works where those times are saved. They are saved for every match, in both files (the last column of the results file, and the bracket file), which is what makes the rule the same wherever a match happens to be in the competition. A match saved before the per group times existed has only one time for the whole result, and every group reads as changed then, so it is compared exactly as before. A running save from a client that does not send a time counts as unstamped and always applies: the merge discriminates only when both sides carry a stamp, so it can never silently hold a legitimate change. A result the server orders itself (a finish or correction that arrives without a time, and every change a server action builds, such as a quick score) takes the server's time instead, never older than the stored result, so it still applies and a save made before it but arriving later is ordered against it rather than overwriting it.
The comparison only holds if the two clocks agree, so a timestamp that is far enough in the future to be impossible is refused and reported rather than trusted or quietly discarded. Both of the alternatives lose work. Trusting it lets that one write beat every later result until real time catches up with the stamp, which freezes the match for the whole of that window. Discarding it makes the write count as unstamped, and an unstamped write always applies, so the device with the wrong clock overwrites a newer result recorded elsewhere and nobody is told. A few seconds ahead is ordinary and is accepted, since a device learns the server time over the network and a little drift is normal. Beyond that the device is told its clock is wrong, nothing is written, and the app resyncs its clock so the operator can enter the same result again. Entering it again is safe precisely because the refused write never landed.
The check is applied when the write arrives, not when it was stamped, so a write held in the outbox escapes it in proportion to how long it waited. A device whose clock is ahead by less than the time its write spent queued delivers a stamp that has already fallen into the server's past. There it is honoured like any other, and it can still beat a result recorded moments later on another court. What the check catches is a clock wrong by more than the queue age plus the few seconds of tolerance, which is the case that would otherwise freeze the match or overwrite a newer result with nobody told.
When a queued write is refused for a wrong clock, the app learns the server time again and rebuilds the stamp before retrying it once. It does this two ways and keeps whichever answer is older. The first way reads the moment the result was entered and adds the freshly learned offset, which is right so long as the device clock has not moved in between. The second way measures how long the result has been waiting on a steadily ticking timer that a clock correction cannot move, and subtracts that from the current server time, which is right even if the clock was corrected but can understate the wait if the device slept.
Keeping the older answer is a deliberate safety choice rather than a coin toss. Too old, and this result can only lose a comparison it might have won, which costs the operator a re-entry they are already being told about. Too new, and a result entered before the outage could quietly beat one recorded during it, which is the silent overwrite the whole check exists to prevent. Where the two answers disagree, the recoverable mistake is the one to make.
One case is still not recovered, and it is worth stating plainly. The steady timer is measured from the moment the page was opened, so it means nothing to a page that has since been reloaded or to a second tab. If the clock is corrected across a reload, the rebuilt stamp is wrong again, the result is refused a second time, and it is then dropped rather than retried forever. That is accepted, and it is loud: the operator is told that nothing was recorded and that this device's clock needs fixing, so the result can be entered again from a device whose clock is right. Dropping it is the lesser harm, because an entry that can never carry a correct stamp would otherwise hold up every write queued behind it.
When a file is wrong
Section 1 promises that a wrong cell is repairable by hand, but not what happens when a hand edit is itself wrong. Two things can be wrong, and they behave differently: a single cell that will not parse, and a whole file that will not parse.
A wrong cell degrades, and the file keeps loading. A malformed cell falls back to its documented default and never fails the row it lives in or the load the row belongs to. A team match's sub-bout cell that will not parse as JSON loads as an empty encounter rather than aborting the read. A seed row missing its rank or name, or too short to hold them, is skipped rather than guessed. A number that will not parse is left at the column's documented default, and a negative count is clamped to zero. One bad cell cannot stop a tournament. Two cases go further and discard data outright, because there is nothing safe to default to: a legacy judges'-decision flag with no attributable winner is dropped rather than guessed onto a side, and a hand-edited bracket file carrying both a legacy score string and the current ippon arrays keeps the arrays and clears the string.
A degraded cell is kept, not overwritten. The results file is rewritten in full every time any match in it is scored. A cell that read as empty would otherwise be written back as empty, so the bytes needed to repair it would be gone within seconds of the next bout. A sub-bout cell that will not parse is therefore held exactly as it was read and written back unchanged for as long as the encounter stays empty. Re-entering the encounter in the app replaces it, which is the other way to repair it. While it is unrepaired the operator console marks the match and the pool standings it feeds, because an encounter that loads as empty contributes no individual victories and no points, and those are the figures a pool tied on wins is separated on.
A wrong file fails the load, and nothing is written to it. When the damage is structural, a bracket file that is not valid JSON or a results file whose rows no longer parse as CSV, there is no row left to degrade. The load fails and every write to that file is refused, so the competition stops rather than continuing from a half-read record. The file is left exactly as it was: refusing the write is what makes a repair possible. The app reports which file failed, the line and column, and what the parser found there, so the fault can be found in the editor already open.
Repairing the file and reloading is always the option that loses nothing. When that is not
possible, what else can be done about an unreadable bracket file depends on where the draw it
held was written down, which is decided by the competition's format. In every case the file
itself is renamed aside rather than deleted, keeping the name it had with .corrupt- and a
timestamp appended, so it stays in the competition's folder for a later repair by hand.
Pools then knockout. The knockout can be reset. The draw survives in the pool file, so the tree is built again from it and every finished pool has its qualifiers seeded back in. That trade is real, and the console states it. The knockout results are not recorded anywhere else and have to be re-entered from the score sheets. The rebuilt pairings should be checked against the printed bracket, because the tree is laid out by the current draw algorithm, and the one that produced the original died with the file.
League and Swiss. Nothing is rebuilt, because neither format draws a knockout bracket at all. A bracket file in one of these competitions is left over and unused, so moving it aside is the whole repair and no result is affected.
Direct elimination. There is no reset. The bracket file is the only record of who was drawn against whom, so rebuilding would produce a different set of pairings rather than restore the original, and it would then disagree with the bracket already printed. The app refuses it and says why. The same refusal covers a competition whose format is missing or unrecognised, which a hand edit to the config file can produce: those are drawn as direct elimination, so the file is the only record of their draw too.
Anyone repairing a file mid tournament should read all of this as: fix the cell, reload, and check the record, rather than trusting that a malformed edit would have been rejected.
6. How the model maps onto rows
The results file is row oriented, and a match is not flat. The mapping between the two is
mechanical: every persisted fact has exactly one representation, and the file holds the
same facts as the structs, encoded for a spreadsheet rather than for a parser. Nesting,
delimited lists, and a mark folded into the score: reading pool-matches.csv alone turns
up three encodings worth knowing.
A team match nests. The sub bouts are stored as a JSON document inside a single CSV cell. That keeps the file to one row per match, at the cost of the richest data in a team competition not being readable as columns.
Lists sit inside cells. A side's struck points are joined with | in one field, so a
two point match reads M|K rather than occupying two columns.
The judges' decision rides in the score. A match won on referee decision has no column of its own. The mark occupies a point slot in the winner's score field, which is exactly how it is drawn on a paper score sheet, and it stays there when the file is read. The mark is part of the recorded score, but everything that counts points knows to skip it, so it can never inflate a result.
Appending columns and leaving some fields unwritten are the two consequences that follow:
- Column position is the contract. New fields are appended, and a file written by an older
version stays readable because the reader leaves each missing trailing column at its
documented default, empty for most and
-1for the round number, which means unknown. - Some fields are useful only in flight and are not written at all. Client revision markers
used to discard out of order writes are one example: they exist to order writes within one
session and carry no meaning once the result has landed. A match's queue position is
another. It is derived from court, status and scheduled time each time a match list is
served, so the queue shrinks as matches finish. Persisting it would let a stale copy
disagree with the schedule it was drawn from, so it is
excluded from
pool-matches.csvby name, on top of carrying a tag that already keeps it out of the YAML-marshalled forms.
If the storage were rebuilt around queries rather than around people reading it, the shape would differ: a match side table would replace the paired columns, and sub bouts would be rows rather than an embedded document. That would trade away the inspect and repair properties in section 1, so the storage stays shaped for people.
7. How the layout is enforced
A single defined column order and a round trip guard on every file hold the layout to its contract:
-
The results file's layout is defined once. The header, the row writer and the reader all derive from a single ordered column list, so the three cannot disagree. A golden test pins the exact bytes. The ordered list, never Go struct order, is the on disk contract.
Only the results file is built this way, because only it earns the machinery: it is by far the widest, and it is the one that grows a column whenever the rules do. Of the others, two could not use a positional column list at all: the roster file's layout varies by row, and the seed file is read by column name rather than position. A third derives a column from row order rather than from a field (pools.csv). The round trip guard that follows protects each of them. * Every CSV file has a round trip guard. Each guard sweeps the persisted struct's fields and fails when a field neither survives a save and reload nor appears in an allow list with the reason it is legitimately transient. The files persisted by marshalling a whole structure are immune by construction, and a companion test pins the property that makes them immune.