From f4cf5511457696e96bdfccd5cf586a6ddde263fc Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 23:13:42 +0100 Subject: [PATCH 1/2] docs(bc-cmrs): move CLAUDE.md's domain rules into path-scoped .claude/rules files CLAUDE.md goes from 240 KB to 69 KB; a fresh session's first call drops from 135k to 72k tokens. The rulings move verbatim into seven files that Claude Code loads only when a matching file is read or edited, with a pointer table in CLAUDE.md and AGENTS.md/GEMINI.md. Subagents now default to Sonnet. Co-Authored-By: Claude Opus 5.5 --- .claude/rules/draw-and-seeding.md | 16 ++ .claude/rules/eligibility-and-queue.md | 14 ++ .claude/rules/match-results-and-marks.md | 106 +++++++++++ .claude/rules/match-writes.md | 92 ++++++++++ .claude/rules/store.md | 13 ++ .claude/rules/teams-and-lineups.md | 22 +++ .claude/rules/workbook.md | 25 +++ AGENTS.md | 4 + CLAUDE.md | 221 ++--------------------- GEMINI.md | 4 + 10 files changed, 313 insertions(+), 204 deletions(-) create mode 100644 .claude/rules/draw-and-seeding.md create mode 100644 .claude/rules/eligibility-and-queue.md create mode 100644 .claude/rules/match-results-and-marks.md create mode 100644 .claude/rules/match-writes.md create mode 100644 .claude/rules/store.md create mode 100644 .claude/rules/teams-and-lineups.md create mode 100644 .claude/rules/workbook.md diff --git a/.claude/rules/draw-and-seeding.md b/.claude/rules/draw-and-seeding.md new file mode 100644 index 000000000..6672c0a6a --- /dev/null +++ b/.claude/rules/draw-and-seeding.md @@ -0,0 +1,16 @@ +--- +paths: + - "internal/helper/{seed,seed_warnings,tree,pool_distribution_tree_aware,pool_partial,pool_bounds,draw,draw_perpool,draw_qualifier_paths,numbers,rounds}*.go" + - "internal/engine/{pools,numbering,knockout,knockout_skeleton,seed_warnings,league_schedule,chusen}*.go" + - "docs/assets/javascripts/pool-draw-animation.js" +--- + +# Draw, seeding and pool distribution + +Moved from CLAUDE.md "Key Algorithms". Read before changing how a draw places, seeds or numbers competitors. + +- **Binary tree brackets** (`helper/tree.go`): `Node` struct with `Left`/`Right` children, recursive subdivision for multi-page output. `helper.MaxPlayersPerTree = 16` (constants.go). +- **Seeding** (`helper/seed.go`): `StandardSeeding()` uses `generateBracketOrder()` for placement. `ApplySeeds()` handles collisions by swapping seed values. Seed names are title-cased with cases.Title(NoLower) and then looked up exactly by (name, dojo) in domain.RosterIndex; accents and non-initial capitals must match (NormalizeParticipantName is NOT on this path). `delayDojoMeetings` (same file) then delays dojo-mate meetings across the unseeded slots; its acceptance is SUM-DRIVEN by operator ruling (2026-08-31): a swap may pull an already-deep dojo's first meeting one round earlier to push the draw's earliest meetings later. Do NOT add a per-dojo never-earlier veto here: that rule belongs to the POOL exchange pass (`improveDojoMeetings`), and a finding that reports the deep-dojo trade as a regression is re-litigating the ruling. +- **Pool creation** (`helper/pool_distribution_tree_aware.go`): members are placed ONE AT A TIME in the roster's pre-shuffled order (no re-sorting -- shuffling is upstream's job), each descending the knockout skeleton tree by recorded per-node dojo counts (fewer of my dojo, then more room, then left); seeds are placed first on their EKC/D7 slots and recorded. A single pairwise exchange pass follows with a strictly tiered objective: per-pool spread-cap excess, winner-path round-1 count, winner-path meeting sum, then the all-qualifier crossing best effort -- each tier only resolves what the tier above left tied. No rotations. A crossed 2nd-place meeting a dojo-mate is ACCEPTED chance (operator ruling); never "fix" it beyond the tie-break tier. The old greedy fill's `assignPlayersToPools`/`discoverPool` survive only for `CreatePools` direct callers (estimator, goldens), where unique-dojo rosters make them identity-equal; `leastConflictedPool` is NOT one of those retirees -- it is load-bearing in the tree-aware distributor itself, called from three sites (`pickDojoTreeAwarePool`'s two fallbacks plus `poolUnderDojoCap`) as the descent's own least-conflicted fallback when the dojo tree has nothing to say. Pools distributed contiguously across shiaijo. **Competitor numbers belong to draw positions, never to people before a draw exists** (operator ruling 2026-09-06): once the entrant count fixes the pools and the tree, the positions are numbered pool by pool (`helper.NumberPools`, K1-K4 the first pool) and seeds and everyone else are placed into those positions; nothing is shown before the draw is generated (the earlier provisional registration-order numbers were removed as confusing). A knockout-only competition follows the SAME rule down the bracket instead of pool by pool (bc-pnum ruling 2, 2026-09-07): `helper.StandardSeeding`'s own placement order is recorded as `state.Bracket.DrawOrder` (participant ids, top of the tree to the bottom) when the draw is generated, and `engine.NumberKnockoutParticipants` composes each competitor's number from their position in it at read time; a competitor excluded from the draw (e.g. not checked in), or a bracket generated before this field existed, carries no number -- there is no participant-order or name-based fallback. **Changing any of this reddens a test that names a docs file**: the pool-draw page carries an interactive walk-through (`docs/assets/javascripts/pool-draw-animation.js`) that replays this descent in JS, and its example rosters' drawn pools are a fixture between markers in that same file, pinned by `TestPoolDrawDocWalkthroughMatchesTheDraw` (`internal/helper`) re-running them through the real distributor. That is deliberate: a recorded outcome rots silently otherwise. Update the fixture, then re-check the JS descent against it in a browser, since the test pins the recorded pools and not the JavaScript. +- **Tree-aware seeding** (`helper/seed.go`): `placeSeedIndices`, called from `buildPoolPhaseTreeAwareCore` (`helper/pool_distribution_tree_aware.go`; `PoolSeeding` itself was removed as dead code, bc-drwx item 11), places seeds so the top seeds land in different halves/quarters of the knockout TREE (D7, enforced via tree paths in seed_warnings.go); the EKF rank-to-court order is that tree contract's sheet encoding, since courts own contiguous chunks of the tree. Courts are NOT an overlay here: `numCourts` is the modulus `seedPoolRank`/`seedCourtOrder` compute the seed spread against, so a wrong court count places seeds in the wrong quarter. (Courts genuinely are an overlay for the SEPARATE dojo-tree descent that places the unseeded -- see the Pool creation bullet above -- which reads a court index only to derive `drawCourts` up front and to reorder for courts at the very end, never in between.) **Wrapped seeds** (rank beyond `numPools`, e.g. rank 5 over 4 pools -- reachable from a contiguous seeding with more ranks than pools, or from check-in dropping a mid-sequence seed and leaving a gap, `TestGenerateDraw_SeedGapFromCheckInKeepsD6Placement`): the extra seed takes the next pool (in the same rank-priority order the natural arithmetic defines) with room holding NO seed; failing that, the next with room holding no DOJO-MATE; only once both fail does it fall through to D6's unconditional placement. Once placed, a wrapped seed is never moved by anything downstream -- not the pool exchange pass (`improveDojoMeetings`, which excludes every seeded slot from both sides of a swap) and not `delayDojoMeetings` (which only ever moves unseeded slots, via its own `occupied` guard). `seed_warnings.go`'s halves/quarters/shiaijo checks are scoped to ranks `<= maxSeedRanks` for the same reason: a wrapped seed has no half/quarter home in D6's structure, so scoring it against those checks by its COUNT position in the placed-seeds list (rather than by its rank value) produced a spurious "could not be split into halves" warning for a wrapped seed that had landed cleanly in its own seed-free pool (bc-pnum operator ruling; restores what an earlier review round had narrowed to the dojo-mate case alone). +- **A pool bout's number is its place in the playing order, and the id carries it** (operator ruling 2026-10-04: "Should be in playing order and both ids should be in sync"). `numberPoolMatchesInPlayingOrder` (engine/pools.go) gives each pool's ids after every reorder the draw makes (the league scheduler's no-back-to-back order), just before the save, so `Pool A-0` is the first bout played. Every surface reads the number from the id: the server label below, the scores list, the workbook (`AttachPoolMatches` sorts by it) and the console's "Match N of M" (`poolPosition`, `poolMatchNumberOf`, viewer_utils.jsx), never from a row position. A queue reorder moves only `scheduledAt`, so a bout keeps its number. A league drawn by an earlier release keeps ids given before its reorder; every surface still shows one number for each of its bouts. diff --git a/.claude/rules/eligibility-and-queue.md b/.claude/rules/eligibility-and-queue.md new file mode 100644 index 000000000..415b08a75 --- /dev/null +++ b/.claude/rules/eligibility-and-queue.md @@ -0,0 +1,14 @@ +--- +paths: + - "internal/engine/{eligibility,scoring,scoring_tx,kachinuki}*.go" + - "internal/state/competitor_status*.go" + - "internal/mobileapp/{handlers_match,handlers_decision,handlers_eligibility,errors}*.go" + - "web-mobile/js/{ineligible_match,start_match,barred_chip,barred_match_notice,admin_shiaijo,admin_scoring_shared}.jsx" +--- + +# Competitor eligibility and the match queue + +Moved from CLAUDE.md "Key Algorithms". Read before changing withdrawals, bars, reinstatement, starting a match or sending one back to the queue. + +- **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409, with `reasonHuman` (`reasonHumanForBarredCompetitor`/`barredCompetitorSentence`, `mobileapp/errors.go`) composing one operator sentence naming the competitor, the match (`engine.OperatorMatchLabel`), and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the fusensho for Carol." (kiken-injury: "...Reinstate Alice if they can fight again, or record the fusensho for Carol."). Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. A withdrawal decision against a competitor ANOTHER match already barred is decided by ONE rule, `engine.alreadyBarredRefusal` (bc-kfup), asked by both T105 halves (`checkConcurrentIneligibility` before the write, the K2 check-and-set in `recordIneligibilityFromDecision`): a kiken is refused with `already_ineligible` (a competitor withdraws once), while a fusenpai is their default loss on a remaining match and records NO new `CompetitorStatus`, so the existing one keeps naming the match where they withdrew. Do not re-add a fusenpai refusal there: StartMatchTx refuses to start that match, so the fusenpai is the operator's exit. Clearing the match that DID record the bar moves it onto such a chained fusenpai rather than lifting it (`standingWithdrawalOf`, fusenpai only: a kiken still on record beside a later bar was lifted by a reinstatement before that bar existed, since a second kiken is refused), and a reopen that clears it lands `scheduled` because `reopenTargetStatusTx` judges the bars the restore will leave (`restoredStatus`, shared with the restore). Accepted as it is (operator decision 2026-09-26): a fusenpai recorded before a reinstatement still carries the bar on, so a competitor who withdrew injured, had a no-show recorded on another match, was reinstated, and then has a later withdrawal cleared as a mistake is barred again by that earlier no-show; the editor names the no-show match, where clearing the fusenpai lifts it. Telling the two apart needs the time of each reinstatement, which is not worth storing for this. Do not report it as a new defect. `engine.BarredSides(statuses, matchID, sideAID, sideBID)` is the ONE extraction point for "is this side barred by a match OTHER than the one being checked" (the `st.MatchID != matchID` undo-path exemption lets a match always be re-scored past its own prior kiken/fusenpai); it replaced three hand-copied loops (`StartMatchTx`, `checkEligibilityExcludingMatch`, `annotateEligibility`). A SCHEDULED match with a barred side carries a READ-ONLY `ineligibleSides` stamp (`state.IneligibleSidesAnnotation{A, B string}`, both `omitempty` decision-code strings) on `MatchResult`/`BracketMatch`, computed at REQUEST TIME ONLY by `mobileapp.annotateEligibility` (`handlers_match.go`; never persisted, `yaml:"-"` on the pool side); a stamped match also has its `queuePosition` zeroed and so omitted from the wire (`state.DeriveQueuePositions`/`annotateBracketQueuePositions`), so it is skipped exactly like an unqueued match. `web-mobile/js/ineligible_match.jsx` is the ONE JS owner reading this stamp: `isBarredMatch`/`awaitedDefaultWin` skip such a match from Up next, Finish + Start Next, the TV next line, the lobby, and on-deck alerts, and drive the operator's one-tap `Record fusensho for ` (`POST /decision {decision: "fusensho", decisionBy: }`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate `. The same request-time pass stamps a COMPLETED match a kiken, fusenpai or fusensho decided with `withdrawnStatus` (`state.WithdrawnStatusAnnotation`: the withdrawn side's status now, eligible / the match that bars it / reinstateable), which `RecordedWithdrawal` words its clear from instead of fetching statuses. The viewer payloads load statuses whenever a match needs either stamp (`anyMatchToAnnotate`), and the `/decision`, `/score` and bulk-score writes stamp the result they push (`stampWithdrawnStatus`), so a pushed row and its refetch read the same. **Recording a withdrawal changes only that match** (operator ruling 2026-09-26, "do not cascade"): the post-kiken Remaining matches panel that offered to close the competitor's other matches was removed, and each of them is closed from its own notice when it comes up. Do not re-add a cascade from one match's editor. Recording one still starts the court's next match in the same competition (mp-nwds item 7, the operator's request), on the court console and the Scores tab alike, both through `start_match.jsx` (one start at a time; a refused start is shown on the refused match, on the console's Up next or queue row and on the Scores tab's row, with one toast: an error `editMatchScore` already toasted is marked (`markToasted`, write_result.jsx) and the host does not toast it again. The notice is bound to the refused match, not to Up next, by one rule both hosts ask (`startRefusalStands`, start_match.jsx): it stays while that match is `scheduled` on the court, and across an empty list, which says nothing about the match and goes when it leaves `scheduled`, when a non-empty court list no longer holds it, when the operator starts a match, or when an eligibility change in its competition is broadcast; Up next moving on does not drop it. A refusal because another start was still out is kept, in the past tense (`startWasBlockedByStartMessage`), on the refused match once that start has gone out: `landed` on the start's record, set by the console's `startMatch` for a start that landed or was queued offline, never for a pick whose deferral of the running bout failed, nor for a start the server refused: a `clock_skew` answer, or a superseded one that holds the `result` group (`heldGroups` non-empty: a different result is stored and nothing of the start was written). A superseded start with NO `heldGroups` is an echo-hold (a start names only `result`, and the server lists a held group only when it differs from the stored one, `reportHeld`/`HeldEcho`): another device's start of the same match landed first and the match is running, so `classifyStartOutcome` answers it `{ok: true}` like a landed start: no sentence, no toast, a pick pins the panel, and a refusal waiting on it is worded in the past tense. The two refusals answer `{ok: false, msg}`, so both hosts take them as refused starts: the sentence (`START_CLOCK_SKEW_MESSAGE`, or `START_SUPERSEDED_MESSAGE`, composed by `notSavedText` from `SUPERSEDED_REASON` and `SUPERSEDED_ADVICE` under the "Not started" lead, which never invites a retry, start_match.jsx) is stored on the refused match and toasted once, and a pick does not pin the panel on that match; after a pick the court is idle, the running bout already sent back to the queue. The refused match is never started on its own; if the blocking start fails instead, its own refusal stays. On the Scores tab `refuseStart` replaces the single waiting refusal and clears `waitingOn`, so its `finally` has nothing to rewrite, and a superseded start that holds its result group, after a decision, lands on that match with its notice. Accepted: the `result` group's projection also holds `CorrectionReason`/`ResultSource`, so a start over a match running under a different source or reason answers `heldGroups: ["result"]` and shows the refused sentence on a running match; the notice drops once live data shows it running (`startRefusalStands`), the toast stays. The console reads `startPatch`, `winnerSideLR` and `teamMatchMarks` by ES import (`start_match.jsx`, `side_marks.jsx`), and admin_shiaijo.jsx still must not import bracket.jsx); both skip the withdrawn competitor's matches through `sideBarredByDecision`/`involvesCompetitor` (`ineligible_match.jsx`), because the list shows them barred only after it refreshes. +- **Send back to queue keeps the score** (operator ruling 2026-09-26, bc-sbq). `RevertMatchToQueue`/`requeueBracketMatch` clear only the verdict and the audit notes; points, penalties, overtime, bouts, flags and rep-bout fighters stay, the operator removes a wrong mark themselves, and the button is always offered (picking another bout sends a scored one back the same way). Starting the match again keeps them: the SPA's `startPatch` sends the request-only `startOnly` flag and `engine.keepQueuedScore` copies the stored score onto that write, because the wire cannot say "no score sent" (the start's empty arrays are the bytes of an operator clearing every mark, which must still clear, so the editors' own Start stays unflagged; `startOnly` with any status but `running` is a 400, since the stored score is swapped in after validation). The start's representative-bout winner is derived AFTER that swap (`deriveDaihyosenWinner`, scoring_tx.go), so it comes from the bouts the start keeps, never from rows it discards. While queued, every display shows the match as NOT STARTED through `matchShowsScore` (match_shows_score.jsx, a leaf every display ES-imports), which `IndividualScore`/`TeamScoreboard` apply to themselves; the score editors are the one exception. A queued knockout match's kept points do not make it "fought" (`bracketMatchStartedOrDecided` reads status and winner only), so correcting or reopening the match that feeds it re-seats it with its points and asks nothing when it starts. A FINISHED later match that a confirmed correction reopens keeps its points, bouts and flags too, only its verdict goes (`reopenDisplacedBracketMatch` through `reopenBracketMatchKeepingTheFight`, both the knockout and the pool-requalification doors). Engi flags are saved as they are entered: a running engi write is not judged on the flag total (`engiRunningWrite`, scoring_tx.go) and the bracket write carries flags, so a send-back or switch keeps them. diff --git a/.claude/rules/match-results-and-marks.md b/.claude/rules/match-results-and-marks.md new file mode 100644 index 000000000..4f262d1d3 --- /dev/null +++ b/.claude/rules/match-results-and-marks.md @@ -0,0 +1,106 @@ +--- +paths: + - "internal/domain/{decision,ippon,result_marks,score,match}*.go" + - "internal/export/**" + - "internal/state/{legacy_hantei,team_result}*.go" + - "internal/engine/{scoring,scoring_tx,engi,match_label,bracket_result,daihyosen}*.go" + - "internal/mobileapp/validation*.go" + - "web-mobile/js/{bracket,side_marks,side_cell,result_slot,result_recency,match_scoreboard,match_shows_score,numbered_name,display_scoreboard,display_helpers,display_lobby,streaming_overlay,pool_ids,admin_scoring_individual,admin_scoring_team,admin_scoring_shared,admin_shiaijo,viewer_match}.jsx" +--- + +# Match results and marks + +Moved from CLAUDE.md "Match results and marks" and "Key Algorithms". Read before changing how a result is recorded, validated, or shown on any surface or in the workbook. + +- **Operator match labels** (`internal/engine/match_label.go`): `OperatorMatchLabel(comp, bracket, matchID)` is the ONE Go owner of how a match is named back to the operator -- in refusal messages, the `court_busy`/`downstream_knockout_running` `label` fields, and `reasonHuman` sentences. A pool/league row reads e.g. `"Pool A · Match 1"` (`"League table · Match N"` for a league; the middle dot is the literal separator, `supplementaryOrNumberedLabel`); a knockout row (`MatchLabel`) reads e.g. `"Match 2 (Final)"`, `"knockout Match N"` (no round recorded), or `"the 3rd-place match"` (bronze). Pinned against the JS side by the shared golden fixture `internal/engine/testdata/match_labels.json`: `TestOperatorMatchLabel_GoldenTable` (Go) and `web-mobile/js/__tests__/match_labels_golden.test.jsx` (JS, `scoreRowMatchLabel`/`matchLabel` from `pool_ids.jsx`/`write_result.jsx`) load the SAME file. +- **Match Colors**: White (Shiro) is always the left column and Red (Aka) is always the right column (this is fixed and not configurable). In the Excel export, `helper.WhiteLeft` (`internal/helper/excel.go`) is the one Go owner of that side-to-column order; no mirror setting exists, and every side-ordered pair (entrant formulas and names, scores, fouls, result marks, header labels and styles, standings side records, a team summary's IV/PW) routes through it rather than restating the order. In pool matches the first-listed player (SideA) is Red; in elimination matches the upper-bracket player (`node.Left`) is Red. **A TINTED surface does not carry a SHIRO/AKA badge as well** (operator ruling 2026-09-20, bc-sccl, which SUPERSEDES bc-dnst's "named once in text, by the header badge"): where a side has a background tint, the tint plus Shiro's 45° hatch carry it, and the badge is redundant. Every side badge in the app went with that ruling: the score editors' header pill (`.sb-side__badge`), the engi card's (`.engi-side__badge`), the scores list and court console's (`.se-color-badge`) and the compact schedule's 14×14 `A`/`S` squares (`.tw-match__badge`). Bout rows and foul counters were already tint-only, so do not re-add per-bout SHIRO/AKA chips or "SHIRO Fouls" prefixes either. A tinted surface still names the side in TEXT for a screen reader — that is the one thing that must never be dropped with a badge (DESIGN.md §4: colour is never the only signal). The list of surfaces that carry no such text is short and closed: the TV board and the bracket card (both below, each by its own ruling), plus the watchlist hero, which names it VISIBLY instead. **`SideCell` (side_cell.jsx) owns this: a surface passes `side` and gets the fill class AND the `sr-only` label together, so the tint cannot arrive without its text; `SideLabel` is the label alone, for a surface that paints its own tint; `sideFillClass` is the class alone, for the watchlist hero only, which names the side visibly and so must not carry the span as well.** Do not hand-type either the class or the span -- that is how `"Shiro: "` and `"Shiro:"` came to coexist. **Use an `sr-only` span**, via that owner. An `aria-label` only works on an element whose role permits naming, and the cells this rule governs are bare `
`s, i.e. role `generic`, where ARIA PROHIBITS it: the court console carried `aria-label={`Shiro: …`}` on four such cells and the side was announced by nothing. An `aria-label` on an interactive wrapper is worse than useless here, because role=button has presentational children, so it REPLACES the side word, the competitor numbers and the dojos inside it — that is how the watchlist hero lost all of them. ONE exception, and it is about SIZE, not colour: the watchlist hero (`.wl-hero__side-lbl`) keeps a visible `Aka`/`Shiro` word (operator decision 2026-09-20). It is a large personal card read at arm's length, not a dense row, so the word costs it nothing -- which is the whole reason the ruling removed the badge from dense rows. Do not copy it onto a row. The exception this REPLACES was `.bc-color-badge`, justified by that card being dark navy; bc-wlhc made the card light and deleted the class, so the old wording named a surface that no longer existed. Controls that PICK a side (the "SHIRO wins"/"AKA wins" buttons, the decision side picker, the team summary band) keep naming it, as does the OBS lower third's heading. The TV board does NOT: its SHIRO/AKA headings went with the same ruling (position plus each name's colour carry it there, and a projector has no screen-reader audience), pinned by `side_is_carried_by_tint.test.jsx`'s "the TV board names no side". Aria-labels and titles may always say the side. The competitor number sits on the OUTER side of the name on every surface, Shiro's before it and Aka's after (operator ruling 2026-09-14, bc-dnst); `NumberedName` (numbered_name.jsx) owns it for JSX and `withNumber` (match_scoreboard.jsx) for strings, so a new surface calls one of those two and never restates the before/after choice. **Which of the two depends on one thing: does the cell CLIP?** A cell with `text-overflow: ellipsis` truncates the END of its run, which on Aka is the number, so the string form silently loses it there while Shiro's leading number survives. A clipping cell therefore passes `clip` to `NumberedName` (or, on the display surfaces, takes `sideLabelParts` from display_helpers.jsx), which makes the chip its own flex child so only the name ellipsises; a wrapping or unconstrained cell keeps the string. Both forms read the same `numberedParts`, so they cannot disagree about a side's name and number. The OBS lower third is MIXED on exactly this basis, not string-only (bc-rvfx): its team lines clip and chip, its individual lines do not clip and keep the string. The TV board WAS mixed on the same basis and is now chip-only (operator decision 2026-09-19, bc-lbty): its `NextPair` rows (the NEXT line and the UP NEXT pool bout list) still do not clip, but they render the non-clip `NumberedName` anyway, because a chipped row directly above a bare-string row read as an oversight rather than a choice. Clipping still decides `clip` vs plain; it no longer decides chip vs string, so do NOT infer from that exception that every non-clipping cell must chip -- the overlay's individual lines are the standing counter-example. **A chip takes the COLOUR of the name it rides beside** (operator decision 2026-09-19, bc-lbty): `.num-prefix` is `color: inherit` with its tint mixed from `currentColor` toward `transparent`, so Shiro's chip is dark and Aka's is red, and the chip never introduces colour the adjacent name does not already carry. The mix must stay transparency-based, never toward a solid light colour: the OBS lower third paints on `rgba(0,0,0,0.85)` with white and pale-pink text, where a white-mixed chip would be invisible. This supersedes the earlier mp-938b neutral-chip note, which the `.num-prefix` comment in styles.css no longer carries and which survives in git history only. Where the two sides STACK vertically instead of sitting left/right (the bracket card, the admin and public schedule rows) there is no outer side: the number sits before the name on both so the numbers align in a column, and the bracket card names no side in text, its leading colour bar and tint carry it (operator ruling 2026-09-14, bc-dnst). +- **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. The team sheet keeps the line's space while it is hidden (`.holds-space`, the one owner of `visibility: hidden` for this, operator ruling 2026-10-06), because it sits above every bout row: appearing at a bout's first point (in kachinuki at every bout, since a recorded bout's marks no longer count) it moved every row 28 px down, and a quick second tap on M opened the fighter list. The individual board's line sits below what a scoring tap aims at, so it still comes and goes. A hantei mark does not raise it: `realIppons` drops it and it undoes through its own "click to undo" control. + + +The rules below were settled by operator rulings and are pinned by tests. Each has one owner in code; do not restate any of them at a call site. + +## Decision types + +`internal/domain/decision.go` defines the canonical wire values: `""` (none), `"fought"`, `"hikiwake"` (draw), `"kiken"` (legacy withdrawal, maps to kiken-voluntary on YAML load), `"kiken-voluntary"` (FIK Art. 31, permanent), `"kiken-injury"` (FIK Art. 30, reinstateable), `"fusenpai"` (no-show), `"fusensho"` (recorded per bout within a team encounter, AND at match level by an operator via `POST /decision` to close a barred competitor's remaining scheduled match -- `domain.IsWithdrawalDecisionStr` deliberately excludes it, since it is awarded on someone ELSE's withdrawal rather than a withdrawal of the match it is recorded on; `domain.IsDefaultWinDecisionStr` includes it. `decisionBy` on either usage names the side that withdrew, did not appear, or is barred -- the LOSING side -- never the side the decision favours; the write then makes the OTHER side the winner (`scoring_tx.go`)), `"daihyosen"` (rep bout), `"kachinuki-exhaustion"` (kachinuki competitions only: a score write carrying it, at match level or on a bout row, in any other competition is refused by `refuseKachinukiDecision`/`refuseKachinukiDecisionForComp` in `validation.go`, a 400 on `PUT /score` and a per-entry error on bulk-score), and `"ippon-shobu"`. The last one is accepted by `Decision.Valid` but has no writer in the tree: supplementary pool tie-break bouts are identified by their match id (`Pool X-TB-N`, see `engine/tiebreaker.go`), not by this decision. Use `domain.IsKikenDecision(d)` / `domain.IsKikenDecisionStr(s)` to check any kiken variant. Legacy YAML `decision: true` migrates to `"hikiwake"`, `false` to `"fought"`, `"kiken"` to `"kiken-voluntary"` (`Decision.UnmarshalYAML`). + +## The middle is a closed set + +Visual marks follow the score sheet's layout, split into two kinds. MIDDLE marks occupy the centre "vs" cell (the score-string centre): exactly one of `X` (tie), `(E)` (overtime, always bare: `periodCount` is recorded but never displayed), `(DH)` (rep bout). They are mutually exclusive by rule: a match that went to encho cannot end tied, so X beats (E); a daihyosen is one-point sudden death, so DH bouts have no encho. RESULT marks ride beside the competitor they name: `Ht` (hantei winner), `Kiken` (withdrawer), `Fus.` (no-show, and also the winner of a match-level fusensho; `sideMarks` (side_marks.jsx, re-exported by bracket.jsx) and `domain.SideMarks` (placed on the sheet by `SideMarksLR`, internal/export/suffix.go) agree on this, so a fusensho match reads identically on every surface and in the exported workbook). + +Web score strings read `[left cell] [middle] [right cell]` with `vs` as the plain middle and `–` (or empty) meaning no points. Never digits: ippon renders as waza letters only, e.g. `M vs –`, `M (E) K`, `M Ht (E) K`. The dash is a CELL value only. The middle never shows a dash, so unplayed and pending match rows read `vs` too. + +A kiken, fusenpai or fusensho awards the WINNER its points as maru, one `○` per point per FIK Regulations Art. 32 and the Score Board appendix p.15 (`["○","○"]` in regulation, one `○` in encho). This is the rulebook's score-board marking, not a house style; it lives in `domain.DefaultWinIppons`. Per the same Article ("Any point scored by the shiai-funo-sha shall remain valid") the LOSER is NOT zeroed: `preserveLoserScore` (engine/scoring.go, called by the one `RecordDecision` body) keeps the withdrawing side's already-struck ippons and preserves the encounter's prior `SubResults`, so a team withdrawal never wipes the sub-bouts already fought (they keep counting in IV/PW standings). It is guarded on a side-matching prior so a drifted prior can't mis-attribute points. `state.EnchoMetadata.On` and `enchoOn` (result_slot.jsx) are the single "did this happen in encho" predicate (non-nil AND positive periodCount), shared by the (E) label, the maru count, and decision validation, so a degenerate `{periodCount: 0}` block can never split the surfaces. Display fallbacks (`DefaultWinMaruAB` in suffix.go, `formatIpponsScore`, the scoreboard win slots) render `○○` for winners whose recorded cells are empty (byes, pre-fill/legacy data). The team editor's per-bout **Fusensho** follows the same rule on the client (bc-fsnp): `applyFusenshoToggle` (admin_scoring_shared.jsx) keeps the other side's points through `struckIppons`, the JS twin of `engine.struckIppons` (result_slot.jsx, beside `DEFAULT_WIN_IPPON`), `fusenshoAllowed` refuses a fusensho against a side that already won the bout (circles against two points would be a 2-2; the refused button stays tappable and shows why under that side until the next bout edit, since a title never shows on a touchscreen), and every scoring edit on a team bout row goes through the editor's `updateSubScore`, whose `applyBoutScoreEdit` ends a fusensho through `clearFusensho` applied LAST, so the circles never survive as ordinary points. One edit keeps it: taking a mark off the side the fusensho went against is a correction, and the snapshot the undo restores follows it. + +The middle-value rule lives in ONE place, `boutMiddle` (bracket.jsx), which returns exactly `vs`/`X`/`(E)`/`(DH)`; `matchMiddleMark` (the chip variant), `matchStateCell`, and the scoreboard separators all derive from it. (`formatIpponsScore` composes the same `isDrawResult`/`middleMark` primitives directly rather than calling `boutMiddle`: its empty-middle-vs-`""` distinction is load-bearing for callers' fallbacks.) + +**The middle is a CLOSED SET and there must NEVER be a centre `Ht`** (operator ruling, absolute). Those four values are the only things that may ever occupy the centre, in either language and on every surface. `Ht`/`Kiken`/`Fus.` name ONE competitor, so they ride in that competitor's cell and can never take the shared cell. This is enforced, not just documented: `web-mobile/js/__tests__/middle_closed_set.test.jsx` and `internal/export/middle_closed_set_test.go` sweep every decision × encho × score (including `decidedByHantei`) and fail on a fifth value OR a side mark in the middle, both mutation-verified. The ruling settles what used to look like an inconsistency: the `loose` path (both slots full) renders `Ht` INBOARD of that side's slots, inside `.msb-slots`, never in `.msb-vs`, so it complies; and an unattributable winner correctly gets NO mark, because "no mark" is the only alternative to a forbidden centre mark. Adding a fifth middle value, or a centre fallback for an unattributable result, is out of bounds. Do not propose either. + +A surface that renders the FIK row must show the mark ONLY in that row's centre (operator ruling: the TV header and lobby chips were duplicates and were removed). A surface that renders NO such row (`MatchCard`'s meta strip, the OBS lower-third) keeps its chip, because there the chip IS the one home. + +## Where a mark lands + +**A middle mark belongs ONLY in the middle of an individual fight** (operator ruling), and a team bout's score is displayed the same way in every format, kachinuki or not: cells either side of a centre, on that bout's row. This governs the WHOLE set (`X`, `(E)`, `(DH)`), i.e. ANY mutating middle, not just one of them. + +- In an INDIVIDUAL competition the match IS the fight, so its own row centre carries the mark (`IndividualScore`, whose synthesised sub threads the match-level `decision`/`encho`/`score`). A tied individual match fought on in overtime shows `(E)` there, e.g. `[M][ ] (E) [K][M]`. +- **Encho is NOT team- or kachinuki-specific.** Any tied match may be fought on, in either competition type. +- In a TEAM match the encounter is not itself a fight, so each mark lands on the row of the bout it describes. **Kachinuki** fights on in overtime on the SAME numbered bout, so `(E)` lands in that bout's middle. **Regular (fixed-order)** teams settle a tie with a daihyosen, which is an EXTRA appended bout (position -1, knockout only: `AddDaihyosen` returns `ErrPoolMatch`, a pool tie is simply a draw), so `(DH)` lands in the middle of THAT extra row. A drawn bout carries `X` in its own middle under either format. +- `TeamScoreboard` gates the extra DH row on `showDH && tied`, and `showDH` additionally requires every regular bout to be FINISHED. That is correct, since a daihyosen only follows a completed tied encounter, so a fixture that appends one while a numbered bout is still unscored will (rightly) not render it. +- `TeamScoreboard`'s §277 summary row is an AGGREGATE (IV/PW), not a fight, so its centre is a deliberately empty spacer and NO mark ever goes in it. That holds even when the aggregate itself is tied: a team encounter drawn on equal IV and PW displays no `X` at the encounter level, and if no individual bout was drawn then no `X` appears at all. This is correct because the mark describes a fight, not a standings figure (the draw is carried by the T column and by each drawn bout's own centre). That row RENDERS only on `variant="card"`: on the TV board (`variant="tv"`) it is suppressed and the headline team-name row in display_scoreboard.jsx carries the pairing plus each side's IV/PW instead (operator decision 2026-09-19, bc-lbty -- the team name was being shown twice, and the summary row was the redundant copy). Both surfaces derive those four figures from the one exported `teamIVPWFrom` (match_scoreboard.jsx), so the headline and the row can never disagree about which source won. Do NOT suppress it on the card: viewer_match.jsx has no separate name row, so there that row is the only thing showing the pairing. The headline's IV/PW take their own side's colour (`var(--ink-1)` / `var(--red)`), matching what `.msb-slot`/`.msb-slot--aka` give the row they replace; the headline centre stays the bare `vs`, since IV/PW belong to a SIDE and the centre is a closed set. + +Two rejected fixes, both reverted, both verified in the browser: threading the match-level mark into the summary spacer (a review round claimed removing the TV header chip left it homeless) put a second `(E)` directly above the bout's own; and placing the encounter tie there was proposed and refused for the same reason. Do not re-add either. Before acting on any "this mark has nowhere to render" finding, seed the state through the real editor path and check a UI can produce it. + +## Hantei is recorded as an ippon + +The centre NEVER carries `Ht`: a hantei always names a winner and is decided from a TIED scoreline. `validation.go` requires a winner plus `domain.HanteiTiedScoreline`, the one owner of the tie test, shared with the engine's `preserveSubHantei` exactly as `IsHanteiCompatibleDecision` is, and counted through `domain.CountScoringIppons` so an "•"/empty cell cannot read tied to one enforcer and untied to the other; `hanteiTied` mirrors it in JS. Encho is NOT a precondition. In a TEAM match `validateSubBout` additionally restricts hantei to the daihyosen bout, position -1; a tied numbered bout is simply a hikiwake. + +`Ht` IS recorded as an ippon-slice entry (operator ruling 2026-08-21: "Ht should be recorded as just another ippon"). The mark in the WINNER's `IpponsA`/`IpponsB` is the record itself, exactly as the FIK sheet writes it: non-scoring (`IsScoringIppon` drops it, so points/PW/standings/the tie test never count it; `realIppons` is the JS mirror) but first-class in the data. Pool CSV cells and bracket.json ippon arrays hold it verbatim (`domain.ParseScore` still consumes `Ht` as its ONE two-rune token by the no-lone-`t` argument, but only inside the legacy read fold of pre-array bracket files), and the wire carries no `decidedByHantei` field in either direction. The SPA DERIVES that property in `normalizeMatch` for its read sites and `toBackendMatchResult` converts the editors' armed boolean into mark placement (`placeHt`), while the server folds legacy flags into the mark at every read boundary (`state/legacy_hantei.go`; the three `DecidedByHantei` struct fields are LEGACY READ-ONLY, never written). + +Placement follows the point rule: the mark fills the winner's next FREE slot in outside-to-inside order (0-0 → the outer slot, 1-1 → the inner one, giving `[K][ ] vs [Ht][M]`), which in array terms is `domain.AppendHantei` (fill a placeholder slot, else append), so the mark's array position IS its slot. Through normal play sanbon-shobu ends at 2, so 0-0 and 1-1 are the only reachable scores and a free slot always exists. 2-2 is IMPOSSIBLE under the rules and closed on both sides: the editors' ippon entry stops each side at 2, and `validateIppons` caps each side at 2 AND rejects 2-2 outright on every sub-bout and on the bulk path. The `loose` return exists solely for hand-edited files. No point is ever overwritten, and each consumer then applies ITS OWN policy: the read-only scoreboard renders the mark beside the slots; both editors drop it because each has a second always-mounted channel for the verdict (see result_slot.jsx's header for the enumeration). + +**Which completed bout is the most recent RESULT has ONE owner too**, `web-mobile/js/result_recency.jsx` (`resultRecencyDesc`, a leaf with no imports). Two surfaces answer that question and used to answer it differently (mp-jnvl): the court console's context strip ("the bout just played") and the public viewer's Recent results both read SCHEDULE order as recency, so a court running out of schedule order had them name different bouts. Recency is the `modifiedAt` write stamp, which is the time of the last WRITE and not of the result: STARTING a bout is a stamped write, and an unstamped write inherits the stored stamp, so a started-then-withdrawn bout keeps its start stamp. A bout is unstamped only when nothing ever stamped it (a file predating the field, or a match closed while still `scheduled`), and those fall back to scheduled time, so an all-unstamped list keeps exactly the ordering both surfaces had before. The console applies it in ONE place, `partitionShiaijoMatches`, which orders the COMPLETED group by play time (oldest first) while running and scheduled keep schedule order. That makes the newest result the TAIL, so the strip's anchor and the Completed preview are both plain tails: the preview is a contiguous window and "Show all" EXTENDS the list upward instead of inserting rows into the middle of it (operator ruling 2026-09-19; the earlier top-N-by-recency pick left visible holes). An all-unstamped list falls back to scheduled time and reads exactly as it did before the stamp existed. + +The strip's heading names what it is showing whenever that is not the live bout: "Up next" before the court's first bout (the highlight there means "play this next"), "Just played" when it fell back to the last result, "Correcting" when the operator opened that result to fix it. With no such qualifier the lead names the panel's CONTENT - "Standings" for a pool, "Bracket" for a knockout fragment. The word "Context" was removed as meaningless (operator ruling 2026-09-19). + +The slot rule has ONE owner, `resultSlot` in web-mobile/js/result_slot.jsx (a small leaf; the dependency reasoning is stated once in that file's header). Its three consumers are `resultCells` in match_scoreboard.jsx, `hanteiSlot`/`ptSlots` in admin_scoring_team.jsx, and the individual editor's slot grid in admin_scoring_individual.jsx (display-only `Ht` chip on the picked side, `hanteiPick`, which adopts `hanteiWinnerKey` from the server). Change the contract and ALL THREE surfaces must be re-checked. + +Score strings TRAIL loose marks as an unattributable-winner degradation, but the scoreboard has no centre-`Ht` equivalent: an unattributable winner gets no mark rather than one in the shared centre cell. ACCEPTED no-mark classes (all drifted/legacy data; the centre is forbidden by the operator ruling and no side is attributable): (i) same-name-no-id pairs; (ii) rename-drifted winner names (note the old centre fallback DID fire for these whenever the middle was empty, which a no-encho hantei makes normal, so this IS a regression there, accepted); (iii) winner-less legacy `decidedByHantei` rows (previously centre `Ht`); (iv) untied legacy `decidedByHantei` rows written before the tied-scoreline validation existed (letters still convey the winner; the judges'-decision marker is dropped). + +Two related deliberate divergences. Score strings (`sideMarks`) and the Excel export (`SideMarksLR`, over `domain.SideMarks`) still mark `Ht` UNCONDITIONALLY: the mirrored pair stays consistent with itself and gains no tie gate. And a winner naming BOTH sides resolves defensively to AKA everywhere in JS (`subWinnerSides`, aka-first) to match Go's `isWinForSide`/`SideMarksLR` order, so rows, summaries, standings and the export agree on the same arbitrary side. That aka-first rule is scoped to the MATCH level, where a team name is unique by rule and a winner naming both sides is therefore drifted or hand-edited data. It does NOT hold for a SUB-BOUT, where two opposing fighters may legally share a display name and the same order would be a coin flip on ordinary valid data: such a bout is attributed by the row's MEMBER IDS and counts for NEITHER side when no id can decide. A team bout row that names no fighter settles at the match level: `domain.SubBoutAttributionForTeamRow` puts the team names in AFTER that same-name blank, so even a legacy same-named team pair is attributed side A first, as the app and the standings do. **`domain.SubBoutAttribution` owns that rule** and its doc comment is the statement of it; every consumer builds its attribution there, and the JS mirror is `subBoutAttribution` (result_slot.jsx). Do not re-add a name tier that runs when `SideA == SideB`, and do not restate the mechanism here: an earlier copy of it named a function by its unexported name and went stale one commit after that function was exported. Stored sub names stay frozen after a rename (nothing rewrites `SubMatchResult.SideA`/`SideB` on disk or on the wire): every DISPLAY instead resolves the CURRENT name by member id at render time, through `resolveBoutSideDisplayName` (web-mobile/js/lineup_resolver.jsx) and its Go twin `resolveKachinukiDisplayName` (internal/engine/kachinuki_export.go) for the Excel export (bc-dnst), so a rename shows on bouts already fought, on every surface, without touching the stored record. Mirrored helpers: `middleMark`/`enchoLabel` (bracket.jsx) and `sideMarks` (side_marks.jsx, the leaf that also holds `placeMarks`, `winnerSideLR` and `teamMatchMarks`; bracket.jsx re-exports them) ↔ `domain.MiddleMark`/`domain.SideMarks`/`domain.EnchoLabel` (internal/domain/result_marks.go, with `domain.SideMarksAB`, and `domain.IpponsScore` beside IsScoringIppon in ippon.go), moved there (bc-kdsc) so the Kachinuki Detail sheet (internal/helper/excel_kachinuki.go, fed by internal/engine/kachinuki_export.go, which cannot import internal/export without a cycle) reads the SAME closed-set rule rather than restating it. internal/export keeps only what adapts them: `MiddleMark` (from `*state.EnchoMetadata`) and `SideMarksLR` (places the marks White-left). The overtime marker is pinned in both languages via `internal/export/testdata/encho_labels.json` (Go side: `domain.EnchoLabel`). + +## Writing `SubResults` + +The verdict travels IN the ippons, atomically with the scoreline it rests on: verdict-silence and scoreline-silence are the same thing. `HanteiDecided()` (both result types) reads the mark; `SubMatchResult.DecidedByHantei` is a legacy read-only field that request/load normalization folds away. Never set it. + +Every forward replacement of a `SubResults` slice must still call `engine.preserveSubHantei(stored, incoming)` first. It carries a stored daihyosen outcome (winner plus the marked scoreline) onto a scoreline-silent row: the copied prior ippons carry the verdict themselves and no flag is raised. It is deliberately narrow in two directions: it PATCHES an existing position -1 row but never RE-APPENDS a dropped one (`DELETE /daihyosen` removes that row on purpose through this same path), and it refuses any `Decision` outside validateSubBout's allow-list because it runs AFTER validation and its output is never re-checked. + +The bracket writes call it via `preserveDaihyosenOutcome` inside **`applyBracketMatchResult`**, the one per-match bracket write shared by `recordBracketMatchResult` and the bronze (3rd-place) fallback. `recordBracketMatchResult` is one body taking an `h state.StoreTx` handle, which `*state.Store` satisfies, so the same `UpdateBracket` dispatch around the shared `applyBracketResultIn` serves the tx and non-tx doors. Do not split it into per-door copies: hand-copied bodies here drifted (the bronze copy missed the LWW guard the round copies had). + +Pool writes get it from **`applyPoolWrite` (engine/scoring.go), the single owner of what a stored pool match contributes to an incoming one**. It replaced a block hand-copied into each write closure, after those copies drifted twice in the direction that hurts: the preserve reached three of the writers and missed the live `/score` path, and the `CorrectionReason` inherit reached only the Tx twin, so a team quick-score after a kachinuki reopen blanked the operator's audit justification. It also performs the whole-struct `*stored = *result` overwrite itself, so the overwrite is unreachable without the merge; a new field that must survive it goes there and nowhere else. Note the scope: that covers ENGINE-side preservation, while server-owned flags a client must not be able to clear (`ReopenPending`) are re-stamped at the HTTP boundary in `handlers_match.go` instead. + +## Write policy and the rollback snapshot + +Both primitives take a `matchWritePolicy`: `matchWriteForward` for a client payload (merged group by group onto the stored match by `mergeMatchWrite`, see "Merge changes, never drop" in `.claude/rules/match-writes.md`; then inherit omitted context, restore a verdict-silent daihyosen), `matchWriteRestore` for the K3 rollback replaying a trusted snapshot (no merge, inherit NOTHING: a nil or empty field means "this was empty", every group and its `GroupStamps` entry is restored exactly, a tombstone included, and a side mismatch is not a client error to reject). + +**That policy makes the snapshot PRODUCER load-bearing.** Because an omitted field is read as an instruction to clear, a partial projection is silent data loss, not a missed optimisation. `bracketMatchAsResult` (engine/bracket_result.go) once omitted the score: a bracket match THEN persisted each side as one rendered string while `MatchResult` carries ippon arrays, so a rollback wrote a blank over a real scoreline. bc-bmsc removed that last dual representation. `BracketMatch` now stores the same ippon arrays + hansoku counts as every other result; its `ScoreA`/`ScoreB` strings are LEGACY READ-ONLY (folded into the arrays and cleared by `BracketMatch.NormalizeLegacy`, `state/legacy_hantei.go`, which folds the score strings FIRST and the hantei flag second; arrays win when a hand-edited file carries both; never written, omitempty so the wire never carries them). The projection is a direct field copy, and it carries `ResultSource`/`CorrectionReason` so the restore both puts back the note the match held and clears one the rejected write added (matching `applyPoolWrite`'s whole-struct overwrite, which always did). + +It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWrite` merges nothing under `matchWriteRestore`, before reading any stamp, so a restore can never lose a comparison; what the projected stamps decide is the stamps LEFT behind, the match's real prior times, so a rolled-back match stays ordered against a stale write instead of being left at 0 for `ApplyByTimestamp`'s unstamped bypass. The pool snapshot gets the same from `lookupExistingResult`, which copies the stored `MatchResult` (`copyMatchResults` deep-copies the map and drops the write-only `Changed`/`WriteDoor`/`Merge`). Pinned in `TestBracketRollbackRestoresTheScore` and `TestMerge_RollbackRestoresTheStamps`. `domain.FormatScore` was deleted with the strings; `domain.ParseScore` remains solely as the legacy fold's decoder, pinned parse-direction-only by `TestParseScore_PinnedLegacyBytes` because old files hold those bytes forever. + +**The policy must reach BOTH branches.** A match id resolves to a pool or a bracket match at run time and the caller cannot know which, so `writeToPoolOrBracket` threads it through the `errMatchNotFound` fall-through as well. `writeToPoolOrBracket` is ONE body: the store handle `h state.StoreTx` (which `*state.Store` satisfies) is how a caller says whether the per-comp lock is already held, and the non-tx entry points `RecordMatchResult`/`RecordMatchResultWithIneligibility` are `WithTransaction` shims. Never compensate for one branch in a caller (e.g. by pre-converting the snapshot's nils into explicit clears): that makes one function enumerate another's nil-preserve fields, and the next such field silently breaks rollback. The policy differences are pinned by mutation in `pool_write_merge_test.go`, `sub_hantei_preserve_test.go` (`TestBracketRollbackDoesNotReapplyTheWrite`) and the pair `TestRollback_BracketSubResults_Cleared`/`_ClearedTx`, which pins both entry points into the one rollback body. + +## Editors adopt the server's verdict + +**A match has ONE result and every surface asking for it shows the same one** (operator ruling), and the score editors are such surfaces. While one is open, the viewer card, the bracket, the TV board, the lobby and the export are all already showing whatever the server holds, so an editor showing something else is a divergence whatever its reason. Both editors therefore ADOPT a verdict recorded on another device: `useEffect` keyed on the VALUE (`hanteiRecorded` in admin_scoring_individual.jsx; `daihyosenHanteiRecorded` + `recordedDaihyosenSide` in admin_scoring_team.jsx), never on the `m`/`match` object, which SSE re-creates on every broadcast and which would fight the operator's every tap. Keying on the primitive means a local arm stands (it does not move the server value), and a team daihyosen pick or withdrawal, which is saved as it is made, is kept until the server holds it (`keepLocalEdits`), while a real server change is followed in either direction, and only the verdict is touched, so an edit in progress survives. `isDirty` compares the verdict against the SERVER's value, not a mount-time snapshot, so adopting is not an "unsaved change" and does not fire the discard prompt on an untouched editor. + +Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). + +## Silence on the wire + +Silence still means "no opinion" on the wire. It is now OMITTED IPPONS (nil, not `[]`) rather than an omitted flag, and the distinction is load-bearing: an explicit `[]` is the operator saying ZERO (a 0-0 withdrawal), a missing key is a writer with nothing to say, whose stored verdict `preserveSubHantei` restores. The team editor IS one of those writers: `buildPatch` (admin_scoring_team.jsx) has a `daihyosenSilent` branch that OMITS the daihyosen row's ippon arrays when the operator has not touched that row this session AND nothing about it is known locally. That branch is the only reason the nil case is reachable from a real client at all, so do NOT "fix" it into an unconditional write. Any touch, or any locally-known verdict or scoreline, falls through to the explicit-array path, so a deliberate withdrawal still withdraws. Scope note: the restore protects a stored HANTEI verdict only; `preserveSubHantei` bails when the stored row carries no mark, so a rep bout decided by a strike is not restored by this mechanism. + +Accepted compat gap (documented in `legacy_hantei.go`): a pre-ruling client re-scoring a hantei match with markless ippons replayed from the offline queue (which has no age limit since bc-mrgc phase 3) clears the verdict when its stamp still orders it after the stored one, because a markless scoreline is now the writer's word. + +## A write answers for what it introduces, not for what it inherited + +The engine-side contradiction guard is `stripInvalidHantei` (replaced `preserveMatchHantei`): forward writes on both branches strip a mark whose preconditions fail (`hanteiStillHolds`) or that sits on a non-winner's side, and LOG the drop (it discards an operator-visible verdict on an otherwise successful write). It strips rather than rejecting BECAUSE the mark is one the write INHERITED: the reachable producer is `handlers_daihyosen.go`'s add path, which copies the stored match wholesale (`u := *match`) and moves Status to running, so a valid stored verdict fails `hanteiStillHolds` on status alone and a 400 would blame the operator for state already on disk (same rule as `applyHansokuIppons`' fold scoping; pinned by `TestStripInvalidHantei_InheritedMarkIsStrippedNotRejected`). A CLIENT-sent bad mark is rejected instead, at the boundary that can still tell whose fault it is (`mobileapp.validateHanteiMarkPlacement`, which the handler's `backfillMatchIdentityForHantei` feeds the stored sides/ids so it attributes by the same triple the engine will). The decision twins are NOT a producer: `preserveLoserScore` filters through `struckIppons` and `domain.IsScoringIppon` drops the mark, so a kiken over a stored hantei arrives with the mark already gone (`TestPreserveLoserScoreDropsTheHanteiMark`). + +`applyHansokuIppons` folds each row and immediately judges what THAT fold rewrote. It is count-compared: a row whose award is already reflected is left untouched, slots and all, and reports NO change; a shortfall is awarded through `domain.AppendIppon`, filling a free `•` slot before growing the slice, so a wire-legal placeholder row stays wire-legal after the award; a surplus H over the count is the operator's own strike (the editors keep hansoku as the OUTSTANDING count and land the H themselves, `applyFoulIncrement`) and is NEVER removed. It applies both checks, hantei-untied and the best-of-3 overflow, at match level and sub level through the one shared `checkFoldedRow`. The check lives inside the producer rather than in a separate function the call sites chain by hand, so a new write path cannot fold without checking. Unscoped, it rejected rows the decision twins had just inherited wholesale via `preserveLoserScore`, so an over-cap row already on disk 400'd an operator's kiken that carried no sub rows at all, with no endpoint able to repair it; unmirrored at match level, the identical fold wedged a match on disk instead. This narrows the engine guard on purpose: a payload that ALREADY carries a bad row is damage `mobileapp.validateIppons` can see and rejects at both levels, so only the post-award state needs checking here. `applyKachinukiMerge` running AFTER the check is the same rule; when you add a guard downstream of an inherit, ask which actor it will blame. diff --git a/.claude/rules/match-writes.md b/.claude/rules/match-writes.md new file mode 100644 index 000000000..e2a9a6297 --- /dev/null +++ b/.claude/rules/match-writes.md @@ -0,0 +1,92 @@ +--- +paths: + - "internal/engine/{match_merge,scoring,scoring_tx,engi,kachinuki,bracket,bracket_result,bracket_seat,bracket_seat_audit,daihyosen,match_history,pool_requalify,tiebreaker,swiss}*.go" + - "internal/state/{match_groups,match_history,match,bracket,pools,transactions,store}*.go" + - "internal/mobileapp/{handlers_match,handlers_daihyosen,handlers_decision,handlers_match_history,handlers_time,validation}*.go" + - "web-mobile/js/{api_client,write_result,match_groups,match_history_view,patch,start_match,admin_scoring_autosave,admin_scoring_shared,admin_scoring_individual,admin_scoring_team,admin_scoring_engi,admin_scoring_modal,admin_schedule_score_editor,admin,admin_shell,viewer_match,server_clock}.jsx" +--- + +# Match writes: merge, refusal and the clock frame + +Moved from CLAUDE.md "Merge changes, never drop" and "Write refusal and the clock frame". Read before changing any match write, its answer, the offline queue, or a score editor's save path. + +## Merge changes, never drop (bc-mrgc) + + +Operator ruling 2026-10-03: "Nothing should be dropped. All events must be ordered." A match write is merged GROUP BY GROUP onto the stored match, ordered by the stamp of when the change was made (`modifiedAt`, server-relative, never arrival order), and what does not apply is kept in the match's history. Each rule below has one owner; change the owner, never a call site. + +- **The groups** (`internal/state/match_groups.go`, the ONE owner of which fields each holds): `points` (IpponsA/B, HansokuA/B), `result` (Status, Winner/WinnerID, Decision/DecisionBy/DecisionReason, and the CorrectionReason/ResultSource that describe it), `encho`, `flags` (engi), `rep` (pool rep players), `recorded` (the SERVER's alone, `state.ServerGroups`: which numbered kachinuki bouts Record bout closed, as sorted positions; a client naming it in `changed` is a 400, the merge copies the stored marks over a client's rows, and `bout:N` does not hold the mark, so a point on bout N and Record bout never fence each other), `repPickA` and `repPickB` (each side's representative picked for a team match's representative bout, SideAMemberID and SideBMemberID of the -1 row: each dated apart from the other side's and from `bout:-1`, so two captains picking at the same moment both land, a point on that bout never erases a pick and a pick never erases a point; the row's WinnerMemberID stays with the bout and the merge re-derives it when the two disagree, `ReconcileWinnerMemberID`), and `bout:` per SubResults row (1..n, -1). Sides and ids, court, schedule, round, ReopenPending, IsOverridden and the bracket structure are NOT groups: their own writers keep them. `CopyGroup` moves a group, `GroupDiffers` compares one, `GroupValue` projects one for the history; nothing hand-copies a field list. +- **The stamps**: `GroupStamps map[string]int64` on `MatchResult` and `BracketMatch` (pool-matches.csv: the `GroupStamps` column, index 29, JSON, appended after `ModifiedAt`; bracket.json: `groupStamps`). A match with NO map reads every group as stamped at its `ModifiedAt` (legacy files behave exactly as the whole-match guard did); once a map exists, a group with no entry was never written (0). `ModifiedAt` stays the newest group stamp (recency and `keepNewerMatches` read it). A bout whose row is gone keeps its stamp: a TOMBSTONE, so an older write still carrying the row cannot resurrect it. `stampGroups` (`MatchResult.StampGroups`/`BracketMatch.StampGroups`) is the ONE stamper: it materializes a legacy match's implicit stamps before stamping one group, or every other group would start reading the new `ModifiedAt`, and it never LOWERS a group's stamp (a caller that already holds a later one for a group -- an engi finish let through as a HeldEcho under an older stamp -- must not drag it backwards, or a change genuinely made between the two would wrongly read as applying after a group it never actually followed; mirrors `ModifiedAt`'s own never-lower rule); a stamp the MERGE decided, which may be lower (S2's displacement moves a newer invalid recount out and gives the group the finish's own stamp), is written as decided through `ApplyMergedGroupStamps`, never through `stampGroups`. Every writer that changes a group outside the merge stamps it through it: the reopen (`reopenPoolMatch`/`reopenBracketMatch`, server now), the requeue (`RevertMatchToQueue`/`requeueBracketMatch`: the verdict only, the kept score keeps its stamps), override-winner (`result`, and its own comparison is on that group), the engi finish (`result`+`flags`, at the client's stamp once the merge has ordered it, `engiFinishHeld`; server now only for an unstamped writer), the kachinuki advance (the `recorded` mark, and the appended `bout:n` + `result`, at the server's time and strictly after the match's `ModifiedAt`, so every device's `keepNewerMatches` orders the marked copy after the one before it; the Record-bout answer echoes the bout log AND the stamps the advance left, `engine.KachinukiAdvance`, and the team editor adopts the stamp with the log, so its next tap on the new bout is floored by it, bc-hlck), `RemoveTrailingKachinukiBout` (the removed bout's tombstone, and `recorded` when it un-records), the merge itself for `recorded` when a write raises a recorded bout's overtime, and a downstream reopen (`reopenDisplacedBracketMatch`, `bout:-1` with it). Byes auto-completed at draw time carry no stamp, which is right. +- **The wire**: `MatchResult.Changed` (`json:"-"`, request-only, beside `ClearsWithdrawal`) is read from `/score`'s and each bulk entry's `changed`; a name that is not a group is a 400. ABSENT means every group the payload carries (`defaultChangedGroups`; on the pool branch a nil SubResults still names the stored rows, the whole-struct write's old meaning), EXCEPT that a client's running or scheduled write over a COMPLETED match never carries `result` (`effectiveChangedGroups`). Every server-built write names its groups: decision `result`+`points`+`encho` (`decisionChangedGroups`), both-barred hikiwake `result`, daihyosen add `bout:-1`+`result`+`repPickA`+`repPickB` and remove `bout:-1`+`result`+`encho`+`repPickA`+`repPickB` (both date both sides' picks, so a pick made on the previous representative bout is held, never landed on the new one; +`points` when clearing the verdict took a hantei mark out, `daihyosenChangedGroups`), quick-score every group and every stored row (`WholeMatchChangedGroups`), a start (`startOnly`) `result` alone. The rows the server itself changes after the writer sent them (kachinuki merge, the padding a match-level decision adds) are added by `noteServerBoutChanges`; in a kachinuki write, which merges its bout log by position, a stored row the writer left out counts too, so the finish's strip of a trailing unscored pairing is named and not copied back. It and `daihyosenChangedGroups` (mobileapp, the representative-bout add and remove, built from the stored match under the same lock) are the two places the server diffs, each its own values under the write's lock; a CLIENT payload is never diffed against the store, or an echoed stale group with a newer stamp would read as an edit. A server-built door reaching the merge with `Changed` nil is logged as a BUG (`serverBuiltWithoutGroups`). A bracket door that gives a side another team without reopening that match records a `reseat` history line naming the cleared pick group (`recordRepPickClears`, bracket_seat_audit.go: a snapshot of every match's picks before the door's write, diffed after, in the same transaction; `ResolveQualifiedPools`, the resolver run after a pool write or a `DELETE .../overrides`, is such a door, in its own `WithTransaction`); a reopened match's `downstream-reopen` line names the pick instead, and a refused write's restore records nothing. `MatchResult.WriteDoor` names the door for the history. +- **The merge owner**: `mergeMatchWrite` (engine/match_merge.go), called by BOTH branches before anything else judges the write (`applyPoolWrite`; `applyBracketResultIn` for rounds and bronze, ahead of `preserveWithdrawalRuling` and the downstream guard), each after folding the stored pairing into the write (`reconcileSides`; on the bracket branch `sidesBeforeMerge`), so a winner worked out again reads the stored sides, never a payload's omitted ones. An UNSTAMPED write the server orders itself (a completing write, or any write a server door builds: quick-score, an engine caller, a decision with no stamp) takes the server's time there, never older than the stored `ModifiedAt` (`writeStamp`), so it leaves a fence a later stale replay is ordered against; only a client's unstamped running or scheduled write keeps the unstamped bypass. A changed group applies when `domain.ApplyByTimestamp(stamp, storedGroupStamp)` (0 on either side applies) and takes the write's stamp; otherwise the stored value is copied in and the group is HELD (its incoming value goes to `MergeReport.HeldValues`). A group the write does not change always takes the stored value. An applied group whose value equals the stored one is `Unchanged` (an echo); for a write that NAMES NO groups at all (`Changed` nil: bulk-score, an older client, which cannot tell an echo from a change) it keeps the stamp it already had rather than taking the write's, and `ModifiedAt` moves only when some applied group actually changed, so a whole-match echo can never fence out a real, older change made on another device in the gap. A write that NAMES a group takes the write's stamp there even when the group echoes: naming it is a real action taken at that time (an operator re-sends the value their screen showed, or a replayed decision), not a re-send of everything the write happened to carry, and `ModifiedAt` moves with it. A HELD group whose value equals the stored one is no loss (`HeldEcho`, bc-mrgc phase 3: a decision's "no overtime" over a match with none): it is neither listed in `heldGroups` nor kept in `HeldValues`, and its history outcome is `unchanged`. The write is SUPERSEDED only when something is held (or echo-held) and every applied group is an echo (`MergeReport.Superseded`): then nothing is written to the match file, exactly as before. The report rides on `MatchResult.Merge`; `ResultChanged` false (the verdict did not move: held, or applied as an echo of the stored one, e.g. the same withdrawal sent again under a later stamp) skips the eligibility record, since re-recording a stored withdrawal would rewrite its status and undo a reinstatement made since. Corrections get no exemption: a stale correction is held like any other change. +- **R2** (operator: "points scored means the withdrawal was a mistake"): a kiken, fusenpai or fusensho is ATOMIC with what it changes and is held whole when any of its groups is held or ANY stored points or bout stamp is newer than it (`withdrawalOutranked`); `recordDecisionTx` asks the same merge on a copy first (`decisionHeldByMerge`), so a stale decision is superseded before its refusals judge it. A scoring change (points, a bout, the engi flags, with a different value) in a RUNNING write stamped after a stored withdrawal clears it (`ClearsWithdrawal` set, the cleared result kept in history as `clearedWithdrawal`); `restoreIfWithdrawalRemoved` restores eligibility. That clear needs an actual SCORING change against a withdrawal of the match itself -- an overtime toggle alone says nothing about whether the withdrawal was a mistake -- and it must give the match its winner: a clear that would leave a knockout tied is abandoned and the decision stands, held as below, never answered `needs_winner`. Overtime enters this rule only when its value actually changes (an echoed (E) is a plain no-op), and a hold records a reason only when it held something that is not an echo. A kiken, fusenpai or fusensho (a fusensho is awarded for a bar recorded on ANOTHER match: the other side cannot fight) already decided who won, and neither a point, a bout, nor an (E) tap the board still sends says otherwise, so the decision stands and the whole scoring side of the write -- points and overtime alike -- is held together instead (`holdDefaultWinScoring`; the history reason names the decision, `defaultWinStandsReason`, e.g. "a fusensho closed this match"), answered with its own code, `heldReason: "default_win_stands"`, and the decision, `heldDecision` (`MergeReport.DefaultWinStands`/`StandingDecision`; the message from `messageForHeldReason`, worded through `domain.DecisionWord`; the client's `writeDefaultWinStands`/`writeHeldDecision` and `decisionWord` in write_result.jsx; `HeldDecision()` is non-empty exactly when `HeldReason()` is `default_win_stands`, so every answer carries the two together), so the operator is told which decision closed the match and to correct it from the match's score editor (the copy names no button: a kachinuki editor offers no Remove), rather than told a newer change exists or to correct the result with a winner. A COMPLETED correction keeps a stored withdrawal exactly as before (`KeepsWithdrawalRuling`, the `clearWithdrawal` flag). +- **R3** (operator: "stay finished, add the point"): a running write's scoring change stamped AFTER a finished match's `result` stamp applies to the finished match, which stays finished; `deriveWinnerAfterMerge` works the winner out again through the existing owners (engi flags, `deriveKachinukiWinner`, `TeamResult` IV then PW then `deriveDaihyosenWinner`, `CountScoringIppons` with the Ht mark breaking a tie). An engi match is never a draw: a winner only on a valid flag total (`engiValidTotal`), pool and knockout alike (bc-mrgc phase 3; a pool recount to an even total used to become a hikiwake). One stamped before it, or unstamped, is held whole. A points change whose write does not apply `result` keeps a stored hantei mark (`carryHantei`, through `domain.AppendHantei`): the judges' decision is the result's, so a late foul or a board that never showed the mark cannot erase it; `stripInvalidHantei` still drops it if the new scoreline is not tied. The score handler treats such a write as a correction: no court gate, no StartMatchTx (`writesOverFinished`), and the old `staleAfterComplete` `{stale:true}` drop is gone. A server-built write that names `result` on purpose (the daihyosen add to a finished knockout match) is not R3's shape. +- **R4** (operator ruling 2026-10-04, replacing the implementer's back-to-running edge: "It needs a winner if the match is finished and is being corrected"): a scoring change that would leave a FINISHED knockout match tied, or an engi match (pool or knockout) with no valid flag count, is NOT applied. `deriveWinnerAfterMerge` reports false and changes nothing, and `mergeHold` (the one place a group is taken back out of a write) holds the scoring groups: the match keeps its recorded finish, winner and advanced winner, never goes back to running, and the change is in the history with `HoldReasonKnockoutNeedsWinner`/`HoldReasonEngiNeedsValidCount`. `MergeReport.NeedsWinner` reaches the answer as `heldReason: "needs_winner"` (`state.HeldReasonNeedsWinner`) beside `heldGroups`, with `NeedsWinnerMessage` on a fully held write, so the operator is told to correct the result with a winner. A pool or league tie becomes a completed draw (`hikiwake`). Nothing on the bracket write retracts a winner for it any more. +- **S2, the other arrival order**: a finish whose `result` applies over scoring stored NEWER than it (a point made after the finish on a board that never saw it, arriving first) gets the verdict that scoring gives, through the same `deriveWinnerAfterMerge`, so both arrival orders end in the same state (status, winner, scoreline, stamps); the result is stamped with that newer scoring's stamp when the verdict moved (`verdictMoved`), as R3 stamps it. The finish's own hantei mark is carried onto the newer scoreline first (`carryHantei`, the S3 rule the other way round). A withdrawal is excluded (R2's `withdrawalOutranked` owns it). When the newer scoring would leave the match without the winner it needs (a knockout tie, an engi count that is not valid), stamp order says that scoring came after the finish and could not apply (R4), so the finish is APPLIED on its own scoreline and each newer scoring group is moved out of the match into the history as held (`mergeHold.displaceNewerScoring`, `MergeReport.Displaced`, one entry per stamp with door `displaced` and the R4 reason, written by `recordDisplacedHistory`, also from the engi branch); `ModifiedAt` falls back to the newest stamp left. Both arrival orders then reach the same state (status, winner, scoreline, stamps, next round) and both histories hold the tying change; the answer is applied and carries `displacedGroups` with `heldReason: "needs_winner"`. The same displacement covers a representative pick stamped after a representative-bout removal: when the removal applies, the pick is moved to the history (reason `HoldReasonRepBoutRemoved`, "the representative bout was removed"), and that answer carries `displacedGroups` with NO `heldReason`; the client asks `writeDisplacedForWinner` (write_result.jsx) before saying a displacement left a match without a winner. Only a finish whose payload does not carry the scoring it stood on (it names `result` alone and sends no scoreline) cannot be replayed: it is held (`needs_winner`) and the match stays as stored. Winners worked out again take a side's id from the stored match when the payload omits it (`mergeCtx.storedAID`/`storedBID`), since ids are backfilled only after the merge. +- **The history** (internal/state/match_history.go): one append-only file per match, `competitions//history/.jsonl`, one JSON line per write that reached the merge: stamp, server `receivedAt`, door, session, changed groups, per-group outcome (`applied`/`unchanged`/`held`), the held groups' incoming values, any cleared withdrawal, and the `reason` when one rule held the write's groups rather than their stamps (`MergeReport.HoldReason`: `HoldReasonOlderRevision`, `HoldReasonEngiAtomic`, `HoldReasonFinishAtomic`, `HoldReasonKnockoutNeedsWinner`, `HoldReasonEngiNeedsValidCount`; the History view shows it as "Not applied: "). `StoreTx.AppendMatchHistory` stages the whole small file in the SAME transaction as the write (`LoadMatchHistory` reads it, read-your-own-writes), so an aborted transaction leaves no entry. Written at the point the write can no longer be rolled back (`recordWriteHistory`: after K3/requalification in `RecordMatchResultWithIneligibilityTx`, in `writeMatchResult`, and on the superseded paths), so a write refused by validation, a side mismatch or K3 leaves none; every direct writer above appends its own (`recordDirectHistory`) INSIDE the transaction that makes its write: the doors already in one (the reopens, the kachinuki bout remove, the downstream reopen) pass their `tx`, and the kachinuki advance, `OverrideBracketWinner` and `RevertMatchToQueue` each open a `WithTransaction` for the write and the entry together (`withBracketMatch` takes the store handle, as `withPoolMatch` does), so a process dying between the two leaves no match change without its line; a held override writes nothing and appends on its own. `recordDirectHistory` logs a failed append rather than returning it, so a failure of the append alone still lets the write commit without its entry (accepted). The history directory is created only under an existing competition directory (`ensureMatchHistoryDirLocked`), so a write after `DeleteCompetition` fails rather than resurrecting it; `DiscardDraw` deletes it (`DeleteMatchHistory`), since a regenerated draw reuses the match ids. A fully-held write must COMMIT its transaction for its entry to land: every door returns nil from its closure on `ErrMatchSuperseded` and answers after (the score/decision handlers always did; bulk-score, both daihyosen paths and `RecordMatchResult` were changed to). +- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared; an equal stamp is an exact replay and still needs one. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. + +- **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, a hantei included since bc-htsd, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop, and by `claim.agree(group)` when an editor's adopt TAKES the server's value for that group, or when the team editor's withdrawal-removal Undo puts the whole board back to the server's (every bout group and both pick groups); an adopt that takes only PART of a group agrees it only when the editor's whole group then equals the server's (the individual editor's hantei adopt skips `points` while a point of its own is unsaved; a representative-side adopt agrees `rep` only when both sides then hold the server's picks); both baselines are kept per group), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. + +- **Phase 3, the remaining drop sites, each with one owner:** + - **The running rev guard holds, never drops.** A running write with a lower `rev` than the same `revSession` already sent is answered by `holdOlderRevision` (handlers_match.go): one bare transaction, no court lock, no start gate, no correction check, and the high-water mark left alone. It calls `RecordMatchResultWithIneligibilityTx` with `engine.ForceOptions{HoldReason: engine.HoldReasonOlderRevision}`, whose `holdWriteTx` asks the merge owner on a copy with `mergeCtx.holdAll` (every changed group held, echoes excepted), records the history entry with the reason, and returns the superseded error; the answer is `respondSuperseded` with `heldGroups`. `{stale:true}` no longer exists anywhere (server, openapi, client: `_handleOkBody` and the admin toast that read it are gone). + - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. + - **Override-winner** stamps and orders the `result` group (phase 1); a held assertion is answered `respondSuperseded(["result"])` (reason `superseded`, `heldGroups`), the client returns `heldGroups`, the Resolve feeders toast says it was kept in the history (`OVERRIDE_HELD_NOTICE`, write_result.jsx), and a queued one the server held counts into the pass's superseded alert as well as asking for a bracket resync. + - **The engi finish** is stamped by `recordScore` like every write, and the server orders it: `engiFinishHeld` (engi.go) asks the merge on a copy built from the stored match (`engiFinishProbe`), BEFORE the downstream guard; the flags and the winner they decide are atomic (either held → both held, `HoldReasonEngiAtomic`, reached when the flags are newer than the stored count but the result is older than the stored one, e.g. after a later reopen; a stale finish over a COMPLETED match is held whole one step earlier, by `HoldReasonFinishAtomic`) -- except that the NEWEST COUNT DECIDES (operator ruling 2026-10-04): when a finish's result applies but a newer stored VALID count holds its flags, the finish closes the match with the winner that count gives, the stored count stays, its stamp stamps the result, and the finish's own count is kept in the history as held, so both arrival orders end alike (`TestMerge_EngiNewestValidCountDecidesInBothOrders`); a newer INVALID count is still moved to the history by the displacement above -- and an ECHO of the stored flags (same count, under an older stamp) is not a hold at all, so the finish still applies; held → history and superseded; applied → `recordEngiMatch` writes `result`+`flags` with the stamps the merge decided (`engiFinishHeld` carries the probe's `GroupStamps` down, `stampEngiChanges`/`ApplyMergedGroupStamps`), so both arrival orders reach the same stamps after a displacement; an unstamped writer still gets the server's time. + - **Read-modify-writes are one transaction.** `InjectPoolDaihyosenMatches`, `InjectTiebreakerMatches` (the engi self-heal too), `GenerateLeagueTiebreakMatches` and `AdvanceSwissRound`'s append each read pool-matches.csv, decide, and save under ONE `WithTransaction` (standings through `computeStandingsFrom(tx)`, the tournament read before the tx, `appendWithSlots` for the new slots); a score landing meanwhile waits for the lock and goes on top. `MaybeAdvanceKachinuki` still reads without the lock, and its locked write re-checks (`kachinukiAdvanceStillHolds`: not completed, same status, stamp and bout log) and on `errKachinukiAdvanceStale` reads the match again and works the pairing out from it (`advanceKachinukiOnce`, up to `kachinukiAdvanceAttempts` times, standing down only then: a stand-down answered the operator's Record bout with no next pairing; a finish that landed is final on the re-read, so it is never reopened; the bracket branch uses `UpdateBracket` with `Bracket.MatchByID` so it can abort). `Engine.afterMatchRead` is the test-only seam at each read (`lost_update_window_test.go` lands a real write there). + - **A rename is not a side mismatch.** `reconcileSides` accepts a payload side whose id equals the stored id whatever name it carries, keeping the stored (current) name on the side, on a winner named by the old name, and on every bout row field that names the side by it (`adoptCurrentSideName`: the representative-bout row, and a fixed-order or id-less kachinuki row whose winner is the team, which `state.SubBoutWinnerSide` would otherwise credit to nobody); only an id disagreement, or a name disagreement no id can settle, is `side_mismatch`. Only an id the CLIENT sent proves a rename: the handler's hantei/winner-id backfill (`backfillMatchIdentityForHantei`) fills a side id only beside the stored name it belongs to, so a payload naming someone else with no ids is still `side_mismatch`. + +Accepted as they are: a replayed queued write applied in part tells nobody (the history has it), unless what was held would have left a finished match without a winner (`writeNeedsWinner`), which the replay reports like a superseded one. An exact decision replay adds a second history line that reads like the first ("Decision recorded", every group unchanged). A queued write the server keeps refusing with a 5xx is retried as long as the page is open and holds back no other write; the editors' **Discard held result** covers a terminal score write, and the admin topbar's held-writes count opens `HeldWritesPanel` (admin_shell.jsx), which lists every held write (`API.heldWrites()`) and discards any one that keeps failing (`API.discardHeldWrite(key)`; both doors go through `_discardIfFailing`, so a write only waiting for the connection is never discarded); a same-session reopen over a write the browser could not store shows the plain held notice. An answer that both held groups and moved a later change (`heldGroups` and `displacedGroups` with one `heldReason`) is read as the move's: the held part gets the plain newer-change note. `unsentWrites().total` counts entries, so two pending writes for one match count two; `terminal` still counts results. `InjectTiebreakerMatches` and `InjectPoolDaihyosenMatches` compute standings uncached, under the competition's lock, on each call. A partial apply on the self-run public sheet's finishing write closes the editor with no note. Every write re-stages its match's whole history file through the WAL, so a long match's history costs a rewrite per autosave; compaction is for a later phase. An R3 correction on a knockout match whose next match was already played is refused like any other knockout correction (`downstream_knockout_played`/`_running`). History entries recorded before phase 3 may still list an echo as held. A lineup save made while a save of the same lineup is queued reads `{queued: true}` until the flush sends it, a moment later when the device is online. A write whose transaction log committed but whose file write then failed (`state.ErrTxCommitted`: a disk that refuses writes) is answered 500 like any internal error, so the device keeps retrying until a write lands (operator decision 2026-10-04): the change is already kept, written on restart or by the next write the disk accepts, and `internalError` (errors.go) logs it as an ERROR naming the disk; a Discard of it does not stop it applying at restart. A completed correction whose result is held because a still-scoring board's LATER change (R3) moved the result's stamp first is held WHOLE, by `HoldReasonFinishAtomic`, even though in plain stamp order its own bouts would have applied before that later change: finish atomicity holds the correction together with the scoreline it stood on rather than letting its bouts land piecemeal beside a verdict a newer write already superseded. Nothing is lost (the held write is in the match's history as always), and the operator is told to check it rather than having the correction's bouts silently re-order themselves around a verdict that has since moved. Do not report this ordering as a bug to fix: it is finding 4's atomicity rule doing its job. + +## Write refusal and the clock frame (bc-lww1 / bc-cse) + + +A match write can be REFUSED by the server, and a refusal is always **HTTP 200** carrying `{"applied": false, "reason": ...}`, never a 4xx/5xx, because the SPA's offline write queue retries 5xx forever (the mp-q8c6 poisoned-queue pattern) and a refused write can never win a retry. Two reasons exist: + +- `superseded`: the merge held every group the write changes (bc-mrgc): a NEWER change to each is already stored. Nothing of it was applied, and it is NOT lost: its values are in the match's history and the answer names `heldGroups`. The operator must **not** blindly re-enter: a fresh entry is stamped now and would overwrite the newer change. +- `clock_skew`: the write's `modifiedAt` was more than 5s in the server's future (`modifiedAtRefuseSkewMs`, handlers_match.go), so it is provably wrong. Nothing was stored, the client resyncs its `/api/time` offset, and re-entering IS the remedy. The copy for the two cases is deliberately opposite; do not merge them. + +**Which endpoints compete on timestamps**: `/score`, `/override-winner`, `/decision` (since mp-jnvl) and both daihyosen paths (since bc-dhas) each carry the client's stamp, so any of them can be refused for either reason. Quick-score alone still builds its result with NO stamp; the merge gives it the server's time, never older than the stored stamp (`writeStamp`, bc-mrgc review), so it still always applies and now leaves a fence a stale replay is ordered against; it maps the refusal shape defensively anyway. The daihyosen add and remove are stamped so the match they write is newer than a copy read before them, which is what lets `keepNewerMatches` keep the post-add copy over a stale refetch. Both take an optional JSON body `{"modifiedAt": N}` (the DELETE too; an empty body is unstamped and always applies), judged by `daihyosenWriteStamp` through the same `clientClockSkew`/`clampClientModifiedAt` pair as `/decision`; the stamp rides on the match the handler writes, so the ordinary write guard decides. `recordDaihyosen`/`removeDaihyosen` hand a refusal back as the refusal body (`_daihyosenOutcome`), and the team editor reports it through `notLandedBanner` and adopts nothing. A decision is stamped because it is the write that CLOSES a match, and a match closed while still `scheduled` (a queue row's Record fusensho) has no earlier write to inherit a stamp from, so the result carried no time at all and nothing downstream could order it. A stale decision is answered `superseded` before any of the decision's refusals judges it (`recordDecisionTx` asks the merge on a copy, `decisionHeldByMerge`: already barred, a running later match), since each of those would otherwise read as something to resolve and send again; an EXACT replay of the recorded decision is answered as recorded, before them too (`exactDecisionReplay`), from the prior and without a write; for a bracket match that prior is `bracketMatchAsResult`'s projection, which carries no court or schedule, so the replay branch fills them from the bracket (`bracketMatchCourtAndSchedule`, bracket_result.go) as a fresh decision's write echoes them, and the answer and its broadcast keep the court. A held override-winner answers `superseded` with `heldGroups: ["result"]`. The stamp reaches the engine through a VARIADIC parameter on `RecordDecision`/`RecordDecisionTx`: the engine-internal caller and the suite's decision tests omit it, and the handler is the one caller that must always pass it. + +**`web-mobile/js/write_result.jsx` owns that whole rule**: `writeDidNotLand` (queued OR refused), `writeWasSuperseded`, `writeWasRefusedForClock`, `notLandedBanner(res)`, `writeKeepsEditorOpen(patch, res)` (bc-plcl: the one rule every score-editor host asks before closing its editor: a write that did not land, or a running write with no winner, keeps it open), `writeWasRefused(res)` (nothing handed back, which is how every host reports a refusal, or `applied: false`: the two-tap commits Finish, Finish + Start Next, End match and the engi Save disarm, because tapping again would re-send the refused write, and a superseded one would win; a held write the operator DISCARDS disarms the same way, bc-cse operator ruling 2026-10-05, via each editor's own one `dropHeldWrite`-style function, passed as `HeldWriteDiscard`'s `onDiscarded` prop for the editor's own Discard held result AND as `useClearPendingWhenNothingHeld`'s callback for a discard made elsewhere, e.g. the admin topbar's held-writes list) and `writeRetryable(res)` (a QUEUED write only: it gates the pending banner's Retry and the kept submit closure, while the not-saved banner never carries a Retry), `queuedNotice(res, { keepsFailing })` (the held-write line: `QUEUED_NOTICE`, or `QUEUED_UNSAVED_NOTICE` "Not sent yet: keep this page open until the connection returns." when the queue answered `persisted: false` because browser storage would not take it, or `QUEUED_REFUSED_NOTICE` once the server keeps refusing it; the editors keep the queued answer in `pendingWrite` and render the line through `HeldWriteNotice`, admin_scoring_shared.jsx, which with the Discard beside it and the banner's own clearing reads ONE hook, `useMatchHeldWrite`: what this device still holds for the match, `held` and `stuck`, re-read on every sync-status change and every held-count change, the counts carrying `failing` so a SECOND write crossing the threshold is published too; the banner goes once the match holds nothing, landed or discarded from anywhere, `useClearPendingWhenNothingHeld`, never "when the whole queue drains"), the two readings of `heldReason: "needs_winner"`: `writeNeedsWinner(res)` (this write's OWN change was held because it would leave a finished match without a winner, superseded or applied in part; `supersededBanner(res)`, the partial-apply note and the superseded queue alert then say to correct the result with a winner) versus `writeDisplacedGroups(res)` (the write was RECORDED and moved a later change of the match to the history, an applied answer with `displacedGroups` and no `heldGroups`: the note says "Saved" and what was moved, `displacedAlertText` for a replay); `keptInHistoryNote(res)` (match_groups.jsx) is the one note built from them, for the editors' `useKeptInHistoryNote` and admin.jsx's `closingHistoryToast`, and every operator-facing string. `notSavedText` is the one "Not saved" line every such banner shows (the three score editors, fed by `terminalFailureBanner`, and the barred-match notice): "Not saved: . ", or the refusal as it is when it is a whole sentence that says what to do (`sentence`, set by `_replayRefusal` in api_client.jsx for a server `message` or `reasonHuman` and for the busy-shiaijo copy, never for a bare code or HTTP status), and `queueAlertMessage` ends its `rejected` alert on that sentence instead of "Re-enter it." for the same reason. Every host also hands the editor back what a write came back with (a refused write throws and returns nothing), which is how the editors' own Start match tells a landed start from a refused one. It is a leaf with no imports, and **every consumer ES-imports it directly**; there are no `window` mirrors and re-adding one fails `make js/check-imports` (see `web-mobile/check-write-result.mjs`, which also forbids hand-rolled `.applied`/`.persisted` comparisons outside the owner and `api_client.jsx`; its walk, comment stripper and line scan are `web-mobile/check-helpers.mjs`, shared with the competitor-number gate below, and its `FORBIDDEN` rules are exported so `rule_gates.test.jsx` can pin them). This exists because the question was originally spelled `res.queued` inline at ten call sites, and when a second not-landed shape appeared the conversion reached five of them. + +Two distinctions that look like details and are not: + +- **`writeDidNotLand` vs `writeWasSuperseded`.** Both mean "not stored", but a QUEUED write lands on reconnect, so an offline court must still advance its local bracket; a REFUSED one never lands, so anything derived from it is wrong. Gate optimistic UI on the stronger predicate and general "did it save" checks on the broader one. +- **Client clock stamps are server-relative**, learned from `GET /api/time` (retried until it first succeeds, relearned on SSE connect, on `'online'`, on any refusal, and when the 15s heartbeat's `nowMs` diverges by more than 3s). The heartbeat value is a divergence TRIPWIRE only, never an offset source: a one-way push has no RTT to correct, so a late frame would drag the device's clock frame backwards. A payload is stamped when the client builds it (an autosave with the time of the edit it carries, see the bc-sync paragraph below) and a queued one replays verbatim; a replay refused for skew is re-stamped once, by `_restampFor`, which computes TWO reconstructions and keeps the OLDER: `enqueuedAt + fresh offset` (wrong if the OS stepped the wall clock mid-outage) and `serverNow - monotonic age` (wrong if `performance.now()` froze during suspend). Taking the older is a safety rule, not a tie-break: an over-old stamp only makes this write lose a comparison it might have won, which is recoverable and announced, while an over-new one is the silent overwrite bc-lww1 exists to stop. The monotonic anchor is stamped at the one enqueue chokepoint and **stripped on persist**: it is measured from a per-document time origin, so a rehydrated reading (or one written by another tab) subtracts two different zeros and yields an age unbounded in BOTH directions, which is exactly what `min()` cannot defend against. A rehydrated entry therefore falls back to wall-clock only, so a clock step spanning a reload still ends in a drop. Retried exactly once either way. +- **A write is never stamped older than the match it was made against** (bc-hlck, operator request 2026-10-03). Two devices' server-time estimates can differ by up to the 5 s the server accepts, so a device running ahead stamps its write later than real time, and a change made a moment later on another device that SAW that write was stamped earlier than it and kept in the history instead of applied. `_stampAfterSeen` (api_client.jsx) is the ONE rule, a hybrid logical clock: this device's stamp, or the seen match's `modifiedAt + 1`, whichever is later, the floor capped at this device's time + 4 s (`HLC_FLOOR_MAX_AHEAD_MS`) so it never draws a `clock_skew` refusal. The server is unchanged: the match's `modifiedAt` is already the newest stamp it holds. What was "seen" is the caller's: the match handed to `recordScore`, `seenModifiedAt` in a decision body (stripped before sending, so `recordDecision` keeps its 4-argument call shape), the trailing argument of `overrideBracketWinner` (omitted by `recordOverrideWinner` when the feeder has no stamp, so that call is as it was) and of `recordDaihyosen`/`removeDaihyosen` (the team editor passes the shown match's stamp, 0 when it has none). The direct clock-skew resends of a score and of a hand-set winner re-stamp through the same floor. An AUTOSAVE is floored by the match as it was at the TAP: `useDebouncedRunningWrite`'s optional `seenStampRef` (every editor passes the shown match's stamp) is read at each tap beside `editedPerf` and rides on the patch as `seenModifiedAt`, explicitly 0 for a tap that saw no stamp, so a change another device made AFTER the tap never moves it (the 2026-09-27 ruling). A queued entry records the floor (`seenModifiedAt` on the descriptor, persisted, the later of two when one write takes another's place) and the skew re-stamp keeps it. Accepted residual: there is no per-device counter, so two writes floored by the same seen stamp share the stamp `seen + 1` and the later arrival wins an equal-stamp tie; across two devices (three on one match within about 4 s, against one operator per court) a local counter could not order them anyway, and on one device the server already does (the running rev guard, R3's strict `<=`). + +**An edit still inside an editor's autosave debounce is unsent work** (bc-sync). `useDebouncedRunningWrite` (admin_scoring_autosave.jsx) registers it with `API.notePendingEdit`, so the sync pill reads "Syncing…" from the first tap, and releases it only once the write is dispatched. On `pagehide` or the tab being hidden it writes a pending edit at once with `durable: true` on the patch, and `recordScore` puts such a running write straight into the persisted outbox (the flag is never sent), because a fetch started then dies with the page. That relies on every score-editor host reaching `API.recordScore` before the page goes, i.e. with no `await` on I/O in front of it (pinned by `score_write_same_tick.test.jsx`: `attemptScoreWrite`, `editMatchScore` and every host's `onSubmit`); do not add one. `api_client` also keeps, on page hide, a running write already SENT but not yet answered (`_keepInflightRunning`, from `_inflightRunningWrites`), because the page going away cancels that request. The copy is an entry of its own (bc-mrgc phase 3): it never replaces a queued entry for the match, nothing coalesces into it, and it replays in its place in the match's order. The copy is dated when the write was sent, the flush skips it while its fetch is still open (`_heldByOpenFetch`), and the fetch's outcome removes or replaces it, so a tab that is only hidden never sends the write twice. + +**Every way out of an editor saves a pending edit, and an autosave is stamped with the time of the edit it carries** (operator ruling 2026-09-27). The unmount writes an edit still inside the debounce window whatever caused it: Close, Prev/Next or their keys, another match picked, a correction opened, a court switch, or the court console moving on by itself (`selectedMatch` falls back to `running[0]`, so another device finishing the match re-keys the editor). The write carries the monotonic reading of the last tap (`editedPerf`, never sent), and `recordScore` takes its age off the stamp (`_editAge`). That is what makes the unmount safe: a tap made before another device finished the match is OLDER than that finish, so the server keeps the finish, where a write stamped at send would have reopened it. Discarding is the one way out that saves nothing: the editor cancels the debounce before it closes. Close and Prev/Next share one leave rule (same ruling): whatever autosave does not carry asks "Discard unsaved scoring changes?" first (the individual editor's Tie toggle or hantei side pick on a running match, a kachinuki row the running patch drops, any unsaved change to a match that is not running); everything else leaves at once. So every user edit the running patch carries must call `markDirty`: an edit that arms no timer is not saved by the unmount or the page-hide flush (the overtime counter, the rep-player pickers and the team daihyosen verdict did not, bc-sync). An adopt for a value saved as it is made keeps the operator's value until the server holds it (`keepLocalEdits`), or the save of one tap, coming back, undoes a second tap made before it arrived. + +**Self-run participants run the representative bout; a hantei stays the organiser's** (operator decisions 2026-09-28, bc-dhas). An anonymous self-run `PUT /score` may carry the representative-bout row (position -1, sub decision `daihyosen`) and score it like any bout. `POST`/`DELETE .../daihyosen` are public, but an anonymous caller adds or removes only on a RUNNING match (`selfRunDaihyosenRefusal`, handlers_daihyosen.go: 409 `result_finalized` with the `/score` sentence once it has finished, 409 `match_not_running` before it starts). The organiser keeps no status rule there, since an add to a finished fixed-order knockout match may be their correction (no `/reopen` exists for one). The add keeps a recorded kiken, fusenpai or fusensho; the organiser's remove of an unscored representative bout does not: it clears the match's decision and puts it back to running (`clearMatchVerdict`), while the competitor's bar stays recorded. Accepted as it is (operator decision 2026-10-02); do not report it as a new defect. An encounter holds ONE representative bout: a second add is 409 `daihyosen_exists` for every caller, and the add refuses engi, kachinuki and individual competitions and pool matches. `state.DaihyosenSubIndex` is the one finder of that row (the first -1 row). + +ONE judge, `holdSelfReportedWriteUnderTx` (handlers_match.go), decides everything a participant's score write is refused or rewritten for that depends on the stored match. The decision allowlist and the Rev/RevSession reset run before the lock, in `enforceSelfRunPolicy`. The judge runs under the write's lock over the stored match the `/score` handler reads ONCE per write for every guard (`matchSnapshot`, whose `Pairing` holds the stored sides and ids; `applyCorrectionReason` reads the same one, and the member judge takes it as the pre-write baseline), and each refusal is a `selfRunRefusal` carrying its own status, code and sentence. Who a write seats is judged on what it LANDS, after the engine write, in the same transaction (`landedMembersRefusal`): the handler reads the match back through `stx`, which sees its own staged bytes, and compares it with the snapshot read before the write. A side member id the stored row now holds and did not hold before must be on that side's team (400 `team_member_not_in_team`: `errBoutFighterNotInTeam` on a participant's numbered row, `errRepMemberNotInTeam` on a representative pick, anyone's), and a winner member id that changed on a participant's numbered row must be one of the row's two fighters (400 `winner_member_not_in_bout`, `errWinnerMemberNotInBout`); the organiser's numbered rows are not judged. A refusal is RETURNED from the transaction closure as a `*landedWriteRefusal`, which ABORTS the transaction (no match write, no history line, no eligibility record), and is unwrapped into `engErr` after `WithTransaction` returns. It is judged only for a write the engine accepted (a superseded or rolled-back write still commits its history and restore) and only when the write, as the earlier judges leave it, INTRODUCES a member id on a judged row against the match as it stood before the write (`introducesJudgedMember`, a pure predicate over the payload rows and the pre-write snapshot; it and the judge, `membersOutsideTeams`, read ONE selection of rows, `judgedRows`: every representative-bout row against the stored one and, for a participant's write, each numbered row against the stored row at its position, `state.SubResultAt`, so a row or field added to one is added to the other): an echo of the stored ids, which the team editor stamps on every row that has a lineup, reads nothing back, because the engine never lands a member id the judge would refuse that the payload did not introduce (pinned from the engine's side by `TestEngineLandsNoMemberIDThePayloadDidNotIntroduce`, over kachinuki bouts on both store branches and a fixed-order team match's representative bout on the bracket, with stamped writes (one older than the group it rewrites, so that group is held and copied back) and both teams' starting lineups and team members saved, whose minted ids no row carries: every member id the write itself lands that the payload did not carry is a STORED one at the same position (`preserveKachinukiMemberIDs`; the merge's copies of the groups it does not apply, held or unnamed, through `CopyGroup`/`SetRepPick`; `keepQueuedScore` on a start, which that test does not send) or a winner id derived from the row's own landed side ids (`ResolveMemberWinnerID`/`ReconcileWinnerMemberID`), and an id taken from those lineups or members inside the write would be a foreign one it refuses; `MaybeAdvanceKachinuki`'s lineup ids are added after the transaction, in the handler, which the test does not reach). It judges the landed match, not the payload, because the engine rewrites a write before its merge (`applyKachinukiMerge` merges stored rows by position and fills member ids; `noteServerBoutChanges` adds the `bout:N` groups it rewrote): a pre-write probe of the payload (`engine.AppliedGroups`, removed) let a participant land another team's member on a kachinuki bout the write left out of `changed`. A group the merge does not land is the stored one in the landed match and is never judged: a stale NAMED group is in `heldGroups` and the history, an unnamed one in neither. Every member-id check (the landed rows, and the lineup PUT's `memberIDsOutsideTeam`) goes through one `memberJudge`, which reads team-members.yaml at most once and only when an id is introduced, and builds each team's member-id set once (`memberJudge.ids`); a file it cannot read refuses the write with 409 `team_members_unreadable` (`errTeamMembersUnreadable`), never a 500 the offline queue would retry forever, and since a refusal is an abort, nothing is written. + +A participant's score write never creates a representative bout. A -1 row on a match that has none is one the write INHERITED, from a sheet a moment behind a remove made elsewhere or from a queued write replayed after it. The judge DROPS that row, logs the drop, and writes the rest through the timestamp guard, which decides a stale one. A write that FINISHES the match on such a row is refused instead, 409 `no_daihyosen` (`errRepBoutRemoved`), and nothing is written, since a finish may take its winner from that row. Nor does it ever remove one (only `DELETE .../daihyosen` does): a write listing the bouts without the -1 row the match has comes from a sheet a moment behind an ADD, so the judge KEEPS the stored row, scored or not, and a finish is refused, 409 `daihyosen_added` (`errRepBoutAdded`). Two rows are 400 `duplicate_daihyosen`. The organiser's write is not judged here, except its representative picks, which `landedMembersRefusal` judges on what the write lands, exactly as a participant's (same 400 `team_member_not_in_team` and sentence); a pick the merge holds (stamped older than the stored pick, or carried by a write held whole) is kept in the history and answered in `heldGroups`, never refused (a pick names a SIDE, whose team a correction can change under an open editor), and the match's status is never asked. Once the organiser records the judges' decision on it, a participant's write may send the row back (`sameHanteiVerdict`: same winner, same side names, mark in the same array) or leave its ippons out. The silence test is `engine.KeepsStoredDaihyosenVerdict`, the one `preserveSubHantei` restores by; a row with two or more outstanding fouls is never silent, because the fold would give it an array. Either way the judge REPLACES that row with a deep copy of the stored one, so the write lands with the decided bout exactly as recorded, whatever else the row said. Any of these is 409 `hantei_organiser_only`: +- recording, moving or clearing the decision; +- dropping the row; +- naming a match `winner`/`winnerId` other than the decided side, read id-first against the STORED sides through `domain.AttributeWinnerSide`. + +Its two sentences are shared with the JS note (`REP_BOUT_DECIDED_NOTE`) through `internal/mobileapp/testdata/rep_bout_hantei_messages.json`. Every public write throws a refusal's `message` before its code (`_refusalText`, api_client.jsx). `IsSelfRunReportableSubDecision` judges one row's decision only, and takes only `""` or `daihyosen` on the -1 row (a draw, kiken, fusenpai or fusensho there would decide the encounter). Both `/daihyosen` routes resolve the caller before reading the body (a wrong password is 401, never `clock_skew`) and answer with `matchForBroadcast`, as the SSE event does. + +The public editor matches: +- under `selfReport` it offers no hantei; +- it offers Add and Remove only while the match is running (`repBoutAddRemoveOpen`); +- once a hantei is recorded, the representative bout is read-only (`repBoutDecidedForParticipant`, overtime stepper included) with the refusal's sentence, and every save sends the verdict back; +- the individual editor hides its whole decision place. + +`POST .../bulk-score` and `PUT .../quick-score` write without the participant's checks, so they are main-gated in self-run; bulk-score does judge each entry's representative picks on what the entry lands, in the entry's own transaction (a refused entry stores nothing and reports `reason: team_member_not_in_team`), and quick-score builds its rows server-side. + +**A representative-bout add or remove holds the team editor's autosave** (bc-dhas). The add and remove go through `_daihyosenRequest` (api_client.jsx) on `_fetchJson`, which keeps one AbortController armed, on a timer rather than the wall clock, until the body is read. So a request the server never answers, or answers with a body that never completes, fails with the not-added/not-removed sentence instead of holding the write forever. The add and the remove are one sequence, `runRepBoutChange` (admin_scoring_team.jsx). It takes `hold()` before its pre-save. The pre-save is bounded by the same `FETCH_TIMEOUT_MS` (write_result.jsx), past which the change is reported as not answered, nothing is sent and the hold is released. Add and Remove both stay disabled while that save is still out (`submitting`), so no second change starts beside a pre-save given up on. The admin hosts' refetch after a write is bounded too (`refreshCompsBestEffort` asks `fetchCompetitions({ bounded: true })`), and one given up on at that deadline says nothing: `_fetchJson` rejects past its deadline with the one `timedOut` error however the abort surfaced, and `refreshFailureToast` (admin.jsx) returns no toast for it, because the hosts refresh after every autosave and on a slow link the toast would follow every few taps while the write reaches the page by push anyway. Any other refresh failure keeps the reload hint. `recordScore`, `recordDecision`, `overrideBracketWinner` and the queue's replay (`_flushQueue`, single-flight, so one stalled replay stopped every later flush) read their answers through `_fetchJson` too, so a body that stalls past the deadline is a network failure: the write comes back `{queued: true}` (the editor stays open, `writeKeepsEditorOpen`) and is replayed with the stamp it was sent with, safe if the server did store it because the write guard is last-write-wins and an equal stamp re-applies. The add saves the sheet first unless the match has finished, so a queued match starts through the score path's gates; the remove saves first only a running sheet with an edit owed. A debounce that fires while held defers its write, and the mounted editor calls `release()` from an effect on `daihyosenBusy`'s true-to-false edge, after the render that adopted the result. The adopt keeps the answer in the override, `{ match, at }`: a stamped answer the prop does not hold yet (`holdsAnswer`) becomes the match the sheet shows, with the answer's bout log, status and team-match overtime (the prop's winner and decision stay, since the client reshapes those), and it goes once the prop's bout log changes or its stamp reaches `at`; a change of the prop's STATUS re-judges it too (an effect on `match.status`, which releases it when the prop holds the same bout log and is not older than the answer), so a finish or reopen made elsewhere is not hidden behind it. `hold()` returns `settle(transform)`, called on every exit (`withServerDaihyosenRow` over the answer's row once landed, or over the last prop's when that prop already holds the answer; bare in `finally`): an edit owed when the editor UNMOUNTS during the request waits for it and is written with the outcome applied (the added row as returned, a removed one left out, unchanged on failure or refusal). A page going away (`pagehide`) meanwhile writes it at once, durably, as it stands, since nothing can wait for it; a tab merely hidden (`visibilitychange`) waits for the request like the unmount does and then writes durably with the outcome applied, since a write at once would put back a removed representative bout or wipe an added one. Accepted residual: a hidden page the system discards before the request answers, with no `pagehide`, loses that one tap. The pre-save (`saveRunningSheet`) repeats while a tap made during it is owed, reading the sheet through the autosave refs: the request is stamped when it is sent, so an edit made before it and written after it would lose the guard silently. + +**The persisted outbox is shared by every tab.** `_persistQueue` merges into what storage holds, keeping the newer copy (`enqueuedAt`) where two tabs hold the same ENTRY (the same key; two writes about one match are two entries since bc-mrgc phase 3, and both are kept), and every removal goes through `_dequeue`, which records it so the merge prunes it from storage. A bare `_writeQueue.delete` leaves the stored copy for the merge to keep, and the write is replayed on the next load. Each tab also follows the others' writes (`_onStoredQueueChanged`, on the `storage` event): it takes up an entry it does not hold, or holds an older copy of, after the rehydrate's checks (`_storedEntryProblem`), and drops its copy of one another tab took out of storage, so a write queued in a tab that then closed is still sent. A write two tabs hold can be sent by both, which is harmless (last-write-wins). A revoked password is scrubbed from every stored entry, another tab's included (`_parkStoredEntries`). + +**A superseded write leaves NO FOOTPRINT on the match, on either branch; its one footprint is its match history entry** (bc-mrgc). `UpdatePoolMatchByID`'s mutate returns an `error` and the store skips the save when it does, the contract `UpdateBracket` has always had, so `applyPoolWrite`'s superseded verdict aborts rather than re-serializing the untouched row and bumping the standings-cache version. Two rules come with it. The abort is reported through the ERROR and NEVER through `found`: `found` stays true because the match WAS there, and signalling `(false, nil)` would make `writeToPoolOrBracket` read a rejected pool write as "not a pool match" and go hunting for it in the bracket. And BOTH `storeTx` branches must honour it, the WAL-staged one included, or a drop leaves no trace outside a transaction and a WAL entry inside one. Pinned across all six cells of `superseded_matrix_test.go`, which asserts the file bytes AND `Store.FileVersion` are unchanged after a drop; reverting the abort reddens exactly the two pool cells. + +**On the client a live score never goes back in time under a score editor.** A refetch can read the data just before a write commits and answer after a newer push or a newer refetch has landed; taken whole, it put the older scoreline back, and an open score editor that had caught up adopted it, so its next save wrote the lost point away. The refetch sinks a score editor reads from therefore merge through `patch.jsx`'s `keepNewerMatches` (`keepNewerCompetitions` for a list, `keepNewerDetail` for a competition detail, `keepNewerTournament` for the tournament): the court console's feed, the admin competition view, the public competition page (`selectedCompData` in app.jsx, every refetch through `takeCompDetail`, since self-run gave that page a score editor), and the tournament's competitions (the Scores tab's source, fed by the admin's refreshes and by the app's `load()` outside display mode). A held match is kept only when BOTH copies are RUNNING and the fetched one is stamped and older. Stamps come from two clocks: a client write carries the device's estimate of server time (accepted up to 5s ahead), a reopen, requeue or send-back the server's own, so a newer server stamp can read older; every server-stamped change moves the status, so running-against-running never weighs one clock against the other. An unstamped fetched copy (a draw discarded and drawn again reuses the match ids) always replaces. The display takes a refetch whole: no editor reads it, and its reload must discard its optimistic broadcast overlay. Accepted residual: a holder that never saw a match leave running (the Scores tab's list, which takes no pushes) can keep a running copy stamped by a device whose clock runs a few seconds ahead over the server-stamped running row of a reopen made within those seconds (e.g. a mistaken withdrawal reopened at once). Both show the match live; the held copy can still show the winner's letters that the withdrawal replaced with its circles, until the next write to that match replaces it. + +**A public-page modal never holds a copy of its match.** `useLiveMatch` (viewer_match.jsx, beside `MatchViewerModal`), used by all four doors (the competition page's tabs and Overview, Home, Schedule), keeps only the match's id and competition and reads the row live every render from the page's own `compMatches` list, so a self-run score editor follows a correction made on another device; Home and Schedule look it up by competition as well as id (`matchInList`), because match ids repeat across competitions; Schedule builds only the open match's competition's list, and only while a match is open. The Overview reads `allMatches` alone. Nothing else is taken from the object the opener hands in: an opener may hold an old copy (Home's alert banner keeps the match from when the alert fired), and its scoreline must never stand in for the live one. Only the competition page's Bracket tab declares anything, `open(match, { round, phase, ...competition fields })`, and declared keys win over the row: a preview bracket is not in `compMatches`, so its live row is the raw one (`bracketMatchIn`). A match that leaves the data closes the modal and is forgotten, because a redrawn draw reuses ids. + +Engine side: `state.IsTransactional(h)` lets a caller whose correctness depends on a live transaction check what handle it was given; `recordIneligibilityFromDecision`'s K2 check-and-set logs if handed the bare store, which is what pins the entry-point `WithTransaction` shims. `mergeMatchWrite` orders every write by stamp **including corrections** (the old `correctionReason` exemption was a workaround for refusals being silent, and it let a replayed offline correction overwrite newer results). The remaining unstamped-bypass overwrite is logged rather than silent (`logUnstampedOverwrite`). diff --git a/.claude/rules/store.md b/.claude/rules/store.md new file mode 100644 index 000000000..38a25c232 --- /dev/null +++ b/.claude/rules/store.md @@ -0,0 +1,13 @@ +--- +paths: + - "internal/state/**/*.go" +--- + +# The file-backed store + +Moved from CLAUDE.md "Key Algorithms". Read before changing anything in `internal/state`. + +- **The server refuses to start on data it cannot write** (operator decision 2026-10-04, "test permissions on load"). `checkDataWritable` (internal/state/writable.go), run by `Store.init` before the WAL replay, creates and removes a scratch file in the data folder, `competitions/`, `.wal/`, every competition folder and its `history/`, and `branding/`/`sponsors/` when present (`state.BrandingDirName`/`SponsorsDirName`, which the upload handlers use); every folder that fails is named in `ErrDataNotWritable`, so a folder the server cannot write is found at startup rather than as a score write failing mid-match. Folders only: every save writes a temporary file and renames it, which needs only the folder. +- **Store Transactions** (`internal/state/transactions.go`): `Store.WithTransaction(compID, fn)` holds the per-comp lock once across multiple load/save operations. Use the `StoreTx` handle inside `fn`. Do NOT call public Store methods (they would deadlock the non-reentrant mutex). +- **Cache invalidation: never key a cache on mtime alone** (`internal/state/store.go`): filesystem mtimes come from the kernel coarse clock (~1ms granularity), so two writes inside one tick are indistinguishable and an mtime-keyed cache serves pre-write data (mp-n6ke: stale standings vs fresh matches injected a phantom tiebreaker bout and stalled bracket advancement; mp-p7n was the same class). Each `fileCache` therefore carries a monotonic `version` counter, exposed as `Store.FileVersion(compID, filename)`; `engine.CalculatePoolStandings` requires BOTH version and mtime to match (the counter catches same-millisecond in-process writes, mtime still catches out-of-process edits). **If you add a writer for a file some cache derives from, call `s.bumpFileVersion` AFTER the bytes land**: an extra bump only costs a recompute, a missed bump serves stale data, and bumping before the write lets a reader stamp OLD bytes with the NEW token. Existing bumps sit at the chokepoints every writer funnels through (`savePoolMatchesLocked`, `saveOverridesLocked`) plus draw deletion and transaction abort. Two further rules follow from the counter being the cache key: **every read path must validate it, not just the fast path** (the `CalculatePoolStandings` single-flight loser returned the winner's entry unvalidated, and the winner stamps *pre-compute* tokens, so it could predate the loser's own write), and **the counter must stay monotonic for the life of the process, including across a competition being deleted and recreated** (IDs are name slugs, so `DeleteCompetition` calls `discardCompCacheBodies` to clear the cached bodies while leaving the counters climbing, rather than `compCache.Delete`, which reset them to 0 and let a recreated competition match its predecessor's stale entry). Prefer a deterministic counter over a sleep, which only hides the race. +- **Only `saveCompetitionChangedLocked` may create the competition directory** (`internal/state/overrides.go`, `competitor_status.go`, `team_lineup.go`): `saveOverridesLocked`, `saveCompetitorStatusLocked` and `saveTeamLineupsLocked` each used to `os.MkdirAll` before writing, so a write landing after `DeleteCompetition` rebuilt `competitions//` around a lone `overrides.json` / `competitor-status.yaml` / `lineups.yaml`. That orphan survives the delete (`ListCompetitions` returns every directory under `competitions/`) and, because IDs are name slugs, a same-named recreation adopts the dead competition's data. **Locking cannot fix this**: the write does not have to interleave with the delete to resurrect the directory, only to run after it (the two status/lineup writers take the very lock `DeleteCompetition` holds, so they cannot interleave at all, and still resurrected it). `atomicWriteFile` opens its temp with `O_CREATE` but never creates the parent, so once the `MkdirAll` is gone a write to a deleted competition correctly fails with ENOENT. Creating the directory is competition *creation*'s job and `saveCompetitionChangedLocked` is the sole legitimate `MkdirAll` site (plus `Store.init` for the root/WAL folders); do not strip that one, and do not reintroduce the pattern in a new per-competition writer. The interleaving half is closed separately: `overrides.json` is the one competition file whose writers serialize on the store-wide `s.mu` rather than the per-competition lock, so `DeleteCompetition` takes **both**, in the order compLock then `s.mu` (`s.mu` is a sink: nothing holding it acquires a comp lock, so the edge cannot close a cycle). **Do not "tidy" this by moving overrides onto the per-competition lock**: `computeStandingsFrom` runs inside `WithTransaction`, which already holds that lock, and reads overrides via `e.store.LoadOverrides`, so it would self-deadlock the live scoring path on a non-reentrant mutex. diff --git a/.claude/rules/teams-and-lineups.md b/.claude/rules/teams-and-lineups.md new file mode 100644 index 000000000..818643c60 --- /dev/null +++ b/.claude/rules/teams-and-lineups.md @@ -0,0 +1,22 @@ +--- +paths: + - "internal/domain/{team_lineup,team_member}*.go" + - "internal/engine/{kachinuki,kachinuki_roster,lineup_in_force,daihyosen}*.go" + - "internal/state/{team_lineup,round_lineups,squad,team_result}*.go" + - "internal/mobileapp/{handlers_lineup,handlers_squad,handlers_daihyosen,team_finish_gate}*.go" + - "web-mobile/js/{lineup_draft,lineup_rename,lineup_resolver,lineup_save,admin_lineup,admin_schedule_lineup,admin_scoring_team,team_default_credit,squad_member_label,kachinuki_advisory}.jsx" +--- + +# Team matches, lineups and kachinuki + +Moved from CLAUDE.md "Key Algorithms". Read before changing team scoring, team members, lineups or kachinuki. + +- **Team credit for a match-level decision**: when a completed, non-kachinuki team match is decided by a match-level kiken, fusenpai or fusensho (`domain.IsDefaultWinDecisionStr`: kiken/kiken-voluntary/kiken-injury/fusenpai/fusensho), every numbered bout with no result of its own is credited to the OTHER side, one individual victory and two points, the same figures a fusensho bout scores. `state.DefaultWinCreditSide(status, decision, decisionBy, att)` decides which side is credited (from `decisionBy` when set, else `domain.AttributeWinnerSide`); `state.SubBoutEffectiveResult` fills a credited bout's `Winner`/ippons (maru) without touching its `Decision`, so a credited bout carries no per-bout Kiken/Fus. mark of its own. These two functions are format-agnostic; kachinuki is excluded one level up, at WRITE time, not inside them: `state.NeedsDefaultWinBoutPadding`/`PadDefaultWinBoutPositions` (which synthesize the empty placeholder rows the credit functions act on) are gated on `!comp.IsKachinuki()` in `RecordMatchResultWithIneligibilityTx` (`engine/scoring_tx.go`), so a kachinuki encounter is never padded and so is never credited in practice. The same rule feeds the wire summary (`MatchResult.TeamResult()`/`BracketMatch.TeamResult()`), standings accrual (`accrueTeamSubResults`, `engine/scoring.go`), and the Excel export (`internal/export/builder.go`); the JS mirror is `web-mobile/js/team_default_credit.jsx`, consulted only when the server's own `teamResult` is absent. A score-sheet correction (`incoming.Decision` empty or `hikiwake`) over a stored kiken, fusenpai or fusensho keeps it: `engine.KeepsWithdrawalRuling(storedStatus, storedDecision, incoming)` was widened from the old withdrawal-only check (`domain.IsWithdrawalDecisionStr`) to `domain.IsDefaultWinDecisionStr`, so a stored fusensho survives a correction exactly as a kiken/fusenpai does. The one correction that REPLACES the ruling is one sent with the request-only `clearWithdrawal` (`state.MatchResult.ClearsWithdrawal`, `json:"-"`, set by the `/score` handler; the editors' **Remove kiken** / **Remove fusenpai** / **Remove fusensho** in `RecordedWithdrawal`, the label naming the recorded decision): the payload is the real result, a win or a draw, the match stays completed and never takes the court, and `restoreIfWithdrawalRemoved` lifts the bar (operator ruling 2026-10-03: "an operator can fix a mistake, but the fix must leave the match resolved"). **Clear and reopen** (e.g. **Clear kiken and reopen**) stays beside it for a match with fighting left. Kachinuki is not offered the Remove button: a finished kachinuki encounter has no Save correction. +- **A fixed-order team bout row records NO fighter name, never the team's** (operator ruling 2026-09-17, bc-dnst: "the storage is the main source of truth"). The bout settles at the MATCH level, so when no lineup names a fighter the row knows no per-fighter identity and `buildPatch` writes `sideA`/`sideB` EMPTY. It used to write the team name there, which is not a person: every display then had to filter it back out, and the ones that did not showed the team's name where the competitor goes. Nothing needed the copy, because attribution reads the MATCH-level name first (`state.SubBoutWinnerSide`, whose `sub.SideA` arm is guarded against `"" == ""`; the JS `teamIVScore` fallback mirrors it) and the server's own quick-score path already wrote this shape. **The DAIHYOSEN row is the exception and keeps the team names**: the hantei mark is placed on the winner's side by comparing the winner against THAT row's `sideA`/`sideB` (`placeHt`), so blanking them drops the mark from the wire. The server keeps them too: `engine.AddDaihyosen` stores the match's team names on the row when the bout is added, and `seatBracketSide` (bracket_seat.go, the ONE owner of seating a knockout side) keeps the row's `sideA`/`sideB` equal to the match's on every seat, and carries the row's `Winner` to the new name when the side keeps its team and the Winner names the side by its old name, the row's own or the match's (a rename: `nameRepBoutRow` with `another` false and `matchOld` the match's name before the seat, since a row's side name can be blank or stale; the carry also runs when the row already reads the new name; `ReplaceParticipantInDraw` seats a side matched by id through it, `renameBracketSide`, and renames an id-less side's row name without clearing a pick), judging "another team" by id when both sides carry one and by name otherwise; a side given another team loses its representative pick there, dated after the pick it clears. `resolveBoutSideName`'s `teamNameA`/`teamNameB` filter stays for rows written BEFORE this rule; it is legacy handling, not a live guard. +- **A lineup vacancy is never flagged on the score sheet** (operator ruling 2026-09-16, bc-dnst). Team sizes are unregulated and a vacancy is legitimate play, so the sheet must not tell the operator to complete a lineup. The "SHIRO/AKA: Lineup incomplete, add the remaining players" notice was REMOVED with its predicate and its CSS; it never blocked anything, but it read as a requirement. Pinned by `team_editor_vacancy_not_flagged.render.test.jsx`, which exists because a removed warning otherwise leaves no failing test behind. Do not re-add it in any form. +- **Team Lineups & Kachinuki** (`internal/domain/team_lineup.go`, `internal/engine/kachinuki.go`): TeamLineup pins position→player for a round or a specific match (mp-825). Team sizes are unregulated and vacancies are never enforced: `TeamLineup.Validate`/`validateFive` (the FIK 5-person back-fill/DQ rule) were REMOVED (mp-gmcg); only the key-only `ValidatePositions` check remains. Kachinuki ("winner-stays-on") is operator-led: `engine.MaybeAdvanceKachinuki` is append-only (adds the next pairing; NEVER auto-finalizes; the roster snapshot is advisory) and the encounter ends only on an explicit `status: completed` score write, which strips trailing unscored auto-appended bouts. Both win rules, exhaustion and taisho-defeated, record `decision: "kachinuki-exhaustion"`; a drawn pool/league encounter is `hikiwake`. Whether a tied pair fights on in encho is the operator's call, on ANY bout and in ANY phase: the app records what happens and never decides how a match is run (operator ruling 2026-09-26). A taisho-only rule (bc-kten: the editor withheld Encho from any pair but the two taisho, and the server refused a new encho on such a pair) was built on 2026-09-25 and reversed the next day; do NOT re-add a pairing gate or a server refusal for encho. Whether a tied pairing must produce a result (e.g. the taisho must be defeated) is likewise OPERATOR DISCRETION, never derived from pool-vs-bracket (the phase-independence rule lives in `allowNumberedEnchoFromStore` (`handlers_match.go`) and `subBoutNeedsNumberedEnchoAllowance` (`validation.go`); do NOT re-scope numbered-bout encho by match-ID phase; that was tried and reverted). Only the knockout END stays blocked while tied (the bracket needs a winner, `validateBracketCompletion`). A kachinuki encounter never carries a match-level encho (operator ruling 2026-09-24, bc-kheb): one bout fought on in encho does not put the encounter in overtime, so (E) lives on that bout's row only. `state.Competition.ClearKachinukiEncounterEncho(matchID, ...)` is the one owner, and it leaves a pool representative bout or tie-break bout (`Pool X-DH-N`, `Pool X-TB-N`, the `state.IsPoolDaihyosenMatchID`/`IsTiebreakerMatchID` grammar) alone at all three sites below: that match IS one individual bout, whose overtime lives at match level: `applyKachinukiMerge` strips the field on every score write (logged, never refused, since an older client's queued write inherits it), the engine's decision write (`recordDecisionTx`) drops it before the decision's circles are counted, and the load repair `upgradeKachinukiEncounterEnchoLocked` clears what older releases stored, gated on the config.md marker `kachinuki_encounter_encho_cleared`, which `POST /competitions` sets on a competition created as kachinuki (it has nothing to repair) and which a match file the repair cannot read leaves unset, so a later start retries. The team editor sends none (`encounterEnchoCount`) and offers no match-level overtime stepper on a kachinuki encounter. Daihyosen does not exist in kachinuki → `POST .../daihyosen` returns 400. Mistakes: `POST /api/competitions/:id/matches/:mid/reopen` (a kachinuki match, or any match a kiken, kiken-injury, fusenpai or fusensho decided; a 400 otherwise) flips a completed match back to running, clearing winner/decision but keeping the bout log -- EXCEPT a kiken, fusenpai or fusensho whose `decisionBy` side is STILL barred by a withdrawal recorded on a DIFFERENT match: `engine.reopenTargetStatus` (`kachinuki.go`) sends that one to `scheduled` instead of `running`, because going straight to `running` would strand the operator behind the eligibility gate with no way to start it (clearing the match that recorded a bar moves that bar onto a chained fusenpai rather than lifting it); once the bar is gone, the competitor reinstated or the match that recorded it cleared, the identical reopen goes to `running` like any other decision; a reopen that lands `scheduled` takes no court, so neither court gate applies to it (the target status is decided before either gate runs); in a knockout, when the winner of the match being reopened or corrected went straight through a bye (a hidden, unnumbered match the draw leaves with one empty side, so nobody can open or reopen it directly), the write takes that winner back out of it (or re-seats the new one) and answers for the first real match past it (`propagatedDownstreamOf`), rather than refusing; a POOL reopen in a mixed competition is never refused for a seated knockout, because the write that finishes it runs the pool requalification check (`engine/pool_requalify.go`); `POST .../matches/:mid/requeue-blocker-and-reopen` atomically frees the target's court (requeuing the blocking match) and reopens under one court-lock hold; `DELETE .../matches/:mid/kachinuki-bout` removes the current trailing unscored auto-appended pairing. The live bout is the highest-position numbered row (`lastNumberedBout`), never the slice's last row: Record bout numbers its pairing one past it (`nextKachinukiBoutPosition`), and the removal, the un-record and the roster read all name that row. Record bout marks the bout it closes recorded (`SubMatchResult.Recorded`, wire `recorded`, the server-only merge group `recorded`), with or without a next pairing; removing a pairing Record bout appended clears it, and a write that adds an overtime period to the bout, or changes its result (winner side or decision), clears it too unless it is older than the mark or unstamped (`mergeHold.unrecordReopenedBouts`; an unstamped write cannot be ordered after the Record bout); Remove clears it only when the advance line that appended the removed pairing also set the mark; the advisory line's roster (`engine.KachinukiRoster`) keeps both fighters of the last bout on until it is recorded as a win (a running score is no result), and it answers Record bout's own decision on the stored match, asked quietly through the one `decideKachinukiAdvance` the advance writes from, over the same queues and retirements (`readKachinukiQueues`, built once per read): `nextPairing` (a tap would append; false on a recorded live bout greys Record bout out, but only from a read for the current bout log and lineups, never from an older answer the closing keeps) and each side's `exhausted` (the loser of a recorded win whose LINEUP has nobody left; never after a draw or without a lineup). A recorded last bout with no pairing after it reads closed (read-only, with the no-fighters-left line) exactly when those flags say so; the sheet never re-derives them from the queues. A bout copy never moves the mark (`state.CopyGroup`), and the merge re-applies the stored `recorded` group once every hold and displacement is done, so a mark a payload carries never lands; the sheet does not send it. Every numbered bout row of a team match shows the fighter's squad number and, when the position is unnamed, a typeable name box (operator ruling 2026-09-15, bc-dnst). The number belongs to the squad member, not the row: team order can change between matches, so picking a different member into a position moves that member's number onto the row. A fresh team's seeded blank slots are members with a number and no name, so an unnamed fixed-order position shows the number of the blank member seeded for it, and a name typed there names that member through `resolveMemberIdsForPositions` (rename, never a new mint while a blank slot with that number exists), so the name stays attached to the number it was shown with. A team's squad is seeded with team size + 2 numbered slots (`SquadReserveSlots`, two reserves), and the row's list shows every slot with its number, blank ones by number alone, so any number the team can field can be picked and then named; a picked slot is written by id (`buildInlineLineupWrite` with a member) and a name typed into it renames that member (operator ruling 2026-09-15, bc-dnst). That makes a picked-but-unnamed slot a real placement everywhere: every lineup writer (the score sheet row, the Up Next "Enter lineup" panel, the Lineups page) sends the position with an empty name and the member id rather than dropping it; `TeamLineup.OrderedMembers` counts a position occupied when it carries a name OR an id, so such a fighter is on the kachinuki roster; and `AdvanceKachinuki` decides which side stayed on through `domain.SubBoutAttribution`/`AttributeWinnerSide` (ids first), because the client records a nameless winner as its TEAM name plus the member id and a name comparison alone can never see it stay on. A lineup may not place one member id at two positions: `memberPlacedElsewhere` (lineup_resolver.jsx) is the ONE client predicate, asked by every writer before it writes (`buildInlineLineupWrite`, the panel's save, the Lineups page's add path) and rendered as the list filter `rosterWithoutPlacedElsewhere` on every picker, so the offer and the refusal (" is already at ", worded once by `alreadyPlacedNote`, the position named by `lineupPositionLabel` on every surface; a change to a position takes a refusal down) never disagree; `ErrLineupDuplicateMember` from `ValidatePositions` (a 400 naming the two positions) is the server backstop. A lineup that already held a duplicate before this guard existed is repaired by clearing one of the two rows. Under kachinuki only the FIRST row is a lineup position; every later row's pick rides the bout itself (`pickManual` with the member id, never a lineup PUT), the list there is unfiltered because the fighter who stayed on legitimately repeats, and a name typed over a picked blank member renames that member and keeps its id. Which side stayed on is decided through `domain.AttributeWinnerSide`, whose id tier names a side when the winner id equals THAT side's id even if the other side carries none (JS mirror `attributeWinnerSide`, result_slot.jsx), with the same name-only residue `RetiredPlayersFromBoutLog` keeps for a row no id can settle. Before the draw a team has no number, so the Lineups page, the one operator surface for a team's people, labels a slot through `squadSlotLabel` ("Slot 3"), a sibling of `squadMemberLabel` in the same owner; spectator surfaces keep `squadMemberLabel` and stay bare before the draw. The competition Settings page's Squad members section was REMOVED on 2026-09-15 by operator decision (bc-dnst): the Lineups page is the one home for a team's people, and its "Team members" list's "Clear name" control (beside Rename) is where clearing a member's name now lives. The Lineups page's "+ Add" confirm is derived from the resolver's own three outcomes (an existing member is selected with no confirm, a blank slot is named, else a member is minted). A rename or a clear reaches every stored lineup: `RenameTeamMember`/`ClearTeamMemberName` rewrite the name of every lineup position holding that member by id (`renameMemberInLineupsLocked`), because the score sheet and the export read the lineup's stored name; fought bouts keep their frozen names. The Excel export indexes lineup positions by member id as well as by name (`memberKey`, `resolveKachinukiBoutPosition`) so a nameless fighter still gets its position label. Positions are ordered in ONE way, `domain.ComparePositions` (the FIK names in fighting order, then numbered positions by number, then any other by name), which the roster walk (`OrderedMembers`), the export and the member-id load repair share; a text sort put Chuken before Senpo and "10" before "2". +- **A team carries the lineup of its previous team match** (operator ruling 2026-10-05: "by default, a team carries the previous team match lineup. You can have a different team lineup in every team match"). `engine.LineupInForce` (`internal/engine/lineup_in_force.go`) is the ONE owner: the match's own lineup (read whole, even one that holds no position), else the latest lineup the team had before it in match order (an earlier match's, or its starting lineup, which comes before every match); a match the draw does not hold gets its own lineup or the starting lineup. A lineup saved for a later round is never read (see the migration bullet below). A lineup is matched by the team's participant id only, never by its name; an id-less side has none. Discarding a draw removes the lineups saved for its matches. Match order is the pool-match number, then the knockout by round and position, the 3rd-place match last; `state.TeamMatches` is the one list of a draw's team matches (a bye, a pool daihyosen or tie-break bout, and a league's or Swiss competition's vestigial bracket are none), for the rule and the migration alike. The kachinuki roster, the Kachinuki Detail export and every client surface (through `GET .../lineup-in-force/:matchId` and `resolveMatchLineup`) ask it; no surface derives a round to read a lineup. The Lineups page edits each team's starting lineup (stored as its round-0 entry) and any match's own lineup; **Use the previous match's lineup** removes a match's own lineup so it inherits again. **Copy from previous match** was removed because carrying over is the default; do not re-add it. A match the Lineups page has chosen that the team is no longer in (a correction seated another team) stays chosen, locked, Use the previous match's lineup included, with a notice naming it (`notInMatch`), rather than the page silently editing the starting lineup. The at-court lineup panel follows the match it was opened for: its host keeps the match's key and reads the row every render, so a side given another team gets that team's editor and a match that leaves the data closes the panel. +- **A lineup save names the positions it changed** (operator decision 2026-10-07, "Only changed positions"). `PUT .../match-lineups/:matchId` and `PUT .../lineups/0` take `changed` (`LineupRequest.Changed`), and the server lands only those positions (`domain.TeamLineup.ApplyChanges`), under the competition's lock, on the base it reads there (`lineupSave.base`): the match's own stored lineup, else the lineup in force there (`engine.LineupInForceFrom`, the one rule over what the transaction loaded, since the store's own reads would wait on the lock it holds), else empty; for the starting lineup, its stored entry, else empty. The answer is the whole lineup as stored. So two devices changing different positions both keep their change in either arrival order, a save sent later from a device that was offline included; the same position keeps the later arrival (accepted with that decision; do not report it as a new defect). A save without `changed` replaces the whole lineup, which is how a save queued by an older build replays. An empty `changed`, or a changed position `positions` has no entry for, is a 400; a member at two positions of the composed lineup is the existing duplicate 400 naming both. `web-mobile/js/lineup_save.jsx` (a leaf) is the client owner: `changedLineupSave` builds the body every writer sends (the Lineups page, the panel, and the sheet's name box through `buildInlineLineupWrite`), a cleared position as an empty name with no id, and `joinQueuedLineupSave` joins a save into a queued save of the same lineup (the union of `changed`, the later value per position; a queued entry with no `changed` stays whole with the change applied), for `_placeWrite`: a save of a lineup with a save of it queued joins that entry rather than going straight to the server, where it could land before the queued replay and lose to it. Both lineup editors share `useLineupForm` and `LineupSourceLine` (`lineup_draft.jsx`): a lineup is saved only after it was read (`loadedKey`), `lineupToSave()` is the form with `changed` and reads nothing, and `confirmSaved` adopts the answered lineup, so a position another device changed shows once the save lands. Removing a match's own lineup waits while a save of it is still queued (`API.queuedLineupSave`, a boolean). The team score sheet's name box sends `changed: [posKey]` on the lineup it holds for that side (`lineupHeldFor`), which the duplicate check and the rows need. A pick on a side it never read (the first read still out, or failed) reads that side itself and is refused when that read fails (`LINEUP_NOT_READ_NOTICE`): with every save now landing only its own position, the reason is no longer an overwrite but that the rows of a lineup never read would show empty positions that may not be, which the operator would then fill over fighters they cannot see; such a side is read again when the connection returns, and a write of the side, sent or queued, counts as the side's latest read, so a read still out from before it never puts the old lineup back. Both lineup editors follow a `lineup-updated` naming their team (one naming no team counts for every team; one naming another is ignored), once a read of the lineup still out has shown it: the lineup is read again and shown while the form has no unsaved change and no removal is out, and the team's members are read again either way, since the list is not the operator's edit (reads begun in the same tick share one `fetchSquads`, `requestMembers`). A Save makes no read: a member another device adds is announced, so the editors' lists hold it. If this device missed that event, a name typed for that member resolves against the stale list, the server refuses a second member of that name (`squadDuplicateNameCheck`), and the position saves by name with the identity warning (accepted, weighed 2026-10-07: a bounded read at every Save added a second wait on a link that does not answer, against a rare race that ends visible). +- **Team members carry a server stamp** (operator decision 2026-10-07): `TeamMember.ModifiedAt`, set by every member write (see the team-members paragraph under Common Pitfalls in CLAUDE.md). `mergeMembers` (lineup_draft.jsx) is the ONE client rule: the union by member id (no screen removes a member), each member's copy with the larger stamp (the arriving copy on a tie), in member number order. Every list that reaches an editor or the sheet (a read's answer, the host's `teamMembers` copy, the viewer payload) and every member write's answer (`addTeamMember`, `renameTeamMember` and `clearTeamMember` each return the stamped member, bounded through `_memberWrite`; an answer with no id is a failed write) goes through it, so an older list can never put back an older name and a later change made elsewhere always shows. The editors' own changes go through `useLineupForm.changeMembers`/`memberRenamed(member)`, the sheet's through `wroteSideMembers` and its lists through `takeSideMembers`; none of them reads again, since every member write is announced. A member object built by hand carries no stamp and loses to every list: an own write's member is the server's answer. A list or an answer made for a team the editor has since left lands nowhere (`teamKey` against `squadTeam`). A name typed before the team's members have been read waits for them, bounded by `FETCH_TIMEOUT_MS` (`newMembersWait`, lineup_draft.jsx, the one owner: `useLineupForm.waitForMembers()` for the panel's Save and the Lineups page's add row, the sheet's per-side wait for its name boxes): resolved against no members, a new name would be minted where the position's seeded slot is free, and the name of a member the team has would be refused by the server as a second one. Only a READ's list ends that wait, even an empty one, never an own write. A read that fails, or one not answered in time, ends the wait for good (so a read that hangs holds up one name, not every name typed after it), and the name is resolved without the members, with the members-unavailable warning. A sheet side is given another team (a correction made elsewhere seats another one) only when both its entry in the match (`sideLookupKey`) and the team it resolves to change: a side that carried only its team's name gaining that team's id is not one. Then the sheet drops that side's list and its wait (`dropSideMembers`) and the old team's lineup (`dropLineup`: a read or write of it still out never lands, and the side counts as never read until the new team's lineup does), and reads the new team's. A pick is checked against the lineup sync's epoch for its side (`watchLineupSide`) after its wait and after its member writes, and when the side was given another team meanwhile nothing more is written and the row says so. A side takes a members list only once its team is known (`sideTeam`: the side carries an id, or the roster resolved its name), and a pick writes for the team the side has when its wait ends (`sideSeen`). The identity effect runs ahead of the lineup and members reads, pinned by source order in `team_editor_members_read_first.render.test.jsx`, since no behavioural test can see the order while the lineup read awaits first. The two lineup reads (`fetchLineupInForce`, `fetchTeamLineup`) are bounded by that same deadline, and an answer that is not JSON is a failed read, never an empty lineup. +- **Lineups saved by v2.1.1 and earlier are migrated, never read** (operator decisions 2026-10-06: "Migrate old content to new format", and 2026-10-07: "Show what v2.1.1 showed"; the lead's ruling that follows from them: a migrated tournament shows, at every match, exactly the lineup v2.1.1 showed there). `state.settleRoundLineups` (internal/state/round_lineups.go) is the ONE owner. A legacy team is one with a round lineup r >= 1, or one that, at the competition's FIRST settlement (`Competition.RoundLineupsLegacy` still nil), holds a match lineup stored under its id for a match of the current draw that seats it by id: v2.1.1 used a match's lineup for that match alone, so carrying it on would show the team's later matches a lineup v2.1.1 never showed there. That pass records every legacy team in config.md's `round_lineups_legacy` (`Competition.RoundLineupsLegacy`, sorted participant ids), and later passes read that list plus any team with a round lineup, so a match lineup saved by THIS release never makes a team legacy. Every match a legacy team is seated in by id gets a lineup of its own, written as `PUT .../match-lineups/:matchId` writes one, equal to v2.1.1's reading there (`legacyReading`, which answers `(lineup, ok)`: the match's own, else the round lineup with the highest round at or below the round the match was read at, else the highest round, else none, and then the match is given an EMPTY own lineup, `positions: {}`, which `inForce` honours whole, so it shows none, as v2.1.1 did; `legacyLineupRound`: a knockout match's bracket round index, the 3rd-place match `len(Rounds)`, a pool or league match's stored circle round, 0 for Swiss). It runs on load (`upgradeRoundLineupsLocked`, by EnsureLegacyUpgraded after the id repairs) and at every write of the draw (`storeTx.settleRoundLineupsAtCommit` at the end of WithTransaction, staged with the write; `Store.settleRoundLineupsAfterWrite` after the direct `SaveBracket`/`UpdateBracket`/`UpdateBracketMatchByID`/`SavePoolMatches`/`UpdatePoolMatchByID`), so a match the team is seated in later is given its lineup in the write that seats it. Each (team, match) pair is settled ONCE: config.md's `round_lineups_given` (`Competition.RoundLineupsGiven`, the team's participant id to its sorted match ids) lists them, so a lineup the operator removes (**Use the previous match's lineup**) stays removed. `Store.ClearDrawLineups` (a discard, and every new draw, which `runDrawPipeline`'s generators call just before their first write) removes the draw's match lineups, clears that list because the next draw reuses the match ids, and trims `round_lineups_legacy` to the teams that still hold a round lineup (the match lineups that made the others legacy went with the draw), setting the marker when none remains. A lineup stored under a team's NAME is keyed by the team's id when exactly one team has that name (one whose team has no id yet waits). On load, a legacy team that has round lineups and no starting lineup gets `legacyReading(rounds, 0)` as one: v2.1.1's own fallback, its HIGHEST round's lineup, so a frame-0 match it is seated in later reads what v2.1.1 showed. The round lineups stay, never read by `engine.LineupInForce`, until the competition is completed; then the next write of the draw or the next load removes them (completion itself is a config.md write the draw hooks do not see; nothing reads them meanwhile) and config.md's `round_lineups_converted` (`Competition.RoundLineupsConverted`, set by POST /competitions on every new competition, and by the settlement as soon as no legacy team waits: at once for a competition with none, else at completion or at a discard that leaves none; setting it clears `round_lineups_given` and `round_lineups_legacy`) stops the settlement. `PUT .../lineups/:round` refuses a round >= 1 with a 400, and the client has no later-round UI. The reading is v2.1.1's score sheet's and server's (`FindBestLineup`); v2.1.1's at-court lineup panel showed only a lineup saved at the match's exact round and blank boxes otherwise, so the two disagreed there, and the migrated match lineups make both show the sheet's (checked on data the real v2.1.1 release wrote, 2026-10-06). Accepted as they are (do not report them as new defects): a mixed competition's round-robin pools and its knockout shared v2.1.1's round numbers, so a knockout match reads a round lineup by its bracket round index, as v2.1.1 did (put to the operator 2026-10-06; answer: "all team matches need lineups"); a team seated in a match after the competition completed gets no reading and carries its previous match's lineup there; no `lineup-updated` event is sent for a lineup given in the write that seats a team; the settlement reads the competition's files on every write of the draw until the marker is set (a marked competition costs one cached read, `Store.roundLineupsSettled`); and a first settlement that finds no legacy team while a lineup still waits for its team's id records nothing, so the next pass settles first again, and a match lineup saved in between would read as one v2.1.1 saved (only a roster where some teams still lack an id reaches it). +- **Unsaved lineup edits are kept as a per-tab draft** (bc-lnul, operator decision 2026-10-05: option A, a draft, over a `beforeunload` prompt, which iPad Safari does not show and which cannot cover the app's Back or closing the panel). `web-mobile/js/lineup_draft.jsx` (`useLineupDraft`, `LineupDraftNotice`, `lineupDraftKey`) is the ONE owner: both lineup editors keep the difference between the lineup as loaded and as shown in `sessionStorage`, and offer it back with "Unsaved lineup changes restored" and **Discard** when the same lineup opens again (the at-court panel and the Lineups page share a match's key). A draft is never written to the server; one whose lineup changed meanwhile is dropped with a notice naming what was not restored, which stays through the operator's edits and goes when the lineup is saved or given up (`useLineupDraft`'s `resolved`) or left. A draft the stored lineup already holds (`holdsDraft`: at every position, the draft's name where the draft has one, and where it has none, no name and the same member id) is the operator's own save landing, a queued one or one sent as the panel closed, and is dropped with no notice: the save attaches member ids the draft never had, so comparing ids there would report a lost change that was saved. Do not add a `beforeunload` prompt. diff --git a/.claude/rules/workbook.md b/.claude/rules/workbook.md new file mode 100644 index 000000000..d0343b0e0 --- /dev/null +++ b/.claude/rules/workbook.md @@ -0,0 +1,25 @@ +--- +paths: + - "internal/helper/{excel,excel_data,excel_kachinuki,excel_styles,excel_tags,excel_tree,fill_bracket,constants}*.go" + - "internal/excel/**" + - "internal/export/**" + - "internal/engine/{workbook,export,kachinuki_export,representative_export,printed_bracket,pdf_export}*.go" + - "cmd/**/*.go" + - "web-mobile/js/admin_schedule_export.jsx" +--- + +# Excel workbooks + +Moved from CLAUDE.md "Key Algorithms" and "Excel workbook construction". Read before changing any workbook output. + +- **Excel Layout**: Uses an **8-column per court** layout. Columns A and G (and their court-shifted counterparts) are 30 units wide; others are 5 units wide. A blank row separates pools vertically. +- **Team block bout rows** (`state.Competition.TeamBoutRows`, with `domain.KachinukiMaxBouts`): the ONE owner of how many numbered bout rows a team match's block has in any workbook: 0 for an individual competition, TeamSize for a team match, 2 x TeamSize - 1 for kachinuki (the most bouts a winner-stays-on encounter can take). `RenderCompetitionWorkbook` passes it to the renderers (the Pool Matches and Elimination Matches blocks, the 3rd-place block, their IV/PW formula ranges and page heights) and `BuildResultsWorkbook` to the overlays, which skip a bout Position past it rather than write into the next block. **The app's "Download blank template" is NOT `ExportCompetitionXlsx`**: `buildXlsxBody` (admin_schedule_export.jsx) posts the roster to the stateless `POST /create` (cmd), which re-draws it (mp-x0u9), sending `teamMatchType=kachinuki` for a kachinuki competition, `teamMatches` as the team size and `format=league` for a league (one pool and no knockout, which `/create` cannot tell from a pools competition that formed a single pool; any other `format` is a 400); cmd builds a `state.Competition` from them to ask the same owners (`TeamBoutRows`, `IsKnockoutEnabled`). The main sheets' team block skips the representative bout (position -1), so its height and IV/PW ranges stay `TeamBoutRows`' alone; that bout is on the Representative Bouts sheet (see Workbook owners). An encounter with more bouts (reserves fielded) keeps them all on the Kachinuki Detail sheet, which lists a match's recorded bouts, or that many empty numbered rows for one with none, in every workbook (`helper.BlankKachinukiSections` for `/create`'s own draw, which leaves that sheet unprotected), so it can be filled in by hand (operator decision 2026-09-27, bc-kdsc). Before the draw, a knockout-only competition's detail sheet lists the skeleton the Elimination Matches sheet printed (the same rounds, through `helper.BlankKachinukiSections`), so the two sheets have one source. `helper.breakBeforeEliminationBlock` is the one page rule for every block on the Elimination Matches sheet, the 3rd-place block included, so a block that fits on one page never prints across two. A block taller than a page (a team of 17 or more, which only the CLI or API allows; the app caps teams at 9) still does. Never restate 2n-1 for a workbook, in Go or JS; the one other use is the score editor's own cap on manually added kachinuki bouts (`kachinukiMaxBouts`, admin_scoring_team.jsx), which is taken from `MAX_TEAM_SIZE`, not from a team's size. `TestCreateHandler_WithoutTeamMatchTypeMatchesTheExamples` reproduces four committed example workbooks byte for byte through `/create`, so a rendering change must regenerate them (`make examples`). +- **Workbook owners.** `engine.AttachPoolMatches` is the ONE Pool Matches source for both engine exports (the results workbook, and the stored-draw export `ExportCompetitionXlsx` behind `GET /api/competitions/:id/export` and the Export PDFs prints): never rely on a cached `pool.Matches`, since pools.csv records membership only and a restart empties it (the stored-draw export printed no match block after a restart until bc-kdsc). `engine.PrintedBracket` is how those exports name a stored bracket match: its block title (`helper.EliminationMatchTitle`, or `helper.ThirdPlaceLabel` for the bronze) and its sides, a side not decided yet as `helper.MatchRefLabel`'s "M n", never an internal id such as "Winner of r2-m1". The results overlay, the Kachinuki Detail sheet and the Representative Bouts sheet all name through it, so a detail section and the "M n" that points at it read alike. `/create` has no stored bracket; `helper.BlankKachinukiSections` names its draw's matches through the same owners (`helper.EliminationMatchTitle`, `helper.MatchRefLabel`, `helper.ThirdPlaceLabel`, `helper.PoolMatchLabel`). +- **Representative Bouts sheet** (`helper.WriteRepresentativeBoutsSheet`, `helper.SheetRepresentativeBouts`): a fixed-order team competition's workbook carries it exactly when a stored match holds a representative bout (a -1 row); kachinuki never has one. `engine.RepresentativeBoutMatches`/`collectRepresentativeBouts` (representative_export.go) walk the draw in the Kachinuki Detail sheet's order (`detailDraw.sections`) and take each bout's score, marks and centre from that sheet's own `boutFigures`, so the two sheets cannot disagree on a bout; the row is labelled `helper.RepresentativeBoutRowLabel` ("DH"). Both engine exports carry it (the stored-draw export and the results workbook); `/create`, the four committed examples and the Export PDFs prints do not (the PDF groups select sheets by name and list neither detail sheet). The row keeps the TEAM names by rule, so a representative is printed only from a member id that resolves in that side's team (`representativeOf`), never from the stored name; otherwise the bout prints its label alone. + +## Excel workbook construction + + +The workbook is built entirely from code in `internal/excel/template.go` (`NewFileFromScratch`). Each sheet (data, Time Estimator, Pool Draw, Pool Matches, Elimination Matches, Names to Print, Tree) is created and styled programmatically. Layout constants (rows-per-page, spacing, max bracket size) and sheet name constants (`SheetData`, `SheetPoolDraw`, etc.) live in `internal/helper/constants.go`. Use these constants everywhere rather than string literals. + +**excelize is pinned to a master commit, not a release** (`v2.11.1-0.20261010030226-066c84b8f7de`, operator decision 2026-10-10, PR #463): v2.11.0 carries fifteen advisories that fail CI's Anchore image scan (`validate`, severity medium and up), each fixed only on excelize's master, and no release after v2.11.0 existed. Move to the first tagged release that contains those fixes. `govulncheck` does not see them (it counts reachable code); to reproduce the scan, run grype on the built binary (`docker run --rm -v :/scan:ro anchore/grype:latest dir:/scan --fail-on medium`; the image is `FROM scratch`, so the binary is all it scans). An excelize version change rewrites the bytes of the committed example workbooks (this one changed only font property order and numbers written to 15 significant digits), so `TestCreateHandler_WithoutTeamMatchTypeMatchesTheExamples` fails: regenerate them with `make examples` and compare the workbooks' XML (cells, formulas, styles, page breaks), not their bytes, before committing. diff --git a/AGENTS.md b/AGENTS.md index c92df834d..56cbb0e22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,10 @@ High-signal instructions for AI agents working in this repository. Before implementing features or making architectural decisions, read the project constitution: **`.specify/memory/constitution.md`**: defines the core principles (YAGNI, DRY, TDD, DDD, evidence-based decisions, bracket integrity, and live-tournament constraints) that all changes must comply with. +## Domain rules + +Detailed domain rulings (match results and marks, match writes and the offline queue, teams and lineups, eligibility, the store, workbooks, the draw) live in `.claude/rules/`, one file per area; each file's `paths` header lists the code it governs. Read the matching file before changing code in that area: nothing loads it for you outside Claude Code. + ## Core Technical Context - **Domain Logic:** Primarily in `internal/helper/`. A new `internal/domain/` package is being introduced to decouple logic from Excel formatting; use it for new pure-domain models but check `helper` for existing Excel-linked logic. - **Excel Generation:** Uses `excelize/v2`. The workbook is constructed from scratch in `internal/excel/template.go`. Coordinates and formulas are hardcoded in `internal/helper/` (e.g., `tree.go`, `excel.go`). Layout/page-break constants live in `internal/helper/constants.go`. diff --git a/CLAUDE.md b/CLAUDE.md index b2ca59212..162fb02d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,8 +49,9 @@ When asked to *plan*, *verify*, or *record a plan on a bead*: do NOT write code. ### Keep the orchestrating session small -Every model call re-reads the session's whole context, this file included (about 60k tokens on its own), so a session's cost grows with its length. One session that ran a multi-bead PR and its review loops made 209 calls at an average of about 360k tokens of context, and never compacted (2026-10-11). +Every model call re-reads the session's whole context, this file included (about 17k tokens on its own since the domain rules moved to `.claude/rules/`), so a session's cost grows with its length. One session that ran a multi-bead PR and its review loops made 209 calls at an average of about 360k tokens of context, and never compacted (2026-10-11). - **Delegate review rounds, investigations, history lookups and verification sweeps to subagents, and keep only their short summary** (findings, decisions, `file:line`) in the main session: ask the subagent for a summary in a set number of lines, not its full report (operator feedback 2026-09-13: "Make better usage of sub agents"). +- **Subagents run on Sonnet unless you say otherwise** (`CLAUDE_CODE_SUBAGENT_MODEL=sonnet` in the user settings, 2026-10-12). That suits searches, history lookups, reference sweeps and verification. Pass `model: "opus"` for an implementer and for the final review round; the pr-review-toolkit's `code-reviewer` and `code-simplifier` pin Opus themselves. - **Start a new session for each PR.** Once a PR merges, begin the next bead's work in a fresh session; the bead and the PR carry the state. ## Governance @@ -126,12 +127,22 @@ Layered with no upward or circular dependencies: presentation (`cmd/`, `mobileap Live-tournament state is file-backed under `tournament-data/` (owned by `internal/state/`). `docs/architecture/data-model.md` owns the model (the file layout, field lists, which records carry ids, what each file's columns mean), so add a fact there rather than growing a second copy here. The root holds `tournament.md`, `.wal/` (pending multi-file transactions, replayed on startup), `branding/` and `sponsors/`, and `competitions//`, whose `history/` keeps one append-only `.jsonl` per match. +### Domain rules (`.claude/rules/`) + +The detailed rulings for each area live in `.claude/rules/`, one file per area, so a session pays for them only when it works there. Claude Code loads a file by itself when a built-in Read, Edit or Write touches a file matching its `paths`, **but a Serena call does not trigger that**: Read the file yourself before you change code in its area (a global guard refuses a Serena edit there until you have). Other agents never load them automatically. + +| Before changing… | Read | +|---|---| +| How a draw places, seeds or numbers competitors; pool bout ids | `.claude/rules/draw-and-seeding.md` | +| How a result is recorded, validated or shown (decisions, the middle mark, hantei, side colours, operator labels) | `.claude/rules/match-results-and-marks.md` | +| Any match write, its answer, the offline queue, a score editor's save path | `.claude/rules/match-writes.md` | +| Team scoring, team members, lineups, kachinuki | `.claude/rules/teams-and-lineups.md` | +| Withdrawals, bars, reinstatement, starting a match, send back to queue | `.claude/rules/eligibility-and-queue.md` | +| `internal/state` (transactions, caches, directories, startup checks) | `.claude/rules/store.md` | +| Any workbook output (layout, owners, sheets, examples) | `.claude/rules/workbook.md` | + ### Key Algorithms -- **Binary tree brackets** (`helper/tree.go`): `Node` struct with `Left`/`Right` children, recursive subdivision for multi-page output. `helper.MaxPlayersPerTree = 16` (constants.go). -- **Seeding** (`helper/seed.go`): `StandardSeeding()` uses `generateBracketOrder()` for placement. `ApplySeeds()` handles collisions by swapping seed values. Seed names are title-cased with cases.Title(NoLower) and then looked up exactly by (name, dojo) in domain.RosterIndex; accents and non-initial capitals must match (NormalizeParticipantName is NOT on this path). `delayDojoMeetings` (same file) then delays dojo-mate meetings across the unseeded slots; its acceptance is SUM-DRIVEN by operator ruling (2026-08-31): a swap may pull an already-deep dojo's first meeting one round earlier to push the draw's earliest meetings later. Do NOT add a per-dojo never-earlier veto here: that rule belongs to the POOL exchange pass (`improveDojoMeetings`), and a finding that reports the deep-dojo trade as a regression is re-litigating the ruling. -- **Pool creation** (`helper/pool_distribution_tree_aware.go`): members are placed ONE AT A TIME in the roster's pre-shuffled order (no re-sorting -- shuffling is upstream's job), each descending the knockout skeleton tree by recorded per-node dojo counts (fewer of my dojo, then more room, then left); seeds are placed first on their EKC/D7 slots and recorded. A single pairwise exchange pass follows with a strictly tiered objective: per-pool spread-cap excess, winner-path round-1 count, winner-path meeting sum, then the all-qualifier crossing best effort -- each tier only resolves what the tier above left tied. No rotations. A crossed 2nd-place meeting a dojo-mate is ACCEPTED chance (operator ruling); never "fix" it beyond the tie-break tier. The old greedy fill's `assignPlayersToPools`/`discoverPool` survive only for `CreatePools` direct callers (estimator, goldens), where unique-dojo rosters make them identity-equal; `leastConflictedPool` is NOT one of those retirees -- it is load-bearing in the tree-aware distributor itself, called from three sites (`pickDojoTreeAwarePool`'s two fallbacks plus `poolUnderDojoCap`) as the descent's own least-conflicted fallback when the dojo tree has nothing to say. Pools distributed contiguously across shiaijo. **Competitor numbers belong to draw positions, never to people before a draw exists** (operator ruling 2026-09-06): once the entrant count fixes the pools and the tree, the positions are numbered pool by pool (`helper.NumberPools`, K1-K4 the first pool) and seeds and everyone else are placed into those positions; nothing is shown before the draw is generated (the earlier provisional registration-order numbers were removed as confusing). A knockout-only competition follows the SAME rule down the bracket instead of pool by pool (bc-pnum ruling 2, 2026-09-07): `helper.StandardSeeding`'s own placement order is recorded as `state.Bracket.DrawOrder` (participant ids, top of the tree to the bottom) when the draw is generated, and `engine.NumberKnockoutParticipants` composes each competitor's number from their position in it at read time; a competitor excluded from the draw (e.g. not checked in), or a bracket generated before this field existed, carries no number -- there is no participant-order or name-based fallback. **Changing any of this reddens a test that names a docs file**: the pool-draw page carries an interactive walk-through (`docs/assets/javascripts/pool-draw-animation.js`) that replays this descent in JS, and its example rosters' drawn pools are a fixture between markers in that same file, pinned by `TestPoolDrawDocWalkthroughMatchesTheDraw` (`internal/helper`) re-running them through the real distributor. That is deliberate: a recorded outcome rots silently otherwise. Update the fixture, then re-check the JS descent against it in a browser, since the test pins the recorded pools and not the JavaScript. -- **Tree-aware seeding** (`helper/seed.go`): `placeSeedIndices`, called from `buildPoolPhaseTreeAwareCore` (`helper/pool_distribution_tree_aware.go`; `PoolSeeding` itself was removed as dead code, bc-drwx item 11), places seeds so the top seeds land in different halves/quarters of the knockout TREE (D7, enforced via tree paths in seed_warnings.go); the EKF rank-to-court order is that tree contract's sheet encoding, since courts own contiguous chunks of the tree. Courts are NOT an overlay here: `numCourts` is the modulus `seedPoolRank`/`seedCourtOrder` compute the seed spread against, so a wrong court count places seeds in the wrong quarter. (Courts genuinely are an overlay for the SEPARATE dojo-tree descent that places the unseeded -- see the Pool creation bullet above -- which reads a court index only to derive `drawCourts` up front and to reorder for courts at the very end, never in between.) **Wrapped seeds** (rank beyond `numPools`, e.g. rank 5 over 4 pools -- reachable from a contiguous seeding with more ranks than pools, or from check-in dropping a mid-sequence seed and leaving a gap, `TestGenerateDraw_SeedGapFromCheckInKeepsD6Placement`): the extra seed takes the next pool (in the same rank-priority order the natural arithmetic defines) with room holding NO seed; failing that, the next with room holding no DOJO-MATE; only once both fail does it fall through to D6's unconditional placement. Once placed, a wrapped seed is never moved by anything downstream -- not the pool exchange pass (`improveDojoMeetings`, which excludes every seeded slot from both sides of a swap) and not `delayDojoMeetings` (which only ever moves unseeded slots, via its own `occupied` guard). `seed_warnings.go`'s halves/quarters/shiaijo checks are scoped to ranks `<= maxSeedRanks` for the same reason: a wrapped seed has no half/quarter home in D6's structure, so scoring it against those checks by its COUNT position in the placed-seeds list (rather than by its rank value) produced a spurious "could not be split into halves" warning for a wrapped seed that had landed cleanly in its own seed-free pool (bc-pnum operator ruling; restores what an earlier review round had narrowed to the dojo-mate case alone). - **Pool Scoring & Tie-breaking**: - **Individual Tournaments**: 1. Higher number of fights won. @@ -148,217 +159,19 @@ Live-tournament state is file-backed under `tournament-data/` (owned by `interna 6. Higher number of individual draws (IT). 7. Higher number of points scored (PW). 8. Lower number of points lost (PL). -- **Team credit for a match-level decision**: when a completed, non-kachinuki team match is decided by a match-level kiken, fusenpai or fusensho (`domain.IsDefaultWinDecisionStr`: kiken/kiken-voluntary/kiken-injury/fusenpai/fusensho), every numbered bout with no result of its own is credited to the OTHER side, one individual victory and two points, the same figures a fusensho bout scores. `state.DefaultWinCreditSide(status, decision, decisionBy, att)` decides which side is credited (from `decisionBy` when set, else `domain.AttributeWinnerSide`); `state.SubBoutEffectiveResult` fills a credited bout's `Winner`/ippons (maru) without touching its `Decision`, so a credited bout carries no per-bout Kiken/Fus. mark of its own. These two functions are format-agnostic; kachinuki is excluded one level up, at WRITE time, not inside them: `state.NeedsDefaultWinBoutPadding`/`PadDefaultWinBoutPositions` (which synthesize the empty placeholder rows the credit functions act on) are gated on `!comp.IsKachinuki()` in `RecordMatchResultWithIneligibilityTx` (`engine/scoring_tx.go`), so a kachinuki encounter is never padded and so is never credited in practice. The same rule feeds the wire summary (`MatchResult.TeamResult()`/`BracketMatch.TeamResult()`), standings accrual (`accrueTeamSubResults`, `engine/scoring.go`), and the Excel export (`internal/export/builder.go`); the JS mirror is `web-mobile/js/team_default_credit.jsx`, consulted only when the server's own `teamResult` is absent. A score-sheet correction (`incoming.Decision` empty or `hikiwake`) over a stored kiken, fusenpai or fusensho keeps it: `engine.KeepsWithdrawalRuling(storedStatus, storedDecision, incoming)` was widened from the old withdrawal-only check (`domain.IsWithdrawalDecisionStr`) to `domain.IsDefaultWinDecisionStr`, so a stored fusensho survives a correction exactly as a kiken/fusenpai does. The one correction that REPLACES the ruling is one sent with the request-only `clearWithdrawal` (`state.MatchResult.ClearsWithdrawal`, `json:"-"`, set by the `/score` handler; the editors' **Remove kiken** / **Remove fusenpai** / **Remove fusensho** in `RecordedWithdrawal`, the label naming the recorded decision): the payload is the real result, a win or a draw, the match stays completed and never takes the court, and `restoreIfWithdrawalRemoved` lifts the bar (operator ruling 2026-10-03: "an operator can fix a mistake, but the fix must leave the match resolved"). **Clear and reopen** (e.g. **Clear kiken and reopen**) stays beside it for a match with fighting left. Kachinuki is not offered the Remove button: a finished kachinuki encounter has no Save correction. -- **A pool bout's number is its place in the playing order, and the id carries it** (operator ruling 2026-10-04: "Should be in playing order and both ids should be in sync"). `numberPoolMatchesInPlayingOrder` (engine/pools.go) gives each pool's ids after every reorder the draw makes (the league scheduler's no-back-to-back order), just before the save, so `Pool A-0` is the first bout played. Every surface reads the number from the id: the server label below, the scores list, the workbook (`AttachPoolMatches` sorts by it) and the console's "Match N of M" (`poolPosition`, `poolMatchNumberOf`, viewer_utils.jsx), never from a row position. A queue reorder moves only `scheduledAt`, so a bout keeps its number. A league drawn by an earlier release keeps ids given before its reorder; every surface still shows one number for each of its bouts. -- **Operator match labels** (`internal/engine/match_label.go`): `OperatorMatchLabel(comp, bracket, matchID)` is the ONE Go owner of how a match is named back to the operator -- in refusal messages, the `court_busy`/`downstream_knockout_running` `label` fields, and `reasonHuman` sentences. A pool/league row reads e.g. `"Pool A · Match 1"` (`"League table · Match N"` for a league; the middle dot is the literal separator, `supplementaryOrNumberedLabel`); a knockout row (`MatchLabel`) reads e.g. `"Match 2 (Final)"`, `"knockout Match N"` (no round recorded), or `"the 3rd-place match"` (bronze). Pinned against the JS side by the shared golden fixture `internal/engine/testdata/match_labels.json`: `TestOperatorMatchLabel_GoldenTable` (Go) and `web-mobile/js/__tests__/match_labels_golden.test.jsx` (JS, `scoreRowMatchLabel`/`matchLabel` from `pool_ids.jsx`/`write_result.jsx`) load the SAME file. - **Team Match Winning Criteria**: An encounter between two teams is decided by: 1. Highest number of individual winners (Victories). 2. Highest number of points scored. - 3. If both are equal, it's a draw (Tie) in pools, or goes to a daihyosen (representative bout) in elimination matches (FIK Article 8-1; see "Where a mark lands" below). + 3. If both are equal, it's a draw (Tie) in pools, or goes to a daihyosen (representative bout) in elimination matches (FIK Article 8-1; see "Where a mark lands" in `.claude/rules/match-results-and-marks.md`). - **Tie-marking Rule**: A match (individual or sub-match) is a tie when the operator enters **'X'** (or 'x') in the "vs" column or when both sides finish with the same total score (equal character-count after stripping spaces/zeros/dashes). For auto-detection, at least one score cell in that row must be non-empty. A team match is automatically a draw (T=1) when both IV and PW totals are equal. -- **Match Colors**: White (Shiro) is always the left column and Red (Aka) is always the right column (this is fixed and not configurable). In the Excel export, `helper.WhiteLeft` (`internal/helper/excel.go`) is the one Go owner of that side-to-column order; no mirror setting exists, and every side-ordered pair (entrant formulas and names, scores, fouls, result marks, header labels and styles, standings side records, a team summary's IV/PW) routes through it rather than restating the order. In pool matches the first-listed player (SideA) is Red; in elimination matches the upper-bracket player (`node.Left`) is Red. **A TINTED surface does not carry a SHIRO/AKA badge as well** (operator ruling 2026-09-20, bc-sccl, which SUPERSEDES bc-dnst's "named once in text, by the header badge"): where a side has a background tint, the tint plus Shiro's 45° hatch carry it, and the badge is redundant. Every side badge in the app went with that ruling: the score editors' header pill (`.sb-side__badge`), the engi card's (`.engi-side__badge`), the scores list and court console's (`.se-color-badge`) and the compact schedule's 14×14 `A`/`S` squares (`.tw-match__badge`). Bout rows and foul counters were already tint-only, so do not re-add per-bout SHIRO/AKA chips or "SHIRO Fouls" prefixes either. A tinted surface still names the side in TEXT for a screen reader — that is the one thing that must never be dropped with a badge (DESIGN.md §4: colour is never the only signal). The list of surfaces that carry no such text is short and closed: the TV board and the bracket card (both below, each by its own ruling), plus the watchlist hero, which names it VISIBLY instead. **`SideCell` (side_cell.jsx) owns this: a surface passes `side` and gets the fill class AND the `sr-only` label together, so the tint cannot arrive without its text; `SideLabel` is the label alone, for a surface that paints its own tint; `sideFillClass` is the class alone, for the watchlist hero only, which names the side visibly and so must not carry the span as well.** Do not hand-type either the class or the span -- that is how `"Shiro: "` and `"Shiro:"` came to coexist. **Use an `sr-only` span**, via that owner. An `aria-label` only works on an element whose role permits naming, and the cells this rule governs are bare `
`s, i.e. role `generic`, where ARIA PROHIBITS it: the court console carried `aria-label={`Shiro: …`}` on four such cells and the side was announced by nothing. An `aria-label` on an interactive wrapper is worse than useless here, because role=button has presentational children, so it REPLACES the side word, the competitor numbers and the dojos inside it — that is how the watchlist hero lost all of them. ONE exception, and it is about SIZE, not colour: the watchlist hero (`.wl-hero__side-lbl`) keeps a visible `Aka`/`Shiro` word (operator decision 2026-09-20). It is a large personal card read at arm's length, not a dense row, so the word costs it nothing -- which is the whole reason the ruling removed the badge from dense rows. Do not copy it onto a row. The exception this REPLACES was `.bc-color-badge`, justified by that card being dark navy; bc-wlhc made the card light and deleted the class, so the old wording named a surface that no longer existed. Controls that PICK a side (the "SHIRO wins"/"AKA wins" buttons, the decision side picker, the team summary band) keep naming it, as does the OBS lower third's heading. The TV board does NOT: its SHIRO/AKA headings went with the same ruling (position plus each name's colour carry it there, and a projector has no screen-reader audience), pinned by `side_is_carried_by_tint.test.jsx`'s "the TV board names no side". Aria-labels and titles may always say the side. The competitor number sits on the OUTER side of the name on every surface, Shiro's before it and Aka's after (operator ruling 2026-09-14, bc-dnst); `NumberedName` (numbered_name.jsx) owns it for JSX and `withNumber` (match_scoreboard.jsx) for strings, so a new surface calls one of those two and never restates the before/after choice. **Which of the two depends on one thing: does the cell CLIP?** A cell with `text-overflow: ellipsis` truncates the END of its run, which on Aka is the number, so the string form silently loses it there while Shiro's leading number survives. A clipping cell therefore passes `clip` to `NumberedName` (or, on the display surfaces, takes `sideLabelParts` from display_helpers.jsx), which makes the chip its own flex child so only the name ellipsises; a wrapping or unconstrained cell keeps the string. Both forms read the same `numberedParts`, so they cannot disagree about a side's name and number. The OBS lower third is MIXED on exactly this basis, not string-only (bc-rvfx): its team lines clip and chip, its individual lines do not clip and keep the string. The TV board WAS mixed on the same basis and is now chip-only (operator decision 2026-09-19, bc-lbty): its `NextPair` rows (the NEXT line and the UP NEXT pool bout list) still do not clip, but they render the non-clip `NumberedName` anyway, because a chipped row directly above a bare-string row read as an oversight rather than a choice. Clipping still decides `clip` vs plain; it no longer decides chip vs string, so do NOT infer from that exception that every non-clipping cell must chip -- the overlay's individual lines are the standing counter-example. **A chip takes the COLOUR of the name it rides beside** (operator decision 2026-09-19, bc-lbty): `.num-prefix` is `color: inherit` with its tint mixed from `currentColor` toward `transparent`, so Shiro's chip is dark and Aka's is red, and the chip never introduces colour the adjacent name does not already carry. The mix must stay transparency-based, never toward a solid light colour: the OBS lower third paints on `rgba(0,0,0,0.85)` with white and pale-pink text, where a white-mixed chip would be invisible. This supersedes the earlier mp-938b neutral-chip note, which the `.num-prefix` comment in styles.css no longer carries and which survives in git history only. Where the two sides STACK vertically instead of sitting left/right (the bracket card, the admin and public schedule rows) there is no outer side: the number sits before the name on both so the numbers align in a column, and the bracket card names no side in text, its leading colour bar and tint carry it (operator ruling 2026-09-14, bc-dnst). -- **Excel Layout**: Uses an **8-column per court** layout. Columns A and G (and their court-shifted counterparts) are 30 units wide; others are 5 units wide. A blank row separates pools vertically. -- **Team block bout rows** (`state.Competition.TeamBoutRows`, with `domain.KachinukiMaxBouts`): the ONE owner of how many numbered bout rows a team match's block has in any workbook: 0 for an individual competition, TeamSize for a team match, 2 x TeamSize - 1 for kachinuki (the most bouts a winner-stays-on encounter can take). `RenderCompetitionWorkbook` passes it to the renderers (the Pool Matches and Elimination Matches blocks, the 3rd-place block, their IV/PW formula ranges and page heights) and `BuildResultsWorkbook` to the overlays, which skip a bout Position past it rather than write into the next block. **The app's "Download blank template" is NOT `ExportCompetitionXlsx`**: `buildXlsxBody` (admin_schedule_export.jsx) posts the roster to the stateless `POST /create` (cmd), which re-draws it (mp-x0u9), sending `teamMatchType=kachinuki` for a kachinuki competition, `teamMatches` as the team size and `format=league` for a league (one pool and no knockout, which `/create` cannot tell from a pools competition that formed a single pool; any other `format` is a 400); cmd builds a `state.Competition` from them to ask the same owners (`TeamBoutRows`, `IsKnockoutEnabled`). The main sheets' team block skips the representative bout (position -1), so its height and IV/PW ranges stay `TeamBoutRows`' alone; that bout is on the Representative Bouts sheet (see Workbook owners). An encounter with more bouts (reserves fielded) keeps them all on the Kachinuki Detail sheet, which lists a match's recorded bouts, or that many empty numbered rows for one with none, in every workbook (`helper.BlankKachinukiSections` for `/create`'s own draw, which leaves that sheet unprotected), so it can be filled in by hand (operator decision 2026-09-27, bc-kdsc). Before the draw, a knockout-only competition's detail sheet lists the skeleton the Elimination Matches sheet printed (the same rounds, through `helper.BlankKachinukiSections`), so the two sheets have one source. `helper.breakBeforeEliminationBlock` is the one page rule for every block on the Elimination Matches sheet, the 3rd-place block included, so a block that fits on one page never prints across two. A block taller than a page (a team of 17 or more, which only the CLI or API allows; the app caps teams at 9) still does. Never restate 2n-1 for a workbook, in Go or JS; the one other use is the score editor's own cap on manually added kachinuki bouts (`kachinukiMaxBouts`, admin_scoring_team.jsx), which is taken from `MAX_TEAM_SIZE`, not from a team's size. `TestCreateHandler_WithoutTeamMatchTypeMatchesTheExamples` reproduces four committed example workbooks byte for byte through `/create`, so a rendering change must regenerate them (`make examples`). -- **Workbook owners.** `engine.AttachPoolMatches` is the ONE Pool Matches source for both engine exports (the results workbook, and the stored-draw export `ExportCompetitionXlsx` behind `GET /api/competitions/:id/export` and the Export PDFs prints): never rely on a cached `pool.Matches`, since pools.csv records membership only and a restart empties it (the stored-draw export printed no match block after a restart until bc-kdsc). `engine.PrintedBracket` is how those exports name a stored bracket match: its block title (`helper.EliminationMatchTitle`, or `helper.ThirdPlaceLabel` for the bronze) and its sides, a side not decided yet as `helper.MatchRefLabel`'s "M n", never an internal id such as "Winner of r2-m1". The results overlay, the Kachinuki Detail sheet and the Representative Bouts sheet all name through it, so a detail section and the "M n" that points at it read alike. `/create` has no stored bracket; `helper.BlankKachinukiSections` names its draw's matches through the same owners (`helper.EliminationMatchTitle`, `helper.MatchRefLabel`, `helper.ThirdPlaceLabel`, `helper.PoolMatchLabel`). -- **Representative Bouts sheet** (`helper.WriteRepresentativeBoutsSheet`, `helper.SheetRepresentativeBouts`): a fixed-order team competition's workbook carries it exactly when a stored match holds a representative bout (a -1 row); kachinuki never has one. `engine.RepresentativeBoutMatches`/`collectRepresentativeBouts` (representative_export.go) walk the draw in the Kachinuki Detail sheet's order (`detailDraw.sections`) and take each bout's score, marks and centre from that sheet's own `boutFigures`, so the two sheets cannot disagree on a bout; the row is labelled `helper.RepresentativeBoutRowLabel` ("DH"). Both engine exports carry it (the stored-draw export and the results workbook); `/create`, the four committed examples and the Export PDFs prints do not (the PDF groups select sheets by name and list neither detail sheet). The row keeps the TEAM names by rule, so a representative is printed only from a member id that resolves in that side's team (`representativeOf`), never from the stored name; otherwise the bout prints its label alone. -- **Clearing a scored mark is a tap on the mark, and each editor says so ONCE** (operator ruling 2026-09-16, bc-dnst). The individual board puts the line under its slot groups; the TEAM sheet puts it after the team names and BEFORE the first bout, never per bout, because the sheet repeats a row per bout. Both use the same words and the same `.sb-hint` owner (`.sb-hint--encounter` only adds the spacing a line between two blocks needs), and both show it only while a mark exists to clear. The team sheet keeps the line's space while it is hidden (`.holds-space`, the one owner of `visibility: hidden` for this, operator ruling 2026-10-06), because it sits above every bout row: appearing at a bout's first point (in kachinuki at every bout, since a recorded bout's marks no longer count) it moved every row 28 px down, and a quick second tap on M opened the fighter list. The individual board's line sits below what a scoring tap aims at, so it still comes and goes. A hantei mark does not raise it: `realIppons` drops it and it undoes through its own "click to undo" control. - **Team Match Labels**: Summaries use **"IV"** (Individual Victories) and **"PW"** (Points Won). - **Court limit**: `--courts` is hard-capped at 16 (`helper.MaxCourts`, derived from the court label alphabet A–P) and any value over that returns an error rather than silently truncating. 16 is the largest allocation any one competition can legally hold, so a venue beyond it could never give all its shiaijo to a single competition. -- **Competitor Eligibility** (`internal/state/competitor_status.go`, `internal/engine/eligibility.go`): a kiken/fusenpai decision auto-writes a `CompetitorStatus{Eligible: false}` for the loser; `engine.StartMatch(compID, matchID)` is the pre-flight gate that returns `*IneligibleCompetitorError` (matches `errors.Is(err, ErrIneligibleCompetitor)`). Maps to HTTP 409, with `reasonHuman` (`reasonHumanForBarredCompetitor`/`barredCompetitorSentence`, `mobileapp/errors.go`) composing one operator sentence naming the competitor, the match (`engine.OperatorMatchLabel`), and what to do, e.g. "Alice withdrew in Pool A · Match 1 and cannot fight again. Record the fusensho for Carol." (kiken-injury: "...Reinstate Alice if they can fight again, or record the fusensho for Carol."). Kiken-injury (FIK Art. 30) sets `CompetitorStatus.Reinstateable: true`; the admin can call `POST /api/competitions/:cid/competitors/:pid/reinstate` to restore eligibility. Kiken-voluntary (Art. 31) and fusenpai are not reinstateable. A withdrawal decision against a competitor ANOTHER match already barred is decided by ONE rule, `engine.alreadyBarredRefusal` (bc-kfup), asked by both T105 halves (`checkConcurrentIneligibility` before the write, the K2 check-and-set in `recordIneligibilityFromDecision`): a kiken is refused with `already_ineligible` (a competitor withdraws once), while a fusenpai is their default loss on a remaining match and records NO new `CompetitorStatus`, so the existing one keeps naming the match where they withdrew. Do not re-add a fusenpai refusal there: StartMatchTx refuses to start that match, so the fusenpai is the operator's exit. Clearing the match that DID record the bar moves it onto such a chained fusenpai rather than lifting it (`standingWithdrawalOf`, fusenpai only: a kiken still on record beside a later bar was lifted by a reinstatement before that bar existed, since a second kiken is refused), and a reopen that clears it lands `scheduled` because `reopenTargetStatusTx` judges the bars the restore will leave (`restoredStatus`, shared with the restore). Accepted as it is (operator decision 2026-09-26): a fusenpai recorded before a reinstatement still carries the bar on, so a competitor who withdrew injured, had a no-show recorded on another match, was reinstated, and then has a later withdrawal cleared as a mistake is barred again by that earlier no-show; the editor names the no-show match, where clearing the fusenpai lifts it. Telling the two apart needs the time of each reinstatement, which is not worth storing for this. Do not report it as a new defect. `engine.BarredSides(statuses, matchID, sideAID, sideBID)` is the ONE extraction point for "is this side barred by a match OTHER than the one being checked" (the `st.MatchID != matchID` undo-path exemption lets a match always be re-scored past its own prior kiken/fusenpai); it replaced three hand-copied loops (`StartMatchTx`, `checkEligibilityExcludingMatch`, `annotateEligibility`). A SCHEDULED match with a barred side carries a READ-ONLY `ineligibleSides` stamp (`state.IneligibleSidesAnnotation{A, B string}`, both `omitempty` decision-code strings) on `MatchResult`/`BracketMatch`, computed at REQUEST TIME ONLY by `mobileapp.annotateEligibility` (`handlers_match.go`; never persisted, `yaml:"-"` on the pool side); a stamped match also has its `queuePosition` zeroed and so omitted from the wire (`state.DeriveQueuePositions`/`annotateBracketQueuePositions`), so it is skipped exactly like an unqueued match. `web-mobile/js/ineligible_match.jsx` is the ONE JS owner reading this stamp: `isBarredMatch`/`awaitedDefaultWin` skip such a match from Up next, Finish + Start Next, the TV next line, the lobby, and on-deck alerts, and drive the operator's one-tap `Record fusensho for ` (`POST /decision {decision: "fusensho", decisionBy: }`, which does not touch the barred competitor's own `CompetitorStatus`) and, for a kiken-injury, `Reinstate `. The same request-time pass stamps a COMPLETED match a kiken, fusenpai or fusensho decided with `withdrawnStatus` (`state.WithdrawnStatusAnnotation`: the withdrawn side's status now, eligible / the match that bars it / reinstateable), which `RecordedWithdrawal` words its clear from instead of fetching statuses. The viewer payloads load statuses whenever a match needs either stamp (`anyMatchToAnnotate`), and the `/decision`, `/score` and bulk-score writes stamp the result they push (`stampWithdrawnStatus`), so a pushed row and its refetch read the same. **Recording a withdrawal changes only that match** (operator ruling 2026-09-26, "do not cascade"): the post-kiken Remaining matches panel that offered to close the competitor's other matches was removed, and each of them is closed from its own notice when it comes up. Do not re-add a cascade from one match's editor. Recording one still starts the court's next match in the same competition (mp-nwds item 7, the operator's request), on the court console and the Scores tab alike, both through `start_match.jsx` (one start at a time; a refused start is shown on the refused match, on the console's Up next or queue row and on the Scores tab's row, with one toast: an error `editMatchScore` already toasted is marked (`markToasted`, write_result.jsx) and the host does not toast it again. The notice is bound to the refused match, not to Up next, by one rule both hosts ask (`startRefusalStands`, start_match.jsx): it stays while that match is `scheduled` on the court, and across an empty list, which says nothing about the match and goes when it leaves `scheduled`, when a non-empty court list no longer holds it, when the operator starts a match, or when an eligibility change in its competition is broadcast; Up next moving on does not drop it. A refusal because another start was still out is kept, in the past tense (`startWasBlockedByStartMessage`), on the refused match once that start has gone out: `landed` on the start's record, set by the console's `startMatch` for a start that landed or was queued offline, never for a pick whose deferral of the running bout failed, nor for a start the server refused: a `clock_skew` answer, or a superseded one that holds the `result` group (`heldGroups` non-empty: a different result is stored and nothing of the start was written). A superseded start with NO `heldGroups` is an echo-hold (a start names only `result`, and the server lists a held group only when it differs from the stored one, `reportHeld`/`HeldEcho`): another device's start of the same match landed first and the match is running, so `classifyStartOutcome` answers it `{ok: true}` like a landed start: no sentence, no toast, a pick pins the panel, and a refusal waiting on it is worded in the past tense. The two refusals answer `{ok: false, msg}`, so both hosts take them as refused starts: the sentence (`START_CLOCK_SKEW_MESSAGE`, or `START_SUPERSEDED_MESSAGE`, composed by `notSavedText` from `SUPERSEDED_REASON` and `SUPERSEDED_ADVICE` under the "Not started" lead, which never invites a retry, start_match.jsx) is stored on the refused match and toasted once, and a pick does not pin the panel on that match; after a pick the court is idle, the running bout already sent back to the queue. The refused match is never started on its own; if the blocking start fails instead, its own refusal stays. On the Scores tab `refuseStart` replaces the single waiting refusal and clears `waitingOn`, so its `finally` has nothing to rewrite, and a superseded start that holds its result group, after a decision, lands on that match with its notice. Accepted: the `result` group's projection also holds `CorrectionReason`/`ResultSource`, so a start over a match running under a different source or reason answers `heldGroups: ["result"]` and shows the refused sentence on a running match; the notice drops once live data shows it running (`startRefusalStands`), the toast stays. The console reads `startPatch`, `winnerSideLR` and `teamMatchMarks` by ES import (`start_match.jsx`, `side_marks.jsx`), and admin_shiaijo.jsx still must not import bracket.jsx); both skip the withdrawn competitor's matches through `sideBarredByDecision`/`involvesCompetitor` (`ineligible_match.jsx`), because the list shows them barred only after it refreshes. -- **Send back to queue keeps the score** (operator ruling 2026-09-26, bc-sbq). `RevertMatchToQueue`/`requeueBracketMatch` clear only the verdict and the audit notes; points, penalties, overtime, bouts, flags and rep-bout fighters stay, the operator removes a wrong mark themselves, and the button is always offered (picking another bout sends a scored one back the same way). Starting the match again keeps them: the SPA's `startPatch` sends the request-only `startOnly` flag and `engine.keepQueuedScore` copies the stored score onto that write, because the wire cannot say "no score sent" (the start's empty arrays are the bytes of an operator clearing every mark, which must still clear, so the editors' own Start stays unflagged; `startOnly` with any status but `running` is a 400, since the stored score is swapped in after validation). The start's representative-bout winner is derived AFTER that swap (`deriveDaihyosenWinner`, scoring_tx.go), so it comes from the bouts the start keeps, never from rows it discards. While queued, every display shows the match as NOT STARTED through `matchShowsScore` (match_shows_score.jsx, a leaf every display ES-imports), which `IndividualScore`/`TeamScoreboard` apply to themselves; the score editors are the one exception. A queued knockout match's kept points do not make it "fought" (`bracketMatchStartedOrDecided` reads status and winner only), so correcting or reopening the match that feeds it re-seats it with its points and asks nothing when it starts. A FINISHED later match that a confirmed correction reopens keeps its points, bouts and flags too, only its verdict goes (`reopenDisplacedBracketMatch` through `reopenBracketMatchKeepingTheFight`, both the knockout and the pool-requalification doors). Engi flags are saved as they are entered: a running engi write is not judged on the flag total (`engiRunningWrite`, scoring_tx.go) and the bracket write carries flags, so a send-back or switch keeps them. -- **A fixed-order team bout row records NO fighter name, never the team's** (operator ruling 2026-09-17, bc-dnst: "the storage is the main source of truth"). The bout settles at the MATCH level, so when no lineup names a fighter the row knows no per-fighter identity and `buildPatch` writes `sideA`/`sideB` EMPTY. It used to write the team name there, which is not a person: every display then had to filter it back out, and the ones that did not showed the team's name where the competitor goes. Nothing needed the copy, because attribution reads the MATCH-level name first (`state.SubBoutWinnerSide`, whose `sub.SideA` arm is guarded against `"" == ""`; the JS `teamIVScore` fallback mirrors it) and the server's own quick-score path already wrote this shape. **The DAIHYOSEN row is the exception and keeps the team names**: the hantei mark is placed on the winner's side by comparing the winner against THAT row's `sideA`/`sideB` (`placeHt`), so blanking them drops the mark from the wire. The server keeps them too: `engine.AddDaihyosen` stores the match's team names on the row when the bout is added, and `seatBracketSide` (bracket_seat.go, the ONE owner of seating a knockout side) keeps the row's `sideA`/`sideB` equal to the match's on every seat, and carries the row's `Winner` to the new name when the side keeps its team and the Winner names the side by its old name, the row's own or the match's (a rename: `nameRepBoutRow` with `another` false and `matchOld` the match's name before the seat, since a row's side name can be blank or stale; the carry also runs when the row already reads the new name; `ReplaceParticipantInDraw` seats a side matched by id through it, `renameBracketSide`, and renames an id-less side's row name without clearing a pick), judging "another team" by id when both sides carry one and by name otherwise; a side given another team loses its representative pick there, dated after the pick it clears. `resolveBoutSideName`'s `teamNameA`/`teamNameB` filter stays for rows written BEFORE this rule; it is legacy handling, not a live guard. -- **A lineup vacancy is never flagged on the score sheet** (operator ruling 2026-09-16, bc-dnst). Team sizes are unregulated and a vacancy is legitimate play, so the sheet must not tell the operator to complete a lineup. The "SHIRO/AKA: Lineup incomplete, add the remaining players" notice was REMOVED with its predicate and its CSS; it never blocked anything, but it read as a requirement. Pinned by `team_editor_vacancy_not_flagged.render.test.jsx`, which exists because a removed warning otherwise leaves no failing test behind. Do not re-add it in any form. -- **Team Lineups & Kachinuki** (`internal/domain/team_lineup.go`, `internal/engine/kachinuki.go`): TeamLineup pins position→player for a round or a specific match (mp-825). Team sizes are unregulated and vacancies are never enforced: `TeamLineup.Validate`/`validateFive` (the FIK 5-person back-fill/DQ rule) were REMOVED (mp-gmcg); only the key-only `ValidatePositions` check remains. Kachinuki ("winner-stays-on") is operator-led: `engine.MaybeAdvanceKachinuki` is append-only (adds the next pairing; NEVER auto-finalizes; the roster snapshot is advisory) and the encounter ends only on an explicit `status: completed` score write, which strips trailing unscored auto-appended bouts. Both win rules, exhaustion and taisho-defeated, record `decision: "kachinuki-exhaustion"`; a drawn pool/league encounter is `hikiwake`. Whether a tied pair fights on in encho is the operator's call, on ANY bout and in ANY phase: the app records what happens and never decides how a match is run (operator ruling 2026-09-26). A taisho-only rule (bc-kten: the editor withheld Encho from any pair but the two taisho, and the server refused a new encho on such a pair) was built on 2026-09-25 and reversed the next day; do NOT re-add a pairing gate or a server refusal for encho. Whether a tied pairing must produce a result (e.g. the taisho must be defeated) is likewise OPERATOR DISCRETION, never derived from pool-vs-bracket (the phase-independence rule lives in `allowNumberedEnchoFromStore` (`handlers_match.go`) and `subBoutNeedsNumberedEnchoAllowance` (`validation.go`); do NOT re-scope numbered-bout encho by match-ID phase; that was tried and reverted). Only the knockout END stays blocked while tied (the bracket needs a winner, `validateBracketCompletion`). A kachinuki encounter never carries a match-level encho (operator ruling 2026-09-24, bc-kheb): one bout fought on in encho does not put the encounter in overtime, so (E) lives on that bout's row only. `state.Competition.ClearKachinukiEncounterEncho(matchID, ...)` is the one owner, and it leaves a pool representative bout or tie-break bout (`Pool X-DH-N`, `Pool X-TB-N`, the `state.IsPoolDaihyosenMatchID`/`IsTiebreakerMatchID` grammar) alone at all three sites below: that match IS one individual bout, whose overtime lives at match level: `applyKachinukiMerge` strips the field on every score write (logged, never refused, since an older client's queued write inherits it), the engine's decision write (`recordDecisionTx`) drops it before the decision's circles are counted, and the load repair `upgradeKachinukiEncounterEnchoLocked` clears what older releases stored, gated on the config.md marker `kachinuki_encounter_encho_cleared`, which `POST /competitions` sets on a competition created as kachinuki (it has nothing to repair) and which a match file the repair cannot read leaves unset, so a later start retries. The team editor sends none (`encounterEnchoCount`) and offers no match-level overtime stepper on a kachinuki encounter. Daihyosen does not exist in kachinuki → `POST .../daihyosen` returns 400. Mistakes: `POST /api/competitions/:id/matches/:mid/reopen` (a kachinuki match, or any match a kiken, kiken-injury, fusenpai or fusensho decided; a 400 otherwise) flips a completed match back to running, clearing winner/decision but keeping the bout log -- EXCEPT a kiken, fusenpai or fusensho whose `decisionBy` side is STILL barred by a withdrawal recorded on a DIFFERENT match: `engine.reopenTargetStatus` (`kachinuki.go`) sends that one to `scheduled` instead of `running`, because going straight to `running` would strand the operator behind the eligibility gate with no way to start it (clearing the match that recorded a bar moves that bar onto a chained fusenpai rather than lifting it); once the bar is gone, the competitor reinstated or the match that recorded it cleared, the identical reopen goes to `running` like any other decision; a reopen that lands `scheduled` takes no court, so neither court gate applies to it (the target status is decided before either gate runs); in a knockout, when the winner of the match being reopened or corrected went straight through a bye (a hidden, unnumbered match the draw leaves with one empty side, so nobody can open or reopen it directly), the write takes that winner back out of it (or re-seats the new one) and answers for the first real match past it (`propagatedDownstreamOf`), rather than refusing; a POOL reopen in a mixed competition is never refused for a seated knockout, because the write that finishes it runs the pool requalification check (`engine/pool_requalify.go`); `POST .../matches/:mid/requeue-blocker-and-reopen` atomically frees the target's court (requeuing the blocking match) and reopens under one court-lock hold; `DELETE .../matches/:mid/kachinuki-bout` removes the current trailing unscored auto-appended pairing. The live bout is the highest-position numbered row (`lastNumberedBout`), never the slice's last row: Record bout numbers its pairing one past it (`nextKachinukiBoutPosition`), and the removal, the un-record and the roster read all name that row. Record bout marks the bout it closes recorded (`SubMatchResult.Recorded`, wire `recorded`, the server-only merge group `recorded`), with or without a next pairing; removing a pairing Record bout appended clears it, and a write that adds an overtime period to the bout, or changes its result (winner side or decision), clears it too unless it is older than the mark or unstamped (`mergeHold.unrecordReopenedBouts`; an unstamped write cannot be ordered after the Record bout); Remove clears it only when the advance line that appended the removed pairing also set the mark; the advisory line's roster (`engine.KachinukiRoster`) keeps both fighters of the last bout on until it is recorded as a win (a running score is no result), and it answers Record bout's own decision on the stored match, asked quietly through the one `decideKachinukiAdvance` the advance writes from, over the same queues and retirements (`readKachinukiQueues`, built once per read): `nextPairing` (a tap would append; false on a recorded live bout greys Record bout out, but only from a read for the current bout log and lineups, never from an older answer the closing keeps) and each side's `exhausted` (the loser of a recorded win whose LINEUP has nobody left; never after a draw or without a lineup). A recorded last bout with no pairing after it reads closed (read-only, with the no-fighters-left line) exactly when those flags say so; the sheet never re-derives them from the queues. A bout copy never moves the mark (`state.CopyGroup`), and the merge re-applies the stored `recorded` group once every hold and displacement is done, so a mark a payload carries never lands; the sheet does not send it. Every numbered bout row of a team match shows the fighter's squad number and, when the position is unnamed, a typeable name box (operator ruling 2026-09-15, bc-dnst). The number belongs to the squad member, not the row: team order can change between matches, so picking a different member into a position moves that member's number onto the row. A fresh team's seeded blank slots are members with a number and no name, so an unnamed fixed-order position shows the number of the blank member seeded for it, and a name typed there names that member through `resolveMemberIdsForPositions` (rename, never a new mint while a blank slot with that number exists), so the name stays attached to the number it was shown with. A team's squad is seeded with team size + 2 numbered slots (`SquadReserveSlots`, two reserves), and the row's list shows every slot with its number, blank ones by number alone, so any number the team can field can be picked and then named; a picked slot is written by id (`buildInlineLineupWrite` with a member) and a name typed into it renames that member (operator ruling 2026-09-15, bc-dnst). That makes a picked-but-unnamed slot a real placement everywhere: every lineup writer (the score sheet row, the Up Next "Enter lineup" panel, the Lineups page) sends the position with an empty name and the member id rather than dropping it; `TeamLineup.OrderedMembers` counts a position occupied when it carries a name OR an id, so such a fighter is on the kachinuki roster; and `AdvanceKachinuki` decides which side stayed on through `domain.SubBoutAttribution`/`AttributeWinnerSide` (ids first), because the client records a nameless winner as its TEAM name plus the member id and a name comparison alone can never see it stay on. A lineup may not place one member id at two positions: `memberPlacedElsewhere` (lineup_resolver.jsx) is the ONE client predicate, asked by every writer before it writes (`buildInlineLineupWrite`, the panel's save, the Lineups page's add path) and rendered as the list filter `rosterWithoutPlacedElsewhere` on every picker, so the offer and the refusal (" is already at ", worded once by `alreadyPlacedNote`, the position named by `lineupPositionLabel` on every surface; a change to a position takes a refusal down) never disagree; `ErrLineupDuplicateMember` from `ValidatePositions` (a 400 naming the two positions) is the server backstop. A lineup that already held a duplicate before this guard existed is repaired by clearing one of the two rows. Under kachinuki only the FIRST row is a lineup position; every later row's pick rides the bout itself (`pickManual` with the member id, never a lineup PUT), the list there is unfiltered because the fighter who stayed on legitimately repeats, and a name typed over a picked blank member renames that member and keeps its id. Which side stayed on is decided through `domain.AttributeWinnerSide`, whose id tier names a side when the winner id equals THAT side's id even if the other side carries none (JS mirror `attributeWinnerSide`, result_slot.jsx), with the same name-only residue `RetiredPlayersFromBoutLog` keeps for a row no id can settle. Before the draw a team has no number, so the Lineups page, the one operator surface for a team's people, labels a slot through `squadSlotLabel` ("Slot 3"), a sibling of `squadMemberLabel` in the same owner; spectator surfaces keep `squadMemberLabel` and stay bare before the draw. The competition Settings page's Squad members section was REMOVED on 2026-09-15 by operator decision (bc-dnst): the Lineups page is the one home for a team's people, and its "Team members" list's "Clear name" control (beside Rename) is where clearing a member's name now lives. The Lineups page's "+ Add" confirm is derived from the resolver's own three outcomes (an existing member is selected with no confirm, a blank slot is named, else a member is minted). A rename or a clear reaches every stored lineup: `RenameTeamMember`/`ClearTeamMemberName` rewrite the name of every lineup position holding that member by id (`renameMemberInLineupsLocked`), because the score sheet and the export read the lineup's stored name; fought bouts keep their frozen names. The Excel export indexes lineup positions by member id as well as by name (`memberKey`, `resolveKachinukiBoutPosition`) so a nameless fighter still gets its position label. Positions are ordered in ONE way, `domain.ComparePositions` (the FIK names in fighting order, then numbered positions by number, then any other by name), which the roster walk (`OrderedMembers`), the export and the member-id load repair share; a text sort put Chuken before Senpo and "10" before "2". -- **A team carries the lineup of its previous team match** (operator ruling 2026-10-05: "by default, a team carries the previous team match lineup. You can have a different team lineup in every team match"). `engine.LineupInForce` (`internal/engine/lineup_in_force.go`) is the ONE owner: the match's own lineup (read whole, even one that holds no position), else the latest lineup the team had before it in match order (an earlier match's, or its starting lineup, which comes before every match); a match the draw does not hold gets its own lineup or the starting lineup. A lineup saved for a later round is never read (see the migration bullet below). A lineup is matched by the team's participant id only, never by its name; an id-less side has none. Discarding a draw removes the lineups saved for its matches. Match order is the pool-match number, then the knockout by round and position, the 3rd-place match last; `state.TeamMatches` is the one list of a draw's team matches (a bye, a pool daihyosen or tie-break bout, and a league's or Swiss competition's vestigial bracket are none), for the rule and the migration alike. The kachinuki roster, the Kachinuki Detail export and every client surface (through `GET .../lineup-in-force/:matchId` and `resolveMatchLineup`) ask it; no surface derives a round to read a lineup. The Lineups page edits each team's starting lineup (stored as its round-0 entry) and any match's own lineup; **Use the previous match's lineup** removes a match's own lineup so it inherits again. **Copy from previous match** was removed because carrying over is the default; do not re-add it. A match the Lineups page has chosen that the team is no longer in (a correction seated another team) stays chosen, locked, Use the previous match's lineup included, with a notice naming it (`notInMatch`), rather than the page silently editing the starting lineup. The at-court lineup panel follows the match it was opened for: its host keeps the match's key and reads the row every render, so a side given another team gets that team's editor and a match that leaves the data closes the panel. -- **A lineup save names the positions it changed** (operator decision 2026-10-07, "Only changed positions"). `PUT .../match-lineups/:matchId` and `PUT .../lineups/0` take `changed` (`LineupRequest.Changed`), and the server lands only those positions (`domain.TeamLineup.ApplyChanges`), under the competition's lock, on the base it reads there (`lineupSave.base`): the match's own stored lineup, else the lineup in force there (`engine.LineupInForceFrom`, the one rule over what the transaction loaded, since the store's own reads would wait on the lock it holds), else empty; for the starting lineup, its stored entry, else empty. The answer is the whole lineup as stored. So two devices changing different positions both keep their change in either arrival order, a save sent later from a device that was offline included; the same position keeps the later arrival (accepted with that decision; do not report it as a new defect). A save without `changed` replaces the whole lineup, which is how a save queued by an older build replays. An empty `changed`, or a changed position `positions` has no entry for, is a 400; a member at two positions of the composed lineup is the existing duplicate 400 naming both. `web-mobile/js/lineup_save.jsx` (a leaf) is the client owner: `changedLineupSave` builds the body every writer sends (the Lineups page, the panel, and the sheet's name box through `buildInlineLineupWrite`), a cleared position as an empty name with no id, and `joinQueuedLineupSave` joins a save into a queued save of the same lineup (the union of `changed`, the later value per position; a queued entry with no `changed` stays whole with the change applied), for `_placeWrite`: a save of a lineup with a save of it queued joins that entry rather than going straight to the server, where it could land before the queued replay and lose to it. Both lineup editors share `useLineupForm` and `LineupSourceLine` (`lineup_draft.jsx`): a lineup is saved only after it was read (`loadedKey`), `lineupToSave()` is the form with `changed` and reads nothing, and `confirmSaved` adopts the answered lineup, so a position another device changed shows once the save lands. Removing a match's own lineup waits while a save of it is still queued (`API.queuedLineupSave`, a boolean). The team score sheet's name box sends `changed: [posKey]` on the lineup it holds for that side (`lineupHeldFor`), which the duplicate check and the rows need. A pick on a side it never read (the first read still out, or failed) reads that side itself and is refused when that read fails (`LINEUP_NOT_READ_NOTICE`): with every save now landing only its own position, the reason is no longer an overwrite but that the rows of a lineup never read would show empty positions that may not be, which the operator would then fill over fighters they cannot see; such a side is read again when the connection returns, and a write of the side, sent or queued, counts as the side's latest read, so a read still out from before it never puts the old lineup back. Both lineup editors follow a `lineup-updated` naming their team (one naming no team counts for every team; one naming another is ignored), once a read of the lineup still out has shown it: the lineup is read again and shown while the form has no unsaved change and no removal is out, and the team's members are read again either way, since the list is not the operator's edit (reads begun in the same tick share one `fetchSquads`, `requestMembers`). A Save makes no read: a member another device adds is announced, so the editors' lists hold it. If this device missed that event, a name typed for that member resolves against the stale list, the server refuses a second member of that name (`squadDuplicateNameCheck`), and the position saves by name with the identity warning (accepted, weighed 2026-10-07: a bounded read at every Save added a second wait on a link that does not answer, against a rare race that ends visible). -- **Team members carry a server stamp** (operator decision 2026-10-07): `TeamMember.ModifiedAt`, set by every member write (see the team-members paragraph under Common Pitfalls). `mergeMembers` (lineup_draft.jsx) is the ONE client rule: the union by member id (no screen removes a member), each member's copy with the larger stamp (the arriving copy on a tie), in member number order. Every list that reaches an editor or the sheet (a read's answer, the host's `teamMembers` copy, the viewer payload) and every member write's answer (`addTeamMember`, `renameTeamMember` and `clearTeamMember` each return the stamped member, bounded through `_memberWrite`; an answer with no id is a failed write) goes through it, so an older list can never put back an older name and a later change made elsewhere always shows. The editors' own changes go through `useLineupForm.changeMembers`/`memberRenamed(member)`, the sheet's through `wroteSideMembers` and its lists through `takeSideMembers`; none of them reads again, since every member write is announced. A member object built by hand carries no stamp and loses to every list: an own write's member is the server's answer. A list or an answer made for a team the editor has since left lands nowhere (`teamKey` against `squadTeam`). A name typed before the team's members have been read waits for them, bounded by `FETCH_TIMEOUT_MS` (`newMembersWait`, lineup_draft.jsx, the one owner: `useLineupForm.waitForMembers()` for the panel's Save and the Lineups page's add row, the sheet's per-side wait for its name boxes): resolved against no members, a new name would be minted where the position's seeded slot is free, and the name of a member the team has would be refused by the server as a second one. Only a READ's list ends that wait, even an empty one, never an own write. A read that fails, or one not answered in time, ends the wait for good (so a read that hangs holds up one name, not every name typed after it), and the name is resolved without the members, with the members-unavailable warning. A sheet side is given another team (a correction made elsewhere seats another one) only when both its entry in the match (`sideLookupKey`) and the team it resolves to change: a side that carried only its team's name gaining that team's id is not one. Then the sheet drops that side's list and its wait (`dropSideMembers`) and the old team's lineup (`dropLineup`: a read or write of it still out never lands, and the side counts as never read until the new team's lineup does), and reads the new team's. A pick is checked against the lineup sync's epoch for its side (`watchLineupSide`) after its wait and after its member writes, and when the side was given another team meanwhile nothing more is written and the row says so. A side takes a members list only once its team is known (`sideTeam`: the side carries an id, or the roster resolved its name), and a pick writes for the team the side has when its wait ends (`sideSeen`). The identity effect runs ahead of the lineup and members reads, pinned by source order in `team_editor_members_read_first.render.test.jsx`, since no behavioural test can see the order while the lineup read awaits first. The two lineup reads (`fetchLineupInForce`, `fetchTeamLineup`) are bounded by that same deadline, and an answer that is not JSON is a failed read, never an empty lineup. -- **Lineups saved by v2.1.1 and earlier are migrated, never read** (operator decisions 2026-10-06: "Migrate old content to new format", and 2026-10-07: "Show what v2.1.1 showed"; the lead's ruling that follows from them: a migrated tournament shows, at every match, exactly the lineup v2.1.1 showed there). `state.settleRoundLineups` (internal/state/round_lineups.go) is the ONE owner. A legacy team is one with a round lineup r >= 1, or one that, at the competition's FIRST settlement (`Competition.RoundLineupsLegacy` still nil), holds a match lineup stored under its id for a match of the current draw that seats it by id: v2.1.1 used a match's lineup for that match alone, so carrying it on would show the team's later matches a lineup v2.1.1 never showed there. That pass records every legacy team in config.md's `round_lineups_legacy` (`Competition.RoundLineupsLegacy`, sorted participant ids), and later passes read that list plus any team with a round lineup, so a match lineup saved by THIS release never makes a team legacy. Every match a legacy team is seated in by id gets a lineup of its own, written as `PUT .../match-lineups/:matchId` writes one, equal to v2.1.1's reading there (`legacyReading`, which answers `(lineup, ok)`: the match's own, else the round lineup with the highest round at or below the round the match was read at, else the highest round, else none, and then the match is given an EMPTY own lineup, `positions: {}`, which `inForce` honours whole, so it shows none, as v2.1.1 did; `legacyLineupRound`: a knockout match's bracket round index, the 3rd-place match `len(Rounds)`, a pool or league match's stored circle round, 0 for Swiss). It runs on load (`upgradeRoundLineupsLocked`, by EnsureLegacyUpgraded after the id repairs) and at every write of the draw (`storeTx.settleRoundLineupsAtCommit` at the end of WithTransaction, staged with the write; `Store.settleRoundLineupsAfterWrite` after the direct `SaveBracket`/`UpdateBracket`/`UpdateBracketMatchByID`/`SavePoolMatches`/`UpdatePoolMatchByID`), so a match the team is seated in later is given its lineup in the write that seats it. Each (team, match) pair is settled ONCE: config.md's `round_lineups_given` (`Competition.RoundLineupsGiven`, the team's participant id to its sorted match ids) lists them, so a lineup the operator removes (**Use the previous match's lineup**) stays removed. `Store.ClearDrawLineups` (a discard, and every new draw, which `runDrawPipeline`'s generators call just before their first write) removes the draw's match lineups, clears that list because the next draw reuses the match ids, and trims `round_lineups_legacy` to the teams that still hold a round lineup (the match lineups that made the others legacy went with the draw), setting the marker when none remains. A lineup stored under a team's NAME is keyed by the team's id when exactly one team has that name (one whose team has no id yet waits). On load, a legacy team that has round lineups and no starting lineup gets `legacyReading(rounds, 0)` as one: v2.1.1's own fallback, its HIGHEST round's lineup, so a frame-0 match it is seated in later reads what v2.1.1 showed. The round lineups stay, never read by `engine.LineupInForce`, until the competition is completed; then the next write of the draw or the next load removes them (completion itself is a config.md write the draw hooks do not see; nothing reads them meanwhile) and config.md's `round_lineups_converted` (`Competition.RoundLineupsConverted`, set by POST /competitions on every new competition, and by the settlement as soon as no legacy team waits: at once for a competition with none, else at completion or at a discard that leaves none; setting it clears `round_lineups_given` and `round_lineups_legacy`) stops the settlement. `PUT .../lineups/:round` refuses a round >= 1 with a 400, and the client has no later-round UI. The reading is v2.1.1's score sheet's and server's (`FindBestLineup`); v2.1.1's at-court lineup panel showed only a lineup saved at the match's exact round and blank boxes otherwise, so the two disagreed there, and the migrated match lineups make both show the sheet's (checked on data the real v2.1.1 release wrote, 2026-10-06). Accepted as they are (do not report them as new defects): a mixed competition's round-robin pools and its knockout shared v2.1.1's round numbers, so a knockout match reads a round lineup by its bracket round index, as v2.1.1 did (put to the operator 2026-10-06; answer: "all team matches need lineups"); a team seated in a match after the competition completed gets no reading and carries its previous match's lineup there; no `lineup-updated` event is sent for a lineup given in the write that seats a team; the settlement reads the competition's files on every write of the draw until the marker is set (a marked competition costs one cached read, `Store.roundLineupsSettled`); and a first settlement that finds no legacy team while a lineup still waits for its team's id records nothing, so the next pass settles first again, and a match lineup saved in between would read as one v2.1.1 saved (only a roster where some teams still lack an id reaches it). -- **Unsaved lineup edits are kept as a per-tab draft** (bc-lnul, operator decision 2026-10-05: option A, a draft, over a `beforeunload` prompt, which iPad Safari does not show and which cannot cover the app's Back or closing the panel). `web-mobile/js/lineup_draft.jsx` (`useLineupDraft`, `LineupDraftNotice`, `lineupDraftKey`) is the ONE owner: both lineup editors keep the difference between the lineup as loaded and as shown in `sessionStorage`, and offer it back with "Unsaved lineup changes restored" and **Discard** when the same lineup opens again (the at-court panel and the Lineups page share a match's key). A draft is never written to the server; one whose lineup changed meanwhile is dropped with a notice naming what was not restored, which stays through the operator's edits and goes when the lineup is saved or given up (`useLineupDraft`'s `resolved`) or left. A draft the stored lineup already holds (`holdsDraft`: at every position, the draft's name where the draft has one, and where it has none, no name and the same member id) is the operator's own save landing, a queued one or one sent as the panel closed, and is dropped with no notice: the save attaches member ids the draft never had, so comparing ids there would report a lost change that was saved. Do not add a `beforeunload` prompt. - **Schedule Estimator** (`internal/engine/schedule.go`): `EstimateSchedule(EstimateInput) ScheduleEstimate` produces total/per-court minutes from match duration × multiplier × slowest-court buffer. Exposed via stateless `GET /api/schedule/estimate` on both the CLI web server and the mobile app. -- **The server refuses to start on data it cannot write** (operator decision 2026-10-04, "test permissions on load"). `checkDataWritable` (internal/state/writable.go), run by `Store.init` before the WAL replay, creates and removes a scratch file in the data folder, `competitions/`, `.wal/`, every competition folder and its `history/`, and `branding/`/`sponsors/` when present (`state.BrandingDirName`/`SponsorsDirName`, which the upload handlers use); every folder that fails is named in `ErrDataNotWritable`, so a folder the server cannot write is found at startup rather than as a score write failing mid-match. Folders only: every save writes a temporary file and renames it, which needs only the folder. -- **Store Transactions** (`internal/state/transactions.go`): `Store.WithTransaction(compID, fn)` holds the per-comp lock once across multiple load/save operations. Use the `StoreTx` handle inside `fn`. Do NOT call public Store methods (they would deadlock the non-reentrant mutex). -- **Cache invalidation: never key a cache on mtime alone** (`internal/state/store.go`): filesystem mtimes come from the kernel coarse clock (~1ms granularity), so two writes inside one tick are indistinguishable and an mtime-keyed cache serves pre-write data (mp-n6ke: stale standings vs fresh matches injected a phantom tiebreaker bout and stalled bracket advancement; mp-p7n was the same class). Each `fileCache` therefore carries a monotonic `version` counter, exposed as `Store.FileVersion(compID, filename)`; `engine.CalculatePoolStandings` requires BOTH version and mtime to match (the counter catches same-millisecond in-process writes, mtime still catches out-of-process edits). **If you add a writer for a file some cache derives from, call `s.bumpFileVersion` AFTER the bytes land**: an extra bump only costs a recompute, a missed bump serves stale data, and bumping before the write lets a reader stamp OLD bytes with the NEW token. Existing bumps sit at the chokepoints every writer funnels through (`savePoolMatchesLocked`, `saveOverridesLocked`) plus draw deletion and transaction abort. Two further rules follow from the counter being the cache key: **every read path must validate it, not just the fast path** (the `CalculatePoolStandings` single-flight loser returned the winner's entry unvalidated, and the winner stamps *pre-compute* tokens, so it could predate the loser's own write), and **the counter must stay monotonic for the life of the process, including across a competition being deleted and recreated** (IDs are name slugs, so `DeleteCompetition` calls `discardCompCacheBodies` to clear the cached bodies while leaving the counters climbing, rather than `compCache.Delete`, which reset them to 0 and let a recreated competition match its predecessor's stale entry). Prefer a deterministic counter over a sleep, which only hides the race. -- **Only `saveCompetitionChangedLocked` may create the competition directory** (`internal/state/overrides.go`, `competitor_status.go`, `team_lineup.go`): `saveOverridesLocked`, `saveCompetitorStatusLocked` and `saveTeamLineupsLocked` each used to `os.MkdirAll` before writing, so a write landing after `DeleteCompetition` rebuilt `competitions//` around a lone `overrides.json` / `competitor-status.yaml` / `lineups.yaml`. That orphan survives the delete (`ListCompetitions` returns every directory under `competitions/`) and, because IDs are name slugs, a same-named recreation adopts the dead competition's data. **Locking cannot fix this**: the write does not have to interleave with the delete to resurrect the directory, only to run after it (the two status/lineup writers take the very lock `DeleteCompetition` holds, so they cannot interleave at all, and still resurrected it). `atomicWriteFile` opens its temp with `O_CREATE` but never creates the parent, so once the `MkdirAll` is gone a write to a deleted competition correctly fails with ENOENT. Creating the directory is competition *creation*'s job and `saveCompetitionChangedLocked` is the sole legitimate `MkdirAll` site (plus `Store.init` for the root/WAL folders); do not strip that one, and do not reintroduce the pattern in a new per-competition writer. The interleaving half is closed separately: `overrides.json` is the one competition file whose writers serialize on the store-wide `s.mu` rather than the per-competition lock, so `DeleteCompetition` takes **both**, in the order compLock then `s.mu` (`s.mu` is a sink: nothing holding it acquires a comp lock, so the edge cannot close a cycle). **Do not "tidy" this by moving overrides onto the per-competition lock**: `computeStandingsFrom` runs inside `WithTransaction`, which already holds that lock, and reads overrides via `e.store.LoadOverrides`, so it would self-deadlock the live scoring path on a non-reentrant mutex. - -### Match results and marks - -The rules below were settled by operator rulings and are pinned by tests. Each has one owner in code; do not restate any of them at a call site. - -#### Decision types - -`internal/domain/decision.go` defines the canonical wire values: `""` (none), `"fought"`, `"hikiwake"` (draw), `"kiken"` (legacy withdrawal, maps to kiken-voluntary on YAML load), `"kiken-voluntary"` (FIK Art. 31, permanent), `"kiken-injury"` (FIK Art. 30, reinstateable), `"fusenpai"` (no-show), `"fusensho"` (recorded per bout within a team encounter, AND at match level by an operator via `POST /decision` to close a barred competitor's remaining scheduled match -- `domain.IsWithdrawalDecisionStr` deliberately excludes it, since it is awarded on someone ELSE's withdrawal rather than a withdrawal of the match it is recorded on; `domain.IsDefaultWinDecisionStr` includes it. `decisionBy` on either usage names the side that withdrew, did not appear, or is barred -- the LOSING side -- never the side the decision favours; the write then makes the OTHER side the winner (`scoring_tx.go`)), `"daihyosen"` (rep bout), `"kachinuki-exhaustion"` (kachinuki competitions only: a score write carrying it, at match level or on a bout row, in any other competition is refused by `refuseKachinukiDecision`/`refuseKachinukiDecisionForComp` in `validation.go`, a 400 on `PUT /score` and a per-entry error on bulk-score), and `"ippon-shobu"`. The last one is accepted by `Decision.Valid` but has no writer in the tree: supplementary pool tie-break bouts are identified by their match id (`Pool X-TB-N`, see `engine/tiebreaker.go`), not by this decision. Use `domain.IsKikenDecision(d)` / `domain.IsKikenDecisionStr(s)` to check any kiken variant. Legacy YAML `decision: true` migrates to `"hikiwake"`, `false` to `"fought"`, `"kiken"` to `"kiken-voluntary"` (`Decision.UnmarshalYAML`). - -#### The middle is a closed set - -Visual marks follow the score sheet's layout, split into two kinds. MIDDLE marks occupy the centre "vs" cell (the score-string centre): exactly one of `X` (tie), `(E)` (overtime, always bare: `periodCount` is recorded but never displayed), `(DH)` (rep bout). They are mutually exclusive by rule: a match that went to encho cannot end tied, so X beats (E); a daihyosen is one-point sudden death, so DH bouts have no encho. RESULT marks ride beside the competitor they name: `Ht` (hantei winner), `Kiken` (withdrawer), `Fus.` (no-show, and also the winner of a match-level fusensho; `sideMarks` (side_marks.jsx, re-exported by bracket.jsx) and `domain.SideMarks` (placed on the sheet by `SideMarksLR`, internal/export/suffix.go) agree on this, so a fusensho match reads identically on every surface and in the exported workbook). - -Web score strings read `[left cell] [middle] [right cell]` with `vs` as the plain middle and `–` (or empty) meaning no points. Never digits: ippon renders as waza letters only, e.g. `M vs –`, `M (E) K`, `M Ht (E) K`. The dash is a CELL value only. The middle never shows a dash, so unplayed and pending match rows read `vs` too. - -A kiken, fusenpai or fusensho awards the WINNER its points as maru, one `○` per point per FIK Regulations Art. 32 and the Score Board appendix p.15 (`["○","○"]` in regulation, one `○` in encho). This is the rulebook's score-board marking, not a house style; it lives in `domain.DefaultWinIppons`. Per the same Article ("Any point scored by the shiai-funo-sha shall remain valid") the LOSER is NOT zeroed: `preserveLoserScore` (engine/scoring.go, called by the one `RecordDecision` body) keeps the withdrawing side's already-struck ippons and preserves the encounter's prior `SubResults`, so a team withdrawal never wipes the sub-bouts already fought (they keep counting in IV/PW standings). It is guarded on a side-matching prior so a drifted prior can't mis-attribute points. `state.EnchoMetadata.On` and `enchoOn` (result_slot.jsx) are the single "did this happen in encho" predicate (non-nil AND positive periodCount), shared by the (E) label, the maru count, and decision validation, so a degenerate `{periodCount: 0}` block can never split the surfaces. Display fallbacks (`DefaultWinMaruAB` in suffix.go, `formatIpponsScore`, the scoreboard win slots) render `○○` for winners whose recorded cells are empty (byes, pre-fill/legacy data). The team editor's per-bout **Fusensho** follows the same rule on the client (bc-fsnp): `applyFusenshoToggle` (admin_scoring_shared.jsx) keeps the other side's points through `struckIppons`, the JS twin of `engine.struckIppons` (result_slot.jsx, beside `DEFAULT_WIN_IPPON`), `fusenshoAllowed` refuses a fusensho against a side that already won the bout (circles against two points would be a 2-2; the refused button stays tappable and shows why under that side until the next bout edit, since a title never shows on a touchscreen), and every scoring edit on a team bout row goes through the editor's `updateSubScore`, whose `applyBoutScoreEdit` ends a fusensho through `clearFusensho` applied LAST, so the circles never survive as ordinary points. One edit keeps it: taking a mark off the side the fusensho went against is a correction, and the snapshot the undo restores follows it. - -The middle-value rule lives in ONE place, `boutMiddle` (bracket.jsx), which returns exactly `vs`/`X`/`(E)`/`(DH)`; `matchMiddleMark` (the chip variant), `matchStateCell`, and the scoreboard separators all derive from it. (`formatIpponsScore` composes the same `isDrawResult`/`middleMark` primitives directly rather than calling `boutMiddle`: its empty-middle-vs-`""` distinction is load-bearing for callers' fallbacks.) - -**The middle is a CLOSED SET and there must NEVER be a centre `Ht`** (operator ruling, absolute). Those four values are the only things that may ever occupy the centre, in either language and on every surface. `Ht`/`Kiken`/`Fus.` name ONE competitor, so they ride in that competitor's cell and can never take the shared cell. This is enforced, not just documented: `web-mobile/js/__tests__/middle_closed_set.test.jsx` and `internal/export/middle_closed_set_test.go` sweep every decision × encho × score (including `decidedByHantei`) and fail on a fifth value OR a side mark in the middle, both mutation-verified. The ruling settles what used to look like an inconsistency: the `loose` path (both slots full) renders `Ht` INBOARD of that side's slots, inside `.msb-slots`, never in `.msb-vs`, so it complies; and an unattributable winner correctly gets NO mark, because "no mark" is the only alternative to a forbidden centre mark. Adding a fifth middle value, or a centre fallback for an unattributable result, is out of bounds. Do not propose either. - -A surface that renders the FIK row must show the mark ONLY in that row's centre (operator ruling: the TV header and lobby chips were duplicates and were removed). A surface that renders NO such row (`MatchCard`'s meta strip, the OBS lower-third) keeps its chip, because there the chip IS the one home. - -#### Where a mark lands - -**A middle mark belongs ONLY in the middle of an individual fight** (operator ruling), and a team bout's score is displayed the same way in every format, kachinuki or not: cells either side of a centre, on that bout's row. This governs the WHOLE set (`X`, `(E)`, `(DH)`), i.e. ANY mutating middle, not just one of them. - -- In an INDIVIDUAL competition the match IS the fight, so its own row centre carries the mark (`IndividualScore`, whose synthesised sub threads the match-level `decision`/`encho`/`score`). A tied individual match fought on in overtime shows `(E)` there, e.g. `[M][ ] (E) [K][M]`. -- **Encho is NOT team- or kachinuki-specific.** Any tied match may be fought on, in either competition type. -- In a TEAM match the encounter is not itself a fight, so each mark lands on the row of the bout it describes. **Kachinuki** fights on in overtime on the SAME numbered bout, so `(E)` lands in that bout's middle. **Regular (fixed-order)** teams settle a tie with a daihyosen, which is an EXTRA appended bout (position -1, knockout only: `AddDaihyosen` returns `ErrPoolMatch`, a pool tie is simply a draw), so `(DH)` lands in the middle of THAT extra row. A drawn bout carries `X` in its own middle under either format. -- `TeamScoreboard` gates the extra DH row on `showDH && tied`, and `showDH` additionally requires every regular bout to be FINISHED. That is correct, since a daihyosen only follows a completed tied encounter, so a fixture that appends one while a numbered bout is still unscored will (rightly) not render it. -- `TeamScoreboard`'s §277 summary row is an AGGREGATE (IV/PW), not a fight, so its centre is a deliberately empty spacer and NO mark ever goes in it. That holds even when the aggregate itself is tied: a team encounter drawn on equal IV and PW displays no `X` at the encounter level, and if no individual bout was drawn then no `X` appears at all. This is correct because the mark describes a fight, not a standings figure (the draw is carried by the T column and by each drawn bout's own centre). That row RENDERS only on `variant="card"`: on the TV board (`variant="tv"`) it is suppressed and the headline team-name row in display_scoreboard.jsx carries the pairing plus each side's IV/PW instead (operator decision 2026-09-19, bc-lbty -- the team name was being shown twice, and the summary row was the redundant copy). Both surfaces derive those four figures from the one exported `teamIVPWFrom` (match_scoreboard.jsx), so the headline and the row can never disagree about which source won. Do NOT suppress it on the card: viewer_match.jsx has no separate name row, so there that row is the only thing showing the pairing. The headline's IV/PW take their own side's colour (`var(--ink-1)` / `var(--red)`), matching what `.msb-slot`/`.msb-slot--aka` give the row they replace; the headline centre stays the bare `vs`, since IV/PW belong to a SIDE and the centre is a closed set. - -Two rejected fixes, both reverted, both verified in the browser: threading the match-level mark into the summary spacer (a review round claimed removing the TV header chip left it homeless) put a second `(E)` directly above the bout's own; and placing the encounter tie there was proposed and refused for the same reason. Do not re-add either. Before acting on any "this mark has nowhere to render" finding, seed the state through the real editor path and check a UI can produce it. - -#### Hantei is recorded as an ippon - -The centre NEVER carries `Ht`: a hantei always names a winner and is decided from a TIED scoreline. `validation.go` requires a winner plus `domain.HanteiTiedScoreline`, the one owner of the tie test, shared with the engine's `preserveSubHantei` exactly as `IsHanteiCompatibleDecision` is, and counted through `domain.CountScoringIppons` so an "•"/empty cell cannot read tied to one enforcer and untied to the other; `hanteiTied` mirrors it in JS. Encho is NOT a precondition. In a TEAM match `validateSubBout` additionally restricts hantei to the daihyosen bout, position -1; a tied numbered bout is simply a hikiwake. - -`Ht` IS recorded as an ippon-slice entry (operator ruling 2026-08-21: "Ht should be recorded as just another ippon"). The mark in the WINNER's `IpponsA`/`IpponsB` is the record itself, exactly as the FIK sheet writes it: non-scoring (`IsScoringIppon` drops it, so points/PW/standings/the tie test never count it; `realIppons` is the JS mirror) but first-class in the data. Pool CSV cells and bracket.json ippon arrays hold it verbatim (`domain.ParseScore` still consumes `Ht` as its ONE two-rune token by the no-lone-`t` argument, but only inside the legacy read fold of pre-array bracket files), and the wire carries no `decidedByHantei` field in either direction. The SPA DERIVES that property in `normalizeMatch` for its read sites and `toBackendMatchResult` converts the editors' armed boolean into mark placement (`placeHt`), while the server folds legacy flags into the mark at every read boundary (`state/legacy_hantei.go`; the three `DecidedByHantei` struct fields are LEGACY READ-ONLY, never written). - -Placement follows the point rule: the mark fills the winner's next FREE slot in outside-to-inside order (0-0 → the outer slot, 1-1 → the inner one, giving `[K][ ] vs [Ht][M]`), which in array terms is `domain.AppendHantei` (fill a placeholder slot, else append), so the mark's array position IS its slot. Through normal play sanbon-shobu ends at 2, so 0-0 and 1-1 are the only reachable scores and a free slot always exists. 2-2 is IMPOSSIBLE under the rules and closed on both sides: the editors' ippon entry stops each side at 2, and `validateIppons` caps each side at 2 AND rejects 2-2 outright on every sub-bout and on the bulk path. The `loose` return exists solely for hand-edited files. No point is ever overwritten, and each consumer then applies ITS OWN policy: the read-only scoreboard renders the mark beside the slots; both editors drop it because each has a second always-mounted channel for the verdict (see result_slot.jsx's header for the enumeration). - -**Which completed bout is the most recent RESULT has ONE owner too**, `web-mobile/js/result_recency.jsx` (`resultRecencyDesc`, a leaf with no imports). Two surfaces answer that question and used to answer it differently (mp-jnvl): the court console's context strip ("the bout just played") and the public viewer's Recent results both read SCHEDULE order as recency, so a court running out of schedule order had them name different bouts. Recency is the `modifiedAt` write stamp, which is the time of the last WRITE and not of the result: STARTING a bout is a stamped write, and an unstamped write inherits the stored stamp, so a started-then-withdrawn bout keeps its start stamp. A bout is unstamped only when nothing ever stamped it (a file predating the field, or a match closed while still `scheduled`), and those fall back to scheduled time, so an all-unstamped list keeps exactly the ordering both surfaces had before. The console applies it in ONE place, `partitionShiaijoMatches`, which orders the COMPLETED group by play time (oldest first) while running and scheduled keep schedule order. That makes the newest result the TAIL, so the strip's anchor and the Completed preview are both plain tails: the preview is a contiguous window and "Show all" EXTENDS the list upward instead of inserting rows into the middle of it (operator ruling 2026-09-19; the earlier top-N-by-recency pick left visible holes). An all-unstamped list falls back to scheduled time and reads exactly as it did before the stamp existed. - -The strip's heading names what it is showing whenever that is not the live bout: "Up next" before the court's first bout (the highlight there means "play this next"), "Just played" when it fell back to the last result, "Correcting" when the operator opened that result to fix it. With no such qualifier the lead names the panel's CONTENT - "Standings" for a pool, "Bracket" for a knockout fragment. The word "Context" was removed as meaningless (operator ruling 2026-09-19). - -The slot rule has ONE owner, `resultSlot` in web-mobile/js/result_slot.jsx (a small leaf; the dependency reasoning is stated once in that file's header). Its three consumers are `resultCells` in match_scoreboard.jsx, `hanteiSlot`/`ptSlots` in admin_scoring_team.jsx, and the individual editor's slot grid in admin_scoring_individual.jsx (display-only `Ht` chip on the picked side, `hanteiPick`, which adopts `hanteiWinnerKey` from the server). Change the contract and ALL THREE surfaces must be re-checked. - -Score strings TRAIL loose marks as an unattributable-winner degradation, but the scoreboard has no centre-`Ht` equivalent: an unattributable winner gets no mark rather than one in the shared centre cell. ACCEPTED no-mark classes (all drifted/legacy data; the centre is forbidden by the operator ruling and no side is attributable): (i) same-name-no-id pairs; (ii) rename-drifted winner names (note the old centre fallback DID fire for these whenever the middle was empty, which a no-encho hantei makes normal, so this IS a regression there, accepted); (iii) winner-less legacy `decidedByHantei` rows (previously centre `Ht`); (iv) untied legacy `decidedByHantei` rows written before the tied-scoreline validation existed (letters still convey the winner; the judges'-decision marker is dropped). - -Two related deliberate divergences. Score strings (`sideMarks`) and the Excel export (`SideMarksLR`, over `domain.SideMarks`) still mark `Ht` UNCONDITIONALLY: the mirrored pair stays consistent with itself and gains no tie gate. And a winner naming BOTH sides resolves defensively to AKA everywhere in JS (`subWinnerSides`, aka-first) to match Go's `isWinForSide`/`SideMarksLR` order, so rows, summaries, standings and the export agree on the same arbitrary side. That aka-first rule is scoped to the MATCH level, where a team name is unique by rule and a winner naming both sides is therefore drifted or hand-edited data. It does NOT hold for a SUB-BOUT, where two opposing fighters may legally share a display name and the same order would be a coin flip on ordinary valid data: such a bout is attributed by the row's MEMBER IDS and counts for NEITHER side when no id can decide. A team bout row that names no fighter settles at the match level: `domain.SubBoutAttributionForTeamRow` puts the team names in AFTER that same-name blank, so even a legacy same-named team pair is attributed side A first, as the app and the standings do. **`domain.SubBoutAttribution` owns that rule** and its doc comment is the statement of it; every consumer builds its attribution there, and the JS mirror is `subBoutAttribution` (result_slot.jsx). Do not re-add a name tier that runs when `SideA == SideB`, and do not restate the mechanism here: an earlier copy of it named a function by its unexported name and went stale one commit after that function was exported. Stored sub names stay frozen after a rename (nothing rewrites `SubMatchResult.SideA`/`SideB` on disk or on the wire): every DISPLAY instead resolves the CURRENT name by member id at render time, through `resolveBoutSideDisplayName` (web-mobile/js/lineup_resolver.jsx) and its Go twin `resolveKachinukiDisplayName` (internal/engine/kachinuki_export.go) for the Excel export (bc-dnst), so a rename shows on bouts already fought, on every surface, without touching the stored record. Mirrored helpers: `middleMark`/`enchoLabel` (bracket.jsx) and `sideMarks` (side_marks.jsx, the leaf that also holds `placeMarks`, `winnerSideLR` and `teamMatchMarks`; bracket.jsx re-exports them) ↔ `domain.MiddleMark`/`domain.SideMarks`/`domain.EnchoLabel` (internal/domain/result_marks.go, with `domain.SideMarksAB`, and `domain.IpponsScore` beside IsScoringIppon in ippon.go), moved there (bc-kdsc) so the Kachinuki Detail sheet (internal/helper/excel_kachinuki.go, fed by internal/engine/kachinuki_export.go, which cannot import internal/export without a cycle) reads the SAME closed-set rule rather than restating it. internal/export keeps only what adapts them: `MiddleMark` (from `*state.EnchoMetadata`) and `SideMarksLR` (places the marks White-left). The overtime marker is pinned in both languages via `internal/export/testdata/encho_labels.json` (Go side: `domain.EnchoLabel`). - -#### Writing `SubResults` - -The verdict travels IN the ippons, atomically with the scoreline it rests on: verdict-silence and scoreline-silence are the same thing. `HanteiDecided()` (both result types) reads the mark; `SubMatchResult.DecidedByHantei` is a legacy read-only field that request/load normalization folds away. Never set it. - -Every forward replacement of a `SubResults` slice must still call `engine.preserveSubHantei(stored, incoming)` first. It carries a stored daihyosen outcome (winner plus the marked scoreline) onto a scoreline-silent row: the copied prior ippons carry the verdict themselves and no flag is raised. It is deliberately narrow in two directions: it PATCHES an existing position -1 row but never RE-APPENDS a dropped one (`DELETE /daihyosen` removes that row on purpose through this same path), and it refuses any `Decision` outside validateSubBout's allow-list because it runs AFTER validation and its output is never re-checked. - -The bracket writes call it via `preserveDaihyosenOutcome` inside **`applyBracketMatchResult`**, the one per-match bracket write shared by `recordBracketMatchResult` and the bronze (3rd-place) fallback. `recordBracketMatchResult` is one body taking an `h state.StoreTx` handle, which `*state.Store` satisfies, so the same `UpdateBracket` dispatch around the shared `applyBracketResultIn` serves the tx and non-tx doors. Do not split it into per-door copies: hand-copied bodies here drifted (the bronze copy missed the LWW guard the round copies had). - -Pool writes get it from **`applyPoolWrite` (engine/scoring.go), the single owner of what a stored pool match contributes to an incoming one**. It replaced a block hand-copied into each write closure, after those copies drifted twice in the direction that hurts: the preserve reached three of the writers and missed the live `/score` path, and the `CorrectionReason` inherit reached only the Tx twin, so a team quick-score after a kachinuki reopen blanked the operator's audit justification. It also performs the whole-struct `*stored = *result` overwrite itself, so the overwrite is unreachable without the merge; a new field that must survive it goes there and nowhere else. Note the scope: that covers ENGINE-side preservation, while server-owned flags a client must not be able to clear (`ReopenPending`) are re-stamped at the HTTP boundary in `handlers_match.go` instead. - -#### Write policy and the rollback snapshot - -Both primitives take a `matchWritePolicy`: `matchWriteForward` for a client payload (merged group by group onto the stored match by `mergeMatchWrite`, see "Merge changes, never drop" below; then inherit omitted context, restore a verdict-silent daihyosen), `matchWriteRestore` for the K3 rollback replaying a trusted snapshot (no merge, inherit NOTHING: a nil or empty field means "this was empty", every group and its `GroupStamps` entry is restored exactly, a tombstone included, and a side mismatch is not a client error to reject). - -**That policy makes the snapshot PRODUCER load-bearing.** Because an omitted field is read as an instruction to clear, a partial projection is silent data loss, not a missed optimisation. `bracketMatchAsResult` (engine/bracket_result.go) once omitted the score: a bracket match THEN persisted each side as one rendered string while `MatchResult` carries ippon arrays, so a rollback wrote a blank over a real scoreline. bc-bmsc removed that last dual representation. `BracketMatch` now stores the same ippon arrays + hansoku counts as every other result; its `ScoreA`/`ScoreB` strings are LEGACY READ-ONLY (folded into the arrays and cleared by `BracketMatch.NormalizeLegacy`, `state/legacy_hantei.go`, which folds the score strings FIRST and the hantei flag second; arrays win when a hand-edited file carries both; never written, omitempty so the wire never carries them). The projection is a direct field copy, and it carries `ResultSource`/`CorrectionReason` so the restore both puts back the note the match held and clears one the rejected write added (matching `applyPoolWrite`'s whole-struct overwrite, which always did). - -It projects `ModifiedAt` and `GroupStamps` too (bc-lww1, bc-mrgc). `mergeMatchWrite` merges nothing under `matchWriteRestore`, before reading any stamp, so a restore can never lose a comparison; what the projected stamps decide is the stamps LEFT behind, the match's real prior times, so a rolled-back match stays ordered against a stale write instead of being left at 0 for `ApplyByTimestamp`'s unstamped bypass. The pool snapshot gets the same from `lookupExistingResult`, which copies the stored `MatchResult` (`copyMatchResults` deep-copies the map and drops the write-only `Changed`/`WriteDoor`/`Merge`). Pinned in `TestBracketRollbackRestoresTheScore` and `TestMerge_RollbackRestoresTheStamps`. `domain.FormatScore` was deleted with the strings; `domain.ParseScore` remains solely as the legacy fold's decoder, pinned parse-direction-only by `TestParseScore_PinnedLegacyBytes` because old files hold those bytes forever. - -**The policy must reach BOTH branches.** A match id resolves to a pool or a bracket match at run time and the caller cannot know which, so `writeToPoolOrBracket` threads it through the `errMatchNotFound` fall-through as well. `writeToPoolOrBracket` is ONE body: the store handle `h state.StoreTx` (which `*state.Store` satisfies) is how a caller says whether the per-comp lock is already held, and the non-tx entry points `RecordMatchResult`/`RecordMatchResultWithIneligibility` are `WithTransaction` shims. Never compensate for one branch in a caller (e.g. by pre-converting the snapshot's nils into explicit clears): that makes one function enumerate another's nil-preserve fields, and the next such field silently breaks rollback. The policy differences are pinned by mutation in `pool_write_merge_test.go`, `sub_hantei_preserve_test.go` (`TestBracketRollbackDoesNotReapplyTheWrite`) and the pair `TestRollback_BracketSubResults_Cleared`/`_ClearedTx`, which pins both entry points into the one rollback body. - -#### Editors adopt the server's verdict - -**A match has ONE result and every surface asking for it shows the same one** (operator ruling), and the score editors are such surfaces. While one is open, the viewer card, the bracket, the TV board, the lobby and the export are all already showing whatever the server holds, so an editor showing something else is a divergence whatever its reason. Both editors therefore ADOPT a verdict recorded on another device: `useEffect` keyed on the VALUE (`hanteiRecorded` in admin_scoring_individual.jsx; `daihyosenHanteiRecorded` + `recordedDaihyosenSide` in admin_scoring_team.jsx), never on the `m`/`match` object, which SSE re-creates on every broadcast and which would fight the operator's every tap. Keying on the primitive means a local arm stands (it does not move the server value), and a team daihyosen pick or withdrawal, which is saved as it is made, is kept until the server holds it (`keepLocalEdits`), while a real server change is followed in either direction, and only the verdict is touched, so an edit in progress survives. `isDirty` compares the verdict against the SERVER's value, not a mount-time snapshot, so adopting is not an "unsaved change" and does not fire the discard prompt on an untouched editor. - -Because an editor can now never rule on something it did not display, an explicit `false` is always the operator's decision and always reaches the wire as what it now IS, a markless scoreline: `daihyosenEnchoFields` still states the local boolean unconditionally, and the serializer chokepoint converts it (true → mark placed on the winner's side, false → arrays stay markless; `realIppons` strips echoed marks first so placement never doubles). Two earlier designs are recorded in `web-mobile/js/__tests__/render/individual_editor_live_hantei.render.test.jsx` and must not be reintroduced: recomputing the baseline from the live prop while freezing the local armed state (which erased the verdict on the next write), and freezing at mount with a `hanteiKnown` silence guard (which stopped the erase by accepting the divergence; the guard was deleted with it). - -#### Silence on the wire - -Silence still means "no opinion" on the wire. It is now OMITTED IPPONS (nil, not `[]`) rather than an omitted flag, and the distinction is load-bearing: an explicit `[]` is the operator saying ZERO (a 0-0 withdrawal), a missing key is a writer with nothing to say, whose stored verdict `preserveSubHantei` restores. The team editor IS one of those writers: `buildPatch` (admin_scoring_team.jsx) has a `daihyosenSilent` branch that OMITS the daihyosen row's ippon arrays when the operator has not touched that row this session AND nothing about it is known locally. That branch is the only reason the nil case is reachable from a real client at all, so do NOT "fix" it into an unconditional write. Any touch, or any locally-known verdict or scoreline, falls through to the explicit-array path, so a deliberate withdrawal still withdraws. Scope note: the restore protects a stored HANTEI verdict only; `preserveSubHantei` bails when the stored row carries no mark, so a rep bout decided by a strike is not restored by this mechanism. - -Accepted compat gap (documented in `legacy_hantei.go`): a pre-ruling client re-scoring a hantei match with markless ippons replayed from the offline queue (which has no age limit since bc-mrgc phase 3) clears the verdict when its stamp still orders it after the stored one, because a markless scoreline is now the writer's word. - -#### A write answers for what it introduces, not for what it inherited - -The engine-side contradiction guard is `stripInvalidHantei` (replaced `preserveMatchHantei`): forward writes on both branches strip a mark whose preconditions fail (`hanteiStillHolds`) or that sits on a non-winner's side, and LOG the drop (it discards an operator-visible verdict on an otherwise successful write). It strips rather than rejecting BECAUSE the mark is one the write INHERITED: the reachable producer is `handlers_daihyosen.go`'s add path, which copies the stored match wholesale (`u := *match`) and moves Status to running, so a valid stored verdict fails `hanteiStillHolds` on status alone and a 400 would blame the operator for state already on disk (same rule as `applyHansokuIppons`' fold scoping; pinned by `TestStripInvalidHantei_InheritedMarkIsStrippedNotRejected`). A CLIENT-sent bad mark is rejected instead, at the boundary that can still tell whose fault it is (`mobileapp.validateHanteiMarkPlacement`, which the handler's `backfillMatchIdentityForHantei` feeds the stored sides/ids so it attributes by the same triple the engine will). The decision twins are NOT a producer: `preserveLoserScore` filters through `struckIppons` and `domain.IsScoringIppon` drops the mark, so a kiken over a stored hantei arrives with the mark already gone (`TestPreserveLoserScoreDropsTheHanteiMark`). - -`applyHansokuIppons` folds each row and immediately judges what THAT fold rewrote. It is count-compared: a row whose award is already reflected is left untouched, slots and all, and reports NO change; a shortfall is awarded through `domain.AppendIppon`, filling a free `•` slot before growing the slice, so a wire-legal placeholder row stays wire-legal after the award; a surplus H over the count is the operator's own strike (the editors keep hansoku as the OUTSTANDING count and land the H themselves, `applyFoulIncrement`) and is NEVER removed. It applies both checks, hantei-untied and the best-of-3 overflow, at match level and sub level through the one shared `checkFoldedRow`. The check lives inside the producer rather than in a separate function the call sites chain by hand, so a new write path cannot fold without checking. Unscoped, it rejected rows the decision twins had just inherited wholesale via `preserveLoserScore`, so an over-cap row already on disk 400'd an operator's kiken that carried no sub rows at all, with no endpoint able to repair it; unmirrored at match level, the identical fold wedged a match on disk instead. This narrows the engine guard on purpose: a payload that ALREADY carries a bad row is damage `mobileapp.validateIppons` can see and rejects at both levels, so only the post-award state needs checking here. `applyKachinukiMerge` running AFTER the check is the same rule; when you add a guard downstream of an inherit, ask which actor it will blame. - -### Merge changes, never drop (bc-mrgc) - -Operator ruling 2026-10-03: "Nothing should be dropped. All events must be ordered." A match write is merged GROUP BY GROUP onto the stored match, ordered by the stamp of when the change was made (`modifiedAt`, server-relative, never arrival order), and what does not apply is kept in the match's history. Each rule below has one owner; change the owner, never a call site. - -- **The groups** (`internal/state/match_groups.go`, the ONE owner of which fields each holds): `points` (IpponsA/B, HansokuA/B), `result` (Status, Winner/WinnerID, Decision/DecisionBy/DecisionReason, and the CorrectionReason/ResultSource that describe it), `encho`, `flags` (engi), `rep` (pool rep players), `recorded` (the SERVER's alone, `state.ServerGroups`: which numbered kachinuki bouts Record bout closed, as sorted positions; a client naming it in `changed` is a 400, the merge copies the stored marks over a client's rows, and `bout:N` does not hold the mark, so a point on bout N and Record bout never fence each other), `repPickA` and `repPickB` (each side's representative picked for a team match's representative bout, SideAMemberID and SideBMemberID of the -1 row: each dated apart from the other side's and from `bout:-1`, so two captains picking at the same moment both land, a point on that bout never erases a pick and a pick never erases a point; the row's WinnerMemberID stays with the bout and the merge re-derives it when the two disagree, `ReconcileWinnerMemberID`), and `bout:` per SubResults row (1..n, -1). Sides and ids, court, schedule, round, ReopenPending, IsOverridden and the bracket structure are NOT groups: their own writers keep them. `CopyGroup` moves a group, `GroupDiffers` compares one, `GroupValue` projects one for the history; nothing hand-copies a field list. -- **The stamps**: `GroupStamps map[string]int64` on `MatchResult` and `BracketMatch` (pool-matches.csv: the `GroupStamps` column, index 29, JSON, appended after `ModifiedAt`; bracket.json: `groupStamps`). A match with NO map reads every group as stamped at its `ModifiedAt` (legacy files behave exactly as the whole-match guard did); once a map exists, a group with no entry was never written (0). `ModifiedAt` stays the newest group stamp (recency and `keepNewerMatches` read it). A bout whose row is gone keeps its stamp: a TOMBSTONE, so an older write still carrying the row cannot resurrect it. `stampGroups` (`MatchResult.StampGroups`/`BracketMatch.StampGroups`) is the ONE stamper: it materializes a legacy match's implicit stamps before stamping one group, or every other group would start reading the new `ModifiedAt`, and it never LOWERS a group's stamp (a caller that already holds a later one for a group -- an engi finish let through as a HeldEcho under an older stamp -- must not drag it backwards, or a change genuinely made between the two would wrongly read as applying after a group it never actually followed; mirrors `ModifiedAt`'s own never-lower rule); a stamp the MERGE decided, which may be lower (S2's displacement moves a newer invalid recount out and gives the group the finish's own stamp), is written as decided through `ApplyMergedGroupStamps`, never through `stampGroups`. Every writer that changes a group outside the merge stamps it through it: the reopen (`reopenPoolMatch`/`reopenBracketMatch`, server now), the requeue (`RevertMatchToQueue`/`requeueBracketMatch`: the verdict only, the kept score keeps its stamps), override-winner (`result`, and its own comparison is on that group), the engi finish (`result`+`flags`, at the client's stamp once the merge has ordered it, `engiFinishHeld`; server now only for an unstamped writer), the kachinuki advance (the `recorded` mark, and the appended `bout:n` + `result`, at the server's time and strictly after the match's `ModifiedAt`, so every device's `keepNewerMatches` orders the marked copy after the one before it; the Record-bout answer echoes the bout log AND the stamps the advance left, `engine.KachinukiAdvance`, and the team editor adopts the stamp with the log, so its next tap on the new bout is floored by it, bc-hlck), `RemoveTrailingKachinukiBout` (the removed bout's tombstone, and `recorded` when it un-records), the merge itself for `recorded` when a write raises a recorded bout's overtime, and a downstream reopen (`reopenDisplacedBracketMatch`, `bout:-1` with it). Byes auto-completed at draw time carry no stamp, which is right. -- **The wire**: `MatchResult.Changed` (`json:"-"`, request-only, beside `ClearsWithdrawal`) is read from `/score`'s and each bulk entry's `changed`; a name that is not a group is a 400. ABSENT means every group the payload carries (`defaultChangedGroups`; on the pool branch a nil SubResults still names the stored rows, the whole-struct write's old meaning), EXCEPT that a client's running or scheduled write over a COMPLETED match never carries `result` (`effectiveChangedGroups`). Every server-built write names its groups: decision `result`+`points`+`encho` (`decisionChangedGroups`), both-barred hikiwake `result`, daihyosen add `bout:-1`+`result`+`repPickA`+`repPickB` and remove `bout:-1`+`result`+`encho`+`repPickA`+`repPickB` (both date both sides' picks, so a pick made on the previous representative bout is held, never landed on the new one; +`points` when clearing the verdict took a hantei mark out, `daihyosenChangedGroups`), quick-score every group and every stored row (`WholeMatchChangedGroups`), a start (`startOnly`) `result` alone. The rows the server itself changes after the writer sent them (kachinuki merge, the padding a match-level decision adds) are added by `noteServerBoutChanges`; in a kachinuki write, which merges its bout log by position, a stored row the writer left out counts too, so the finish's strip of a trailing unscored pairing is named and not copied back. It and `daihyosenChangedGroups` (mobileapp, the representative-bout add and remove, built from the stored match under the same lock) are the two places the server diffs, each its own values under the write's lock; a CLIENT payload is never diffed against the store, or an echoed stale group with a newer stamp would read as an edit. A server-built door reaching the merge with `Changed` nil is logged as a BUG (`serverBuiltWithoutGroups`). A bracket door that gives a side another team without reopening that match records a `reseat` history line naming the cleared pick group (`recordRepPickClears`, bracket_seat_audit.go: a snapshot of every match's picks before the door's write, diffed after, in the same transaction; `ResolveQualifiedPools`, the resolver run after a pool write or a `DELETE .../overrides`, is such a door, in its own `WithTransaction`); a reopened match's `downstream-reopen` line names the pick instead, and a refused write's restore records nothing. `MatchResult.WriteDoor` names the door for the history. -- **The merge owner**: `mergeMatchWrite` (engine/match_merge.go), called by BOTH branches before anything else judges the write (`applyPoolWrite`; `applyBracketResultIn` for rounds and bronze, ahead of `preserveWithdrawalRuling` and the downstream guard), each after folding the stored pairing into the write (`reconcileSides`; on the bracket branch `sidesBeforeMerge`), so a winner worked out again reads the stored sides, never a payload's omitted ones. An UNSTAMPED write the server orders itself (a completing write, or any write a server door builds: quick-score, an engine caller, a decision with no stamp) takes the server's time there, never older than the stored `ModifiedAt` (`writeStamp`), so it leaves a fence a later stale replay is ordered against; only a client's unstamped running or scheduled write keeps the unstamped bypass. A changed group applies when `domain.ApplyByTimestamp(stamp, storedGroupStamp)` (0 on either side applies) and takes the write's stamp; otherwise the stored value is copied in and the group is HELD (its incoming value goes to `MergeReport.HeldValues`). A group the write does not change always takes the stored value. An applied group whose value equals the stored one is `Unchanged` (an echo); for a write that NAMES NO groups at all (`Changed` nil: bulk-score, an older client, which cannot tell an echo from a change) it keeps the stamp it already had rather than taking the write's, and `ModifiedAt` moves only when some applied group actually changed, so a whole-match echo can never fence out a real, older change made on another device in the gap. A write that NAMES a group takes the write's stamp there even when the group echoes: naming it is a real action taken at that time (an operator re-sends the value their screen showed, or a replayed decision), not a re-send of everything the write happened to carry, and `ModifiedAt` moves with it. A HELD group whose value equals the stored one is no loss (`HeldEcho`, bc-mrgc phase 3: a decision's "no overtime" over a match with none): it is neither listed in `heldGroups` nor kept in `HeldValues`, and its history outcome is `unchanged`. The write is SUPERSEDED only when something is held (or echo-held) and every applied group is an echo (`MergeReport.Superseded`): then nothing is written to the match file, exactly as before. The report rides on `MatchResult.Merge`; `ResultChanged` false (the verdict did not move: held, or applied as an echo of the stored one, e.g. the same withdrawal sent again under a later stamp) skips the eligibility record, since re-recording a stored withdrawal would rewrite its status and undo a reinstatement made since. Corrections get no exemption: a stale correction is held like any other change. -- **R2** (operator: "points scored means the withdrawal was a mistake"): a kiken, fusenpai or fusensho is ATOMIC with what it changes and is held whole when any of its groups is held or ANY stored points or bout stamp is newer than it (`withdrawalOutranked`); `recordDecisionTx` asks the same merge on a copy first (`decisionHeldByMerge`), so a stale decision is superseded before its refusals judge it. A scoring change (points, a bout, the engi flags, with a different value) in a RUNNING write stamped after a stored withdrawal clears it (`ClearsWithdrawal` set, the cleared result kept in history as `clearedWithdrawal`); `restoreIfWithdrawalRemoved` restores eligibility. That clear needs an actual SCORING change against a withdrawal of the match itself -- an overtime toggle alone says nothing about whether the withdrawal was a mistake -- and it must give the match its winner: a clear that would leave a knockout tied is abandoned and the decision stands, held as below, never answered `needs_winner`. Overtime enters this rule only when its value actually changes (an echoed (E) is a plain no-op), and a hold records a reason only when it held something that is not an echo. A kiken, fusenpai or fusensho (a fusensho is awarded for a bar recorded on ANOTHER match: the other side cannot fight) already decided who won, and neither a point, a bout, nor an (E) tap the board still sends says otherwise, so the decision stands and the whole scoring side of the write -- points and overtime alike -- is held together instead (`holdDefaultWinScoring`; the history reason names the decision, `defaultWinStandsReason`, e.g. "a fusensho closed this match"), answered with its own code, `heldReason: "default_win_stands"`, and the decision, `heldDecision` (`MergeReport.DefaultWinStands`/`StandingDecision`; the message from `messageForHeldReason`, worded through `domain.DecisionWord`; the client's `writeDefaultWinStands`/`writeHeldDecision` and `decisionWord` in write_result.jsx; `HeldDecision()` is non-empty exactly when `HeldReason()` is `default_win_stands`, so every answer carries the two together), so the operator is told which decision closed the match and to correct it from the match's score editor (the copy names no button: a kachinuki editor offers no Remove), rather than told a newer change exists or to correct the result with a winner. A COMPLETED correction keeps a stored withdrawal exactly as before (`KeepsWithdrawalRuling`, the `clearWithdrawal` flag). -- **R3** (operator: "stay finished, add the point"): a running write's scoring change stamped AFTER a finished match's `result` stamp applies to the finished match, which stays finished; `deriveWinnerAfterMerge` works the winner out again through the existing owners (engi flags, `deriveKachinukiWinner`, `TeamResult` IV then PW then `deriveDaihyosenWinner`, `CountScoringIppons` with the Ht mark breaking a tie). An engi match is never a draw: a winner only on a valid flag total (`engiValidTotal`), pool and knockout alike (bc-mrgc phase 3; a pool recount to an even total used to become a hikiwake). One stamped before it, or unstamped, is held whole. A points change whose write does not apply `result` keeps a stored hantei mark (`carryHantei`, through `domain.AppendHantei`): the judges' decision is the result's, so a late foul or a board that never showed the mark cannot erase it; `stripInvalidHantei` still drops it if the new scoreline is not tied. The score handler treats such a write as a correction: no court gate, no StartMatchTx (`writesOverFinished`), and the old `staleAfterComplete` `{stale:true}` drop is gone. A server-built write that names `result` on purpose (the daihyosen add to a finished knockout match) is not R3's shape. -- **R4** (operator ruling 2026-10-04, replacing the implementer's back-to-running edge: "It needs a winner if the match is finished and is being corrected"): a scoring change that would leave a FINISHED knockout match tied, or an engi match (pool or knockout) with no valid flag count, is NOT applied. `deriveWinnerAfterMerge` reports false and changes nothing, and `mergeHold` (the one place a group is taken back out of a write) holds the scoring groups: the match keeps its recorded finish, winner and advanced winner, never goes back to running, and the change is in the history with `HoldReasonKnockoutNeedsWinner`/`HoldReasonEngiNeedsValidCount`. `MergeReport.NeedsWinner` reaches the answer as `heldReason: "needs_winner"` (`state.HeldReasonNeedsWinner`) beside `heldGroups`, with `NeedsWinnerMessage` on a fully held write, so the operator is told to correct the result with a winner. A pool or league tie becomes a completed draw (`hikiwake`). Nothing on the bracket write retracts a winner for it any more. -- **S2, the other arrival order**: a finish whose `result` applies over scoring stored NEWER than it (a point made after the finish on a board that never saw it, arriving first) gets the verdict that scoring gives, through the same `deriveWinnerAfterMerge`, so both arrival orders end in the same state (status, winner, scoreline, stamps); the result is stamped with that newer scoring's stamp when the verdict moved (`verdictMoved`), as R3 stamps it. The finish's own hantei mark is carried onto the newer scoreline first (`carryHantei`, the S3 rule the other way round). A withdrawal is excluded (R2's `withdrawalOutranked` owns it). When the newer scoring would leave the match without the winner it needs (a knockout tie, an engi count that is not valid), stamp order says that scoring came after the finish and could not apply (R4), so the finish is APPLIED on its own scoreline and each newer scoring group is moved out of the match into the history as held (`mergeHold.displaceNewerScoring`, `MergeReport.Displaced`, one entry per stamp with door `displaced` and the R4 reason, written by `recordDisplacedHistory`, also from the engi branch); `ModifiedAt` falls back to the newest stamp left. Both arrival orders then reach the same state (status, winner, scoreline, stamps, next round) and both histories hold the tying change; the answer is applied and carries `displacedGroups` with `heldReason: "needs_winner"`. The same displacement covers a representative pick stamped after a representative-bout removal: when the removal applies, the pick is moved to the history (reason `HoldReasonRepBoutRemoved`, "the representative bout was removed"), and that answer carries `displacedGroups` with NO `heldReason`; the client asks `writeDisplacedForWinner` (write_result.jsx) before saying a displacement left a match without a winner. Only a finish whose payload does not carry the scoring it stood on (it names `result` alone and sends no scoreline) cannot be replayed: it is held (`needs_winner`) and the match stays as stored. Winners worked out again take a side's id from the stored match when the payload omits it (`mergeCtx.storedAID`/`storedBID`), since ids are backfilled only after the merge. -- **The history** (internal/state/match_history.go): one append-only file per match, `competitions//history/.jsonl`, one JSON line per write that reached the merge: stamp, server `receivedAt`, door, session, changed groups, per-group outcome (`applied`/`unchanged`/`held`), the held groups' incoming values, any cleared withdrawal, and the `reason` when one rule held the write's groups rather than their stamps (`MergeReport.HoldReason`: `HoldReasonOlderRevision`, `HoldReasonEngiAtomic`, `HoldReasonFinishAtomic`, `HoldReasonKnockoutNeedsWinner`, `HoldReasonEngiNeedsValidCount`; the History view shows it as "Not applied: "). `StoreTx.AppendMatchHistory` stages the whole small file in the SAME transaction as the write (`LoadMatchHistory` reads it, read-your-own-writes), so an aborted transaction leaves no entry. Written at the point the write can no longer be rolled back (`recordWriteHistory`: after K3/requalification in `RecordMatchResultWithIneligibilityTx`, in `writeMatchResult`, and on the superseded paths), so a write refused by validation, a side mismatch or K3 leaves none; every direct writer above appends its own (`recordDirectHistory`) INSIDE the transaction that makes its write: the doors already in one (the reopens, the kachinuki bout remove, the downstream reopen) pass their `tx`, and the kachinuki advance, `OverrideBracketWinner` and `RevertMatchToQueue` each open a `WithTransaction` for the write and the entry together (`withBracketMatch` takes the store handle, as `withPoolMatch` does), so a process dying between the two leaves no match change without its line; a held override writes nothing and appends on its own. `recordDirectHistory` logs a failed append rather than returning it, so a failure of the append alone still lets the write commit without its entry (accepted). The history directory is created only under an existing competition directory (`ensureMatchHistoryDirLocked`), so a write after `DeleteCompetition` fails rather than resurrecting it; `DiscardDraw` deletes it (`DeleteMatchHistory`), since a regenerated draw reuses the match ids. A fully-held write must COMMIT its transaction for its entry to land: every door returns nil from its closure on `ErrMatchSuperseded` and answers after (the score/decision handlers always did; bulk-score, both daihyosen paths and `RecordMatchResult` were changed to). -- **The answers**: a write applied in part answers applied (the merged result) plus `heldGroups` (`scoreResponseWithReopened`, `withHeldGroups`, bulk-score's top-level `heldGroups`); a fully-held one keeps the 200 `{applied:false, reason:"superseded"}` shape the SPA keys on, plus `heldGroups` (`respondSuperseded`, `SupersededMessage`: "kept in the match's history"). Either answer adds `heldReason: "needs_winner"` when R4/S2 held them (`MergeReport.HeldReason`; a fully held one then carries `NeedsWinnerMessage`; bulk-score puts it on the entry), and an applied finish that moved stored newer scoring to the history adds `displacedGroups` (`MergeReport.DisplacedGroups`) with the same `heldReason` (score, daihyosen, bulk-score's `heldGroups[]` items). `ErrMatchSuperseded` is returned as `*SupersededError` carrying the held groups and that code (`errors.Is` still matches; `HeldGroupsOf`/`HeldReasonOf` read them). A completed write stamped OLDER than the stored `result` group over a finished match is not asked for a correction reason (`applyCorrectionReason`, `matchSnapshot.ResultStamp`): a write that completes the match is atomic with everything else it changes (`HoldReasonFinishAtomic`, mirroring `HoldReasonEngiAtomic`), so a stale finish is held WHOLE and kept in the history rather than landing its scoreline beside a verdict it never declared; an equal stamp is an exact replay and still needs one. The request-only fields (`Changed`, `WriteDoor`, `Merge`, `ClearsWithdrawal`) never ride on a stored copy: `MatchResult.ClearRequestFields` is the one list, called on the pool write's overwrite and on every pool-match copy the store hands out. - -- **The client (phase 2)**: every score write names its groups. `web-mobile/js/match_groups.jsx` is the ONE client owner of the names (pinned against `state.ScalarGroups`/`BoutGroup` by the shared table `internal/state/testdata/match_groups.json`: `TestMatchGroups_SharedTable` and `match_groups_table.test.jsx`), of the comparison (`changedGroups`, on WIRE shapes only: both sides go through `toBackendMatchResult`, the adopted copy via `matchWire` in api_serializers.jsx, so a placeholder slot, an Ht placement or a winner object never reads as a change; a field the write leaves out, e.g. an omitted correction reason or a silent representative bout's ippons, is not compared, and a bout row the write does not carry is never a change), of the union (`unionChanged`: no list on either side means every group and stays so) and of a group's words (`groupLabel`). `useChangedGroups(m)` (admin_scoring_autosave.jsx) is the one hook the three editors call; every patch goes through its `claim` (the individual and team `buildPatch`, a hantei included since bc-htsd, the engi save and autosave builder). A group counts against TWO baselines: the server value the editor last AGREED with for that group (seeded at mount, moved on at each build where local equals the prop, and by `claim.agree(group)` when an editor's adopt TAKES the server's value for that group, or when the team editor's withdrawal-removal Undo puts the whole board back to the server's (every bout group and both pick groups); an adopt that takes only PART of a group agrees it only when the editor's whole group then equals the server's (the individual editor's hantei adopt skips `points` while a point of its own is unsaved; a representative-side adopt agrees `rep` only when both sides then hold the server's picks); both baselines are kept per group), NOT the latest prop, or an edit held while another device changed a group the editor kept its own value for would put the old value back; and the last write the editor built, or a tap taken back before the first write's echo would name nothing. A reopen is a write the server makes, not a build, so when the match the editor renders leaves `completed` (a reopen, or a send back to the queue) the editor takes the result it now holds as agreed, that group alone (`useChangedGroups`): without it, ending a reopened match again with the verdict it had named nothing, and the server kept the match running with no error. It keys on the status, not on `reopenPending`: a reopen without a reason carries that stamp, but one a correction's reason comes with (a downstream reopen, a requalification, a pool-rank override) leaves the match scheduled with its points and no stamp, and an editor that watched only the stamp named no result for it. `startPatch` names `result` alone. The outbox keeps `changed` verbatim (persist, cross-tab, rehydrate, replay); a score write that takes a queued one's place claims the union (`_claimingQueuedGroups` in `_placeWrite`, and the absorb in `recordScore`, both against the one entry `_coalesceTarget` names); `_broadcastPatch` strips it. Answers: `writeHeldGroups`/`writePartlyHeld` (write_result.jsx) for a partial apply, which the editors answer with a quiet note (`useKeptInHistoryNote`/`KeptInHistoryNote`, worded by `heldGroupsNote`) and `editMatchScore` with a toast when the write closes the editor; a superseded write leads "Not applied", never "Not saved" (`SUPERSEDED_LEAD`, `notSavedText`'s `lead`, `supersededAlertText`). The editors' closed-by-default **History** disclosure (`MatchHistoryDisclosure`, match_history_view.jsx, `data-testid="match-history"`, hidden under `selfReport`) reads `GET /api/competitions/:id/matches/:mid/history` (`RegisterMatchHistoryHandler`, stamp order with an unstamped entry at its `receivedAt`, `[]` when none, 404 for an unknown competition or match, main-gated in self-run), readable values through `formatIpponsScore`/`withdrawalLabel`/`sideWord`. - -- **Phase 3, the remaining drop sites, each with one owner:** - - **The running rev guard holds, never drops.** A running write with a lower `rev` than the same `revSession` already sent is answered by `holdOlderRevision` (handlers_match.go): one bare transaction, no court lock, no start gate, no correction check, and the high-water mark left alone. It calls `RecordMatchResultWithIneligibilityTx` with `engine.ForceOptions{HoldReason: engine.HoldReasonOlderRevision}`, whose `holdWriteTx` asks the merge owner on a copy with `mergeCtx.holdAll` (every changed group held, echoes excepted), records the history entry with the reason, and returns the superseded error; the answer is `respondSuperseded` with `heldGroups`. `{stale:true}` no longer exists anywhere (server, openapi, client: `_handleOkBody` and the admin toast that read it are gone). - - **The offline queue keeps, per match, a list of pending writes, each sent on its own** (api_client.jsx). Each pending write is its own entry, keyed `${base}${QUEUE_ENTRY_SEP}${id}` (`_newEntryKey`; the separator is `\u001f`), where the BASE is what the write is about (`_revKey` for a score or a decision, `override:…`, `lineup:…`; `_baseOf`). A key persisted by the previous build has no separator, so it is its own base: an old queue loads and replays unchanged, which is the migration. The ONE coalescing rule is `_coalesceTarget`: a score write takes the place of its match's LAST entry only when that entry is a score write not newer than it and not the page-hide copy of a write still being sent, and a RUNNING write only of a running one (`_placeWrite` then claims the union and carries a pending `kachinukiBoutFinal`); a LINEUP save joins a queued save of the same lineup (`joinQueuedLineupSave`, lineup_save.jsx: the union of `changed`, the later value per position): a lineup PUT carries no stamp the server could order it by, so a direct save could land before the queued one's replay and lose to it on a position both changed; the caller reads `{queued: true}` for the moment until the flush sends it. Nothing else replaces anything, so a running write never replaces a queued Finish or a decision, and a decision and a score are two entries. A pass sends entries in the ONE replay order, `_byEntryOrder` (the payload's `modifiedAt`, else `enqueuedAt`, then the key), but each entry on its own: one that does not settle (offline, 5xx, parked, re-stamped for skew, held by an open fetch) holds back nothing after it, and a new score, decision or override goes straight to the server whatever is queued, because the server merges by stamp, not arrival (bc-mrgc review C2: the old wait-behind rule let one write failing with a 5xx stop every later write for its match). The editors' "Retry now" on a queued Finish therefore sends it at once: landing, it drains the queued copy it claimed; failing, it takes that copy's place. A landed direct score write drains only the entry it claimed (`claimedEntry`), never a queued Finish or decision. The page-hide copy (`_keepInflightRunning`) is an entry of its own (`w.keptKey`); recordScore settles its OWN copy through the record it created (`inflightRecord`), even after a later running write took the map slot, and when it had one and its fetch answered it schedules a flush a microtask later, after the answer has drained its claimed entry. A queued answer says whether the write reached browser storage (`_queuedAnswer`: `persisted: false` only when `_persistQueue` failed, read before the enqueue kicks a flush). A write the server keeps refusing (past `SERVER_REJECTION_NOTICE_THRESHOLD`) can be discarded alone from its match's editor (`HeldWriteDiscard`, admin_scoring_shared.jsx; `API.heldWriteKeepsFailing`/`discardFailingHeldWrites`, that match's failing entries only, after a confirm). `resumeAfterAuth` gives the new password to every parked write AND every queued write still carrying a password the server refused, never to one queued with none (a self-run participant's). Every refusal on replay is reported: a decision's 409 (`already_ineligible` included) goes to the terminal-fail and `rejected` alert channels like any other, because the server answers an EXACT replay of a recorded decision (same decision, side and stamp: a send whose answer was lost) as recorded before any of its refusals (`exactDecisionReplay`, scoring_tx.go). A held finish that lands and moves a later change to the history (`displacedGroups`) counts as sent and raises one `displaced` information alert per pass. Nothing is dropped for its age: `QUEUE_TTL_MS` and the `expired` alert are gone, and an old entry is sent; the server orders it and keeps what loses in the history. Pinned by `queue_ordered_writes.test.jsx` and `sync_queue.test.jsx`. - - **Override-winner** stamps and orders the `result` group (phase 1); a held assertion is answered `respondSuperseded(["result"])` (reason `superseded`, `heldGroups`), the client returns `heldGroups`, the Resolve feeders toast says it was kept in the history (`OVERRIDE_HELD_NOTICE`, write_result.jsx), and a queued one the server held counts into the pass's superseded alert as well as asking for a bracket resync. - - **The engi finish** is stamped by `recordScore` like every write, and the server orders it: `engiFinishHeld` (engi.go) asks the merge on a copy built from the stored match (`engiFinishProbe`), BEFORE the downstream guard; the flags and the winner they decide are atomic (either held → both held, `HoldReasonEngiAtomic`, reached when the flags are newer than the stored count but the result is older than the stored one, e.g. after a later reopen; a stale finish over a COMPLETED match is held whole one step earlier, by `HoldReasonFinishAtomic`) -- except that the NEWEST COUNT DECIDES (operator ruling 2026-10-04): when a finish's result applies but a newer stored VALID count holds its flags, the finish closes the match with the winner that count gives, the stored count stays, its stamp stamps the result, and the finish's own count is kept in the history as held, so both arrival orders end alike (`TestMerge_EngiNewestValidCountDecidesInBothOrders`); a newer INVALID count is still moved to the history by the displacement above -- and an ECHO of the stored flags (same count, under an older stamp) is not a hold at all, so the finish still applies; held → history and superseded; applied → `recordEngiMatch` writes `result`+`flags` with the stamps the merge decided (`engiFinishHeld` carries the probe's `GroupStamps` down, `stampEngiChanges`/`ApplyMergedGroupStamps`), so both arrival orders reach the same stamps after a displacement; an unstamped writer still gets the server's time. - - **Read-modify-writes are one transaction.** `InjectPoolDaihyosenMatches`, `InjectTiebreakerMatches` (the engi self-heal too), `GenerateLeagueTiebreakMatches` and `AdvanceSwissRound`'s append each read pool-matches.csv, decide, and save under ONE `WithTransaction` (standings through `computeStandingsFrom(tx)`, the tournament read before the tx, `appendWithSlots` for the new slots); a score landing meanwhile waits for the lock and goes on top. `MaybeAdvanceKachinuki` still reads without the lock, and its locked write re-checks (`kachinukiAdvanceStillHolds`: not completed, same status, stamp and bout log) and on `errKachinukiAdvanceStale` reads the match again and works the pairing out from it (`advanceKachinukiOnce`, up to `kachinukiAdvanceAttempts` times, standing down only then: a stand-down answered the operator's Record bout with no next pairing; a finish that landed is final on the re-read, so it is never reopened; the bracket branch uses `UpdateBracket` with `Bracket.MatchByID` so it can abort). `Engine.afterMatchRead` is the test-only seam at each read (`lost_update_window_test.go` lands a real write there). - - **A rename is not a side mismatch.** `reconcileSides` accepts a payload side whose id equals the stored id whatever name it carries, keeping the stored (current) name on the side, on a winner named by the old name, and on every bout row field that names the side by it (`adoptCurrentSideName`: the representative-bout row, and a fixed-order or id-less kachinuki row whose winner is the team, which `state.SubBoutWinnerSide` would otherwise credit to nobody); only an id disagreement, or a name disagreement no id can settle, is `side_mismatch`. Only an id the CLIENT sent proves a rename: the handler's hantei/winner-id backfill (`backfillMatchIdentityForHantei`) fills a side id only beside the stored name it belongs to, so a payload naming someone else with no ids is still `side_mismatch`. - -Accepted as they are: a replayed queued write applied in part tells nobody (the history has it), unless what was held would have left a finished match without a winner (`writeNeedsWinner`), which the replay reports like a superseded one. An exact decision replay adds a second history line that reads like the first ("Decision recorded", every group unchanged). A queued write the server keeps refusing with a 5xx is retried as long as the page is open and holds back no other write; the editors' **Discard held result** covers a terminal score write, and the admin topbar's held-writes count opens `HeldWritesPanel` (admin_shell.jsx), which lists every held write (`API.heldWrites()`) and discards any one that keeps failing (`API.discardHeldWrite(key)`; both doors go through `_discardIfFailing`, so a write only waiting for the connection is never discarded); a same-session reopen over a write the browser could not store shows the plain held notice. An answer that both held groups and moved a later change (`heldGroups` and `displacedGroups` with one `heldReason`) is read as the move's: the held part gets the plain newer-change note. `unsentWrites().total` counts entries, so two pending writes for one match count two; `terminal` still counts results. `InjectTiebreakerMatches` and `InjectPoolDaihyosenMatches` compute standings uncached, under the competition's lock, on each call. A partial apply on the self-run public sheet's finishing write closes the editor with no note. Every write re-stages its match's whole history file through the WAL, so a long match's history costs a rewrite per autosave; compaction is for a later phase. An R3 correction on a knockout match whose next match was already played is refused like any other knockout correction (`downstream_knockout_played`/`_running`). History entries recorded before phase 3 may still list an echo as held. A lineup save made while a save of the same lineup is queued reads `{queued: true}` until the flush sends it, a moment later when the device is online. A write whose transaction log committed but whose file write then failed (`state.ErrTxCommitted`: a disk that refuses writes) is answered 500 like any internal error, so the device keeps retrying until a write lands (operator decision 2026-10-04): the change is already kept, written on restart or by the next write the disk accepts, and `internalError` (errors.go) logs it as an ERROR naming the disk; a Discard of it does not stop it applying at restart. A completed correction whose result is held because a still-scoring board's LATER change (R3) moved the result's stamp first is held WHOLE, by `HoldReasonFinishAtomic`, even though in plain stamp order its own bouts would have applied before that later change: finish atomicity holds the correction together with the scoreline it stood on rather than letting its bouts land piecemeal beside a verdict a newer write already superseded. Nothing is lost (the held write is in the match's history as always), and the operator is told to check it rather than having the correction's bouts silently re-order themselves around a verdict that has since moved. Do not report this ordering as a bug to fix: it is finding 4's atomicity rule doing its job. - -### Write refusal and the clock frame (bc-lww1 / bc-cse) - -A match write can be REFUSED by the server, and a refusal is always **HTTP 200** carrying `{"applied": false, "reason": ...}`, never a 4xx/5xx, because the SPA's offline write queue retries 5xx forever (the mp-q8c6 poisoned-queue pattern) and a refused write can never win a retry. Two reasons exist: - -- `superseded`: the merge held every group the write changes (bc-mrgc): a NEWER change to each is already stored. Nothing of it was applied, and it is NOT lost: its values are in the match's history and the answer names `heldGroups`. The operator must **not** blindly re-enter: a fresh entry is stamped now and would overwrite the newer change. -- `clock_skew`: the write's `modifiedAt` was more than 5s in the server's future (`modifiedAtRefuseSkewMs`, handlers_match.go), so it is provably wrong. Nothing was stored, the client resyncs its `/api/time` offset, and re-entering IS the remedy. The copy for the two cases is deliberately opposite; do not merge them. - -**Which endpoints compete on timestamps**: `/score`, `/override-winner`, `/decision` (since mp-jnvl) and both daihyosen paths (since bc-dhas) each carry the client's stamp, so any of them can be refused for either reason. Quick-score alone still builds its result with NO stamp; the merge gives it the server's time, never older than the stored stamp (`writeStamp`, bc-mrgc review), so it still always applies and now leaves a fence a stale replay is ordered against; it maps the refusal shape defensively anyway. The daihyosen add and remove are stamped so the match they write is newer than a copy read before them, which is what lets `keepNewerMatches` keep the post-add copy over a stale refetch. Both take an optional JSON body `{"modifiedAt": N}` (the DELETE too; an empty body is unstamped and always applies), judged by `daihyosenWriteStamp` through the same `clientClockSkew`/`clampClientModifiedAt` pair as `/decision`; the stamp rides on the match the handler writes, so the ordinary write guard decides. `recordDaihyosen`/`removeDaihyosen` hand a refusal back as the refusal body (`_daihyosenOutcome`), and the team editor reports it through `notLandedBanner` and adopts nothing. A decision is stamped because it is the write that CLOSES a match, and a match closed while still `scheduled` (a queue row's Record fusensho) has no earlier write to inherit a stamp from, so the result carried no time at all and nothing downstream could order it. A stale decision is answered `superseded` before any of the decision's refusals judges it (`recordDecisionTx` asks the merge on a copy, `decisionHeldByMerge`: already barred, a running later match), since each of those would otherwise read as something to resolve and send again; an EXACT replay of the recorded decision is answered as recorded, before them too (`exactDecisionReplay`), from the prior and without a write; for a bracket match that prior is `bracketMatchAsResult`'s projection, which carries no court or schedule, so the replay branch fills them from the bracket (`bracketMatchCourtAndSchedule`, bracket_result.go) as a fresh decision's write echoes them, and the answer and its broadcast keep the court. A held override-winner answers `superseded` with `heldGroups: ["result"]`. The stamp reaches the engine through a VARIADIC parameter on `RecordDecision`/`RecordDecisionTx`: the engine-internal caller and the suite's decision tests omit it, and the handler is the one caller that must always pass it. - -**`web-mobile/js/write_result.jsx` owns that whole rule**: `writeDidNotLand` (queued OR refused), `writeWasSuperseded`, `writeWasRefusedForClock`, `notLandedBanner(res)`, `writeKeepsEditorOpen(patch, res)` (bc-plcl: the one rule every score-editor host asks before closing its editor: a write that did not land, or a running write with no winner, keeps it open), `writeWasRefused(res)` (nothing handed back, which is how every host reports a refusal, or `applied: false`: the two-tap commits Finish, Finish + Start Next, End match and the engi Save disarm, because tapping again would re-send the refused write, and a superseded one would win; a held write the operator DISCARDS disarms the same way, bc-cse operator ruling 2026-10-05, via each editor's own one `dropHeldWrite`-style function, passed as `HeldWriteDiscard`'s `onDiscarded` prop for the editor's own Discard held result AND as `useClearPendingWhenNothingHeld`'s callback for a discard made elsewhere, e.g. the admin topbar's held-writes list) and `writeRetryable(res)` (a QUEUED write only: it gates the pending banner's Retry and the kept submit closure, while the not-saved banner never carries a Retry), `queuedNotice(res, { keepsFailing })` (the held-write line: `QUEUED_NOTICE`, or `QUEUED_UNSAVED_NOTICE` "Not sent yet: keep this page open until the connection returns." when the queue answered `persisted: false` because browser storage would not take it, or `QUEUED_REFUSED_NOTICE` once the server keeps refusing it; the editors keep the queued answer in `pendingWrite` and render the line through `HeldWriteNotice`, admin_scoring_shared.jsx, which with the Discard beside it and the banner's own clearing reads ONE hook, `useMatchHeldWrite`: what this device still holds for the match, `held` and `stuck`, re-read on every sync-status change and every held-count change, the counts carrying `failing` so a SECOND write crossing the threshold is published too; the banner goes once the match holds nothing, landed or discarded from anywhere, `useClearPendingWhenNothingHeld`, never "when the whole queue drains"), the two readings of `heldReason: "needs_winner"`: `writeNeedsWinner(res)` (this write's OWN change was held because it would leave a finished match without a winner, superseded or applied in part; `supersededBanner(res)`, the partial-apply note and the superseded queue alert then say to correct the result with a winner) versus `writeDisplacedGroups(res)` (the write was RECORDED and moved a later change of the match to the history, an applied answer with `displacedGroups` and no `heldGroups`: the note says "Saved" and what was moved, `displacedAlertText` for a replay); `keptInHistoryNote(res)` (match_groups.jsx) is the one note built from them, for the editors' `useKeptInHistoryNote` and admin.jsx's `closingHistoryToast`, and every operator-facing string. `notSavedText` is the one "Not saved" line every such banner shows (the three score editors, fed by `terminalFailureBanner`, and the barred-match notice): "Not saved: . ", or the refusal as it is when it is a whole sentence that says what to do (`sentence`, set by `_replayRefusal` in api_client.jsx for a server `message` or `reasonHuman` and for the busy-shiaijo copy, never for a bare code or HTTP status), and `queueAlertMessage` ends its `rejected` alert on that sentence instead of "Re-enter it." for the same reason. Every host also hands the editor back what a write came back with (a refused write throws and returns nothing), which is how the editors' own Start match tells a landed start from a refused one. It is a leaf with no imports, and **every consumer ES-imports it directly**; there are no `window` mirrors and re-adding one fails `make js/check-imports` (see `web-mobile/check-write-result.mjs`, which also forbids hand-rolled `.applied`/`.persisted` comparisons outside the owner and `api_client.jsx`; its walk, comment stripper and line scan are `web-mobile/check-helpers.mjs`, shared with the competitor-number gate below, and its `FORBIDDEN` rules are exported so `rule_gates.test.jsx` can pin them). This exists because the question was originally spelled `res.queued` inline at ten call sites, and when a second not-landed shape appeared the conversion reached five of them. - -Two distinctions that look like details and are not: - -- **`writeDidNotLand` vs `writeWasSuperseded`.** Both mean "not stored", but a QUEUED write lands on reconnect, so an offline court must still advance its local bracket; a REFUSED one never lands, so anything derived from it is wrong. Gate optimistic UI on the stronger predicate and general "did it save" checks on the broader one. -- **Client clock stamps are server-relative**, learned from `GET /api/time` (retried until it first succeeds, relearned on SSE connect, on `'online'`, on any refusal, and when the 15s heartbeat's `nowMs` diverges by more than 3s). The heartbeat value is a divergence TRIPWIRE only, never an offset source: a one-way push has no RTT to correct, so a late frame would drag the device's clock frame backwards. A payload is stamped when the client builds it (an autosave with the time of the edit it carries, see the bc-sync paragraph below) and a queued one replays verbatim; a replay refused for skew is re-stamped once, by `_restampFor`, which computes TWO reconstructions and keeps the OLDER: `enqueuedAt + fresh offset` (wrong if the OS stepped the wall clock mid-outage) and `serverNow - monotonic age` (wrong if `performance.now()` froze during suspend). Taking the older is a safety rule, not a tie-break: an over-old stamp only makes this write lose a comparison it might have won, which is recoverable and announced, while an over-new one is the silent overwrite bc-lww1 exists to stop. The monotonic anchor is stamped at the one enqueue chokepoint and **stripped on persist**: it is measured from a per-document time origin, so a rehydrated reading (or one written by another tab) subtracts two different zeros and yields an age unbounded in BOTH directions, which is exactly what `min()` cannot defend against. A rehydrated entry therefore falls back to wall-clock only, so a clock step spanning a reload still ends in a drop. Retried exactly once either way. -- **A write is never stamped older than the match it was made against** (bc-hlck, operator request 2026-10-03). Two devices' server-time estimates can differ by up to the 5 s the server accepts, so a device running ahead stamps its write later than real time, and a change made a moment later on another device that SAW that write was stamped earlier than it and kept in the history instead of applied. `_stampAfterSeen` (api_client.jsx) is the ONE rule, a hybrid logical clock: this device's stamp, or the seen match's `modifiedAt + 1`, whichever is later, the floor capped at this device's time + 4 s (`HLC_FLOOR_MAX_AHEAD_MS`) so it never draws a `clock_skew` refusal. The server is unchanged: the match's `modifiedAt` is already the newest stamp it holds. What was "seen" is the caller's: the match handed to `recordScore`, `seenModifiedAt` in a decision body (stripped before sending, so `recordDecision` keeps its 4-argument call shape), the trailing argument of `overrideBracketWinner` (omitted by `recordOverrideWinner` when the feeder has no stamp, so that call is as it was) and of `recordDaihyosen`/`removeDaihyosen` (the team editor passes the shown match's stamp, 0 when it has none). The direct clock-skew resends of a score and of a hand-set winner re-stamp through the same floor. An AUTOSAVE is floored by the match as it was at the TAP: `useDebouncedRunningWrite`'s optional `seenStampRef` (every editor passes the shown match's stamp) is read at each tap beside `editedPerf` and rides on the patch as `seenModifiedAt`, explicitly 0 for a tap that saw no stamp, so a change another device made AFTER the tap never moves it (the 2026-09-27 ruling). A queued entry records the floor (`seenModifiedAt` on the descriptor, persisted, the later of two when one write takes another's place) and the skew re-stamp keeps it. Accepted residual: there is no per-device counter, so two writes floored by the same seen stamp share the stamp `seen + 1` and the later arrival wins an equal-stamp tie; across two devices (three on one match within about 4 s, against one operator per court) a local counter could not order them anyway, and on one device the server already does (the running rev guard, R3's strict `<=`). - -**An edit still inside an editor's autosave debounce is unsent work** (bc-sync). `useDebouncedRunningWrite` (admin_scoring_autosave.jsx) registers it with `API.notePendingEdit`, so the sync pill reads "Syncing…" from the first tap, and releases it only once the write is dispatched. On `pagehide` or the tab being hidden it writes a pending edit at once with `durable: true` on the patch, and `recordScore` puts such a running write straight into the persisted outbox (the flag is never sent), because a fetch started then dies with the page. That relies on every score-editor host reaching `API.recordScore` before the page goes, i.e. with no `await` on I/O in front of it (pinned by `score_write_same_tick.test.jsx`: `attemptScoreWrite`, `editMatchScore` and every host's `onSubmit`); do not add one. `api_client` also keeps, on page hide, a running write already SENT but not yet answered (`_keepInflightRunning`, from `_inflightRunningWrites`), because the page going away cancels that request. The copy is an entry of its own (bc-mrgc phase 3): it never replaces a queued entry for the match, nothing coalesces into it, and it replays in its place in the match's order. The copy is dated when the write was sent, the flush skips it while its fetch is still open (`_heldByOpenFetch`), and the fetch's outcome removes or replaces it, so a tab that is only hidden never sends the write twice. - -**Every way out of an editor saves a pending edit, and an autosave is stamped with the time of the edit it carries** (operator ruling 2026-09-27). The unmount writes an edit still inside the debounce window whatever caused it: Close, Prev/Next or their keys, another match picked, a correction opened, a court switch, or the court console moving on by itself (`selectedMatch` falls back to `running[0]`, so another device finishing the match re-keys the editor). The write carries the monotonic reading of the last tap (`editedPerf`, never sent), and `recordScore` takes its age off the stamp (`_editAge`). That is what makes the unmount safe: a tap made before another device finished the match is OLDER than that finish, so the server keeps the finish, where a write stamped at send would have reopened it. Discarding is the one way out that saves nothing: the editor cancels the debounce before it closes. Close and Prev/Next share one leave rule (same ruling): whatever autosave does not carry asks "Discard unsaved scoring changes?" first (the individual editor's Tie toggle or hantei side pick on a running match, a kachinuki row the running patch drops, any unsaved change to a match that is not running); everything else leaves at once. So every user edit the running patch carries must call `markDirty`: an edit that arms no timer is not saved by the unmount or the page-hide flush (the overtime counter, the rep-player pickers and the team daihyosen verdict did not, bc-sync). An adopt for a value saved as it is made keeps the operator's value until the server holds it (`keepLocalEdits`), or the save of one tap, coming back, undoes a second tap made before it arrived. - -**Self-run participants run the representative bout; a hantei stays the organiser's** (operator decisions 2026-09-28, bc-dhas). An anonymous self-run `PUT /score` may carry the representative-bout row (position -1, sub decision `daihyosen`) and score it like any bout. `POST`/`DELETE .../daihyosen` are public, but an anonymous caller adds or removes only on a RUNNING match (`selfRunDaihyosenRefusal`, handlers_daihyosen.go: 409 `result_finalized` with the `/score` sentence once it has finished, 409 `match_not_running` before it starts). The organiser keeps no status rule there, since an add to a finished fixed-order knockout match may be their correction (no `/reopen` exists for one). The add keeps a recorded kiken, fusenpai or fusensho; the organiser's remove of an unscored representative bout does not: it clears the match's decision and puts it back to running (`clearMatchVerdict`), while the competitor's bar stays recorded. Accepted as it is (operator decision 2026-10-02); do not report it as a new defect. An encounter holds ONE representative bout: a second add is 409 `daihyosen_exists` for every caller, and the add refuses engi, kachinuki and individual competitions and pool matches. `state.DaihyosenSubIndex` is the one finder of that row (the first -1 row). - -ONE judge, `holdSelfReportedWriteUnderTx` (handlers_match.go), decides everything a participant's score write is refused or rewritten for that depends on the stored match. The decision allowlist and the Rev/RevSession reset run before the lock, in `enforceSelfRunPolicy`. The judge runs under the write's lock over the stored match the `/score` handler reads ONCE per write for every guard (`matchSnapshot`, whose `Pairing` holds the stored sides and ids; `applyCorrectionReason` reads the same one, and the member judge takes it as the pre-write baseline), and each refusal is a `selfRunRefusal` carrying its own status, code and sentence. Who a write seats is judged on what it LANDS, after the engine write, in the same transaction (`landedMembersRefusal`): the handler reads the match back through `stx`, which sees its own staged bytes, and compares it with the snapshot read before the write. A side member id the stored row now holds and did not hold before must be on that side's team (400 `team_member_not_in_team`: `errBoutFighterNotInTeam` on a participant's numbered row, `errRepMemberNotInTeam` on a representative pick, anyone's), and a winner member id that changed on a participant's numbered row must be one of the row's two fighters (400 `winner_member_not_in_bout`, `errWinnerMemberNotInBout`); the organiser's numbered rows are not judged. A refusal is RETURNED from the transaction closure as a `*landedWriteRefusal`, which ABORTS the transaction (no match write, no history line, no eligibility record), and is unwrapped into `engErr` after `WithTransaction` returns. It is judged only for a write the engine accepted (a superseded or rolled-back write still commits its history and restore) and only when the write, as the earlier judges leave it, INTRODUCES a member id on a judged row against the match as it stood before the write (`introducesJudgedMember`, a pure predicate over the payload rows and the pre-write snapshot; it and the judge, `membersOutsideTeams`, read ONE selection of rows, `judgedRows`: every representative-bout row against the stored one and, for a participant's write, each numbered row against the stored row at its position, `state.SubResultAt`, so a row or field added to one is added to the other): an echo of the stored ids, which the team editor stamps on every row that has a lineup, reads nothing back, because the engine never lands a member id the judge would refuse that the payload did not introduce (pinned from the engine's side by `TestEngineLandsNoMemberIDThePayloadDidNotIntroduce`, over kachinuki bouts on both store branches and a fixed-order team match's representative bout on the bracket, with stamped writes (one older than the group it rewrites, so that group is held and copied back) and both teams' starting lineups and team members saved, whose minted ids no row carries: every member id the write itself lands that the payload did not carry is a STORED one at the same position (`preserveKachinukiMemberIDs`; the merge's copies of the groups it does not apply, held or unnamed, through `CopyGroup`/`SetRepPick`; `keepQueuedScore` on a start, which that test does not send) or a winner id derived from the row's own landed side ids (`ResolveMemberWinnerID`/`ReconcileWinnerMemberID`), and an id taken from those lineups or members inside the write would be a foreign one it refuses; `MaybeAdvanceKachinuki`'s lineup ids are added after the transaction, in the handler, which the test does not reach). It judges the landed match, not the payload, because the engine rewrites a write before its merge (`applyKachinukiMerge` merges stored rows by position and fills member ids; `noteServerBoutChanges` adds the `bout:N` groups it rewrote): a pre-write probe of the payload (`engine.AppliedGroups`, removed) let a participant land another team's member on a kachinuki bout the write left out of `changed`. A group the merge does not land is the stored one in the landed match and is never judged: a stale NAMED group is in `heldGroups` and the history, an unnamed one in neither. Every member-id check (the landed rows, and the lineup PUT's `memberIDsOutsideTeam`) goes through one `memberJudge`, which reads team-members.yaml at most once and only when an id is introduced, and builds each team's member-id set once (`memberJudge.ids`); a file it cannot read refuses the write with 409 `team_members_unreadable` (`errTeamMembersUnreadable`), never a 500 the offline queue would retry forever, and since a refusal is an abort, nothing is written. - -A participant's score write never creates a representative bout. A -1 row on a match that has none is one the write INHERITED, from a sheet a moment behind a remove made elsewhere or from a queued write replayed after it. The judge DROPS that row, logs the drop, and writes the rest through the timestamp guard, which decides a stale one. A write that FINISHES the match on such a row is refused instead, 409 `no_daihyosen` (`errRepBoutRemoved`), and nothing is written, since a finish may take its winner from that row. Nor does it ever remove one (only `DELETE .../daihyosen` does): a write listing the bouts without the -1 row the match has comes from a sheet a moment behind an ADD, so the judge KEEPS the stored row, scored or not, and a finish is refused, 409 `daihyosen_added` (`errRepBoutAdded`). Two rows are 400 `duplicate_daihyosen`. The organiser's write is not judged here, except its representative picks, which `landedMembersRefusal` judges on what the write lands, exactly as a participant's (same 400 `team_member_not_in_team` and sentence); a pick the merge holds (stamped older than the stored pick, or carried by a write held whole) is kept in the history and answered in `heldGroups`, never refused (a pick names a SIDE, whose team a correction can change under an open editor), and the match's status is never asked. Once the organiser records the judges' decision on it, a participant's write may send the row back (`sameHanteiVerdict`: same winner, same side names, mark in the same array) or leave its ippons out. The silence test is `engine.KeepsStoredDaihyosenVerdict`, the one `preserveSubHantei` restores by; a row with two or more outstanding fouls is never silent, because the fold would give it an array. Either way the judge REPLACES that row with a deep copy of the stored one, so the write lands with the decided bout exactly as recorded, whatever else the row said. Any of these is 409 `hantei_organiser_only`: -- recording, moving or clearing the decision; -- dropping the row; -- naming a match `winner`/`winnerId` other than the decided side, read id-first against the STORED sides through `domain.AttributeWinnerSide`. - -Its two sentences are shared with the JS note (`REP_BOUT_DECIDED_NOTE`) through `internal/mobileapp/testdata/rep_bout_hantei_messages.json`. Every public write throws a refusal's `message` before its code (`_refusalText`, api_client.jsx). `IsSelfRunReportableSubDecision` judges one row's decision only, and takes only `""` or `daihyosen` on the -1 row (a draw, kiken, fusenpai or fusensho there would decide the encounter). Both `/daihyosen` routes resolve the caller before reading the body (a wrong password is 401, never `clock_skew`) and answer with `matchForBroadcast`, as the SSE event does. - -The public editor matches: -- under `selfReport` it offers no hantei; -- it offers Add and Remove only while the match is running (`repBoutAddRemoveOpen`); -- once a hantei is recorded, the representative bout is read-only (`repBoutDecidedForParticipant`, overtime stepper included) with the refusal's sentence, and every save sends the verdict back; -- the individual editor hides its whole decision place. - -`POST .../bulk-score` and `PUT .../quick-score` write without the participant's checks, so they are main-gated in self-run; bulk-score does judge each entry's representative picks on what the entry lands, in the entry's own transaction (a refused entry stores nothing and reports `reason: team_member_not_in_team`), and quick-score builds its rows server-side. - -**A representative-bout add or remove holds the team editor's autosave** (bc-dhas). The add and remove go through `_daihyosenRequest` (api_client.jsx) on `_fetchJson`, which keeps one AbortController armed, on a timer rather than the wall clock, until the body is read. So a request the server never answers, or answers with a body that never completes, fails with the not-added/not-removed sentence instead of holding the write forever. The add and the remove are one sequence, `runRepBoutChange` (admin_scoring_team.jsx). It takes `hold()` before its pre-save. The pre-save is bounded by the same `FETCH_TIMEOUT_MS` (write_result.jsx), past which the change is reported as not answered, nothing is sent and the hold is released. Add and Remove both stay disabled while that save is still out (`submitting`), so no second change starts beside a pre-save given up on. The admin hosts' refetch after a write is bounded too (`refreshCompsBestEffort` asks `fetchCompetitions({ bounded: true })`), and one given up on at that deadline says nothing: `_fetchJson` rejects past its deadline with the one `timedOut` error however the abort surfaced, and `refreshFailureToast` (admin.jsx) returns no toast for it, because the hosts refresh after every autosave and on a slow link the toast would follow every few taps while the write reaches the page by push anyway. Any other refresh failure keeps the reload hint. `recordScore`, `recordDecision`, `overrideBracketWinner` and the queue's replay (`_flushQueue`, single-flight, so one stalled replay stopped every later flush) read their answers through `_fetchJson` too, so a body that stalls past the deadline is a network failure: the write comes back `{queued: true}` (the editor stays open, `writeKeepsEditorOpen`) and is replayed with the stamp it was sent with, safe if the server did store it because the write guard is last-write-wins and an equal stamp re-applies. The add saves the sheet first unless the match has finished, so a queued match starts through the score path's gates; the remove saves first only a running sheet with an edit owed. A debounce that fires while held defers its write, and the mounted editor calls `release()` from an effect on `daihyosenBusy`'s true-to-false edge, after the render that adopted the result. The adopt keeps the answer in the override, `{ match, at }`: a stamped answer the prop does not hold yet (`holdsAnswer`) becomes the match the sheet shows, with the answer's bout log, status and team-match overtime (the prop's winner and decision stay, since the client reshapes those), and it goes once the prop's bout log changes or its stamp reaches `at`; a change of the prop's STATUS re-judges it too (an effect on `match.status`, which releases it when the prop holds the same bout log and is not older than the answer), so a finish or reopen made elsewhere is not hidden behind it. `hold()` returns `settle(transform)`, called on every exit (`withServerDaihyosenRow` over the answer's row once landed, or over the last prop's when that prop already holds the answer; bare in `finally`): an edit owed when the editor UNMOUNTS during the request waits for it and is written with the outcome applied (the added row as returned, a removed one left out, unchanged on failure or refusal). A page going away (`pagehide`) meanwhile writes it at once, durably, as it stands, since nothing can wait for it; a tab merely hidden (`visibilitychange`) waits for the request like the unmount does and then writes durably with the outcome applied, since a write at once would put back a removed representative bout or wipe an added one. Accepted residual: a hidden page the system discards before the request answers, with no `pagehide`, loses that one tap. The pre-save (`saveRunningSheet`) repeats while a tap made during it is owed, reading the sheet through the autosave refs: the request is stamped when it is sent, so an edit made before it and written after it would lose the guard silently. - -**The persisted outbox is shared by every tab.** `_persistQueue` merges into what storage holds, keeping the newer copy (`enqueuedAt`) where two tabs hold the same ENTRY (the same key; two writes about one match are two entries since bc-mrgc phase 3, and both are kept), and every removal goes through `_dequeue`, which records it so the merge prunes it from storage. A bare `_writeQueue.delete` leaves the stored copy for the merge to keep, and the write is replayed on the next load. Each tab also follows the others' writes (`_onStoredQueueChanged`, on the `storage` event): it takes up an entry it does not hold, or holds an older copy of, after the rehydrate's checks (`_storedEntryProblem`), and drops its copy of one another tab took out of storage, so a write queued in a tab that then closed is still sent. A write two tabs hold can be sent by both, which is harmless (last-write-wins). A revoked password is scrubbed from every stored entry, another tab's included (`_parkStoredEntries`). - -**A superseded write leaves NO FOOTPRINT on the match, on either branch; its one footprint is its match history entry** (bc-mrgc). `UpdatePoolMatchByID`'s mutate returns an `error` and the store skips the save when it does, the contract `UpdateBracket` has always had, so `applyPoolWrite`'s superseded verdict aborts rather than re-serializing the untouched row and bumping the standings-cache version. Two rules come with it. The abort is reported through the ERROR and NEVER through `found`: `found` stays true because the match WAS there, and signalling `(false, nil)` would make `writeToPoolOrBracket` read a rejected pool write as "not a pool match" and go hunting for it in the bracket. And BOTH `storeTx` branches must honour it, the WAL-staged one included, or a drop leaves no trace outside a transaction and a WAL entry inside one. Pinned across all six cells of `superseded_matrix_test.go`, which asserts the file bytes AND `Store.FileVersion` are unchanged after a drop; reverting the abort reddens exactly the two pool cells. - -**On the client a live score never goes back in time under a score editor.** A refetch can read the data just before a write commits and answer after a newer push or a newer refetch has landed; taken whole, it put the older scoreline back, and an open score editor that had caught up adopted it, so its next save wrote the lost point away. The refetch sinks a score editor reads from therefore merge through `patch.jsx`'s `keepNewerMatches` (`keepNewerCompetitions` for a list, `keepNewerDetail` for a competition detail, `keepNewerTournament` for the tournament): the court console's feed, the admin competition view, the public competition page (`selectedCompData` in app.jsx, every refetch through `takeCompDetail`, since self-run gave that page a score editor), and the tournament's competitions (the Scores tab's source, fed by the admin's refreshes and by the app's `load()` outside display mode). A held match is kept only when BOTH copies are RUNNING and the fetched one is stamped and older. Stamps come from two clocks: a client write carries the device's estimate of server time (accepted up to 5s ahead), a reopen, requeue or send-back the server's own, so a newer server stamp can read older; every server-stamped change moves the status, so running-against-running never weighs one clock against the other. An unstamped fetched copy (a draw discarded and drawn again reuses the match ids) always replaces. The display takes a refetch whole: no editor reads it, and its reload must discard its optimistic broadcast overlay. Accepted residual: a holder that never saw a match leave running (the Scores tab's list, which takes no pushes) can keep a running copy stamped by a device whose clock runs a few seconds ahead over the server-stamped running row of a reopen made within those seconds (e.g. a mistaken withdrawal reopened at once). Both show the match live; the held copy can still show the winner's letters that the withdrawal replaced with its circles, until the next write to that match replaces it. - -**A public-page modal never holds a copy of its match.** `useLiveMatch` (viewer_match.jsx, beside `MatchViewerModal`), used by all four doors (the competition page's tabs and Overview, Home, Schedule), keeps only the match's id and competition and reads the row live every render from the page's own `compMatches` list, so a self-run score editor follows a correction made on another device; Home and Schedule look it up by competition as well as id (`matchInList`), because match ids repeat across competitions; Schedule builds only the open match's competition's list, and only while a match is open. The Overview reads `allMatches` alone. Nothing else is taken from the object the opener hands in: an opener may hold an old copy (Home's alert banner keeps the match from when the alert fired), and its scoreline must never stand in for the live one. Only the competition page's Bracket tab declares anything, `open(match, { round, phase, ...competition fields })`, and declared keys win over the row: a preview bracket is not in `compMatches`, so its live row is the raw one (`bracketMatchIn`). A match that leaves the data closes the modal and is forgotten, because a redrawn draw reuses ids. - -Engine side: `state.IsTransactional(h)` lets a caller whose correctness depends on a live transaction check what handle it was given; `recordIneligibilityFromDecision`'s K2 check-and-set logs if handed the bare store, which is what pins the entry-point `WithTransaction` shims. `mergeMatchWrite` orders every write by stamp **including corrections** (the old `correctionReason` exemption was a workaround for refusals being silent, and it let a replayed offline correction overwrite newer results). The remaining unstamped-bypass overwrite is logged rather than silent (`logUnstampedOverwrite`). ### The competitor-number search rule (bc-nsrc) A picker that FILTERS A LIST by what the reader typed asks ONE rule for the competitor number, `matchesCompetitorNumber` in `web-mobile/js/competitor_search.jsx` (composed into `competitorMatchesQuery` for a record and `matchMentions` for a match), and never re-derives it (operator rulings 2026-09-21 and 2026-09-23). Two arms over the record's own competition prefix: the query EQUALS the whole number, or the query BEGINS the competition's `numberPrefix`. So "k" lists every K draw (K and an auto-minted K02 alike), "k02" lists the K02 draw, "k1" is K1 alone (not K12), and bare digits find nobody. The prefix has to be a FIELD on the record, because the number string cannot say where its prefix ends ("K021" is K02's first competitor or K's twenty-first): `buildRoster` (`viewer_watchlist_core.jsx`) stamps `numberPrefix` on every roster record and `buildPlayerMap` (`api_serializers.jsx`) on every resolved match side, `prefixOf` (`competitor_identity.jsx`, beside `numberOf`, trimmed as Go's `EffectiveNumberPrefix` trims) reads it back, and a competitor only carries a number when their competition carries a prefix (`handlers_viewer.go` `numberingApplies`), so there is no fallback arm. Names and dojos stay substring matches. Two admin surfaces are ruled OUT and carried as data in the gate: `admin_participants.jsx` does not search numbers, and `admin_registration_desk.jsx` keeps its fuzzy ranked search (it still finds K12 from a bare "12"); do not "unify" them. The gate is `web-mobile/check-competitor-search.mjs` (a `.number`, `numberOf(` or `prefixOf(` read on the same line as `.includes(`/`.startsWith(` outside the owner fails the build); it shares `check-helpers.mjs` with `check-write-result.mjs` and exports `FORBIDDEN`, and `rule_gates.test.jsx` pins both gates. The one call site the gate cannot see, the admin Scores filter, is pinned by a render test instead (`score_editor_filter_finds_a_number.render.test.jsx`). -### Excel workbook construction - -The workbook is built entirely from code in `internal/excel/template.go` (`NewFileFromScratch`). Each sheet (data, Time Estimator, Pool Draw, Pool Matches, Elimination Matches, Names to Print, Tree) is created and styled programmatically. Layout constants (rows-per-page, spacing, max bracket size) and sheet name constants (`SheetData`, `SheetPoolDraw`, etc.) live in `internal/helper/constants.go`. Use these constants everywhere rather than string literals. - -**excelize is pinned to a master commit, not a release** (`v2.11.1-0.20261010030226-066c84b8f7de`, operator decision 2026-10-10, PR #463): v2.11.0 carries fifteen advisories that fail CI's Anchore image scan (`validate`, severity medium and up), each fixed only on excelize's master, and no release after v2.11.0 existed. Move to the first tagged release that contains those fixes. `govulncheck` does not see them (it counts reachable code); to reproduce the scan, run grype on the built binary (`docker run --rm -v :/scan:ro anchore/grype:latest dir:/scan --fail-on medium`; the image is `FROM scratch`, so the binary is all it scans). An excelize version change rewrites the bytes of the committed example workbooks (this one changed only font property order and numbers written to 15 significant digits), so `TestCreateHandler_WithoutTeamMatchTypeMatchesTheExamples` fails: regenerate them with `make examples` and compare the workbooks' XML (cells, formulas, styles, page breaks), not their bytes, before committing. - ### Resource Embedding `main.go` embeds `web/*` via `//go:embed`. The global var `helper.WebFs` exists for backward compatibility with code paths that still reference it directly. Must rebuild after changing embedded files. diff --git a/GEMINI.md b/GEMINI.md index 6f3202d48..ba101ed74 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -5,6 +5,10 @@ Before implementing features or making architectural decisions, read the project constitution: **`.specify/memory/constitution.md`**: defines the core principles (YAGNI, DRY, TDD, DDD, evidence-based decisions, bracket integrity, and live-tournament constraints) that all changes must comply with. +## Domain rules + +Detailed domain rulings (match results and marks, match writes and the offline queue, teams and lineups, eligibility, the store, workbooks, the draw) live in `.claude/rules/`, one file per area; each file's `paths` header lists the code it governs. Read the matching file before changing code in that area: nothing loads it for you outside Claude Code. + ## Project Overview `bracket-creator` is a specialized CLI and Web application designed to generate tournament brackets for Kendo competitions. It supports both straight knockout and round-robin (Pools) formats, accommodating both individual and team matches. From 1cda339b8f939368150b21b9f975b80e9b0a285d Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 12 Oct 2026 23:16:01 +0100 Subject: [PATCH 2/2] docs(bc-cmrs): name which subagents the Sonnet default reaches Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 162fb02d1..9ec5cb31d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ When asked to *plan*, *verify*, or *record a plan on a bead*: do NOT write code. Every model call re-reads the session's whole context, this file included (about 17k tokens on its own since the domain rules moved to `.claude/rules/`), so a session's cost grows with its length. One session that ran a multi-bead PR and its review loops made 209 calls at an average of about 360k tokens of context, and never compacted (2026-10-11). - **Delegate review rounds, investigations, history lookups and verification sweeps to subagents, and keep only their short summary** (findings, decisions, `file:line`) in the main session: ask the subagent for a summary in a set number of lines, not its full report (operator feedback 2026-09-13: "Make better usage of sub agents"). -- **Subagents run on Sonnet unless you say otherwise** (`CLAUDE_CODE_SUBAGENT_MODEL=sonnet` in the user settings, 2026-10-12). That suits searches, history lookups, reference sweeps and verification. Pass `model: "opus"` for an implementer and for the final review round; the pr-review-toolkit's `code-reviewer` and `code-simplifier` pin Opus themselves. +- **Subagents run on Sonnet unless you say otherwise** (`CLAUDE_CODE_SUBAGENT_MODEL=sonnet` in the user settings, 2026-10-12). That suits searches, history lookups, reference sweeps and verification. Pass `model: "opus"` for an implementer and for the final review round; the pr-review-toolkit agents are unaffected, since each names its own model (`opus`, or `inherit` for the session's). The ones that drop to Sonnet are `general-purpose`, `Explore`, `claude` and `beads:task-agent`. - **Start a new session for each PR.** Once a PR merges, begin the next bead's work in a fresh session; the bead and the PR carry the state. ## Governance