# Adversarial Architecture Review **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. --- ## Verdict **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. --- ## Finding 1 — CRITICAL: Underspecified API Contracts (shared/src/api.ts) **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** ```markdown ### 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). ``` --- ## Finding 2 — CRITICAL: Two Owners of Word-Bank Shape (solver writes, server reads) **Hole:** The architecture gives two packages authority over `data/word-bank.json`: - The **solver** (FR-16, FR-17) writes it: "orchestrates solvers, writes rankings." - The **server** (AD-4) reads it at startup: "loaded at server startup." 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: ```json { "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: ```json { "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 `WordBank` and `TargetWord` interfaces in `shared/src/word-bank.ts`. The `TargetWord` interface SHALL include a single numeric `difficulty` field (not per-algorithm scores). The solver SHALL aggregate its per-algorithm results into this single `difficulty` field 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`." --- ## Finding 3 — HIGH: Post-Game Word Reveal vs. AD-5 (Word Bank Does Not Leave Server) **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: - **Dev A (strict AD-5 reading):** "If the word list never leaves the server, then no individual word leaves the server either." The loss screen shows no word; only says "Better luck next time." The PRD requirement FR-13 is violated. - **Dev B (PRD-prioritized reading):** "AD-5 means the *full list* stays server-side for validation purposes; returning one word on game-over is a controlled exception." The loss response includes the word string. AD-5 is technically violated. 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 }`. --- ## Finding 4 — HIGH: Feedback Color Representation Undefined **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:** - **Dev A (server)** returns feedback as an array of strings: `["gray", "yellow", "gray", "green", "gray"]`. - **Dev B (client)** expects an enum: `[FeedbackColor.Gray, FeedbackColor.Yellow, FeedbackColor.Gray, FeedbackColor.Green, FeedbackColor.Gray]` serialized as numbers `[0, 1, 0, 2, 0]`. - **Dev C (another interpretation)** returns objects: `[{ 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** ```markdown ### 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. ``` --- ## Finding 5 — HIGH: Client-Side Persistence Has No Schema or Key Contract **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: - Which localStorage key(s) to use - The shape of the persisted object - Whether hooks access localStorage directly or through a single persistence layer - Migration/versioning strategy **Incompatible outcome:** - **Dev A** writes `usePersistence.ts`: stores `{ stats: {...}, rulesSeen: true, skill: 3.2 }` under key `"wordle-state"`, reads/writes localStorage directly. - **Dev B** writes `useStats.ts`: stores `{ gamesPlayed: 42, winRate: 0.67, histogram: {...}, streak: {...} }` under key `"wordle-stats"`, also reads/writes localStorage directly. - **Dev C** writes `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** ```markdown ### 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. ``` --- ## Finding 6 — MEDIUM: Skill Metric Formula Deferred — Divergent Difficulty Sampling **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:** - **Dev A** implements skill as simple average of attempts-to-win (range: 1-6). - **Dev B** implements skill as an exponentially weighted moving average that biases recent performance. 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. --- ## Summary Table | # | 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] | --- ## Summary of Deferred-Item Risk 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 |