Reviewed document: ARCHITECTURE-SPINE.md (draft, 2026-07-07)
Referenced: prds/prd-wordle-2026-07-07/prd.md (final)
Method: Two hypothetical developers (Dev A, Dev B) working on independent packages each obey every AD to the letter, then attempt to integrate.
6 findings — 2 CRITICAL, 3 HIGH, 1 MEDIUM. The architecture spine is well-structured at the paradigm level but lacks concrete type contracts that would prevent independent implementers from building incompatible artifacts. The deferred items (§Deferred) are the primary source of risk — each one leaves a gap that, when filled independently by two developers, produces irreconcilable divergence.
Hole: The consistency conventions table references shared/src/api.ts for API request/response shapes, but no concrete interface is specified anywhere in the architecture spine. Dev A writes the server routes; Dev B writes the client fetch wrappers. Both reference shared/src/api.ts, but the file is empty — so each writes types that "feel right."
Incompatible outcome:
| Concern | Dev A (server author) | Dev B (client author) |
|---|---|---|
| POST /api/guess request | { wordId: number, tryNo: number, guess: string } |
{ wordId: string, attemptNumber: number, guess: string } |
| POST /api/guess response (valid) | { feedback: Array<"green"\|"yellow"\|"gray"> } |
{ colors: Array<{ letter: string, status: "g"\|"y"\|"d" }> } |
| POST /api/guess response (invalid) | { error: "Unknown word" } (status 400 per convention) |
{ valid: false, message: "Not in word bank" } (status 400) |
| POST /api/play-again request | { skillMetric: number } |
{ skill: number } |
| POST /api/play-again response | { wordId: number } |
{ nextWordId: string } |
| GET /api/daily response | { wordId: number } |
{ dailyWordId: number } |
Both developers followed AD-3 (TypeScript everywhere), AD-9 (shared types), and the conventions table — but since the shared types themselves are undefined, integration fails on field-name mismatch, type mismatch, and response-shape mismatch.
Recommendation — New AD (AD-10): Concrete API Type Contracts
### AD-10 — API type contracts are binding
- **Binds:** shared/src/api.ts, server/src/routes/*, client/src/api.ts
- **Prevents:** field-name drift, type mismatches, divergent response shapes
- **Rule:** `shared/src/api.ts` SHALL export request/response interfaces for every endpoint before any consuming code is written. The interfaces SHALL specify: HTTP method, path, request body shape, response body shape for each status code (200, 400, 404). No package may define a copy or variant of these interfaces.
**Minimum required interfaces:**
typescript // GET /api/daily interface DailyResponse { wordId: number }
// POST /api/guess interface GuessRequest { wordId: number; tryNo: number; guess: string } interface GuessResponseValid { feedback: FeedbackColor[] } interface GuessResponseInvalid { error: string } type GuessResponse = GuessResponseValid | GuessResponseInvalid
// POST /api/play-again interface PlayAgainRequest { skillMetric: number } interface PlayAgainResponse { wordId: number }
`FeedbackColor` is defined in `shared/src/game.ts` (see Finding 4).
Hole: The architecture gives two packages authority over data/word-bank.json:
AD-4 says the file contains guessable and targets (with "solver difficulty scores"), but the exact field names, types, and structure are deferred. AD-9 says shared/src/word-bank.ts exports the WordBank interface — but no concrete fields are specified. The PRD (FR-17) adds: "solver output includes: word, algorithm name, attempt count" — suggesting a per-algorithm breakdown, not a single difficulty score.
Incompatible outcome:
Dev A (solver) reads FR-17 literally and outputs per-algorithm data:
{ "guessable": ["apple","crane",...], "targets": [
{ "word": "apple", "scores": { "entropy": 3.2, "minimax": 4.1 } },
{ "word": "crane", "scores": { "entropy": 2.1, "minimax": 2.8 } }
]}
Dev B (server) reads AD-8 (Gaussian sampling needs a single difficulty number) and AD-4 (targets have "solver difficulty scores") and expects:
{ "guessable": ["apple","crane",...], "targets": [
{ "word": "apple", "difficulty": 3.65 },
{ "word": "crane", "difficulty": 2.45 }
]}
The server cannot consume the solver's output. Both obeyed AD-4 (static JSON), AD-9 (shared types exist), and the binding FRs.
Recommendation — Tighten AD-4 with schema contract:
Add to AD-4's rule text:
The word bank JSON schema is defined by the
WordBankandTargetWordinterfaces inshared/src/word-bank.ts. TheTargetWordinterface SHALL include a single numericdifficultyfield (not per-algorithm scores). The solver SHALL aggregate its per-algorithm results into this singledifficultyfield before writing the JSON. The server SHALL validate the loaded JSON against this interface at startup and refuse to start on mismatch.
Additionally, add a new finding-driven requirement to the solver package: "The solver SHALL accept a configurable aggregation strategy (default: average of all algorithm attempt counts) and emit exactly the shape defined by WordBank."
Hole: FR-9 and FR-13 require revealing the correct word on a loss: "Screen 3 shows: the correct word revealed" (UJ-4), "Loss → 'The word was: XXXXX'" (FR-13). AD-5 states: "The guessable word list never leaves the server." The correct word is, by definition, a member of the word bank.
Two developers interpret the boundary differently:
Same developer could also split the difference: the server returns the word only on the final (losing) guess response. But there is no convention for this — no AD governs that case.
Recommendation — Tighten AD-5 with an explicit exception:
Add to AD-5:
Exception: When the 6th attempt is exhausted without a correct guess (FR-9), the server MAY return the target word string to the client solely for display on the Results screen (Screen 3). The full word list SHALL NOT be transmitted in any other response. The word-reveal response remains governed by the shared API type contract.
And add a corresponding response field to the POST /api/guess response type: { ..., gameOver: boolean, answer?: string }.
Hole: The Feedback Scoring Rule (glossary, FR-7) names three colors: "green," "yellow," "gray." AD-6 says the server computes feedback and the client renders it. But the wire format for feedback is never specified. shared/src/game.ts is mentioned as exporting Guess, Feedback, Colors, etc. but no concrete type is given.
Incompatible outcome:
["gray", "yellow", "gray", "green", "gray"].[FeedbackColor.Gray, FeedbackColor.Yellow, FeedbackColor.Gray, FeedbackColor.Green, FeedbackColor.Gray] serialized as numbers [0, 1, 0, 2, 0].[{ letter: "h", color: "gray" }, ...].All three obey AD-3 (TypeScript), AD-6 (server computes, client renders), and AD-9 (type in shared) — but integration breaks because the type was never concretely defined.
Recommendation — New AD (AD-11): Feedback Wire Format
### AD-11 — FeedbackColor is a string-literal union
- **Binds:** shared/src/game.ts, server/src/feedback.ts, client/src/components/Grid.tsx, client/src/components/Keyboard.tsx
- **Prevents:** mismatched color representations, integer-enum-serialization bugs
- **Rule:** `FeedbackColor` in `shared/src/game.ts` SHALL be the union type `"green" | "yellow" | "gray"`. Feedback for one guess SHALL be `FeedbackColor[]` (length 5, index-aligned with guess letters). No numeric encoding, no object-per-letter wrapper. The server emits these string values directly in JSON; the client receives and renders them.
Hole: AD-7 says the client persists game state to browser storage. The source tree lists three hooks: useGame, useStats, usePersistence. But no AD, interface, or convention defines:
Incompatible outcome:
usePersistence.ts: stores { stats: {...}, rulesSeen: true, skill: 3.2 } under key "wordle-state", reads/writes localStorage directly.useStats.ts: stores { gamesPlayed: 42, winRate: 0.67, histogram: {...}, streak: {...} } under key "wordle-stats", also reads/writes localStorage directly.useGame.ts: stores { currentAttempt: 3, history: [...], wordId: 42 } under key "wordle-game", also reads/writes localStorage directly.All three obey AD-7 ("client tracks" state) — but their storage keys collide or diverge, and no component can read another's data. Screen 3 (Results) reads from useStats but can't see the game outcome saved by useGame. The rules cookie from usePersistence is stored separately from the stats it gates.
Recommendation — New AD (AD-12): Single Persistence Contract
### AD-12 — Single persistence key and schema
- **Binds:** client/src/hooks/usePersistence.ts
- **Prevents:** localStorage key collisions, divergent serialized shapes, direct localStorage access from components
- **Rule:** All browser-persisted state SHALL live under a single localStorage key `"wordle-v1-state"`. The value SHALL be a JSON-serialized object conforming to the `PersistedState` interface in `shared/src/game.ts`. `usePersistence` is the ONLY module that reads/writes localStorage. `useStats` and `useGame` SHALL call `usePersistence` to load/save their state; they SHALL NOT access localStorage directly.
Hole: The architecture spine defers the "Skill metric formula" to implementation (§Deferred). AD-8 uses skillMetric to center a Gaussian distribution for Play Again word selection. The PRD assumption (§4.4) says "skill is measured as average attempts-to-solve" but marks it for confirmation during implementation.
Incompatible outcome:
Both obey AD-8 (Gaussian sampling centered on skill metric). But for the same player history, Dev A produces skillMetric = 3.8 and Dev B produces skillMetric = 2.9. These center the Gaussian at different points, producing materially different word distributions from the Play Again endpoint. If Dev A wrote the client (useStats produces the metric) and Dev B wrote the server (play-again uses it), the server's sampling doesn't match the intended difficulty — but neither violated any AD.
This is lower-severity than the others because the outcome is "different word is selected" rather than "integration crashes" — but it's still a divergence that two compliant implementations produce different runtime behavior.
Recommendation — Tighten AD-8 or add a new decision:
Add to AD-8:
The skill metric SHALL be the simple arithmetic mean of the player's attempts-to-win across all completed games (losses count as 7). The formula is fixed: no alternative weighting or decay SHALL be applied in v1. The range is [1, 7]; the server SHALL clamp Gauss-sampled indices to valid word-bank bounds.
| # | Severity | Finding | Recommended Fix |
|---|---|---|---|
| 1 | CRITICAL | API contract types undefined — field-name, type, and shape divergence | New AD-10: concrete typed interfaces in shared/src/api.ts |
| 2 | CRITICAL | Two owners of word-bank shape — solver emits per-algorithm scores, server expects single difficulty | Tighten AD-4: mandatory WordBank interface with single difficulty field |
| 3 | HIGH | Post-loss word reveal vs. AD-5 word-bank confidentiality | Tighten AD-5: explicit exception for game-over word reveal |
| 4 | HIGH | FeedbackColor representation undefined — string, enum, or object? | New AD-11: FeedbackColor as "green" \| "yellow" \| "gray" union |
| 5 | HIGH | Client persistence: no key, no schema, multiple direct localStorage accessors | New AD-12: single key, single hook, typed PersistedState interface |
| 6 | MEDIUM | Skill metric formula deferred — same history produces different skillMetric values |
Tighten AD-8: fix formula as simple mean, clamp to [1,7] |
All three deferred implementation decisions flagged in the spine create divergence risk:
| Deferred item | Risk if filled independently |
|---|---|
| Skill metric formula | Two components compute different skill values from identical data (Finding 6) |
| Solver algorithm selection | Two algorithms produce different score units/structures — exposing per-algo data without a single aggregation contract (Finding 2) |
| Word bank source | Different corpus → different word IDs for same index → daily word mismatch between solver output and server data |