Skip to content

Exam report ​

1 · Introduction ​

What the game is, the loop a player is in, and the vocabulary the rest of this document is written in.

What FantasyWiki is ​

Every day, millions of people read Wikipedia. A footballer scores, a country makes the news, a film releases, and the pageviews spike.

FantasyWiki turns that into a fantasy league. A player joins a league, gets a budget, buys article contracts, arranges them into a formation, and scores as the world reads. The season runs for weeks; the squad never stops needing management.

This page is the orientation layer. It says what the pieces are and where each one is specified, it does not restate any of the rules, because every rule in this system is written down exactly once, under domain/.

The loop a player is in ​

Four decisions, repeated: what to own, where to place it, when to let it go, and which league to spend the effort in.

The four systems it is built from ​

Each of these is a body of rules with one canonical document. If two pages ever disagree about one of them, the one linked here is the one that is right.

SystemThe question it answersCanonical
ScoringHow a day's pageviews become points, and why doubling readers is a step rather than a jackpotScoring & Economy System
ChemistryWhy two articles score extra when they sit side by side, and what "side by side" meansChemistry Links
The economyWhat a contract costs, what it pays back, and why the money supply is closedADR 0003 · ADR 0005
LeaguesWho can join, when a season starts and ends, and what happens to a league nobody plays any moreLeague Lifecycle · League Season

The system at a glance ​

Five deployable pieces and one shared vocabulary. The frontend and the Worker share their types through two framework-free packages; the nightly collector shares nothing with either but an HTTP contract and a secret.

The layers inside each of those, and the seams between them, are in the architecture overview. The one worth knowing before reading any further is why the collector exists at all.

Why it needs a nightly batch at all ​

The obvious design scores a team when someone opens the app. It does not work, for a reason worth stating plainly: a day's pageviews are only knowable after the day is over. Wikimedia publishes them in arrears, the same figure for everybody, and no amount of asking earlier produces a number.

So scoring is a batch over yesterday, run once, for every team in every league.

Two properties keep this honest, and both are architectural rather than incidental:

The collector computes nothing. It posts raw facts, views per article, the resolved level of each chemistry link, and the Worker turns them into points using the single implementation in model/scoring.ts. One scoring formula, in one language, so the JVM and the TypeScript runtimes cannot drift apart.

The collector knows nothing about the game. No formation, no position, no language calibration. The backend resolves the formation into a flat list of article pairs and hands them over. Adding a formation touches one enum.

The full pipeline, including the failure modes and the cost analysis, is in Nightly Scoring Pipeline.

Where the words come from ​

The domain has a vocabulary and it is enforced: Top Read Snapshot, Article Availability, Chemistry Link, Free Agent, Owner Team. These are not stylistic preferences, they are the terms the code, the commit messages and these docs all use, and each one has an "avoid" list of the synonyms that would otherwise creep in.

That glossary is CONTEXT.md, and it is canonical. A concept that does not have a name there does not yet have a name. It is rendered, term by term, on the vocabulary.

Where each rule is written down ​

The documentation under docs/ is grouped by what would have to change for a document to become wrong:

  • domain/: a game rule changed
  • architecture/: a refactor changed it
  • development/ and deployment/: a tool or an environment changed
  • adr/: nothing changes it; a decision record is immutable, and when an ADR disagrees with any other document, the ADR wins

Those documents live in the repository, beside the code. Pages like this one, the architecture overview and the data flow do not: they carry diagrams and orientation, and deliberately carry no rules, so there is nothing on them that can contradict the repository. Which is which, and why, is in About this site.

The vocabulary ​

The domain has one set of words and they are not stylistic preferences. A term below is what the code names its types after, what a commit message says, and what these pages call the thing, and each one carries the synonyms it exists to displace, because the cost of a domain language is paid entirely in the near-misses that creep in beside it.

A bold word inside a definition is another term on this page, so a concept can be followed rather than looked up. A concept with no entry here does not yet have a name.

A term marked core is one nothing else in the vocabulary can be read without: the entities the game is played with, the economy they move in, and the scoring they feed. Those are the terms the single-document exam report carries, so that a document read straight through introduces the words it then uses rather than the whole ingestion pipeline.

The 30 terms the rest of this document is written in. The whole vocabulary, 52 terms, is on the vocabulary page.

Top Read Snapshot

A daily ranking result bound to a specific Project Domain and retrieval date.

Neverlive rankingreal-time top list

Language Scale Factor

A per-language constant that multiplies raw pageviews to lift each language onto one common reference scale (the reference language's factor is 1.0; lower-volume languages get a factor > 1). A single factor per language drives both tier granularity and synergy balance, so the tier model and synergy table are tuned once and reused for every language.

Neverper-language tier tableper-language synergy tablecross-league multiplier

Normalized Views

Raw pageviews multiplied by the Language Scale Factor. This is the input to the universal tiered base-points model, never raw pageviews directly.

Neveradjusted viewsweighted views

Base Points

The view-driven score of one article for a day, computed from its Normalized Views by the universal geometric model: max(0, log₂(views / 2000)) up to a 150k kink, then linear above at +1 point per 50,000 views. Continuous and decimal-valued (the "+1 per doubling" rule is just the headline at the rungs); crosses zero at 2,000 views. Concave (diminishing) in the mid-field, convex at the top so the volatile daily elite is rewarded. Excludes synergy and events.

Neverraw pointspageview points

Player

A person with an account, identified by a sign-in and a username. A player holds one Team in each League they belong to, and nothing they own in one league counts in another.

Neverusermanagercoach

Team

A player's single entry in one League: a name, a Credits balance, the Contracts it holds and the Formation it fields. Ownership, scoring and standing are all team-level and never player-level, so a player with teams in three leagues is playing three separate games.

Neversquadclubrosterprofile

League

The competition a set of Teams play in. It fixes one Wikipedia language edition, one Season, one League Visibility, one Invite Policy and the Language Scale Factor frozen at the moment it was founded, and every price and every point inside it is computed against those.

Neverroomlobbygrouptournament

Global League

The one public League every player is enrolled in by naming their first Team. Its id is the fixed string global and nobody may leave it, so it is where a player plays before they have joined or founded anything else.

Neverdefault leaguemain leaguehome league

League Admin

The player who created a League. The admin may close it before its Season ends, and under the admin Invite Policy is the only one who may hand out its Invitation Code.

Neverownermoderatorhost

Season

The stretch a League runs for, chosen at creation from two weeks, one, two, three or six months and starting the moment the league is created. The player picks a length and never an end date. The two-week floor is the shortest season in which a LONG Contract Tier can still reach Expiry Settlement.

Nevergameweekmatchdayroundcampaign

Article Availability

The ownership status of an article at the time detail is shown.

One ofFree AgentOwned by ViewerOwned by Other Team

Nevergeneric unavailable flag

Contract

A Team's holding of one article for a committed term: signed at a Purchase Price, running from its purchase date to its expire date, and leaving by exactly one of Early Sell or Expiry Settlement. An article is never held loosely, only under a contract, which is why the contract and not the article is what scores, what is priced and what is placed on a Position.

Nevertransferplayer cardownership record

Formation

A lineup that assigns contracts to the positions required by a Formation Schema.

Neverlineuproster

Position

A named slot in a formation (e.g., LW, CM, GK) where an article contract can be placed.

Neverslotrole

Formation Schema

A named layout that defines which Positions are required and which Chemistry Links exist between them.

Neverformation typelayout only

A schema-defined connection between two Positions that carries a Chemistry Level for the articles placed there.

Neveradjacency lineedge

Chemistry Level

A four-step rating (Excellent/Good/Weak/Empty) assigned to a Chemistry Link that drives UI color and an additive score contribution (flat points), not a multiplier. Chemistry is only evaluated between schema-adjacent Positions, not between every pair of owned articles.

Nevercolor tiersynergy multiplierall-pairs synergy

Daily Performance

One Team's score for one Snapshot Date (UTC): the Base Points of the articles placed on its Positions, plus the flat points its Chemistry Links carry, stored once per team per day. It is the only thing Standings are built from.

Nevermatchfixturegameweekresult

Scoring Collector

The nightly batch that scores every Team in every League on the previous day's pageviews. It reads each team's placed articles from the backend, fetches per-article views and the link graph from Wikimedia, and posts back raw facts rather than points, because the scoring model has one implementation and the collector is not it. See docs/architecture/scoring-pipeline.md.

Neverscoring jobcronscorer

Standings

A League's table: cumulative Daily Performance points, descending, with each team's movement against the order as it stood before its own last scored day. Nothing else is weighted in, not credits, not team value, not days played, and ties are broken by nothing. The API and its DTOs call the same table leaderboard. See docs/domain/standings-and-podium.md.

Neverrankingscoreboardleague position

Market

The screen a Team signs Contracts from: search across articles, the Article Availability of each, and the Article Genie for the article a player cannot name. Every contract begins here.

Nevershopstoretransfer marketauction

Contract Tier

The committed duration picked when a Contract is signed: SHORT is 3 days, MEDIUM 7, LONG 14 (ADR 0005). The tier is what Contract Price charges for and what Early Sell prorates against, and a renewal rolls the same length forward rather than choosing a new one.

One ofSHORTMEDIUMLONG

Nevercontract length in weeksplansubscription

Credits

What a Team pays for Contracts with, and the only currency in the game. Never a stored balance: credits are derived on every read from the team's own contracts, starting budget minus every Purchase Price paid, plus every payout already settled back (ADR 0007). Affordability is enforced inside the statement that writes the contract, not above it.

Nevercoinsmoneywalletbudget

Contract Price

The credits required to hold an article, D × BasePoints(Normalized_30-day-average_Views)^k × contract_days (ADR 0005, derived from the scoring curve's own BasePoints shape, not raw views; days-based; supersedes both the original linear /1000 × weeks form and an intermediate convex-in-raw-views form). Priced on the smoothed 30-day average, never daily views, this is the deliberate decoupling that makes daily spikes cheap-but-fleeting and sustained popularity expensive. The k exponent applies convexity to the already-log-compressed points value (not to raw, power-law-skewed views), so giants/top-tier articles cost progressively more per marginal point than mid-tier ones without the price curve diverging from the value it buys.

Neverdaily-view pricespot pricelinear-in-views priceconvex-in-views price

Purchase Price / Current Price

Purchase Price is the Contract Price locked at signing. Current Price is the same formula re-evaluated with live 30-day-average views, at the contract's original tier duration, a "replacement cost" number (ADR 0003). It's the shared basis for both exit paths: prorated for an Early Sell, or a full Expiry Settlement against Purchase Price at natural term completion.

Neverfixed price onlysingle priceresale price (ambiguous about which exit path)

Early Sell

Voluntarily exiting a contract before its committed term ends: Current Price × (remaining days / tier days) credited, pays only for the unused portion of the term, at today's rate (ADR 0003). Proration is the sole anti-exploit guard (there is no minimum hold): holding 3 of 14 days recovers only 11/14, so a partial hold can never return the full price plus free days of points.

Neverresalecash out (ambiguous with Expiry Settlement)

Expiry Settlement

The "sold to system" outcome when a contract completes its full committed term without renewal: the team is credited the full Current Price, i.e. Purchase Price + (Current Price − Purchase Price) (ADR 0003). The buy already debited Purchase Price, so expiry returns the whole stake plus the mark-to-market P&L, net profit if views rose over the whole hold, net loss if they fell (crediting only the delta, as an earlier draft did, would wrongly forfeit the stake). Can only trigger by holding the entire term, so it can't be reached via early exit. This is the mechanism that makes buying a viral/trending-spike article at its peak genuinely risky: a spike that fully reverts before the term ends turns into a real loss at settlement, not just "no profit." Runs via a daily Cloudflare Cron sweep on the backend (single money-writer, ADR 0004), idempotent on a contract status guard.

Neverexpiry (bare)auto-sellcontract end (without specifying settlement)

Renewal Election

The owner's choice, in the final 24h of a Contract's term, to renew rather than let it reach Expiry Settlement (ADR 0003). The default is to let it expire and the choice locks for expiry once the window closes. Renewing rolls the term forward at the Current Price plus the Renewal Premium.

Neverright of first refusalauto-renewextension

Article Genie

The market-page assistant that finds articles a player cannot name exactly (ADR 0006). It serves two intents through one input: chemistry scouting ("find me a relation between OpenAI and Portugal") and tip-of-the-tongue recall ("the female mathematician who worked at NASA"). It never invents a title, it seeds a bounded list of real articles from search and uses an LLM only to narrow it through questions. When the daily model quota is exhausted the Genie is asleep and the market falls back to the ordinary search bar.

NeverAkinator assistantAI searchchatbot

League Visibility

Whether a league is public (any player may join) or private (joining requires its Invitation Code, or being the League Admin). Visibility governs joining only, a private league's page and standings stay readable by anyone with its id. The Global League is public. See docs/domain/league-visibility.md.

Neverleague privacyclosed leaguelocked league

Rendered from CONTEXT.md, which is where it is edited and where the entity relationships and the resolved ambiguities are also kept.

2 · Domain-driven design ​

How the model was discovered before it was written down, which DDD archetype each concept became and the perimeter the model deliberately sets, and one aggregate invariant followed all the way to the line that enforces it.

Requirements ​

Requirements here are traceable rather than declared. Every functional obligation points at the document that specifies it and, where it is already built, at the module that satisfies it. A requirement with no document is a requirement nobody has agreed on yet, and this page says so rather than implying otherwise.

The original brief is the Game Design Document, FantaWiki Requirements v5.5. It is kept as written and is partly superseded: its scoring, economy and tournament sections were reconciled against the locked design, and where it disagrees with an ADR, the ADR wins.

Where they came from ​

The brief was not written in one sitting and then built. The concept was put in front of people first: described, argued with, and revised on what came back, before any of it became a requirement. That round is why several of the obligations below are narrower than the original idea.

How the idea got to this shape ​

The starting point was Polymarket, a prediction market where people buy and sell shares in the outcome of a real-world event, so the share price reads as the crowd's estimate of how likely it is.

The observation this project rests on followed from it: Wikipedia pageview volume tracks the same trends a prediction market prices, and it is free to read.

Two ideas were dropped on the way to the current one:

IdeaWhy it was dropped
A prediction market of open-ended bets, on anythingNo way to settle them. An arbitrary bet needs an oracle the project could not build or afford
Trends sourced from Google search analyticsThe analytics API costs hundreds of dollars a month, which a project running on free tiers cannot justify

Dropping settlement is what turned the concept from a market into a game. With no bet to adjudicate, scoring had to come from a formula over public numbers; once scoring is a formula, the format that fits it is fantasy football. The derivation in full: prediction market, minus settlement, plus a squad.

What the pitch came back with ​

Who: the university friends who would later play it, and other people from the university outside that group.

How: informally, before anything was built. A conversation, a walkthrough of the product, then a short spoken set of questions on what they made of the idea and whether they would play it.

What came back:

ResponseEffect on the requirements below
Broadly positive. The appeal was using Wikipedia data as a game about current news and trendsThe premise was kept
A preliminary auction for articles was rejected as tedious. A fantasy-football auction works because the players can be listed; Wikipedia articles cannot be enumerated that wayThe market is a fixed price against a live valuation, not an auction
Google analytics would be the better data sourcePriced out, as above
What nobody asked about ​

The leaderboard. Neither group questioned how teams would be ranked against each other. Total points across the season went unchallenged, and nobody raised head-to-head fixtures, a tournament, or any alternative.

That is the one worth recording, because a requirement nobody questioned and a requirement that survived scrutiny look identical once written down. The playtest separated them: players reported that nothing felt competitive and asked, unprompted, for the head-to-head format nobody had thought to raise beforehand.

The auction ran the other way. Rejected at the pitch, then requested by the same people after a month of play.

The second round of evidence came much later and from real play rather than opinion. A month on production, in the playtest.

The model, as a wall of notes ​

Requirements are easier to argue with as a domain model than as a list. Four stories, told the way they would be told on a wall: who asks for something, what they ask for, which thing is allowed to say yes, what became true as a result, the values the decision is made of, and the policy that answers without being asked.

Each role carries its own outline as well as its own colour: a circle acts, a rounded box is what was asked for, a bracketed box is what may say yes, a flag is what became true, a hexagon is a value it is made of, and a slanted box is a standing rule that answers before anyone asks. The shape is the legend; the colour only agrees with it.

Founding a league ​

As a league admin, I want to found a league on the Wikipedia edition my friends read, so that we compete over articles we recognise.

A league freezes its Language Scale Factor at founding rather than reading it live, because a later recalibration would silently re-rate every contract already priced. → ADR 0002

Buying a contract ​

As a player, I want to buy the articles I think the world is about to read, so that my squad scores before anyone else notices.

An article is a Free Agent or somebody's, never both, and the price comes from a smoothed thirty-day average rather than yesterday's spike. What is paid at signing is fixed there; what the contract is worth on the way out is worked out again on the day. → ADR 0005 · ADR 0003

Fielding a formation ​

As a player, I want to place my contracts where they reinforce each other, so that arranging the squad is a decision and not decoration.

Two adjacent articles score extra when Wikipedia itself links them, so where a contract sits is worth as much as which contract it is. → Chemistry Links

Scoring the night ​

As a player, I want yesterday settled before I wake up, so that the standings are a fact rather than a calculation I have to trigger.

The actor here is the clock, and that is the whole reason the collector exists: a day's pageviews are only knowable once the day is over. → What FantasyWiki is

Every requirement, as a story ​

The wall carries the four stories the rest of the game hangs off. Every functional requirement below has a story of its own, and the ones the wall does not draw are told here in words: who wants it, what they want, and what it is for. The "so that" is the part to argue with, because it is what a requirement is measured against when two of them pull apart.

#Story
F1As a player, I want to sign in with the Google account I already have and stay signed in, so that I never create or remember another password for a game.
F1bAs someone running FantasyWiki on their own machine without a Google project, I want to register and sign in with a username and a password, so that the local build is playable without registering an OAuth client.
F2As a League Admin, I want to found a league on the edition my friends read. On the wall: Founding a league.
F3As a League Admin, I want my league closed to anyone without its Invitation Code, and to decide whether members may pass the code on, so that the league is the group of friends I meant it for.
F4As a League Admin, I want to choose how long the Season runs, from two weeks to six months, so that the league lasts as long as my friends will stay interested and ends with a result.
F5As a player, I want to buy an article at a price that reflects its recent month, not yesterday's spike. On the wall: Buying a contract.
F6As a player, I want a contract I held to its term settled at its live value, gain or loss, so that picking an article before it trends is rewarded and buying one at its peak is a real risk.
F7As a player, I want yesterday scored before I wake up. On the wall: Scoring the night.
F8As a player, I want adjacent articles that link to each other to score more. On the wall: Fielding a formation.
F9As a player, I want to see at a glance whether an article is free, mine, or another team's. On the wall: Buying a contract.
F10As a player, I want to rearrange my formation without ever losing a contract. On the wall: Fielding a formation.
F11As a player whose league has ended, I want to open its standings and my team as they finished, so that the season we played is something we can look back at, not something that vanished.
F12As a player who hit a problem, I want to report it from inside the app and attach a screenshot, so that telling the authors costs me less than giving up.
F13As a player who half-remembers an article, or wants one that links two others, I want to describe it and answer questions until it is found, so that I can buy an article I cannot name.

Functional requirements ​

The same obligations as a table, each pointing at what specifies it and, where it is already built, at what satisfies it.

#The system must…Specified inBuilt in
F1Authenticate a person through Google and hold the session in an HTTP-only cookieData flowbackend/src/routes/auth.ts
F1bAlso admit a username and password, in the build that has a credential store to check them againstAuth Modesroutes/passwordAuth.ts · indexPassword.ts
F2Let a player found a league on any Wikipedia edition that passes the calibration floorWikipedia Language Editionsservices/wikipediaEditions.ts
F3Keep a league private unless its founder says otherwise, and admit by codeLeague Visibility · ADR 0008services/invitationCode.ts
F4Bound a season between two weeks and six monthsLeague Seasonservices/league.ts
F5Price a contract from a smoothed 30-day average rather than a spikeADR 0005model/pricing.ts
F6Keep the money supply closed: no stipend, no fee, gains and losses settled at expiryADR 0003 · ADR 0007workflows/contractSettlement.ts
F7Score each team on the previous day's pageviews, once, for every leagueScoring & Economy Systemscoring-collector/ + services/scoring.ts
F8Award chemistry for adjacent articles that link to each other on WikipediaChemistry Linksservices/performance.ts
F9Show a player which articles are free, theirs, or another team'sArticle Availabilitymodel/contract.ts
F10Never lose a contract when a formation changesLineup Rulesservices/lineup.ts
F11Keep a league readable after it ends, and never delete what someone can still readLeague Lifecycleservices/league.ts
F12Let a player report a problem without leaving the appProblem Reportsservices/problemReport.ts
F13Answer questions about an article without naming it, as a guessing gameADR 0006 · Article Genieservices/articleGenie.ts

Definition of done ​

A requirement is done when all of the following hold, and not before:

  1. The rule is written down once, in the doc the table above points at, in the vocabulary of CONTEXT.md.
  2. A test states it. An automated test asserts the observable condition in the table below, at the tier that owns the rule: a backend rule in the backend suite, which runs on both persistence targets, and a rule that lives in the browser in the frontend suite.
  3. The contract is described. Every route it added or changed is in backend/openapi.yaml, and the suite that gates the spec against the mounted routes passes.
  4. ci-cd / success is green on the pull request: format, lint, typecheck, audit and every suite.
  5. It is on master, which means deployed to production, and released if the change was a feat or a fix (Release Process).

The table is condition 2, requirement by requirement: what has to be observably true, and the test that shows it. Where the test covers less than the requirement says, the gap is named rather than rounded up.

#Done whenShown byGap
F1A sign-in sets a session_token cookie whose JWT the /api/* guard accepts, and a request without it is refused with 401routes/index.spec.ts · integration/devAuth.integration.test.tsThe Google callback's own cookie is asserted through the shared session code, not by a test of the callback
F1bRegistering and signing in with a password mints the same session, a wrong password and an unknown user answer alike, and no password is stored in clearintegration/passwordAuth.password.test.ts · auth/passwordHash.spec.ts
F2Every live edition is offered; one below the calibration floor is refused and writes nothing; one never played is calibrated before its league existsintegration/languageScaleCalibration.integration.test.ts · wikipediaEditions.spec.ts
F3A private league turns away anyone without its code, admits the right code however it was typed, and one league's code opens no otherintegration/leagueVisibility.integration.test.ts · integration/leagueCreation.integration.test.tsVisibility is a required field, so "private unless the founder says otherwise" is the form's default, which no test asserts
F4Only the offered season lengths are accepted, and a league runs for exactly the length chosenintegration/leagueCreation.integration.test.tsThe two ends, two weeks and six months, are not asserted on their own
F5A price is computed from the 30-day average alone, and a purchase with no 30-day average is refused rather than priced from one daycontract.spec.ts · integration/contract.integration.test.tsThe averaging arithmetic itself has no dedicated test
F6An expired contract settles at the live price, at a profit or a loss, with nothing deducted, and a team with no contracts holds exactly the starting budgetintegration/contractSettlement.integration.test.ts · repositories/conformance/derivedCredits.integration.test.ts
F7Ingesting the same team and day twice leaves one performance, recomputed, not twointegration/scoring.integration.test.ts · repositories/conformance/performanceRepository.integration.test.ts · CollectorTest.ktThat the nightly run picks the previous UTC day is structural, not asserted
F8Two adjacent articles that link both ways are Excellent, one way Good, not at all Weak, and each level adds its pointsChemistryTest.kt · integration/scoring.integration.test.ts · scoring.spec.ts
F9Every owner and viewer pair resolves to Free Agent, owned by the viewer, or owned by another team, and the article page shows whichcontractLifecycle.spec.ts · frontend: articleDetail/useArticleOwnership.spec.ts
F10Changing formation benches a contract that no longer fits a position rather than dropping itfrontend: formation/lineupMutations.spec.ts · integration/lineup.integration.test.ts
F11A league whose season is over still resolves, and a league is deleted only when its last member leavesintegration/joinByCode.integration.test.ts · integration/leagueLifecycle.integration.test.ts
F12A report files a GitHub issue carrying the pseudonymous player id and never an email, and a failure falls back to a pre-filled link without leaving the appintegration/problemReport.integration.test.ts · integration/reportsRoute.integration.test.ts · frontend: useProblemReport.spec.ts
F13A turn returns a question and the surviving candidate ids, and the prompt refers to candidates by id alonearticleGenie.spec.ts · integration/genieRoute.integration.test.tsThat a generated question never names the article rests on the prompt and the ids-only response, since the model is stubbed in tests

Backend paths are under backend/src/tests/, frontend ones under frontend/src/tests/, and the Kotlin ones under scoring-collector/src/test/.

Quality attributes ​

These are the non-functional requirements: the properties the architecture was actually shaped by, as opposed to what it does. Each one names the mechanism that enforces it, because a quality attribute with no mechanism is an aspiration.

Portability of persistence ​

The obligation. The system must be able to change database without rewriting its business logic.

The mechanism. Every persistence contract is an interface under repositories/, each store is one implementation beneath it (repositories/d1/ and repositories/mongo/), and composition.ts is the only module allowed to choose one, from a binding. The rule is enforced by ESLint: nothing under services/, routes/ or tests/ may import either implementation directory, so the seam cannot erode by accident.

The evidence. This is the one quality attribute here that has been discharged rather than argued. MongoDB was added as a second target with no change above the repository layer, and the conformance suite in tests/repositories/conformance, written against the interfaces and nothing below them, became its acceptance criteria unchanged. Both targets run the same suite on every ./gradlew check, so the second implementation cannot rot while the first one is the one being used.

What the exercise cost is worth recording, because it is what a portability claim usually hides: the interfaces held, and everything that had to be re-derived was a guarantee the relational store had been giving away for free (single-statement atomicity, a cascade on delete, a view). Each one is now stated explicitly on the document side, and named in the data model.

→ Persistence Targets · Backend Architecture

Determinism of scoring ​

The obligation. Re-running a night must produce the same standings.

The mechanism. Ingest is an idempotent upsert keyed on (teamId, date), and the formation used for a day is frozen into the performance row as an immutable snapshot rather than read back from the current lineup. A player who rearranges their team today cannot change what they scored yesterday.

→ Nightly Scoring Pipeline

One formula, one language ​

The obligation. A scoring rule must not be implemented twice.

The mechanism. The collector is a JVM process and the backend is a TypeScript Worker, so the temptation to compute points on both sides is real and permanent. It is closed off by contract: the collector posts raw facts only, and model/scoring.ts is the sole implementation of the curve.

Cost that scales with use ​

The obligation. A handful of players must cost nothing at all, and a great many must cost something that rises with them rather than ahead of them.

The mechanism. Almost nothing is always-on and almost nothing is bought by capacity: a Worker rather than a server, GitHub Actions rather than a scheduler that has to be running in order to schedule.

The database is the one place that argument is split, and it is worth stating plainly rather than smoothing over. D1, which the Cloudflare deployment runs on, is billed by rows read and written, the same shape as the Worker. A MongoDB cluster is capacity bought in advance: free at the size the game is played at, and the first line of the bill that would become a decision if it grew. The repository seam is what keeps that a deployment choice rather than an architectural one.

At the numbers the game is played at today the whole system lands inside the free tiers with room to spare. Past them, every line of the bill but the cluster is per request and per row, so it follows the league count up a slope rather than a staircase: there is no size at which the architecture has to be bought again.

The one quantity that does not grow with players is the nightly fan-out to Wikimedia, which grows with distinct articles instead. It is throttled, and it is deduplicated by article across every team in every league, so a thousand squads holding the same article cost one request. That number is costed explicitly rather than assumed away.

→ Nightly Scoring Pipeline

Testability ​

The obligation. A rule must be testable without a browser and without a network.

The mechanism. Tiers with an explicit rule about which layer each may name, a seeding helper that goes through the production write path instead of raw queries, and a real database reset before every backend test: every collection emptied and re-seeded on MongoDB, the schema dropped and the migrations replayed on D1.

→ Test strategy

Approachability for a new contributor ​

The obligation. Someone should be able to run the whole thing without obtaining a single credential.

The mechanism. ./gradlew noGenie against published images, a dev sign-in route that refuses to exist outside the local environment, and MSW standing in for the API in the browser. Only the Article Genie asks for a credential, and only the two tasks that name it.

→ Running FantasyWiki in Docker

Constraints ​

ConstraintConsequence
Wikimedia's API etiquetteThe nightly fan-out is throttled and identifies itself with a user agent; the collector holds no persistent state
Pageviews are published in arrearsScoring can only ever be a batch over a completed UTC day
Cloudflare Workers CPU budgetIngest is chunked, and heavy work is pushed into a Workflow rather than a request
A Worker owns its sockets per requestThe MongoDB client is built per request and never cached in a module. A cached one breaks every request after the first, and does it silently
Mongo transactions are snapshot-isolated, not serializableA guarded write also writes the league it is guarding against, so the losing transaction is retried rather than committed against a stale snapshot
Multi-document transactions need a replica setEven a local run is a single-node replica set, and the test suite starts one of its own
The MongoDB driver reaches for node:net and node:tlsNo Cloudflare deployment runs on MongoDB: the driver is aliased out of the Worker bundle, and the Mongo target has a wrangler config of its own
SQLite via D1ALTER TABLE cannot add a NOT NULL UNIQUE column, which is visible in more than one migration
AGPL-3.0The deployed service must carry its source link

DDD Building Blocks ​

This page is a mapping, not a rule book. It names which Domain-Driven Design archetype each concept in FantasyWiki was modelled as, and which module holds it. Every rule it refers to is stated once elsewhere, under domain/ in the documentation index, and linked from here.

It exists because the codebase applies the archetypes without naming them. The repository interface with one implementation per store is an anti-corruption layer; the import rule that keeps services away from those implementations is layering enforced in the build. Both were built for their own reasons and are documented under those reasons. A reader who arrives looking for the vocabulary finds the practice and not the word, so this page supplies the word and points at the practice.

The model also has a perimeter, and the perimeter is a design decision like any other. Scope of the model names the archetypes the domain did not call for, and the constraint that made each one unnecessary.

The Ubiquitous Language is CONTEXT.md ​

CONTEXT.md is the glossary. It is what the code names its types after, what a commit message says, and what these documents call the thing.

Two of its conventions are doing DDD work:

  • Each entry carries an _Avoid_ list of the near-misses the term exists to displace: Top Read Snapshot avoids "live ranking", Normalized Views avoids "adjusted views". A synonym that has been written down and rejected cannot quietly come back as a second name for one concept.
  • _Core_. marks the terms nothing else can be read without. Those are the terms the single-document exam report carries, so a reader going straight through it meets the vocabulary it is written in rather than the whole ingestion pipeline.

The glossary is the artefact, and the discipline is that a term is added there before it is added to a type name. A word that appears in code and not in CONTEXT.md is the failure this arrangement prevents.

Which archetype each concept became ​

ConceptArchetypeLives in
LeagueAggregate Rootmodel/league.ts, leagueRepository.ts
TeamAggregate Rootmodel/team.ts, teamRepository.ts
ContractEntity, inside the Team aggregatemodel/contract.ts, contractRepository.ts
PlayerEntitymodel/player.ts, playerRepository.ts
Lineup / FormationEntity, inside the Team aggregatemodel/lineup.ts, model/formation.ts
Contract TermValue Objectmodel/contract.ts
Chemistry Link, Chemistry LevelValue Objectmodel/enums.ts
Language Scale FactorValue Objectmodel/languageScale.ts
PerformanceValue Objectmodel/performances.ts
Article AvailabilityValue Objectmodel/contract.ts
Top Read EntryValue Objectexternal-apis/wikimedia/wikimedia.ts
Invitation CodeValue Objectmodel/league.ts, as a validated string
NotificationDomain Event notificationmodel/notification.ts, notificationRepository.ts
Every business operationServicebackend/src/services/
Every persistence contractRepositorybackend/src/repositories/*.ts

Entity or Value Object ​

The discriminator is identity, not shape. Both model elementary concepts; what separates them is whether the domain distinguishes one instance from another. A Contract is an Entity because two contracts on the same article at the same price are still two contracts. A Contract Term is a Value Object because two windows over the same dates are the same window.

The consequences are visible in model/:

  • Value Objects are immutable and compared by attribute. ContractTerm is read-only, and every lifecycle question about it is a pure function over it, termDays, remainingDays, isActive, isExpired. None of them mutates.
  • Entities are compared by identity and may hold mutable state. A Contract carries settled, renewalCount and renewalElected, all of which change over its life while its id does not.

Identifiers are strings, and that is a deliberate boundary. DDD can model an Entity's identifier as a Value Object of its own, so the type system tells a team identifier from an article identifier. In model/ every identifier is a bare string:

ts
export interface Contract {
  id: string;
  teamId: string;
  articleId: string;
  // ...
}

The trade is a known one. Branded types in model/ would make the compiler the enforcer, at the cost of a wrapper on every identifier crossing the DTO and persistence boundaries — where the value has to be a plain string again. With one bounded context and one team, the mix-up that branding prevents has not happened; the upgrade path stays open because the identifiers are already centralised in model/.

Aggregates, and the one reference they may hold ​

Components of one aggregate do not hold references to components of another. The exception is a reference to the other aggregate's identifier, and it is the exception FantasyWiki relies on: a Team holds a leagueId, not a League. Expanding that identifier into a nested object is the DTO layer's job, never the model's. This is stated in What Are Model Entities and Shared DTO Package.

An aggregate root also guarantees the consistency of what it contains, and the Team aggregate's core invariant is its credit balance. ADR 0007 records where that invariant is enforced and why the placement looks wrong: the principle says the invariant belongs to the aggregate, and the purchase check lives in the INSERT instead, because the reading-then-writing alternative is not merely less tidy but incorrect under concurrent buys. Read that ADR before moving the check.

The repository layer is an anti-corruption layer ​

Of the four model-integrity patterns, the one FantasyWiki implements is the anti-corruption layer, and the upstream it defends against is the store.

backend/src/repositories/*.ts holds contracts expressed in domain terms. Underneath, repositories/d1/ speaks SQL and repositories/mongo/ speaks documents, pipelines and transactions. A store's own error wording never leaves the layer. composition.ts is the only module that names an implementation, and the boundary is enforced mechanically rather than by convention: no-restricted-imports forbids anything under services/, routes/ or tests/ from naming repositories/d1/** or repositories/mongo/**.

That an anti-corruption layer exists is a claim; that this one holds is a measurement. MongoDB was added without a change above the repository layer, and the conformance suite the first target passed became the second's acceptance criteria unchanged. See Persistence Targets.

composition.ts is also the codebase's one Factory in the DDD sense: repositoriesFor(env) selects the adequate implementation dynamically while hiding the choice from every caller.

There is a second anti-corruption layer, against a different kind of upstream. The store is a technology chosen by this project; the Wikimedia APIs are a web service nobody here controls, whose response shapes can change without notice. external-apis/wikimedia/ is the boundary: raw API rows arrive as WikimediaTopReadArticle and leave as TopReadEntry, a Value Object named in the Ubiquitous Language, and no caller sees the shape in between. That the layer is shared by the Worker and the browser is what makes it one boundary rather than two that drift. See Wikimedia Client Architecture.

The layers, and what enforces them ​

The hexagonal arrangement is that outer layers depend on inner ones and never the reverse. FantasyWiki's modules map onto it as follows, with the domain layer at the centre because model/ imports nothing from a framework and so a Worker and a browser can both use it.

Two things make this more than a drawing:

  • The layers are packaging units, not folders by naming convention. model/ and dto/ are separate packages both the Worker and the SPA depend on, and scoring-collector/ is a JVM module that shares neither: it is given an HTTP contract and a bearer secret, and that is all it is allowed to know.
  • The one dependency direction that matters is checked by a lint rule. See Backend Architecture for the layer responsibilities and what each is forbidden from doing.

Scope of the model ​

An archetype is a tool for a pressure. Naming the pressures this domain does not have is what makes the archetypes it does use load-bearing rather than decorative, so each of these is a decision with the constraint that settled it.

  • Factories stay at repositoriesFor(env). Domain objects are constructed from plain object literals and validated by predicate functions such as isSchema and isLeagueDuration. Creation is total and cheap: there is no multi-step assembly to encapsulate, so an encapsulating type would add a layer without removing one.
  • Domain events are reified, not propagated. Notification is a domain event made durable, written by the service that causes it. Publish-subscribe earns its cost when the producer must not know its consumers; here there is one consumer, the player reading their notifications, so the broker would be infrastructure with nothing to route. What a notification may and may not do is in Notifications.
  • State is current state, with one ledger. The contracts ledger is the exception, and it is the one place the sourcing property is worth its cost: a team's credit balance is derived on every read rather than stored, so it cannot disagree with the transactions that produced it (ADR 0007). Everywhere else a snapshot answers every question asked of it.
  • Reads and writes share one path. CQRS pays when the two have genuinely different shapes or load profiles. At a private league of friends they have neither, and one path means one place a rule can be wrong.
  • One bounded context, one team. The three model-integrity patterns that describe relations between teams — shared kernel, customer-supplier, conformist — describe a situation this project does not have. The anti-corruption layer is the one that does apply, because its upstream is a technology and a third-party API rather than another team.

ADR 0007: Team credits are derived, not stored, and enforced at the write ​

Status: decided and implemented (#503). Migration 0006_team_credits_view.sql introduces the team_credits view; the four repositories that previously re-derived the balance now read it, and so does the guarded purchase INSERT.

A team's credit balance is the Team aggregate's core invariant. This ADR records two decisions about it that look, at first glance, like violations of the layering rules the rest of the codebase follows, and why both are deliberate.

Decision ​

  1. Credits are derived from the contracts ledger on every read, never stored.

    credits = STARTING_CREDITS − Σ purchasePrice + Σ salePayout (where settled)
  2. The derivation is stated once, as the team_credits SQL view. Every read path joins it.

  3. Affordability is enforced inside the purchase INSERT, not in the service layer, the same statement that writes the contract also checks the balance and the contract cap.

  4. model/team.ts mirrors the rule as deriveCredits(), a pure function. It does not enforce anything; it is the readable, unit-testable statement of what the SQL means.

Why derived rather than stored ​

A stored teams.credits column is a second copy of a number that the contracts ledger already determines. Two copies can disagree, and when they do the disagreement is silent and unrecoverable, there is no way, after the fact, to tell which one is right. Migration 0005_derive_team_credits dropped the column for exactly this reason, adding contracts.salePayout so the ledger holds every term of the formula.

The cost is a GROUP BY per read. At this scale it does not matter: a team holds at most MAX_TEAM_CONTRACTS (22) contracts, the lookup is an indexed one on idx_contracts_teamId, and FantasyWiki's unit of play is a private league of friends, not a global market.

Why the check lives in the INSERT, the part that looks wrong ​

This is the one place where we knowingly trade a DDD principle for a concurrency guarantee. The principle says an aggregate's invariants belong to the aggregate: the Team should decide whether it can afford a contract. Our repository decides, in SQL.

The reason is that the obvious alternative is incorrect, not merely less tidy. Read the balance in the service, compare it to the price, then write the contract, and two concurrent buys interleave like this:

Request ARequest B
1reads credits = 100
2reads credits = 100
3100 ≥ 80 ✓
4100 ≥ 80 ✓
5writes contract (80)
6writes contract (80)

Both checks passed against the same stale read; the team has now spent 160 of its 100 credits. The identical race applies to MAX_TEAM_CONTRACTS, which is why that check sits in the same statement: two concurrent buys at 22 contracts would both see 22 and both commit, giving the team 24.

Cloudflare D1 offers no interactive transaction that would let the service hold a read and a write together. What it does guarantee is that a single statement is atomic against concurrent writers. So the check and the write have to be one statement, and a statement is SQL. The guarded INSERT is not a shortcut around the domain model, it is the only construct available that makes the invariant actually hold.

Do not "fix" this by moving the check into the service layer. Doing so would reintroduce the race above, and no unit test would catch it.

What was actually wrong, and what changed ​

The enforcement location was never the problem. The problem was that the formula had been hand-copied into four separate repositories, contractRepositoryD1 (three sites, including the INSERT), teamRepositoryD1, notificationRepositoryD1, performanceRepositoryD1, as SQL the domain model could not see. Four copies of a rule is four chances to update three of them.

The team_credits view collapses those to one. It is team-anchored (teams LEFT JOIN contracts) rather than contract-anchored, so a team with no contracts still appears at the full starting budget and callers need no COALESCE fallback of their own.

The view is used by the guarded INSERT too, so the formula now has exactly one SQL statement across both reads and the write. This is safe on both counts that matter:

  • Atomicity is preserved. SQLite expands a view at compile time; the INSERT is still one statement.
  • The query plan is preserved. EXPLAIN QUERY PLAN confirms SQLite pushes the teamId predicate into the view, SEARCH t USING COVERING INDEX sqlite_autoindex_teams_1 (id=?) and SEARCH c USING INDEX idx_contracts_teamId (teamId=?). It is still an indexed lookup, not a scan of the whole contracts table.

Where the constant is pinned ​

A SQL view takes no bind parameters, so it cannot receive STARTING_CREDITS from model/team.ts, the migration inlines 1000 as a literal. The duplication is deliberate, and it is pinned by a test rather than by the type system: backend/src/tests/repositories/d1/teamCreditsView.d1.test.ts asserts that a team with an empty ledger reads back exactly STARTING_CREDITS. Change the constant without changing the migration and that test fails. It sits in the D1 tier because the literal is D1's, and so is the COALESCE over a NULL salePayout that the same file pins.

That every read path returns one balance, and that deriveCredits() agrees with whatever computes it, are rules about the answer rather than about the view, so they live in backend/src/tests/repositories/conformance/derivedCredits.integration.test.ts, where a second persistence implementation has to keep them too. That agreement is the whole reason the pure function exists: the storage enforces, the function documents, and the conformance suite is what keeps them the same rule.

Consequences ​

  • teams has no credits column and must not regain one.
  • Any new read path that needs a balance joins team_credits. It does not write the formula out.
  • Any new rule that must hold across a concurrent write belongs in a guarded single statement, for the reason above, not in the service layer.
  • A settled contract whose salePayout is NULL contributes its purchase and no payout. The view's COALESCE handles it and a fixture row pins the behaviour.

3 · Development process ​

How a change reaches production: the branches sized for two authors, the commit convention the release is computed from, the one gate every merge passes, how a version is cut and what it names, and the licence the work is published under.

Development Process ​

How a change gets from an idea to master: the branches it lives on, the shape its commits must have, and the one gate every change passes. What happens after master, the deploy and the version, is in Deploy Strategy and Release Process.

The process is sized for what the project is: two authors, one deployed service, one production version at a time. Every rule below is there because it pays for itself at that size, and the heavier conventions a larger team would reach for are left out on purpose, with the reason.

Branches ​

BranchLivesIs for
masterforeverProduction. Every push deploys it and may release it.
devforeverIntegration and QA. Every push deploys it to the preview environment.
<type>/<topic>daysOne change. <type> is the Conventional Commits type it carries: feat/, fix/, docs/, refactor/, ci/, chore/.
renovate/*hoursOne dependency update, opened by Renovate.

A topic branch is merged into dev when it needs to be seen next to the other author's work on a deployed environment first, and straight into master when it does not. dev reaches master through a pull request like any other branch.

Why not GitFlow. Its release/* and hotfix/* branches exist to prepare and patch versions that are shipped and supported side by side. This project ships one version, the one running in production, and never patches an older one, so those branches would be ceremony with nothing to protect. What GitFlow gets right, an integration branch that is exercised before production, is kept, and it is dev.

Why not trunk only. With two people changing the same screens in the same week, a shared environment that is not production is where one author's change meets the other's before a player does. That is the one job dev has, and it is why it deploys.

Commits ​

Every commit follows Conventional Commits: feat(frontend): reveal the podium while the season is still running. The type is not decoration. It decides the next version (Release Process), it names the branch, and it sorts the release notes.

It is enforced where a commit is written: a commit-msg hook, installed by the Gradle build itself (settings.gradle.kts), rejects a subject that does not parse. There is no step to remember, a first ./gradlew run installs it.

The hook has one blind spot, and it has been hit. A squash merge on GitHub takes its subject from the merge dialog, where no local hook runs. That is how Feat: Implement the team dashboard reached master on 2026-03-23, with a type the release tooling does not recognise. The pull request template asks for a Conventional Commits title for that reason, and the one who merges is the check.

One commit is one change. A pull request whose history is a series of attempts is squashed; one whose commits are each a finished change is rebased, and the commits land as they are.

The gate: pull requests into master ​

master is guarded by a repository ruleset, not by convention:

RuleWhat it stops
A pull request is requiredA change nobody but its author has seen
ci-cd / success must pass, on a branch up to date with masterA change that is green only against an older master
Merge by squash or rebase only; linear historyA merge commit, see below
Signed commitsA commit whose author cannot be verified
No force push, no deletionRewriting what production was built from

ci-cd / success is the aggregate of check.yml: format, lint, typecheck, audit, and every test suite on both persistence targets (Continuous delivery).

The ruleset asks for no approval, and that is deliberate at this size. A mandatory second review would stop whichever author is working while the other is not, and the checks carry the part of review a machine can do. Review by the other author happens on the pull request when the change calls for it, and the tooling does not force it: of the last 25 pull requests other than Renovate's, 10 carry a formal review and 23 a discussion.

Why no merge commits. master's history is the input to the release: the version is computed from its commits and the notes are its commits, grouped. A merge commit adds an entry that is not a change, and a branch's commits interleaved with master's make a release's contents depend on commit dates rather than on what was merged. A linear master is also one git bisect can walk without being told which parent to follow.

Pull requests from forks run check.yml like any other: the workflow names no secret, and the jobs that need one either skip a fork or run only on a deploying branch. See the dispatcher in .github/workflows/ci-cd.yml.

Dependencies ​

Every dependency is declared: package-lock.json locks each npm project, and the JVM module's direct dependencies are pinned in Gradle version catalogs. Renovate opens a renovate/* branch per update and merges it once ci-cd / success passes. The shared preset it extends holds an npm release back three days first, npm's window for retracting one. Merging updates nobody watched is acceptable for one reason only: the gate is the whole test suite on both persistence targets, and an update that breaks an interface breaks a test.

Release Process ​

A version is computed, never typed. Nobody decides that the next release is 1.4.0; the commits merged since the last one decide it, by a rule written down once in release.config.mjs. There is no release branch, no release checklist, and no command to run: merging into master is the whole procedure.

What a version names ​

One version names the whole system at one commit: the SPA, the Worker and the scoring collector image. They are built, checked and deployed from the same commit on master, so a version per component would only be three names for one fact.

A version is cut after the production deploy succeeds, so a version is what production is running. A revision that failed to deploy is not released; its commits go out with the next release that does deploy.

How it is computed ​

.github/workflows/release.yml runs semantic-release on every push to master, after the deploy. It reads the Conventional Commits since the last tag and applies the course's shared preset, semantic-release-preconfigured-conventional-commits, unmodified:

Commits since the last release includeRelease
a ! after the type, or a BREAKING CHANGE: footermajor
a featminor
a fix, perf, docs or revertpatch
only chore, ci, build, refactor, test, stylenone

When there is something to release it does three things and nothing else:

  1. Tags the commit with the bare version, 1.4.0, no v, the form gitSemVer in the Gradle build also reads.
  2. Publishes a GitHub release whose notes are those commits, grouped by type and linked to their pull requests.
  3. Hands the version to publish-images.yml, which adds it as a tag to the collector's container image on GHCR beside latest and sha-<short>. The image and the notes that describe it share a name.

It writes no CHANGELOG.md. A changelog file only means something if it is committed back to master, and master accepts pull requests and signed commits only. The notes live on the release, where a reader looks for them.

The version fields in the package.json files are not the release version. None of those packages is published, and nothing reads the field as a version of FantasyWiki; the tag is the only place a version is written.

To release, merge; to not release, choose the type ​

Everything about a release is decided by the commits, so the lever is the commit type, chosen when the commit is written:

  • A change a player can see is a feat or a fix, and it will be released.
  • A change to the build, the tests or the tooling is a build, test, ci or chore, and it will not, even though it deploys.
  • A breaking change says so with !. For the HTTP API that also means a new path version, see API Naming Rules.

Versions so far ​

VersionLanded on masterMilestone
0.1.02026-02-26The app shell and the landing page
0.2.02026-04-29The first playable screens: dashboard, formation, leagues on the backend
0.3.02026-06-02Real Wikipedia data, Chemistry Links, player accounts
0.4.02026-07-14The contract economy, and in-app problem reports
0.5.02026-07-28Nightly scoring on the JVM collector; the beta opens
0.6.02026-08-14Built during the beta: league detail, the Article Genie, rival line-ups
0.7.02026-08-22The league section, every Wikipedia edition, the app in Docker
0.8.02026-08-31MongoDB, password sign-in, WCAG 2.2 AA, the documentation site
0.8.12026-09-18Documentation

Why 0.y.z. SemVer reserves major version zero for initial development, when anything may change, and that is what these months were: a game whose rules were still being found, and from 28 July played in a beta by one league of friends.

Why 1.0.0 is the API. SemVer says 1.0.0 defines the public API. The first breaking change after 0.8.1 is the one that moved the HTTP API under /api/v1, so the release that follows it is 1.0.0, and the version that declares the API stable is the one that versions it.

ADR 0009: FantasyWiki is licensed under the GNU AGPL v3.0 ​

Status: decided when the repository was created (2025-12-14), recorded 2026-09-21.

FantasyWiki's source is published under the GNU Affero General Public License v3.0 (LICENSE). Anyone may run, study, change and redistribute it, and anyone who offers a modified FantasyWiki to players over a network must offer those players the modified source.

The constraint that shaped it ​

FantasyWiki is not distributed; it is served. Nobody installs it. A player opens a page, and the code runs on Cloudflare. That one fact decides between the licences, because the obligations of most of them are triggered by distribution, and a hosted game never distributes anything.

Options considered ​

LicenceWhat a third party hosting a modified copy owes its playersVerdict
MIT / Apache-2.0NothingRejected: a closed fork of an open game is allowed
GPL-3.0Nothing, because serving is not distributionRejected: copyleft in name, permissive in practice for a web service
AGPL-3.0The modified source, to every player it servesChosen
ProprietaryNot applicable, nobody may host itRejected: the project is public and is meant to be read, studied and played with

GPL-3.0 is the instructive rejection. It is the licence usually meant by "copyleft", and for this project it would protect nothing: a host could change the scoring, the economy or the data it keeps about players and never publish a line, because no copy of the program ever leaves its servers. The AGPL's section 13 closes exactly that gap, and it is the only difference between the two that matters here.

Consequences ​

  • The deployed service carries its source link. The app's footer links to this repository (frontend/src/layout/InfoFooter.vue); removing it would put the deployment itself out of compliance. It is listed as a constraint in Requirements.
  • Dependencies must be compatible, and are. The shipped dependencies are under permissive licences (MIT, ISC, Apache-2.0, BSD), which may be combined into an AGPL work. A dependency under a licence that forbids that, or under the SSPL, cannot be added.
  • Contributions arrive under the same terms. There is no contributor licence agreement; a pull request is a contribution under the AGPL, which is what the licence's own section 5 provides for. See Development Process.
  • The licence covers the code, not the data. Article text shown in the app is Wikipedia's, under CC BY-SA, and pageview counts are Wikimedia's, under CC0. Neither becomes AGPL by passing through FantasyWiki.

4 · Architecture and target platforms ​

The system in context, containers and layers; the boundary that let a second persistence target arrive without a change above it; and the two runtimes the project technically involves, with the decision record that explains why the second one exists.

Architecture overview ​

FantasyWiki is a monorepo with five deployable or publishable pieces and two shared type packages, orchestrated by Gradle over npm. This page is the map. The rules each piece implements live under domain/; the detail of how each is built lives under architecture/.

Context ​

Who talks to the system, and what it talks to.

Containers ​

Five runtimes. Note what does not connect: the collector has no database credential, and the frontend never talks to the database at all.

The store inside the Cloudflare box is the one production runs on. The box beside it is the same system persisted somewhere else entirely: the backend holds repository interfaces, and MongoDB is a second implementation of every one of them, run locally rather than deployed. Which implementation a build gets is decided in a single module, below.

ContainerRuntimeDeployed byDocumented in
FrontendVue 3 + Ionic SPACloudflare Pages, per branchFrontend
BackendHono on a Cloudflare WorkerWrangler, per branchBackend Architecture
D1SQLite at the edge, reached through a bindingMigrations replayed on deployData model
MongoDBA replica set, reached over the driver — the second target, not deployedIndexes and baseline on first connectionPersistence Targets
Settlement WorkflowCloudflare WorkflowsBundled with the WorkerADR 0003
Scoring CollectorKotlin/JVM, application pluginGitHub Actions cron + GHCR imageScoring Pipeline

Packages ​

Two of the seven directories exist purely so the other five cannot disagree with each other.

model/ holds normalised, framework-free entities, what a Contract is, independent of how it is stored or sent. It imports nothing from a framework, so both a Worker and a browser can use it. → What Are Model Entities

dto/ holds the wire shapes, what the API sends, which is deliberately not the same as what the domain contains. DTOs aggregate and nest; entities stay normalised. Each side "dresses" the domain for its own purpose. → Shared DTO Package · DTO Dressing Pattern

The collector shares neither. It is a JVM process, and giving it generated types would couple a compiled module to a TypeScript build. It gets an HTTP contract and a bearer secret, and that is all it is allowed to know.

The backend's layers ​

Three layers, each talking only to the one below it.

The interesting line is the dotted one. composition.ts is the single place that decides which store the system is running on, it reads a binding and returns one set of implementations, and the rule is enforced mechanically: no-restricted-imports forbids anything under services/, routes/ or tests/ from naming repositories/d1/** or repositories/mongo/**.

That the second implementation exists is what makes this more than an intention. MongoDB was added without a change above the repository layer, and the conformance suite the first target passed became the second's acceptance criteria unchanged. → Persistence Targets

Services may call other services, and are encouraged to: a rule implemented twice is a rule that will eventually be two different rules.

→ Backend Architecture · Backend Error Constants

The seams worth knowing about ​

Each of these is a place where the code was deliberately cut so that one side can change without the other noticing.

SeamWhat it separatesWhy
composition.tsBusiness logic from the databaseSo the store can be replaced without touching a service, and a second one was (the layers, above)
/internal/*The scoring engine from the gameThe collector computes nothing and knows no rules (pipeline)
DraftLineupEditing a formation from saving onePure mutations, testable without a server (lineup editing)
buildArticleDetailArticle facts from viewer contextOwnership is resolved once, asynchronously (ownership resolution)
Wikimedia client capabilitiesTransport from behaviourA capability is added without touching the composition root (client architecture)
Query keys moduleCache identity from call sitesOne module owns every TanStack key (query keys)

Where to go next ​

  • Data flow: sign-in, a request through the layers, a night of scoring
  • Data model: the collections, the derived balance, and the invariants
  • Frontend: state ownership, bootstrapping order, mocking
  • Deployment: branches, environments, and what ships where

Backend Architecture ​

Overview ​

The backend is a Cloudflare Worker built with Hono. It follows a layered structure:

  1. Routes: HTTP handling (backend/src/routes)
  2. Services: business workflows (backend/src/services)
  3. Repositories: persistence access (backend/src/repositories), with one implementation per store under repositories/d1/ and repositories/mongo/

Shared domain models are in the top-level model/ package and are reused across frontend and backend.

Current Repository Layout ​

text
FantasyWiki/
├── model/
├── backend/
│   ├── migrations/
│   └── src/
│       ├── index.ts
│       ├── routes/
│       │   ├── auth.ts
│       │   ├── leagues.ts
│       │   └── session.ts
│       ├── services/
│       │   ├── login.ts
│       │   ├── player.ts
│       │   ├── leagues.ts
│       │   └── wikimediaClient.ts
│       ├── composition.ts
│       └── repositories/
│           ├── playerRepository.ts
│           ├── result.ts
│           ├── d1/
│           │   └── playerRepositoryD1.ts
│           └── mongo/
│               └── playerRepositoryMongo.ts
└── frontend/

Layer Responsibilities ​

Routes (backend/src/routes) ​
  • Parse request input
  • Enforce auth/HTTP constraints
  • Call services
  • Map results to HTTP responses
Services (backend/src/services) ​
  • Implement business logic and orchestration
  • Depend on repository interfaces (PlayerRepository) rather than route concerns
  • Depend on other services whose functionality they need
  • Return typed Result values consumed by routes
Composing services ​

A service may, and is encouraged to, call another service when that service already provides something useful. Reuse beats restating: a rule implemented twice is a rule that will eventually be two different rules. Take the dependency through the constructor like any repository, so tests can substitute it, and let the caller's Result carry the callee's failure outward rather than re-wording it.

The rule is one of preference, and it holds even when all you want is the data: when what service A needs is offered both by service B and by repository C, reach for B. Go to C only when there is no such B. A service's read is rarely only a read, it dresses rows into DTOs, fills in derived fields, applies the rules that decide what counts as absent, and calling it means you inherit those, including the ones added after you wrote the call. Reaching past it to the repository buys one fewer hop and gives up all of that.

One limit keeps this from becoming a tangle: keep the dependencies acyclic. If A calls B, B must not call back into A. A cycle usually means the shared part wants to be its own service (or to move down into a repository) rather than to be reached for in both directions.

Repositories (backend/src/repositories) ​
  • Define repository contracts (playerRepository.ts)
  • Implement each store's access under repositories/d1/ and repositories/mongo/
  • Encapsulate SQL, queries and persistence error handling: a store's own error wording never leaves this layer

A deployment runs on exactly one store, and composition.ts is the only module that picks it. See Persistence Targets.

Runtime and Data ​

  • Runtime: Cloudflare Workers + Hono (backend/src/index.ts)
  • Primary persistence: Cloudflare D1 via db binding
  • Schema and migrations: backend/migrations/

Testing ​

  • Backend tests run with Vitest, in the Workers pool, against a real database
  • Commands: cd backend && npm run test (D1), npm run testmongo (MongoDB)
  • Which layer a test may name: and why only composition.ts chooses an implementation, is in Backend Testing

Persistence Targets ​

The backend runs on Cloudflare D1, SQLite at the edge. It is what production and preview deploy to, and it is the only store any deployment has ever used.

MongoDB is a second implementation of the same repository interfaces, run locally and never deployed. It is not a fallback and not a migration in progress: it exists so that the boundary between the domain and its storage is demonstrated rather than asserted. Nothing above repositories/ knows which of the two it is talking to, services and routes are handed the Repositories interfaces, and the same integration suite runs against either on every ./gradlew check.

That is what keeps the store a decision instead of a dependency. A system with one implementation of a storage interface has an untested claim; this one has a second store that passed the first one's conformance suite unchanged, so moving to whichever offer is cheaper later is work that has already been rehearsed once, rather than a rewrite discovered at the worst moment.

Choosing one ​

backend/src/composition.ts is the only module that names an implementation of the domain repositories, and a binding is what it reads. (The credential store username/password sign-in uses is composed separately, by src/passwordComposition.ts, and only in the build that has it, see Auth Modes.)

BindingD1 deploymentMongo deployment
PERSISTENCEabsentmongo
dbthe D1 databaseabsent
MONGO_URLabsentconnection string
MONGO_DBabsentoptional, overrides the database in the URL's path

repositoriesFor(env) is synchronous, and has to stay that way: the request middleware, the settlement Workflow and the test seam all call it without awaiting. So the Mongo repositories are built around a target and open their connection on the first call that needs one, and hold it for as long as they may, which is the request, not the isolate. See One connection per request below, which is the part of this to read before changing any of it.

No Cloudflare deployment runs on MongoDB. Production and preview are D1, and the Mongo target runs locally. Keeping it that way took more than good intentions: composition.ts names both targets, so the driver is reachable from the Worker's entry point, and making the import dynamic is not enough, esbuild follows dynamic imports and inlines them. Measured on the production dry-run, that shipped 1.5MB of driver (2722 KiB total, 446 KiB gzipped) with a Worker that can never use it, and the build failed without nodejs_compat, because the driver imports net, tls and child_process.

So wrangler.jsonc aliases mongodb to repositories/mongo/driverAbsent.ts, which brings the production bundle back to 962 KiB / 220 KiB gzipped, 8 KiB gzipped above the pre-MongoDB baseline, that being the repository code itself, which is small and unreachable. No compatibility flag is needed anywhere in wrangler.jsonc, so the D1 deployments are configured exactly as they were.

Two things make that safe rather than clever:

  • alias is a top-level wrangler key: it is ignored inside an environment, so it covers every wrangler build, which is the right scope when no deployment runs on MongoDB.
  • The Vitest pool resolves modules through Vite, not wrangler's bundler, so it is unaffected and npm run testmongo exercises the real driver.

The layering rule is mechanical. no-restricted-imports in backend/eslint.config.ts forbids both repositories/d1/** and repositories/mongo/** to services, routes and tests, exempting only tests/repositories/<target> and tests/support/<target>.

Running the Worker on MongoDB ​

The Mongo driver runs inside workerd, that is how the suite runs it. Two things make that work, and both live in vitest.shared.ts, deliberately rather than in wrangler.jsonc:

  • compatibilityFlags: ["nodejs_compat"], passed to Miniflare for the Mongo run only. The driver reaches for node:net, node:tls, node:crypto and friends. It is not in wrangler.jsonc because env.test is a mirror of production, and a compatibility flag, which changes how dependencies resolve and what globals exist, is exactly the kind of difference that would stop it being one.
  • Vite pre-bundles mongodb for the test pool. The driver is CommonJS, and the Workers Vitest pool cannot serve a require("node:x") from inside a CommonJS module; deps.optimizer.ssr converts it to ESM, with the Node builtins in exclude so they stay imports the runtime resolves rather than files rolldown tries to read from disk.

MongoDB must be a replica set, Atlas always is, and locally a single-node one is a flag. Multi-document transactions need it, and the guarded writes below are transactions. The test suite starts one of its own, which is what lets ./gradlew check run against both targets on any machine (Backend Testing).

Running it locally ​

alias has no per-environment form, so it applies to wrangler dev on wrangler.jsonc too. That is why the MongoDB run has a config file of its own, backend/wrangler.mongo.jsonc, no alias, nodejs_compat on, PERSISTENCE and MONGO_URL set, and no D1 binding at all:

bash
npm run devmongo         # or ./gradlew devMongo, with the frontend beside it

It also names a different main, src/indexPassword.ts, which is what gives this run username/password sign-in and the deployed one none of it. main being per-file is what makes that possible, and the reasoning is the mirror of the alias argument above: see Auth Modes.

A file rather than a fifth environment in wrangler.jsonc, for two reasons. It is not a deployment, nothing on Cloudflare runs on MongoDB, and the file that deploys should not carry a configuration that can never be deployed. And it could not be an environment anyway: the alias it needs switched off is top-level-only.

It binds less than env.local does, on purpose: no D1, and no settlement Workflow or cron, that one being started by a daily trigger which does not fire locally. It keeps the rate limiters, which the join and report routes call unconditionally, and it keeps AI, so the Article Genie is the real thing here. Workers AI has no local simulator, so that binding is marked remote: true, a Genie call leaves the machine and spends the account's allocation even in dev. The server starts without Cloudflare credentials all the same; only a Genie call needs them, and it fails as ASLEEP rather than stopping the run.

One connection per request, and why it cannot be cached ​

A Worker owns its I/O objects per request: a socket opened while handling one request may not be touched by the next. The MongoDB driver's pooled connection is exactly such a socket, so a MongoClient cached in a module variable serves the request that opened it and then breaks every request after, and it does not break loudly. The driver never reports an error; the promise simply never settles, and workerd eventually kills the request with "your Worker's code had hung and would never generate a response".

So a client's lifetime is a request's lifetime. MongoStore holds one and repositoriesFor builds one store per request, which gives each request a single connection shared by all its repository calls. Nothing closes them, sockets are reclaimed when the request context ends.

Two consequences worth carrying:

  • The test suite cannot catch this. It runs outside any request context, where the restriction does not apply, so a module-level cache passes all 571 tests and fails on the second request in wrangler dev. If you change how the connection is held, exercise it with two requests.
  • A Workflow is not one request. ContractSettlementWorkflow.run builds its service once and uses it across several step.do(...) calls, which is fine on D1 and would hit exactly this wall on Mongo. Nothing runs into it today, no Cloudflare deployment uses MongoDB, and the local config binds no Workflow, but a Mongo target on Workers would have to build its repositories inside each step.

What is stored ​

Collection names and field names mirror the D1 columns, so backend/migrations/ stays readable as the description of what is stored for either target. Ids are _id.

CollectionKeyed byNotes
google_accountsaccount idgoogleId unique
playersplayer idusername and accountId unique
leaguesleague idinvitationCode unique when present (partial index)
teamsteam id(playerId, leagueId) unique, one row per player per league, ever
contractscontract idindexed by teamId and by (settled, expireDate)
notificationsnotification id
performancesteamId:date(teamId, date) unique
lineupsteam ida team has at most one
language_scaleslanguage code

Dates are stored as text in both targets, ISO-8601 instants for leagues, YYYY-MM-DD for contract terms, so a plain comparison is a chronological one.

There is no migration runner. Mongo has no schema to migrate, so a fresh database needs only the indexes and the baseline the product assumes exists (the Global League, its system admin, and the en scale factor, migrations 0002 and 0009). Both live in repositories/mongo/bootstrap.ts, both are idempotent, and they run on the first connection an isolate opens.

Team credits ​

Credits are derived from the contracts ledger on every read and never stored (ADR 0007). D1 states the rule once as the team_credits view; Mongo states it once as teamCreditsStages in repositories/mongo/schema.ts, attached to the five reads that need a balance. Both agree with deriveCredits in model/team.ts, which is what the conformance suite checks them against.

Guarded writes, and what replaces single-statement atomicity ​

Several repository contracts say the conditions are evaluated inside the write: the purchase conditions, the join gate, the departure, the league closure. D1 gets that from SQLite's single-statement atomicity.

Mongo has multi-document transactions, but they are snapshot-isolated, not serializable, two transactions that merely read the same league would both commit against a snapshot taken before a concurrent close. So every such transaction also writes the document it is guarding against, bumping leagues.revision. That puts the two in each other's write set, so the loser hits a write conflict and the driver retries it against the state the winner left.

revision doubles as join order. The value a joiner bumps it to becomes its team's seq, which is the seniority leave hands a league on by, the same thing D1 reads rowid for.

The grain is a league, which is coarse: two players buying different articles in the same league contend, and one of them is retried. Article Availability is league-scoped, so a league-scoped guard is the honest one, but the Global League holds every player in the game, so in practice that one document is where all of it lands. Nothing to do about it while the guard has to cover "no team in this league holds the article"; worth knowing before treating the Mongo target as something to run at scale.

Single-document guards need none of this and use none of it: settling a sale, settling an expiry, renewing, electing and closing are one conditional updateOne each, exactly as they are one conditional UPDATE in D1.

The one thing D1 gets for free and Mongo spells out is the cascade. Deleting the last member's league takes its teams, contracts, notifications, performances and lineups with it; D1 declares that as ON DELETE CASCADE, and TeamRepositoryMongo.leave deletes them inside the same transaction.

Technologies ​

Nothing here was chosen because it is popular. Each entry names what it was picked over and the constraint that settled it, usually one of three: the system must cost nothing to run at the size it is played at, a scoring rule must exist in exactly one place, and a new contributor must be able to start it without obtaining a credential.

The shape those constraints forced ​

The runtime ​

Cloudflare Workers, over a container on a rented VM. The deciding property is that nothing is always-on: a league nobody opened today costs nothing, and there is no instance to keep warm between the nightly run and the morning. The price is a real one, a request has a CPU budget, which is why ingest is chunked and the settlement sweep runs as a Workflow rather than inside a request.

Hono, over Express or a bare fetch handler. Express assumes Node's http, which the Workers runtime does not have; a bare handler would mean writing routing and middleware. Hono is built for the runtime and its JWT and OAuth middleware are the two pieces of infrastructure this project would otherwise have written itself.

The database ​

Cloudflare D1, over a managed database on a rented instance. It is what the deployment runs on, and the property that decided it is the one that decided the runtime: D1 is billed by rows read and written rather than by an instance sized in advance, so a league nobody opened today costs nothing and there is nothing to keep warm overnight. It also lives inside the platform the Worker already runs in, so a query is a binding rather than a connection held open across a network.

Its price is visible in the migrations. SQLite cannot add a NOT NULL UNIQUE column to an existing table, so several of them arrive in two steps, and the one derivation this system has — a team's balance — is a view over the contracts ledger rather than a column that could disagree with it. → ADR 0007

MongoDB is the second implementation, and it is what makes the first one replaceable. It is not what production runs: wrangler.jsonc aliases the driver away, so the deployed Worker neither carries it nor could reach it. It runs locally, against a single-node replica set, from its own wrangler.mongo.jsonc — a config kept separate because one that can never be deployed does not belong in the one that deploys.

It earns its place by being a genuinely different shape: documents instead of rows, an aggregation pipeline instead of a view, and transactions that are snapshot-isolated rather than serializable, which is why every guarded write also writes the league document it guards against. Nothing above the repository layer knows any of that.

Two targets rather than one is not hedging. It is the evidence for the claim the architecture makes everywhere else: every persistence contract is an interface, one module picks the implementation, and the same conformance suite runs against both on every ./gradlew check. A store that can be swapped in a test run is a store that can be swapped when the bill changes — which is what keeps the choice of vendor a decision rather than a dependency. → Persistence Targets · Backend Architecture · Data model

The client ​

Vue 3 with Ionic, over plain Vue or React Native. The game is played on a phone more than a desk, and Ionic supplies the platform-shaped navigation and components for a web build that can also be packaged with Capacitor, without the second codebase a native framework would have meant.

Pinia and TanStack Query, deliberately as two different things. Pinia holds what the app knows about itself; TanStack Query holds what the server knows and owns the caching, refetching and invalidation that hand-rolled store actions get wrong. The split is the rule, and it is written down. → Frontend

MSW, so the frontend can be developed and tested with no backend running at all. It is also why the test suite can assert on a request that was never sent over a network.

The scoring collector ​

Kotlin/JVM in a container, on a GitHub Actions cron, over doing the nightly work in the Worker. A night's scoring is thousands of Wikimedia calls under an etiquette limit, and a Worker invocation is allowed fifty subrequests — no paid tier removes that ceiling, and a Worker would be V8 again, so it would add no second runtime either. A scheduled job that exists only while it runs is the same always-on argument, one platform over; Actions is already where this repository's automation lives, so it costs no new account.

The rule that keeps this from splitting the game in two is that the collector computes nothing: it posts raw facts, and the single implementation of the scoring curve lives in the TypeScript model/ package. Two runtimes, one formula. → ADR 0004 · Nightly Scoring Pipeline

Why the JVM share is small ​

The split between the two platforms falls short of the guideline that the dominant one stay under three quarters of the project: the TypeScript side is most of the code, and the collector is a small module beside it. That is the consequence of a design decision, and it is the same decision that keeps the two platforms coherent.

The second platform was designed to be larger. The first version of ADR 0004, on 2026-06-21, had the nightly engine read contracts and write scores and standings straight to D1, and compute base points, chemistry and a weekly tournament itself, in a runtime of its own. Most of the game's scoring would have lived there.

It was cut in two steps, both on purpose. By 2026-07-13 the engine posted to two backend endpoints instead of writing the database, because a second writer would have to carry every rule about what a valid row is. Then the arithmetic went too. The scoring rules were already implemented in model/, in TypeScript, because the Worker prices contracts with the same curve, and a Kotlin engine that computed points would have been a second implementation of them, kept in step by hand and checked by golden vectors. On 2026-07-14 commit d1910f0 moved all of it to model/scoring.ts, deleted the Kotlin Scoring.kt, and left the collector a fetcher that posts raw facts.

The two platform criteria pull against each other here, and the project chose coherence. Core entities that span platforms should be defined so as to minimise duplication; a large second platform, in a system whose rules already live on the first, is duplication by construction. Keeping one formula in one language is what made the collector small, and moving code to the JVM to raise its share would bring back exactly the second copy the cut removed.

Work that needs a long-running process rather than a request, such as the daily challenges planned next, belongs on the JVM for the reason the collector does, and the share will move with it.

The workspace ​

A Gradle-orchestrated monorepo over three separate repositories or a bare npm workspace. dto/ and model/ are consumed by both the frontend and the backend, so a change to a wire shape has to be able to fail both sides in one run, which it does, because one ./gradlew check builds everything. Gradle rather than npm workspaces because one of the four packages is a JVM project.

TypeScript everywhere it can be, including the shared packages, so that the API's shapes are checked at both ends of the wire rather than agreed by convention. What TypeScript cannot check, that the HTTP surface matches what is documented, is checked by a test instead. → OpenAPI spec

Everything that runs the project ​

WhatUsed forChosen over
Cloudflare D1Persistence in production, billed by rows rather than by an instanceA managed database on a rented instance, always on and always billed
A MongoDB replica setThe second persistence target, run locally, and the transactions its guarded writes needStaying on one store, which would leave the repository boundary untested
Cloudflare PagesFrontend hosting, per-branch previewsA static bucket with a CDN in front
Cloudflare WorkflowsThe settlement sweep, which outlives a requestA long-running request, which the CPU budget refuses
Cloudflare Cron TriggersStarting the nightly sweepAn external scheduler that has to be running to schedule
Workers AIThe Article Genie's questioningA hosted LLM API, which would be the project's only paid dependency
GitHub ActionsCI on every branch, deploys on master and devA CI service that has to be provisioned separately
Vitest + @cloudflare/vitest-pool-workersBackend tests in the real runtimeNode-based tests against a mocked runtime
Docker ComposeRunning the whole stack with no credentialsA page of setup instructions
VitePressThis documentation siteA generated API-doc site with no room for prose

ADR 0004: Scoring Engine Platform ​

Status: decided and implemented. The platform choice — a standalone Kotlin/JVM batch, autonomous from the Worker, as the project's second target platform — is what runs. Two mechanisms were revised between the decision and the implementation, the scheduler and the delivery path; both are recorded under Alternatives considered with the constraint that decided each. What runs in detail is Nightly Scoring Pipeline.

Decision. The daily scoring engine is a standalone Kotlin/JVM service, packaged as a container image published to GHCR and run once a night by a GitHub Actions cron. It reads its inputs from the backend over GET /internal/scoring-inputs, sources pageviews from the per-article Wikimedia Analytics API, resolves chemistry links against Wikimedia, and posts raw facts back over POST /internal/performances. It computes no points: the backend turns facts into scores through the single implementation in model/scoring.ts. It holds no database credential and no persistent state.

It is the SPE second target platform — a JVM runtime distinct from the all-V8/JS frontend and backend, and the repository's first compiled Gradle module.

Why this batch cannot run on a Worker ​

This is the analysis that moved the batch off Cloudflare, and it is unchanged by everything that was revised later. The per-invocation Worker limits make the batch infeasible. The free "100k requests/day" allowance is a request count; a daily batch is 1 invocation/day. The binding limits are per-invocation:

Limit (per invocation)FreePaid ($5/mo)Needed by the batchSource
CPU time10 ms30 s → 5 min; cron ≥1h → 15 minfetch and score every league, daily[1][2]
Memory128 MB128 MB (unchanged)hold the working set[2]
Subrequests50raised, not unlimitedone fetch per contracted article, plus the link graph[2]
Requests/day100,00010M/mo1[1]

Subrequests are the binding constraint. A night's work is one range request per distinct contracted article, plus the link graph among the paired ones: ~150 calls at the scale the game is actually played at, ~6k at the ceiling this ADR budgets for. A Worker invocation is allowed 50, and the smaller of those numbers already exceeds it threefold.

No Workers tier fixes this: the free tier is impossible on CPU time alone (10 ms), the paid tier lifts CPU and subrequests but not the 128 MB ceiling and not to "unlimited", it costs money, and a Worker is still V8 — the same platform as the frontend and backend, so it would add no second runtime and satisfy none of the exam's platform requirement either.

A separate runtime, with no per-invocation ceiling and its own concurrency control, is the only shape that fits.

The chosen stack ​

LayerChoiceWhySource
RuntimeKotlin / JVMThe first real compiled Gradle module in a repository where Gradle otherwise only shells out to npm; the build is already .kts, so one language spans build script and module. Coroutines give the throttled per-article fan-out.,
PackagingContainer image on GHCRpublish-images.yml builds scoring-collector from docker/scoring-collector.Dockerfile on every master and dev. The host becomes a property of where the image runs, not of the code.,
SchedulingGitHub Actions cron, 0 5 * * *Already the repository's CI home, so it adds no account, no IAM surface and no second place to look when a run fails. Free at this cadence. SCORING_RUNNER=external hands the schedule to something else without touching code.,
PageviewsPer-article Analytics (AQS) API, one range request per articleOne request returns the whole 30-day daily history for an article, so ≤ ~6k distinct contracted articles cost ~6k requests/day, comfortably inside a nightly batch. Targeted, clean JSON, no bzip2 and no title matching. Pageview Complete dumps stay the scale-out fallback past tens of thousands of articles.[3][4][8]
Delivery/internal/* on the backendThe backend stays the only writer of its own database. See below.,

Why the collector posts facts instead of writing the database ​

The collector could have held a database token and written its results directly. It does not, and the reason is an invariant rather than a convenience.

The backend is the sole writer of its own store. Every rule about what a valid performance row is — the scoring curve, the Language Scale Factor, the idempotency of a re-run — lives above the repository layer, in code the collector does not have. A second writer would either duplicate those rules in Kotlin, and they would drift, or write rows that satisfy none of them.

So the seam is two endpoints on backend/src/routes/internal.ts, mounted outside the /api/* JWT guard and behind Hono's bearerAuth, because the caller is a batch job rather than a person:

  • GET /internal/scoring-inputs?date=D hands back one row per team: the articles it fielded, the article pairs chemistry is resolved over, and an opaque formation snapshot the collector echoes back untouched.
  • POST /internal/performances takes raw facts in chunks of 100 and upserts them, INSERT … ON CONFLICT(teamId, date) DO UPDATE inside db.batch(), so re-running a date overwrites and cannot duplicate.

Three properties fall out of this, and all three are worth more than the autonomy the direct-write design would have bought:

  • One scoring implementation, in one language. model/scoring.ts is the only basePoints in the repository, and pricing.ts imports it too. The JVM and TS runtimes cannot disagree about what a day was worth, because only one of them computes it.
  • The collector knows nothing about the game. No schema, position, formation or language calibration. Adding a formation touches model/enums.ts and nothing in the Kotlin module.
  • The blast radius of the batch's secret is two endpoints, not a whole database. The collector talks to exactly two surfaces: the backend, with one bearer secret, and Wikimedia, which is public.

Chunking at 100 also keeps each backend invocation inside the Worker limits tabulated above — the ingest side of the pipeline is many small invocations, each of which parses a small JSON chunk and awaits I/O, which is exactly the shape a Worker is good at.

Data sourcing and rate budget ​

Pageviews and links are fetched per article, so the Wikimedia rate limits, not Worker limits, are the operative constraint. They are comfortably met because the daily volume is small and the throughput is paced; a compliant User-Agent carrying contact information is mandatory and lifts the floor:

ClientLimitTime to fetch ~6k articlesSource
Unauthenticated, bare IP10 req/min~10 h ✗[3]
UA-compliant200 req/min~30 min ✓[3]
Authenticated (token)2,000 req/min~3 min ✓[3]
Concurrency (recommended)≤ 3 concurrent,[3]

→ Run as a UA-compliant client at ≤3 concurrent; ~6k requests is 3–30 minutes of a batch that has all night. Friends-scale leagues are far smaller. Links follow the same budget.

Authentication ​

Authentication is headroom, not a requirement: a UA-compliant unauthenticated client already gets 200 req/min, enough for ~6k articles. Authenticating raises the ceiling to 2,000 req/min and gives Wikimedia a contactable identity.

  • Flow: OAuth 2.0 client credentials, non-interactive and server to server. Register at Special:OAuthConsumerRegistration/propose/oauth2 on Meta-Wiki for a client ID and secret [9].
  • Per run: exchange them for an access token (valid 4 h, far longer than a run), then send Authorization: Bearer <token> and the contact-info User-Agent on every request [9].
  • Secrets live in GitHub Actions secrets, alongside the backend's bearer token, and reach the container as environment variables.
  • Caveat: the 2,000/min tier requires an established account [3]; a fresh one still gets 200/min. Harmless either way, since 200/min already suffices.

Alternatives considered ​

Cloud Run Jobs with Cloud Scheduler, which this ADR originally chose. Both are free at this cadence and run containers to completion with no cluster to operate. It lost to GitHub Actions on a single constraint: Actions is already where this repository's automation lives, so it adds no Google Cloud account, no IAM surface, and no second place to look when a nightly run fails. The choice is also no longer expensive to revisit — the collector is a container image, and SCORING_RUNNER=external hands the schedule over without a code change. The one remaining obstacle is specific to Cloud Run: it pulls only from Artifact Registry or GCR, so targeting it needs the GHCR image mirrored. Anything that can pull a public OCI image — a VPS, Fly.io, a scheduler on a machine you own — needs no mirror at all.

Direct D1 writes over the REST API, with the collector holding a database token. Rejected for the invariant above: it would have made the collector a second writer of rules it does not contain. Its one advantage was independence from the backend's uptime, and that advantage is smaller than it looks — the collector reads its inputs from the backend regardless, so a backend that is down stops the run either way.

Running the batch on a paid Workers plan. Rejected on the limits table: the memory ceiling does not move, subrequests are raised rather than removed, and it would add no second runtime.

Scope and behaviour ​

  • Cadence: daily at ~05:00 UTC, scoring the last completed UTC day D, roughly two hours after Wikimedia publishes that day's figures. Idempotent on re-run.
  • Responsibilities: scoring only. The economy — credits, pricing, expiry, settlement — stays in the backend, which remains the single money writer.
  • Scope: per-league daily performances for every league, public and private, scored identically. The weekly and monthly tournaments in the original game design are not built; nothing in this pipeline assumes them.
  • State: none in the collector. It is stateless compute between two endpoints and one public API.

Consequences ​

  • Two target platforms, unambiguously. Kotlin/JVM differs from V8 on the runtime and is the repository's first compiled Gradle module, so the exam's platform requirement is met without a borderline reading, and the build-process story gains a real compiled module.
  • One scoring formula. Keeping computation in the backend is what makes this true, and it is checked by the tests that exercise model/scoring.ts from both sides.
  • The host is a deployment detail. Because the collector ships as an image and reads its schedule from the environment, moving it costs a workflow file, not a rewrite.
  • Accepted cost: no type sharing with the TypeScript DTOs. The collector re-declares the handful of wire shapes it touches as Kotlin data classes, and a change to those endpoints has to be made in two languages.
  • Accepted cost: a small second platform. Keeping every rule in the backend leaves the collector a fetcher, a small module beside the TypeScript codebase. The larger JVM module this ADR first planned would have held a second copy of the scoring rules, which is the drift the decision above exists to prevent. Why that trade was taken over the platform split is on Technologies.
  • Supersedes the Requirements document's §2/§4 daily-at-00:00 and global-tournament framing; docs/domain/fantawiki-requirements.md is reconciled to match.

Sources ​

  1. Cloudflare Workers, Pricing: https://developers.cloudflare.com/workers/platform/pricing/
  2. Cloudflare Workers, Limits: https://developers.cloudflare.com/workers/platform/limits/
  3. Wikimedia APIs, Rate limits: https://www.mediawiki.org/wiki/Wikimedia_APIs/Rate_limits
  4. Wikimedia, Pageview Complete dumps: https://dumps.wikimedia.org/other/pageview_complete/
  5. Google Cloud, Always Free features (Cloud Run): https://cloud.google.com/free/docs/free-cloud-features
  6. Google Cloud, Cloud Scheduler pricing: https://cloud.google.com/scheduler/pricing
  7. Cloudflare D1, Limits: https://developers.cloudflare.com/d1/platform/limits/
  8. Wikimedia, Analytics (AQS) pageviews API: https://doc.wikimedia.org/analytics-api/
  9. Wikimedia APIs, Authentication (OAuth 2.0 client credentials): https://www.mediawiki.org/wiki/Wikimedia_APIs/Authentication

5 · Automation and continuous integration ​

The workflow graph and what a green build is allowed to mean, then the suites that decide it and the tiers they are split into.

Continuous delivery ​

Ten workflows, one entry point. ci-cd.yml fires on every push and pull request and fans out to the rest; everything else is either called by it or runs on a schedule of its own.

The graph ​

check.yml is the gate and the only one. It runs format, lint, typecheck, audit and every test suite across both Node packages and the Kotlin module. Everything downstream, deploys, releases, images, waits for it, so nothing ships from a revision that did not pass. Its aggregate, ci-cd / success, is the one status the master ruleset requires.

The backend suite is a matrix. One leg runs it against D1, the other against MongoDB, on separate runners and with fail-fast off: a failure on one store only is a different finding from a failure on both, and cancelling one leg because the other failed would hide exactly that comparison. The format, lint, typecheck and audit gates belong to the code, not to a store, so the D1 leg runs them and the Mongo leg runs its suite alone. The coverage report comes from the D1 leg only, so two legs never race to upload one artefact. → Persistence Targets

Who may run it ​

Every pull request runs check.yml, a fork's included. The workflow names no secret, and on a pull_request event GitHub hands a fork none, so there is nothing to refuse. What does need a secret is either gated on a deploying branch (deploy, release, images, docs) or skipped for a fork (the Codecov upload, which has no token to upload with).

A pull request from a branch of this repository is also built on push, and both runs are kept. On push, the dispatcher compares against the previous push; on a pull request, against the base branch, which is the whole diff and so the run that decides a merge. A filter that compared push to push alone could skip a suite that failed one push earlier.

What a push can skip ​

On master and dev, nothing: both deploy, and a filtered run there would publish a coverage board with holes in it. Elsewhere, the dispatcher asks the compare API which paths a change touched and runs only the jobs those paths can have broken. A change to model/ or dto/ counts as both Node packages, and a change to the build or to CI itself runs everything. Anything the dispatcher cannot answer counts as touched: a filter that wrongly skips ships a break behind a green check, one that wrongly runs costs two minutes.

docs.yml is the one job with a filter of its own: it publishes only from master, and only when the push touched something the site is built from. → About this site: what that job actually does

What "green" means ​

CheckFails when
formatPrettier or ktlint would rewrite a file
lintESLint has any warning at all in the backend (--max-warnings 0); detekt finds an issue in the collector
typechecktsc --noEmit disagrees, including the separate test tsconfig; the Kotlin compiler warns (allWarningsAsErrors)
testAny suite in any package fails, on either persistence target
auditA production dependency has an advisory above the configured threshold

Coverage is measured on every run and reported, not gated. Codecov records the backend's line coverage from every run that tests the backend, and the coverage board draws all three suites; neither is a status the ruleset reads, and codecov.yml marks Codecov's own status informational so it cannot become one by accident. A floor would be a number to steer by, and the number does not say what it is usually taken to say (Test strategy).

Releases ​

A push to master that reached production is handed to release.yml, which computes the next version from the Conventional Commits since the last one, tags it and publishes a GitHub release with the notes. The collector image is tagged with the same version on its way to GHCR, so an image and the notes that describe it share a name. A push whose commits are all chore, ci, test, build, refactor or style deploys and releases nothing. → Release Process

Conventions the pipeline depends on ​

Conventional Commits, enforced by a commit-msg git hook the Gradle build installs. The commit type is also the branch prefix, feat/, fix/, refactor/, so a branch name says what kind of change it carries, and it is the input the release is computed from. → Development Process

npm scripts are camelCase with no separators, formatfix, not format:fix. Gradle's node plugin reads : as subproject notation and _ as a space, so the naming is a build constraint rather than a preference. → NPM Script Naming

Renovate opens dependency updates on renovate/* branches, which are checked, merged once green, and never deployed on their own.

The scheduled jobs ​

Two things run without anyone asking.

Nightly scoring, ~05:00 UTC, roughly two hours after Wikimedia publishes the previous UTC day, a buffer wide enough to absorb GitHub's cron jitter. It runs the collector image from GHCR against production only, keyed for concurrency on the date so two runs never score the same day at once.

Contract settlement, 07:00 UTC, a Cloudflare Cron Trigger inside the Worker, which starts a durable Workflow. It is not a GitHub job: it has to survive interruption and resume, which is what Workflows are for. The two-hour gap after scoring is deliberate and neither job may be moved alone, a contract expiring today is still scorable for yesterday, and settling it first would cost that team its last day (Contract Settlement).

Test strategy ​

The backend's testing rule is unusual and worth stating first, because everything else follows from it: what separates the tiers is not how much of the stack they exercise; it is which layer they are allowed to name.

That rule exists to protect one property. The persistence target must be replaceable, so a second implementation of the repository interfaces should be able to run this suite unchanged and have it mean the same thing. A test that reaches for a query because it is convenient has quietly made that impossible.

This is no longer a promise the suite makes about a hypothetical target. There are two, D1 and MongoDB, and ./gradlew check runs the same files against both.

Three suites, three purposes ​

Every backend test runs in the Workers pool against a real database, reset before each test, every collection emptied and the baseline re-seeded on MongoDB, the schema dropped and the migrations replayed on D1. There is no in-memory substitute and no mocked query builder: the thing under test talks to the thing that ships.

The two runs are the same files. npm test runs them against D1 and npm run testmongo against MongoDB, and the seam between them is a single test module, tests/support/target.ts, which reads the same PERSISTENCE binding production reads, through the same composition root. The suite therefore cannot be pointed at a combination a deployment could not also be. Locally ./gradlew check runs the two one after the other; in CI they are the two legs of a matrix, on separate runners, each reporting on its own (Continuous delivery).

The Mongo run starts a single-node replica set of its own, because the guarded writes are multi-document transactions and a standalone server refuses them. Starting it rather than requiring one is what lets ./gradlew check run both targets on any machine, with nothing installed and no service to remember to start.

The backend tiers ​

WhereMay nameFor
tests/**/*.spec.tsinterfaces, fakesUnit tests. No database at all.
tests/integration/services, RepositoriesThe rules a caller sees
tests/routes/routers, RepositoriesStatuses, payload shapes, and what never leaves
tests/repositories/conformance/Repositories, nothing belowThe gate a second implementation must pass
tests/repositories/d1/*RepositoryD1, SQL, env.dbFacts that are true of D1 alone
*.password.test.tscredentials(), src/indexPassword.tsUsername/password sign-in, which only one build has
tests/support/Repositories (a target only under support/<target>/)The seam and the fixtures

The conformance tier is the interesting one. It is written against the interfaces and nothing below them, which makes it a portable specification: point tests/support/target.ts at another implementation and the same file becomes that implementation's acceptance criteria.

The rule is enforced, not trusted. no-restricted-imports forbids anything under services/, routes/ or tests/ from importing repositories/d1/** or repositories/mongo/**, with the directories whose purpose is naming a target exempted by name. Lint catches the erosion that code review eventually stops catching.

Two tiers are collected by one run and not the other, and for different reasons. The target-specific tier is about a store: an inlined literal in a view, a NOT NULL that provokes a rollback, an empty IN (), none of them questions to put to a document database, so the Mongo run skips them. The password tier is about a build: only the MongoDB entry module mounts username/password sign-in, and the deployed Worker does not contain that code at all, so only the Mongo run has anything to run.

→ Backend Testing: the canonical rules · Auth Modes: why one build has a route the other does not

Seeding ​

Through the interfaces, never with a query, and never with defaulted fixture values. tests/support/subjects.ts holds the helpers, aPlayer(), aTeamIn(leagueId), aLeague(league, foundingTeamName), and the last one takes a whole NewLeague so that a test says everything a production caller says.

The reason to ban defaults is specific: a fixture that quietly fills in a field is a test that passes for a reason the test does not state, and it keeps passing after the field starts mattering.

Two constraints the Workers pool imposes ​

Both were found the hard way and are worth knowing before writing a backend test:

  • vi.mock silently does nothing. A test that appears to stub a module is testing the real one. Substitute through the constructor instead, which is why services take their dependencies that way.
  • A WorkflowEntrypoint cannot be constructed in a test. The settlement logic therefore lives in a service the Workflow calls, not in the entrypoint, and the service is what the tests drive.

The frontend, deliberately looser ​

Frontend specs are regression smoke. They cover the stores, the services and a handful of views, and they exist to catch a refactor that breaks rendering, not to re-assert the game's rules. Those are tested where they are implemented.

Duplicating a rule into the browser suite would mean two places to change every time the rule moves, and the second one would be found late, by a red build nobody expected.

Two specifics that repeatedly cost time:

  • Route guards get their own spec. Testing a beforeEnter by mounting the page gives the guard a different Pinia instance and quietly tests nothing.
  • A page that watches its route must be unmounted, or the suite hangs.

MSW backs every test with onUnhandledRequest: "error", so a request no handler expects fails the test rather than escaping to the network.

→ Frontend Testing: what every mount already has, how to stub one response, and where a test goes

No end-to-end suite, and what stands in for it ​

There is no browser-driven end-to-end suite, and that is a decision rather than an omission. It is worth being exact about what it leaves untested, because most of what an end-to-end suite would check is already checked closer to where it can break:

SeamChecked by
A rule, against a real databaseThe backend suite, on both persistence targets
A route's statuses and payloads, over HTTP, in the Workers runtimeThe routes tier
The backend's routes against what the frontend is told they areopenapi.spec.ts, in both directions
A screen, against those payloadsThe frontend suite, through MSW
The collector, against Wikimedia's and the backend's responsesThe Kotlin suite, through a mock HTTP engine

What none of them covers is the real browser talking to the real Worker over the real network: a cookie a browser refuses, a proxy that drops a header, a page that renders against a live response nobody mocked. That seam was tested by the only thing that can test it completely, which is people playing on production. The playtest ran for 35 days with thirteen players, and what it found is recorded there.

The trade-off is stated plainly: a regression in that seam is found by a player, or by an author on the dev preview environment, rather than by CI. An end-to-end suite would move that discovery earlier at the price of the slowest and flakiest tier there is, run against a Worker and a database that would have to be stood up per run. For a game of friends' leagues that ships from one commit to one environment, that price was judged not worth it.

The figures, and what they say ​

72.9%lines
Across every measured package

4,043 of 5,545 executable lines are exercised by the test suites. Codecov gates the project at 70% overall and does not gate the changed lines of a pull request on their own.

Measured 21 September 2026 at 15:13 UTC, on the revision that built this page.

Backend

Cloudflare Worker · TypeScript
64.9%
lines
64.9% 1202/1853
branches
65% 640/984
functions
62.6% 261/417

Routes are excluded from the report: they are covered end to end by the integration tier, which drives them through real HTTP.

Frontend

Vue 3 + Ionic · TypeScript
76.2%
lines
76.2% 2644/3469
branches
67.4% 1253/1858
functions
67.7% 981/1448

The lowest of the three, and expected to be. Frontend specs are regression smoke around stores, services and views; the game rules they would otherwise re-assert are tested where they live, in the backend. Mocks, the bootstrap and the specs themselves are excluded.

Scoring Collector

JVM · Kotlin
88.3%
lines
88.3% 197/223
branches
56.7% 110/194
functions
90.2% 55/61

The nightly batch. Kover counts the whole module, including the Wikimedia dump parsing that carries most of its risk.

Measured on master at 3680074, 2026-09-18, from the CI run's own reports:

SuiteLinesBranches
Backend (D1 leg)64.8%65.0%
Frontend76.2%67.4%
Scoring collector88.3%56.7%

The backend figure understates the backend. Of its 651 uncovered lines, 370 are the MongoDB repositories and 157 are test helpers that only the Mongo leg runs, and the Mongo leg is not instrumented: coverage comes from the D1 leg alone. Leave those two out and about 90% of the lines on the D1 path execute. Instrumenting the Mongo leg and merging the two reports is the change that would make the number mean what it looks like it means.

The collector's branches are the weakest figure here, and they are in one place. 64 of its 84 uncovered branches are in WikimediaClient.kt, the client for the one upstream the project does not control, and 22 of its 26 uncovered lines are Main.kt, the entry point, which no test runs. The first is where the next collector test belongs.

How to read the coverage figures ​

The board reports line coverage for all three suites. Two things about it are worth stating, because both are routinely assumed the other way.

A covered line is a line some test caused to execute. It is not a line whose behaviour anyone asserted. A suite can push the figure up by importing modules and never checking their output; what stops that here is the seeding rule above and the habit of reviewing for it, not the percentage.

The three figures are not comparable with one another. The backend excludes its route modules from the report on purpose: routes are exercised end to end by the integration tier, which drives them over real HTTP, and counting them twice would flatter the number. The frontend's figure should be read with the first point above in mind: its specs are regression smoke, and mounting a view executes code that no assertion checks. The Kotlin collector counts everything it has, including the Wikimedia response parsing that carries most of its risk.

Running them ​

bash
./gradlew check            # everything: format, lint, test, audit, both targets

cd backend && npm test         # the Workers-pool suite, against D1
cd backend && npm run testmongo # the same files, against MongoDB
cd backend && npm run test-coverage
cd frontend && npm test
cd frontend && npm run hot-test

npx vitest run src/tests/auth/LoginPage.spec.ts   # one file

./gradlew check is what CI runs, and it is the definition of "green". Anything that passes locally but not there is a difference in the environment, not in the tests.

6 · Deployment, containerization and orchestration ​

Which branch reaches which environment and in what order, the target that is shipped as a container image rather than deployed, and the whole stack orchestrated locally from one file.

Deployment ​

Everything is branch-driven. There is no deploy button and no manual promotion: what is on master is production, what is on dev is QA, and a feature branch is checked and nothing else.

Branch to environment ​

The two environments are genuinely separate down to the database: QA has its own D1 instance, its own Google OAuth client and its own JWT secret. A bad migration on dev cannot reach a real player's league.

→ Deploy Strategy · Dev Branch Deployment

What runs where ​

The order a deploy happens in ​

Migrations first, then the Worker, then the frontend. The order is not negotiable: a Worker deployed against a schema it expects but D1 has not received yet is a production error for as long as the gap lasts.

The Worker bundles model/ and dto/ from the repository root, so the deploy installs the root dependencies before running Wrangler, esbuild resolves those imports from the root node_modules, not the backend's.

The target that is not deployed here ​

The backend runs on either of two stores, and this page describes only one of them. No Cloudflare deployment runs on MongoDB: production and preview are D1, and the MongoDB target runs locally, against a replica set.

Keeping it that way took more than intent. composition.ts names both targets, so the driver is reachable from the Worker's entry point, and making the import dynamic does not help, esbuild follows dynamic imports and inlines them. The dry run measured it: 1.5MB of driver shipped with a Worker that can never use it, and the build failed without nodejs_compat, because the driver imports net, tls and child_process.

So wrangler.jsonc aliases the driver to a stub, which brings the production bundle back to within 8 KiB gzipped of its pre-MongoDB size and leaves the D1 deployments configured exactly as they were. The alias is a top-level key with no per-environment form, which is why the MongoDB run has a wrangler config of its own rather than a fifth environment, and a configuration that can never be deployed does not belong in the file that deploys.

bash
./gradlew devMongo   # the frontend, and the Worker on MongoDB

That config also names a different entry module, which is what gives the MongoDB run username/password sign-in and the deployed Worker none of it. → Persistence Targets · Auth Modes

The nightly scoring run ​

Scoring is the one scheduled job that lives outside Cloudflare, and it runs the published container image rather than the sources.

  • It targets production only. QA is not scored.
  • It runs the image publish-images.yml pushed to GHCR for master, so the night scores exactly the artefact that was published, no checkout, no JDK.
  • Concurrency is keyed on the date, so two runs never score the same day at once. Ingest is idempotent anyway, but overlapping runs waste Wikimedia budget.
  • A repository variable, SCORING_RUNNER=external, hands the nightly over to something else without a code change. A manual dispatch always runs regardless , clicking Run is an explicit instruction, and it is how a missed day gets backfilled mid-handover.
  • An unrecognised value for that variable runs the job anyway, deliberately: scoring twice costs Wikimedia budget, scoring never costs a missing day, so a typo fails toward the cheap mistake.

→ Nightly Scoring Pipeline

Secrets, and who holds them ​

SecretHeld byWhat it opens
CLOUDFLARE_API_TOKEN · CLOUDFLARE_ACCOUNT_IDThe deploy jobWrangler and Pages
GOOGLE_CLIENT_ID · GOOGLE_CLIENT_SECRETThe Worker, per environmentSign-in
JWT_SECRETThe Worker, per environmentSession signing
SCORING_INGEST_SECRETThe Worker and the nightly job/internal/*
GH_APP_PRIVATE_KEYThe WorkerFiling problem reports as the bot
CLOUDFLARE_D1_MIGRATION_RUNNER_SECRETThe migration jobReplaying migrations
GITHUB_TOKENActions, automaticallyGHCR, and publishing this site

The GitHub App key is optional on purpose: a deploy still succeeds before it is set, and the report form answers 502 until it is. A feature that is not configured yet should degrade, not block a release.

The collector holds exactly two credentials, the ingest secret and a Wikimedia user agent, and no database access at all. It is the least privileged thing in the system despite being the one that runs unattended.

Running the whole stack locally ​

./gradlew noGenie brings up the frontend, the Worker and a local D1 with no credentials to obtain: the compose file wires the dev sign-in route, which mints a normal session and refuses to exist outside the local environment. Three sibling tasks, up, demo, demoNoGenie, add the Article Genie, the seeded demo league, or neither.

→ Running FantasyWiki in Docker · Local Development Setup

Running FantasyWiki in Docker ​

For someone who wants to run FantasyWiki without installing Node or npm, and, the harder half, without being handed any of the project's credentials.

bash
git clone https://github.com/FantasyWiki/FantasyWiki.git
cd FantasyWiki
./gradlew noGenie

Then open http://localhost:5173 and sign in with Continue as demo player.

There is nothing else to obtain: no Cloudflare account, no Google OAuth client secret, no env file to fill in. That gives you an empty database to build in, and the Article Genie switched off, which is what noGenie names.

The four commands ​

Two things about the stack are optional, and they switch independently, so there are four combinations. Naming each one is the whole point: the alternative is remembering which -f files and --profile flags compose into which run, and getting it wrong at the worst moment.

Genie offGenie on
Empty database./gradlew noGenie./gradlew up
Seeded database./gradlew demoNoGenie./gradlew demo
  • Genie: the Article Genie, the one feature that needs a Cloudflare account. On means the Worker runs wrangler's local-genie environment, which binds Workers AI; off means local, which binds no model. See The Article Genie.
  • Demo: whether the database arrives with the demo league in it. See Filling the database.

Each task is a wrapper thin enough to read: it sets one environment variable and runs docker compose up. ./gradlew tasks --group docker prints all four, and the raw form still works if you prefer it,

bash
NPM_CMD=dev docker compose --profile demo up   # == ./gradlew demoNoGenie

NPM_CMD is the Genie axis (which npm script the backend container runs, defaulting to devgenie) and --profile demo is the data axis. Gradle sets NPM_CMD through the process environment rather than the command line, so the tasks behave the same under bash, PowerShell and WSL.


What makes that possible ​

Two features would normally each stop a fresh clone dead, and each is switched off rather than faked.

FeatureNeedsWithout it
Article GenieA Cloudflare account (Workers AI)noGenie runs the local environment, which binds no model, so the backend starts and the market shows no Genie at all, see Article Genie LLM Integration
Google sign-inThe project's OAuth client secretGET /auth/dev mints the same session without it
/auth/dev ​

It is the Google flow with the identity provider removed: the same LoginService call, the same claims, the same JWT_SECRET, the same session_token cookie, the same redirect to /auth/callback. The demo player is an ordinary player, so nothing downstream knows this route exists.

It is gated twice, and the two gates do not trust each other:

  • Backend: 404 unless ENVIRONMENT is "local". Only the local and local-genie environments in wrangler.jsonc carry that value; production, preview and test do not. 404 rather than 403 because outside local development the route should not appear to exist at all.
  • Frontend: the button renders only when the build was started with VITE_DEV_LOGIN=true.

JWT_SECRET is still required, but it is self-generated randomness, not a shared credential: the container entrypoint writes a backend/.dev.vars from the committed .dev.vars.example with a fresh random value when the file is missing.


What Docker is not for ​

Compose is a development convenience, and it cannot become a deployment target: the frontend is a Cloudflare Pages project and the backend a Worker, both serverless. Nothing in production runs a container of this app, so no compose.yaml here is ever up anywhere but a laptop. The one image the project does ship, the Kotlin scoring collector, is not part of this stack; it is below.

The image is deliberately narrow in two places, for different reasons. It carries Node but no JDK, the bind mount puts gradlew right there in /workspace with nothing behind it, and the only Cloudflare credential it is ever handed is the Genie's API token, which is narrower than the deploy credential CI holds but is not read-only, see the note on Workers Scripts · Edit in Creating a Cloudflare API token. noGenie hands it none at all, which is what keeps a fresh clone runnable.

In a containerNatively
Run it, click around, sign in✅✅
Edit code with hot reload✅✅
npm test / lint / format, per subproject✅✅
wrangler dev, local D1 migrations, cf-typegen✅✅
The Article Genie✅ with an API token✅ with wrangler login
./gradlew check --parallel, the PR gate❌ no JDK✅
wrangler deploy, db:migrate:remote❌ no Pages or D1 credential✅

So: containers to run FantasyWiki, the native toolchain to ship it. Anyone opening a PR needs the second as well, which is what Local Development Setup installs, and that path needs no Cloudflare account either, only a JDK.


The Article Genie ​

up and demo run the Worker on wrangler's local-genie environment, which binds Workers AI. Workers AI has no local simulator, so every call is proxied to the real model on Cloudflare's edge and Wrangler has to authenticate. That is the whole reason this needs credentials and noGenie does not.

Why not wrangler login ​

Natively, wrangler login is the usual answer. It cannot work here: its OAuth callback listens on localhost:8976, and inside a container that is the container's localhost, the browser doing the consenting is on the host, and nothing routes back. The flow would sit on a URL nobody can complete.

So use an API token, which Wrangler reads straight from the environment and prefers over any stored OAuth session. No browser, no login step.

Getting one ​

Creating the token is the same job whether you run in Docker or not, so it is written once, next to the other credentials: Creating a Cloudflare API token. The short version is the dashboard's https://dash.cloudflare.com/profile/api-tokens → Create Token → Create Custom Token, with Workers Scripts · Edit (needed for the remote preview session, and the one everybody misses), Workers AI · Read and Account Settings · Read.

What is specific to Compose is only where the two values go:

bash
cp backend/.dev.vars.example backend/.dev.vars   # if you have none yet

Fill the two CLOUDFLARE_* values in at the bottom of that file. There is no .env in the repository root and deliberately so: this project's local secrets already have a home, and Compose passes the whole of backend/.dev.vars into the container's environment (env_file), which is where Wrangler looks for them. That file is gitignored and dockerignored, so it reaches neither a commit nor an image layer.

One Compose detail worth knowing if you ever add a secret with a `

Exam report ​

in it: the env_file entry carries format: raw, without which Compose expands `

Exam report ​

inside the values it reads and the secret arrives silently truncated.

Then ./gradlew up: the backend prints ==> Article Genie on and the market grows its Genie button.

Without a token, up and demo stop before Wrangler starts, with a message naming this section, a clear failure rather than a hang on an OAuth flow that cannot finish. noGenie and demoNoGenie are unaffected: Compose passes both variables always, so an undefined one arrives as the empty string, and the entrypoint unsets it. An empty token is worse than none, Wrangler would see a credential, try it, and fail authentication instead of running credential-free.

Nothing else in the stack ever sees these variables. The token is not read-only, though, the remote preview session forces Workers Scripts · Edit, so it can publish a Worker, if not a Pages build or a D1 migration.


Where the production bundle is ​

Not here. All four tasks bind-mount the repository and serve the frontend from the Vite dev server, so an edit on the host restarts Wrangler and hot-reloads Vite. They publish the same ports, so nothing about the app's URLs changes between them.

There was once a second compose file that baked the sources in and served the built bundle through vite preview. It was deleted, because the thing it was for is done better elsewhere: every push to dev runs npm run build and wrangler pages deploy (.github/workflows/deploy-target.yml), so the real production bundle is already live at https://dev.fantasywiki.pages.dev, against the real Worker and the real database, and at a URL you can send someone, which a container on your laptop is not.

The backend never had a second mode to lose. wrangler dev is the only way to run a Worker locally, so it runs the same command either way; only the frontend had a dev/built split at all.


Filling the database ​

The two answers differ deliberately.

demo and demoNoGenie arrive populated. You get a public league, Wikipedia Premier, with three rival teams, full 4-3-3 squads and four scored days each, so the market shows owned articles, the standings rank somebody, and the podium has a reason to appear. A second command standing between a visitor and a working app is one too many.

The demo Compose profile does this by adding a db-seed service that runs to completion before the backend starts. It is the same image and the same entrypoint, so it applies the migrations first and seeds a schema that exists, and because the backend waits on it the two never hold the D1 files at once.

up and noGenie start empty, which is the honest state for someone about to found their own league. To fill one anyway, without restarting:

bash
docker compose exec backend npm run db:seed:demo

Both paths run the same backend/seeds/demo.sql, deliberately not a migration, because no deployed database should ever see it. It deletes its own rows before reinserting them, so re-running it replaces the demo league rather than stacking copies, and its timestamps are relative to now, so the season never ages out from under the data. That is what makes seeding-on-every-boot safe rather than merely first-run.

The database lives in a named volume and survives docker compose down. To start clean:

bash
docker compose down -v

Things that will bite ​

Do not remap the ports. http://127.0.0.1:8787/auth/google is registered with Google as a redirect URI, and FRONTEND_URL feeds both that redirect and the session cookie's Secure flag. 8787 and 5173 are effectively part of the configuration.

The installs are the container's, not yours. Each node_modules sits in a named volume mounted over the bind mount. The backend's better-sqlite3 is a native module, and a copy compiled on Windows or macOS cannot load inside a Linux container. A mysterious invalid ELF header means one of those mounts is missing.

127.0.0.1 means "this container". Vite's dev proxy therefore reads BACKEND_ORIGIN (http://backend:8787 under Compose) rather than hardcoding the loopback address it uses when both processes run on one machine. This is the single likeliest thing to get wrong.

Everything binds 0.0.0.0. Wrangler and Vite both bind loopback by default, which is unreachable through a published port, hence --ip 0.0.0.0 and VITE_HOST.

File watching polls. Bind-mounted filesystems do not deliver inotify events on Windows or macOS, so CHOKIDAR_USEPOLLING and VITE_POLL are set. It costs some CPU; without it, edits are simply never noticed.

Compose environment beats .env.local. Vite lets a real environment variable win over the file, so the container behaves the same whatever a developer happens to have in their own frontend/.env.local.

wrangler login does nothing useful in a container. Its OAuth callback listens on localhost:8976, which inside a container is the container's own loopback; the browser consenting is on the host and nothing routes back. Use a CLOUDFLARE_API_TOKEN in .env, The Article Genie.

Sign in with Google fails in a container unless GOOGLE_CLIENT_SECRET is filled into backend/.dev.vars, the backend answers a raw 500. Use the demo button, or supply the secret and both routes work.


Publishing images ​

.github/workflows/publish-images.yml pushes to GHCR on master and dev, behind the same gate as the Cloudflare deploys: an image is only worth publishing for a revision that passed check. GITHUB_TOKEN is the whole credential, no new secret, unlike the deploys next door.

Today it publishes the one service Cloudflare does not host:

ImageTags
ghcr.io/fantasywiki/scoring-collectorsha-<short> always, plus latest on master and dev on dev

sha-<short> is the tag a rollback names; the branch aliases are what a human or a compose file pulls.

Its Dockerfile is runtime-only, the Gradle distribution is built before the image, not inside it. A Gradle stage would have to carry the whole monorepo, because settings.gradle.kts configures the Node subprojects and installs git hooks, all for a build the runner has already done with a warm cache. To build it by hand:

bash
./gradlew :scoring-collector:installDist
docker build -f docker/scoring-collector.Dockerfile -t scoring-collector .

Adding the backend and frontend demo images is one more entry in that workflow's matrix, the day someone wants docker compose pull instead of a local build.

Running the collector ​

Publishing an image rather than building from source at run time buys two things: the nightly scores exactly the artefact that passed check for master, and the nightly does not have to run on a GitHub runner at all. Any host that can pull the image and set three environment variables can own the day instead. Both routes, the command each takes, and the repository variable that hands over between them are in Nightly Scoring Pipeline.


7 · Use of generative AI ​

Which AI tools were used to build the project, at what level of involvement in each process, how agents were kept inside the project's rules, and which skills they used and why.

AI Assistance ​

Generative AI was used to build FantasyWiki, and this page says for what, how, and under which constraints. The machine-readable form of the same statement is AI-DECLARATION.md, which follows the AI-DECLARATION.md convention: when the two disagree, fix whichever is wrong, they are one claim.

It is about how the code was written. The Article Genie also uses a language model, but as a feature that runs in production, and it is covered where the feature is: ADR 0006.

The tools, and the level of involvement ​

Two tools: Claude Code, in the terminal and the editor, and GitHub Copilot, both as a coding agent that opens pull requests from copilot/* branches and in the editor.

ProcessLevelWhat that meant here
DesignpairThe domain model, the economy and every ADR were decided by the authors, with an agent as the other side of the argument
ImplementationcopilotAn author states the task; the agent carries it out, asking before it acts; the author reviews the diff
TestingcopilotAs implementation, mostly test-first
DocumentationcopilotAs implementation, against the voice and rules in docs/agents/documentation-site.md
DeploymentcopilotWorkflows and build scripts, as implementation
ReviewassistAn agent points at problems; the decision to merge is an author's

The levels are the convention's: pair is both acting on the task with the human understanding its internals; copilot is the agent doing the whole task while asking for permission or clarification; assist is the agent acting on a part of it. Nothing here is auto, an agent completing a task with no human in the loop.

How an agent is kept inside the lines ​

One instruction file. AGENTS.md is what every agent reads, Claude Code through CLAUDE.md, which imports it, and Copilot directly. There used to be three copies, one per tool, and the Copilot one had fallen behind the build it described. A second copy of an instruction is a second instruction.

The vocabulary and the decisions are written down first. CONTEXT.md is the glossary an agent must use, and docs/adr/ holds the decisions it must not relitigate. Both are human-owned: an agent may propose a term or an ADR, and an author writes it. This is what keeps a model's fluent synonym for Free Agent out of the code.

The tests are the check, not the agent's confidence. Backend tests run against a real database on both persistence targets, and a rule an agent implemented is accepted when a test states it, not when the agent says it is done (Backend Testing).

Only an author merges. master accepts nothing but pull requests with signed commits and a green ci-cd / success (Development Process). An agent's work, whether a Copilot pull request or a Claude Code session's diff, reaches master when an author merges it, and in the Claude Code case after the author has committed it themselves.

Where to see it. Commits written with Claude Code carry a Co-Authored-By: Claude trailer when the session added one; 36 commits on master do, the first on 2026-06-21. The six commits the Copilot agent authored carry Copilot as their author. The absence of a trailer is not evidence that no agent was involved.

Skills ​

A skill is a packaged set of instructions an agent loads for one kind of task. Four were used, each for a job where an unguided agent fails in a recognisable way.

SkillUsed forWhy a skill rather than a prompt
grill-meStress-testing a design before it became an ADRAn agent asked to review a plan tends to agree with it. This one interviews the author branch by branch until every open decision is resolved, and it is the author who answers.
tddRed, green, refactor on backend rulesIt makes the agent write the failing test first and stop there, so the test states the rule before any code can shape it to fit.
diagnoseHard bugsIt forces reproduce, minimise, hypothesise, instrument, before any fix, instead of the plausible patch an agent reaches for first.
improve-codebase-architectureFinding modules worth deepening or mergingIt reads CONTEXT.md and docs/adr/ before proposing anything, so its suggestions use the project's words and respect decisions already taken.

The skills read their project configuration from fixed paths under docs/agents/, which is why that directory must not move. It also holds the configuration for an issue-tracker and a triage skill; those were set up with the others and not used, which is why no issue carries a triage label.

8 · Conclusions ​

What was built, which decisions held, and what would be done differently.

What we learned ​

The two halves came apart. The architecture delivered what it promised: built, run on production for a month, cost nothing, nothing bought again to carry the load. The game it was built to serve does not work, for a reason no further building would fix.

The playtest is where that was found. This page is what follows from it.

Which decisions held ​

ADRVerdict
0001 Base Scoring ModelHeld, and unbalanced. The curve behaved, but its additive chemistry term outgrew the pageview scoring it was meant to garnish. Players played for an all-Excellent board, not for points.
0002 Language Scale FactorNo verdict. One edition, all month. It solves a problem the run never presented.
0003 Closed Trading EconomyChallenged. The least-liked part of the game. Renewal went effectively unused (three uses against two hundred contracts) and Early Sell was spent during setup rather than played. Players asked for a stock market instead.
0004 Scoring Engine PlatformHeld, with the scheduler and the delivery path revised during implementation. Free, and both failures were the project's own code rather than the platform.
0005 Contract PricingHeld. The curve priced a 1,000-credit budget into real choices across two hundred contracts, from free articles up to 625 credits, and a team that spent badly kept a low-credit route back into scoring.
0006 Article GenieVindicated, against expectation. Arrived mid-run to open scepticism and was received warmly, as a humorous and occasionally useful extra. The only feature that landed better than it was pitched.
0007 Derived Team CreditsHeld, and checked rather than assumed. Across 13 teams and 200 contracts: no negative balances, and no contract settled twice. Final balances ran 302 to 1,570 against a 1,000 budget.
0008 League Invitation CodesUntested. Nobody joined a league by code.

Two are marked no verdict or untested, and that is itself the finding. A month of play tested the decisions about scoring, money and infrastructure, and left the decisions about leagues almost entirely alone, because the run was one league everyone was already in. The parts of the system that get a player from nothing to a league with their friends in it are the least evidenced parts of the project.

What we would do differently ​

Four, in descending order of how much they matter. Only the first is about the game.

  1. Attach the game to an event, or stop calling it a fantasy game. Fantasy formats borrow their rhythm from something people watch together, and Wikipedia's pageviews have no such moment. Not visible from inside the design, not a feature gap, and it sits under every other finding in the playtest. Everything below is small next to it.
  2. Ship the leaderboard before opening, not two days after. It was the first thing thirteen players asked for. Seeing other teams' line-ups was the second, and took until 14 August.
  3. Close the gap between buying and fielding. Two teams bought twenty and eleven contracts, then fielded an empty Formation every day for a month and scored zero with nothing telling them. Owning without fielding should not be a silent state.
  4. Decide what renewal is for, or remove it. Three uses in two hundred contracts is not a mechanic, and carrying one costs schema, rules and documentation regardless of use.

What is not built, and why ​

Decided against. Sourcing trends from Google search rather than Wikipedia. Several players thought it would make a better game and they may be right, but the analytics API costs hundreds of dollars a month, which ends it for a project whose whole cost argument is that it runs on free tiers. Wikipedia's openness is not incidental here; it is what makes the project possible at all.

Proposed by players. Two ideas came out of the run, offered as ways the game could be better for them rather than as requests for a backlog:

IdeaNote
An auction for contracts, instead of a fixed price against the marketRejected at the original pitch as tedious, then asked for after a month of play
Head-to-head competition: fixtures scored one against one, three points for a win in an all-play-all, then the top of the table into a knockoutA leaderboard turned out to be a ranking, not a rivalry

Neither is committed to. Both are recorded because they came from people who had played for a month, which makes them better evidence than an idea from inside the project. Neither answers the question below, so building either now would answer the wrong thing.

Deferred earlier, still deferred: weekly and monthly tournaments, demand-reactive pricing, and a global season. That last is a season format open to everyone, and is not the seeded league named Global League the playtest ran in.

The open question ​

The premise still looks right. Wikipedia's daily trends are good raw material for a game, and nothing in the run argued against it. What the run took away is the shape: a fantasy-sport format cannot carry it, because there is nothing for it to be a fantasy of.

Planning stopped there rather than picking one of the proposals above. What comes next is game design, not another sprint, and it has not been done yet.

Built from the repository's own documentation. Source on GitHub.