Purpose. This is not the README (that tells someone how to run the app). This doc explains why every piece is the way it is, so you can defend any schema decision or trade-off cold in the live review. The AI-usage policy says understanding matters more than output — this is where that understanding lives. Fill in each phase's section as you build it, while the reasoning is fresh.
There are two systems that barely know about each other:
- The Forum — posts, comments, solutions. It is a normal CRUD app. Its only obligation to the engine is: whenever a user does something meaningful, emit an event.
- The Engine — knows nothing about posts or comments. It knows about events, challenges, progress, and rewards. It receives events, evaluates them against data-driven challenge configs, updates progress, and disburses rewards.
The seam between them is a single event stream. This decoupling is the whole point of the assignment, and it's why the design below keeps insisting that the forum never calls the evaluator directly.
The data flow, end to end:
user action (comment)
→ forum handler writes the comment
→ AND emits an event (POST /api/events, or the internal equivalent)
→ event stored (idempotent on event_id) ── same DB txn ──▶ job enqueued
→ API returns 202 immediately (no evaluation on the request path)
… later, asynchronously …
→ worker picks up the job
→ generic evaluator runs the user's active challenges against the config
→ progress rows updated
→ if a challenge just completed → reward disbursed (idempotently) into the ledger
→ frontend, which has been polling, sees progress move and the reward appear
Everything else is detail hanging off this spine.
The requirement: an admin creates a challenge by POSTing a config; the engine evaluates all challenges from that config with no hardcoded per-type logic. If the domain layer ever branches on type, the design has failed.
The shape. A challenge row carries:
event_type— which event this challenge cares about (e.g.comment_posted).type— how to interpret progress (countorstreak).rule_config(JSONB) — the parameters for that interpretation.reward(JSONB) — what to grant on completion.
rule_config examples:
Where type is allowed to be switched on — exactly one place: a strategy registry.
STRATEGIES: dict[str, ChallengeStrategy] = {
"count": CountStrategy(),
"streak": StreakStrategy(),
}Each strategy implements the same interface: given a user, a challenge, and the user's relevant events, return (current_value, target_value, is_complete). The evaluator loops over a user's active challenges, looks up STRATEGIES[challenge.type], and asks it to compute progress. Adding a "unique-days-with-2-events" challenge later = add one strategy, touch nothing else. That extensibility is the exact thing the live review will test ("now add a challenge type that…").
Why JSONB for rule_config and reward: the config is polymorphic by design — a count challenge and a streak challenge don't share a fixed column set. Columns would force nullable sprawl or a table per type; JSONB keeps the schema honest ("this is opaque config the strategy interprets") and validates at the application edge (Pydantic per type) rather than the DB. Trade-off: no DB-level constraints on config contents — mitigated by strict validation on the admin write path.
explain.md note (fill when Phase 4 lands): paste your
ChallengeStrategyinterface and the two implementations, and note any defaulting you did for missing config fields.
People conflate these; keeping them distinct is a fast way to look sharp in review.
Place #1 — event ingestion. The client generates event_id. Re-submitting the same event_id must return the original response without reprocessing. So:
events.event_idhas a unique constraint.- On insert conflict, we don't just "skip" — we fetch and return the stored original response. (Store the response, or enough to reconstruct it, at first processing.)
- Why it matters: networks retry. A double-submitted "comment_posted" must not count twice toward a challenge.
Place #2 — reward disbursal. A challenge rewards a user at most once per qualifying completion. So:
- The ledger has a unique constraint on
(user_id, challenge_id, completion_key). - Disbursal is an insert guarded by that constraint (insert-or-ignore / catch-unique). If the row exists, the reward was already given — do nothing.
completion_keyencodes which completion: for a one-shot challenge it's constant; for a weekly challenge it's the ISO week (so it can pay out once per week and reset Monday — see §4).
Together: event idempotency stops double-counting input; reward idempotency stops double-paying output. The worker can safely run a job twice (see §3) precisely because both guards exist.
explain.md note (fill when Phases 3 & 5 land): paste the two unique constraints and the exact insert-on-conflict code.
The requirement: evaluation runs asynchronously via a background job; ingestion returns 202 immediately; document the choice.
The trap: FastAPI BackgroundTasks. It runs in the same process, has no durability (a crash between 202 and evaluation loses the work), no retries, and breaks with >1 worker. It demos fine and fails the review question.
The choice here: a Postgres-backed outbox / job table, polled by a worker.
- When an event is ingested, in the same transaction we insert the event row and a
jobrow (status=pending). Same-txn means we never have an event with no job or a job with no event — this sidesteps the dual-write problem you'd get by publishing to an external queue after committing. - A worker loop selects pending jobs (
FOR UPDATE SKIP LOCKEDso multiple workers don't collide), evaluates, marksdone. Failures → retry with backoff /failedafter N attempts. - Delivery is at-least-once. Combined with the idempotent progress/reward writes from §2, the effective semantics are exactly-once. That sentence is the one to say out loud in review.
Why this over Celery/RQ + Redis: those are perfectly good and show queue fluency, but they add infra to run and reason about. For a 5-day, single-reviewer submission, "it's just Postgres, here's the table, here's the worker loop" is the most explainable option — and explainability is the graded axis. (If you'd rather show Redis/Celery, the trade-off note flips: more infra, but battle-tested retries/scheduling out of the box.)
explain.md note (fill when Phase 4 lands): paste the job table schema, the
SKIP LOCKEDselect, and your retry policy.
Two requirements are secretly the same problem: "N consecutive days" and "resets every Monday" are both undefined until you pick a timezone. Decide once; apply everywhere.
Decision (default): a fixed forum timezone, set as a single config constant (default UTC). Rationale: streak-day bucketing and the weekly window must agree; a fixed TZ makes both trivially testable and removes per-user ambiguity. The alternative — per-user local time — is more "correct" for a global forum but multiplies edge cases (DST, travel) and is exactly the kind of extension they might ask you to add live. Keeping it a single constant means that extension is a localized change, and you can say so.
Streak-day bucketing: map each qualifying event's timestamp to a day in the forum TZ, take the distinct set of days (two comments same day = one day), sort, and find consecutive runs.
- Current streak = the run ending today (define your grace rule: does a gap yesterday break it? Write down the choice).
- Best streak = the longest run ever.
Weekly window: "this week" = from Monday 00:00 forum-TZ to now. Progress for the weekly challenge counts events in that window; the reward's completion_key is the ISO week so a new week is a fresh completion (§2). The widget "resets Monday" for free because the window and the key both roll over.
explain.md note (fill when Phases 4–5 land): state your chosen TZ, your grace rule for current streak, and paste the day-bucketing function.
Forum handlers emit events; they must not evaluate. Concretely: POST /posts writes the post and produces a post_created event through the same ingestion path that the public POST /api/events uses. One code path means one place for idempotency, one place jobs get enqueued, and no way for a forum action to secretly bypass the engine.
Why the public /events endpoint exists at all alongside server-side emission: it's the engine's contract with any producer, and it's what makes the engine genuinely decoupled/reusable. Document that forum-originated events and externally-posted events converge on the same handler.
explain.md note: describe how your forum handlers call into ingestion (shared function vs internal HTTP) and why.
Quick map of each graded behaviour to where it should live, so nothing gets dropped:
- Optimistic UI (post + comment): TanStack Query
onMutatewrites the cache immediately,onErrorrolls back to the snapshot and toasts. Reviewers will force a failure — make rollback real, not cosmetic. - Polling (Challenges page + weekly widget):
refetchInterval. Interval choice: ~30s (the wireframes' own note). Reasoning to document: evaluation is async and human-scale — sub-10s hammers the API for progress that changes on the order of user actions; 30s keeps the widget feeling live without wasteful load. State it explicitly. - Data-viz: streak heatmap or progress rings via Recharts/D3. A shadcn/Tailwind bar is explicitly disqualified.
- Loading: skeletons, never spinners, on every fetch surface.
- Error boundaries: every fetch section degrades to a visible "couldn't load · retry" fallback; the page stays usable.
- URL state: feed
sort/pagein the querystring — shareable, and back-navigation restores it. Use the router +useSearchParams. - Custom hook: at least one named hook, single responsibility. Good candidates:
useWeeklyChallenge()(encapsulates the polling + reset math) oruseFeedParams()(URL ↔ filter state).
explain.md note (fill across Phases 6–8): name your custom hook and its single responsibility in one sentence; note your final polling interval.
Fill one entry per phase as it lands. Keep each to: what I built, the one decision I'd be asked about, anything I'd do differently.
- Built:
- Key decision (error/response envelope, TZ constant):
- Notes:
- Built:
userstable (idUUID pk,emailunique+indexed,password_hash,rolestring with aCHECK (role in ('user','admin'))constraint,created_at) via Alembic migrationc1cf4340c3f3;POST /api/auth/register,POST /api/auth/login,GET /api/auth/me;app/core/security.py(bcrypt hashing, PyJWT encode/decode);app/api/deps.pywithget_current_user/require_adminexposed as reusableAnnotatedaliases (CurrentUser,AdminUser). - Key decision (JWT vs session, role gating): JWT, stateless — no session table, no server-side revocation list needed for a 5-day assignment scope. The token payload only carries
sub(user id) +iat/exp, deliberately not a role claim:require_adminre-readsrolefrom the DB on every request viaget_current_user, so a role change (e.g. promoting a user to admin) takes effect on the user's very next request instead of waiting for a fresh login/token. The trade-off is one extra DB read per authenticated request — acceptable at this scale, and it closes the "stale role in an old token" class of bug for free. - Notes: Email is lowercased at the schema layer (
RegisterRequest/LoginRequestvalidators) before it ever reaches a query or an insert, so uniqueness and login lookups are case-insensitive without needing a DB-sidecitextextension. There's no self-serve "become an admin" endpoint by design — admin promotion is an out-of-band DB write (a seed script arrives in Phase 9); Phase 1's tests promote a user directly viaUPDATE users SET role = 'admin'to exerciserequire_admin. Since Phase 4's admin challenge CRUD is the first real consumer ofrequire_admin, Phase 1 adds a test-only probe route (GET /api/auth/_admin_probe, mounted only insidetests/test_auth.py) purely to prove the dependency rejects ausertoken with403and accepts anadmintoken with200— this is the "admin-only test route" the plan called for, not a shipped product endpoint.
- Built:
posts(title,body, denormalizedcomment_count/view_count,solution_comment_id) andcommentstables, plus theeventstable one phase early (migrationd3a1f9b2c7e4) —event_idUUID primary key doubles as invariant #4's uniqueness guarantee.app/services/events.py::ingest_event()is the one ingestion function (invariant #2);POST /posts,GET /posts/{id}(detail view),POST /posts/{id}/comments,POST /posts/{id}/solution(owner-only, 403 otherwise) each write their domain row and callingest_eventin the same session before a singlecommit()— so the domain write and the event write are one DB transaction, not two.GET /postssupportssort=latest|trending+page/limit. - Key decision (trending formula, emit-not-evaluate):
trending_score = (comment_count*3 + view_count) / power(age_hours + 2, 1.5)— comments outweigh views 3:1 as the stronger engagement signal, and the+2offset keeps a just-created post's score finite (no divide-by-~0 spike) while still decaying fast with age. Computed at query time from denormalized counters rather than stored, since "now" keeps moving. Forum handlers never call the evaluator or write progress/reward rows directly — they only ever callingest_event, which for now (pre-Phase-3) just inserts anEventrow; Phase 3 will extend that same function withevent_idconflict-replay and the transactional job-row enqueue, not replace it, so the "one road in" invariant holds before and after. - Notes:
eventsand its uniqueevent_idPK were built now instead of deferred to Phase 3 so the forum handlers had something real to call — the plan's Phase 3 scope (idempotent replay of the stored response, and the outbox job row) is intentionally not implemented yet. Mark-solution validates that the comment actually belongs to the post (cross-postcomment_id→404, not silently accepted); re-marking a different comment as solution is allowed (last call wins), each mark still emits its ownsolution_markedevent — de-duplicating reward eligibility for that is an engine-side (Phase 4/5) concern, not the forum's.posts.solution_comment_idandcomments.post_idform a genuine FK cycle; the migration createspostswith a bare (FK-less)solution_comment_idcolumn, createscommentsreferencingposts, then adds theposts → commentsFK constraint afterward.
- Built:
jobstable (migrationd2d29db3cc1e) —id,event_id(FK →events.event_id, unique),status(pending|processing|done|failed, CHECK-constrained, mirroring theUserRole/enum.StrEnumpattern from Phase 1),attempts,last_error,created_at/updated_at.ingest_event()(app/services/events.py) is extended in place, not replaced: it now returns(Event, is_new: bool), wraps the event insert + job insert in a singleSAVEPOINT(db.begin_nested()), and on anevent_idconflict fetches and returns the existing row instead of reprocessing.POST /api/events(app/api/events.py) is a thin wrapper — auth viaCurrentUser, callsingest_event, commits, returns202with anEventOutbuilt straight from the (possibly pre-existing)Eventrow. - Key decision (stored-response replay, transactional enqueue): I didn't add a separate "stored response" column. The
POST /api/eventsresponse body is a pure function of theEventrow's own columns (event_id,event_type,payload,created_at) — so replaying means fetching the original row and serializing it again, which is byte-identical by construction, no extra storage needed. The job enqueue lives insideingest_eventitself (not just the public endpoint) because forum handlers need jobs enqueued too — acomment_postedevent fromPOST /posts/{id}/commentshas to reach the evaluator exactly like one from the public endpoint. Putting both the event insert and the job insert in oneSAVEPOINTmeans: a genuinely new event always gets exactly one job, and a replayed event never gets a second one, without ever risking a mid-request rollback of whatever the calling forum handler had already flushed (thePost/Commentrow) in the same outer transaction — that's what the savepoint isolates. - Notes: On conflict, resubmission's
event_type/payloadare silently ignored in favor of the stored original — that's what an idempotency-key contract means, not "detect and reject mismatches" (this could be added if the review pushes on it, but isn't in the plan). Theassert-vs-explicit-raise call: onIntegrityErrorfrom the savepoint, I only treat it as a replay if a row with thatevent_idgenuinely exists; anything else (e.g. a brokenuser_idFK) re-raises so an unrelated bug doesn't get silently swallowed as if it were an idempotent replay. Forum handlers (Phase 2) didn't need any changes — they already callingest_eventand never use its return value, so widening its return type to a tuple was invisible to them; they now transitively get aJobrow per event, which is what makes them Phase-4-ready. Could not exercisealembic upgrade head/pytestin this session (no reachable Postgres in the sandbox — docker unavailable, native Windows Postgres service present but credentials unknown); verified viaruff+mypy --strictonly. Runalembic upgrade headandpytestlocally before relying on this phase.
- Built:
challengestable (migrationf6a2c8d4e1b3) —name,description,type,event_type,rule_config/reward(JSONB),status(CHECK-constraineddraft|active|expired|archived),start_at/end_at.progresstable alongside it —(user_id, challenge_id)unique,current_value/target_value/is_complete/completed_at.app/services/strategies.pyholds the registry (STRATEGIES: dict[str, ChallengeStrategy]) plusCountStrategy({"target": N}) andStreakStrategy({"length": N}), each withvalidate_config()(schema-time) andevaluate()(worker-time).app/services/evaluator.py::evaluate_event()is the generic loop: find active challenges forevent.event_type, look upSTRATEGIES[challenge.type], upsert the result intoprogressvia PostgresINSERT ... ON CONFLICT DO UPDATE.app/worker.pyis the durable poller (claim_and_process_onedoes theSELECT ... FOR UPDATE SKIP LOCKEDclaim + evaluate + mark done/failed;run_foreverloops it with a 1s backoff when the queue is empty). Admin CRUD lives inapp/api/admin_challenges.py(POST/GET/PATCH /api/admin/challenges,DELETEarchives rather than deletes) — added as its ownworkerservice indocker-compose.ymlrunningpython -m app.workeralongsidebackend. - Key decision (strategy registry, outbox worker): Neither
evaluate_eventnor the worker ever branches onchallenge.type— even the admin-write-path validation doesn't hardcodeLiteral["count","streak"];app/schemas/challenges.py::validate_challenge_config()looks the type up inSTRATEGIESitself and delegates to that strategy's ownvalidate_config(). So adding a third strategy is genuinely one file (implementChallengeStrategy, add one registry line) — the schema, the evaluator, and the worker are all unaware anything changed. The bigger decision: progress is a materialized cache the worker recomputes wholesale on every run, not a counter it increments.CountStrategy.evaluate()re-counts matching events in the window from scratch;StreakStrategy.evaluate()re-derives the whole day-set and re-runs the streak scan. This is what makes "the worker can run a job twice safely" (invariant #5) true for progress writes specifically, not just reward disbursal (Phase 5) — there's no "+1" step to accidentally double, so replayingevaluate_eventfor the same event is a no-op by construction (test_evaluate_event_is_idempotent_when_run_twice). The trade-off is a full re-scan of the user's matching events on every evaluation instead of an O(1) increment — acceptable at this scale, and it's the difference between "idempotent because we recompute truth" and "idempotent because we hope a lock held." - Notes: Streak grace rule (documented as promised in §4): the current streak is the run ending on the most recent active day, as long as that day is today or yesterday — a day with no event yet today doesn't break a streak that ran through yesterday, but a full skipped day does. Implemented as a pure function,
compute_streak(days: set[date], today: date) -> tuple[current, best], inapp/services/strategies.py, tested in isolation intests/test_strategies.pywithout touching the DB. Retry policy:job.attemptsincrements on any exception fromevaluate_event; belowMAX_ATTEMPTS = 5the job goes back topending(picked up on the worker's next poll — a poll-interval's worth of backoff, not exponential); atMAX_ATTEMPTSit's markedfailedpermanently. Gotcha worth flagging:zoneinfo.ZoneInfo("UTC")raisesZoneInfoNotFoundErroron both Windows and slim Linux Docker images (python:3.12-slimships no IANA tzdata) — addedtzdataas an explicit dependency rather than relying on the OS. Discovered this by actually trying to instantiateZoneInfo("UTC")in this sandbox before assuming stdlibzoneinfowas enough; would have silently 500'd every streak evaluation in the built Docker image otherwise.RewardConfig({"type": str, "amount": int > 0}) is intentionally generic and unvalidated beyond shape — Phase 5 owns what "disbursing" a reward means. Could not exercisealembic upgrade head/pytestagainst a live DB in this session (same constraint as Phase 3 — no reachable Postgres in the sandbox); verified viaruff,mypy --strict, and by exercisingcompute_streak/validate_configdirectly as plain Python outside the test harness. Runalembic upgrade headandpytestlocally before relying on this phase.
- Built:
rewardstable (migrationa7f3e91c2d05) —user_id,reward_type,amount,source_challenge_id,completion_key,created_at, unique(user_id, source_challenge_id, completion_key). Same migration addschallenges.period(one_time|weekly, CHECK-constrained, defaultone_time) — a field orthogonal totypethat Phase 4 didn't need yet.app/services/rewards.py::disburse_reward()is a plainINSERT ... ON CONFLICT DO NOTHINGagainst that constraint, called fromapp/services/evaluator.py::evaluate_event()in the same transaction as the progress upsert, whenever a strategy reportsis_complete. Read endpoints:GET /api/challengesandGET /api/challenges/weekly(app/api/challenges.py, outer-joinschallengesto the caller's ownprogressrow),GET /api/users/me/progress,GET /api/users/me/streaks,GET /api/users/me/rewards(paginated, samepage/limit/metashape asGET /posts) — all inapp/api/users.py. - Key decision (completion_key, ledger constraint):
completion_keyis"once"for aone_timechallenge (any later completion collides with the same key, so the unique constraint blocks a second payout forever) and the ISO week ("2026-W27") for aweeklyone (a new week is a new key, so it's a fresh completion each Monday). Bothapp/services/rewards.py::completion_key()andapp/services/strategies.py::effective_window()are pure functions takingnow/tzas explicit arguments rather than reading the wall clock internally — that's what makes the weekly rollover unit-testable (test_completion_key_rolls_over_by_iso_week_for_weekly_challenges) without freezing time.perioddeliberately isn't folded intotype: invariant #3 ("no branching ontypeoutside the strategy registry") only restrictstype, and a weekly count challenge and a one-time count challenge use the sameCountStrategy— only the event window it counts within differs. Soeffective_window()clips the strategy's[start_at, end_at]to[max(start_at, this-week-Monday), end_at]whenperiod == weekly, and bothCountStrategyandStreakStrategycall it identically; addingperiodtouched zeroif type ==branches. - Notes: Disbursal is called unconditionally whenever
result.is_completeisTrue— not just "on the transition from incomplete to complete" — because the unique constraint (not an application-level check) is what actually prevents a double payout; this mirrors invariant #5's progress-recompute pattern (recompute-and-let-the-constraint-absorb-it, not detect-and-guard).GET /users/me/streaksneededbest_streak, which theprogresscache doesn't store (it only keeps the length-cappedcurrent_valueneeded for completion) — rather than add a column,compute_user_streak()recomputes(current, best)directly from source events, reusing the same_fetch_qualifying_days()/compute_streak()machineryStreakStrategy.evaluate()uses internally. The endpoint selects which challenges are "streak-shaped" viaSTREAK_CHALLENGE_TYPES(frozensetderived fromisinstance(STRATEGIES[name], StreakStrategy)), not a hardcodedChallenge.type == "streak"literal, so a second streak-like strategy would show up there for free. Same DB-availability constraint as Phases 3–4: no reachable Postgres in this sandbox (docker absent; the native Windows Postgres service is running but its credentials aren't known here), soalembic upgrade headandpytestwere not run — verified viaruffandmypy --strictonly. Run both locally before relying on this phase.
- Built: Auth flow (
app/(auth)/login,.../register) backed bylib/auth-context.tsx'sAuthProvider/useAuth, storing{token, user}inlocalStorageand re-derived throughuseSyncExternalStore(not an effect+setState — see the key decision below).lib/api.tsis the onefetchwrapper: unwraps the{data, meta}envelope, throws a typedApiErroron non-2xx (mirroringerror.code/message/details/request_id), and dispatches acre:unauthorizedwindow event on any401soAuthProvidercan drop the session without every caller handling it. Shell A lives incomponents/shell/{sidebar,app-shell}.tsx, mounted once byapp/(app)/layout.tsx— a client-side auth gate (skeleton whilestatus === "loading", redirect to/loginotherwise) wrapping all five route placeholders (feed,posts/new,posts/[id],challenges,profile). The weekly widget (components/weekly-challenge-widget.tsx) sits in the shell's right rail behindhooks/use-weekly-challenge.tsand a general-purposeErrorBoundary(components/error-boundary.tsx). Primitives added viashadcn add:skeleton,card,avatar,dropdown-menu,input,label,badge,separator,sonner. - Key decision (polling interval, custom hook): Polling — 30s (
WEEKLY_POLL_INTERVAL_MSinlib/config.ts), because evaluation is async and human-scale; anything sub-10s would hammer the API for progress that only moves on the order of user actions. Custom hook —useWeeklyChallenge(token), single responsibility: encapsulate the polling fetch of/challenges/weeklysoWeeklyChallengeWidgetstays purely presentational (loading skeleton / error+retry / data, nothing else). Auth storage — the JWT lives inlocalStorage, read viauseSyncExternalStorerather thanuseEffect+setState. The naive effect version (useEffect(() => { setSession(read()) }, [])) has two problems: (1) it's exactly what the newerreact-hooks/set-state-in-effectESLint rule flags — reading an external store and pushing it into state via an effect is a textbook cascading-render case; (2) it causes a real one-tick flash: the server always renders "logged out" (nowindow), so an already logged-in user visiting/feedwould flash-redirect to/loginbefore the effect fires and corrects it.useSyncExternalStore's third argument (getServerSnapshot) makes "don't know yet" an explicitstatus === "loading"value instead of quietly aliasing it tounauthenticated— that's what the auth gate inapp/(app)/layout.tsxchecks before ever deciding to redirect. - Notes: Route protection is deliberately client-side only, not Next middleware — middleware runs on the edge before any client JS, and the backend is a pure bearer-token API (no cookies for it to read), so there is no server-visible signal to gate on. The trade-off: a hard refresh of a protected route always paints the
loadingskeleton for one tick before the real shell (or a redirect) appears — accepted for a 5-day scope; the anticipated fix if this became a real product would be to move the token into anhttpOnlycookie and add matching Next middleware.apiRequestintentionally has no retry/backoff of its own — that's TanStack Query's job (providers.tsxsetsretry: 1globally); the wrapper's only responsibilities are the envelope shape and the 401 event. Verified end-to-end with a Playwright smoke pass againstnext dev(no backend/Postgres available in this sandbox, same constraint as Phases 3–5): root/redirects to/loginwhen logged out,/feedredirects to/loginwhen hit directly without a session, and — after seeding a fake session intolocalStorage— the shell renders (sidebar nav with active-route highlighting, avatar/email, logout), the weekly widget shows its skeleton then its error+retry fallback (since there's no live backend to answer/challenges/weekly), and it persists unremounted across a/feed→/profilenavigation. Zero console errors beyond the expectedERR_CONNECTION_REFUSEDfrom the absent backend. Runnpm run typecheck/npm run lint/npm run buildlocally before relying on this phase against a real backend.
- Built:
lib/posts-api.ts(types + fetch functions for list/detail/create/comment/mark-solution, mirroringapp/schemas/posts.pyfield-for-field).lib/api.tsgainedapiRequestEnvelope(returns the full{data, meta}envelope) withapiRequestnow a thin wrapper around it — needed because the feed is the first consumer that cares aboutmeta.total_pages, not justdata. Two new hooks:hooks/use-feed-params.ts(sort/page↔ URL querystring, replacinguseState) andhooks/use-create-post.ts(the optimistic-create mutation, kept separate from the page component since it's invoked from/posts/newbut writes into the feed's cache — a cross-page concern that didn't belong inlined in either page). Feed (app/(app)/feed/page.tsx): sort tabs + pagination driven byuseFeedParams, skeleton cards while loading, error+retry, wrapped in<Suspense>(required byuseSearchParamsin a static client page) andErrorBoundary. Create post (.../posts/new/page.tsx): plain controlled form (matching the Phase 6 login/register pattern, no form library) callinguseCreatePost(). Post detail (.../posts/[id]/page.tsx): converted from an async Server Component to a Client Component using React'suse()to unwrap the still-Promise-typedparams(needed client-side hooks for auth/query/mutations); nested comment list, an optimistic comment-post mutation, and an owner-only "Mark as solution" mutation, both following the same optimistic-cache-write/rollback/toast shape as the post-creation mutation. Addedcomponents/ui/textarea.tsx(plain<textarea>styled likeInput—@base-ui/reacthas no textarea primitive) andformatRelativeTime()inlib/utils.ts. - Key decision (optimistic rollback, URL state): Rollback —
useCreatePost'sonMutatesnapshots the feed's["posts","list",{sort:"latest",page:1}]query, prepends an optimisticPostSummarywith aoptimistic-<uuid>id, then callsrouter.push("/feed")immediately — the user is looking at the feed with their new post already in it before the request has even resolved. On failure,onErrorrestores the exact snapshot (the optimistic post disappears) and fires asonnertoast;onSettledinvalidates the query either way so a real success reconciles with the server's actual row. This only fires the mutation's ownonMutate/onError/onSuccessbecause they're declared onuseMutationitself (not passed to.mutate()) — TanStack Query keeps those attached to the mutation, not the component, so they still run after the triggering page has unmounted from therouter.push. The post-detail comment mutation and mark-solution mutation reuse the identical shape (snapshot → optimistic write → rollback-on-error → invalidate-on-settle) against["posts","detail",id]instead. URL state —useFeedParamsreadssort/pagefromuseSearchParams()and writes viarouter.pushwith a rebuiltURLSearchParams, never localuseState; this is what makes a/feed?sort=trending&page=2link land on the right tab/page on a cold load, survive a reload, and have the browser back button restore the exact prior filter state (verified in the Playwright smoke pass below) — state that lived inuseStatewould lose all three. - Notes:
apiRequestEnvelopeexists becauseapiRequestalone (Phase 6) discardsmeta, which every other Phase-6 caller didn't need but paginated feed does — extending the existing function's return shape rather than adding a parallel fetch path. Comments are a flat list under a post (the schema has noparent_comment_id), so "nested comments" here means comments rendered nested within the post detail view, not a reply tree — matches whatapp/schemas/posts.py::PostDetailOutactually returns. Mark-solution intentionally has no "unmark" affordance in the UI (mirrors the backend, which only supports re-marking a different comment, not clearing it). Verified end-to-end with a Playwright smoke pass againstnext dev(no backend/Postgres available in this sandbox, same constraint as Phases 3–6): seeded a fake session intolocalStorage, then confirmed — feed shows skeleton rows then a visible error+retry (no live backend to answer/posts); sort-tab clicks update the URL even mid-error; a direct load of/feed?sort=trending&page=2renders with Trending active, survives a reload, and going back after clicking Latest restoressort=trending&page=2exactly; publishing a post from/posts/newnavigates to/feedwith the optimistic post visible immediately, shows an error toast around ~2.5s later when the (absent) backend request fails, and the optimistic post is gone from the feed by the time the toast appears — the rollback is real, not cosmetic; post detail shows skeleton then error+retry the same way. Zero console errors beyond the expectedERR_CONNECTION_REFUSEDfrom the absent backend. Runnpm run typecheck/npm run lint/npm run build(all pass) and a manual pass against a real backend before relying on this phase.
- Built: Backend — one new read endpoint,
GET /users/me/rewards/summary(app/api/users.py), added because Phase 5 never exposed an aggregate: the paginated ledger alone can't answer "what's my points balance" correctly (a client-side sum only ever totals whatever page happened to be loaded). It's a singleGROUP BY reward_typequery returningRewardsSummaryOut { total_points: int, badges: list[RewardTypeSummary] }(app/schemas/rewards.py) —"points"rows collapse into the scalar balance, every otherreward_typean admin configured becomes a badge with a count andlatest_at. No migration, no invariant touched — it's a pure read alongside the four from Phase 5. Frontend —lib/progress-api.ts(fetchStreaks) andlib/rewards-api.ts(fetchRewardspaginated,fetchRewardsSummary), mirroring the existing per-domain file convention (posts-api.ts,challenges-api.ts).app/(app)/challenges/page.tsx: three polling sections — a weekly breakdown (reuses the sameuseWeeklyChallengequery/cache the sidebar widget already polls, so it's not a second request for identical data), a RechartsRadialBarChartof percent-to-target across all active challenges (labels rendered as a plain adjacent list rather than Recharts' ownLegend, which groups by data key, not by bar, for this chart shape), and a RechartsBarChartcomparingcurrent_streakvsbest_streakper streak challenge.app/(app)/profile/page.tsx: a points-balance + badges card backed by the new summary endpoint, and a paginated reward ledger (localpagestate, not URL — unlike the feed, Phase 8's "done when" doesn't require shareable/back-restorable pagination here) with skeleton rows matching the feed's loading-row shape. Every fetch section on both pages is its own<ErrorBoundary>wrapping an inlineisLoading/isError/empty/data branch (same shape asFeedContentandWeeklyChallengeWidget), so one section's failure (e.g. streaks) doesn't take down the others (e.g. progress rings) on the same page. Addedrechartsas the only new frontend dependency. - Key decision (which data-viz, why): Progress rings, not a streak heatmap. The backend only ever exposes
current_streak/best_streakas scalars (GET /users/me/streaks) — there's no day-by-day activity endpoint, so a genuine calendar heatmap isn't supportable without adding one. Rather than build a fake/degenerate heatmap out of two numbers, I used a RechartsRadialBarChart(real per-challenge percent-to-target rings — readsprogress.current_value/progress.target_value, falling back torule_config.targetwhen a challenge has no progress row yet) plus a RechartsBarChartfor the streak comparison, both driven by the theme's existing--chart-1..5CSS variables (already wired for light/dark inglobals.css, evidently anticipating this exact phase). Both are genuinely chart-library-rendered, not a shadcn/Tailwind div-bar — the risk register's explicit disqualification. - Notes: The reward-ledger pagination intentionally does not live in the URL (unlike the feed's
sort/page) — CLAUDE.md/plan.md's Phase 7 "done when" criterion about shareable/back-restorable state was scoped to the feed specifically; addinguseRewardsParamshere would be an unrequested abstraction for a page with no such requirement. All three Challenges-page sections poll at the existing 30s cadence (WEEKLY_POLL_INTERVAL_MS) rather than introducing a second interval constant — same reasoning as Phase 6 (evaluation is async/human-scale). Verified vianpm run typecheck,npm run lint, andnpm run build(all pass) and anext devsmoke check that/challengesand/profilereturn200with no server-side errors; could not exercise the authenticated data-fetching paths (charts, ledger, summary) end-to-end against a live backend in this sandbox — same constraint as every prior phase (no reachable Postgres; docker unavailable). Backend verified viaruffandmypy --strictonly, same constraint. Runalembicisn't needed for this phase (no schema change); run a manual pass against a real backend + logged-in session before relying on the charts/ledger rendering correctly.
- Built:
README.mdfinalized (quickstart, provisioning, full-flow verification steps, design-decision summary, bonus status).backend/app/scripts/seed.py— idempotent provisioning script (python -m app.scripts.seed): upserts one admin user by email, and inserts five sample challenges matched byname(skip if present) covering every axis the engine supports —count/streaktype,one_time/weeklyperiod, and a non-pointsreward type (badge) to demonstratereward.typewas already free-form since Phase 4. Bonus — rate limiting:app/core/rate_limit.py::TokenBucketRateLimiter, a plain in-memory token bucket keyed byuser_id, checked at the top ofPOST /api/eventsbefore any DB work (app/api/events.py); raisesAppError("RATE_LIMITED", ..., 429)with a computedretry_after_seconds. New settingsEVENT_RATE_LIMIT_CAPACITY/EVENT_RATE_LIMIT_WINDOW_SECONDS(default 20/60s). Tested both in isolation (tests/test_rate_limit.py, pure — injectsnowinstead of sleeping) and live against the endpoint (tests/test_events.py::test_exceeding_the_event_rate_limit_returns_429, which shrinks the module-global limiter's capacity for one test and restores it). - Key decision (rate limiting infra, deploy scope): In-memory token bucket, not Redis — same reasoning this repo already used once for the job queue (§3: the most explainable infra that satisfies the requirement beats the more "correct" one for a 5-day, single-reviewer submission). Trade-off stated in the module docstring rather than hidden: state is per-process, so the ceiling is per uvicorn worker, not global across replicas; swapping in Redis later is a localized change to one file. Deploy — descoped to "polish the local run" (user's explicit call): no public URL for this submission, but every piece (Dockerfiles,
.env.example, the seed script) is deploy-ready, so pointing it at a host is a config exercise, not a code change. Remaining bonus items (leaderboard, a second reward-type UI) were explicitly descoped by the user to leave more time for hardening quality over bonus surface area — unit tests (already done across Phases 1–8) and rate limiting were the two the user asked for. - Notes: This phase is the first time the backend ran against a real, reachable Postgres instance (a native Windows Postgres 18 install, credentials supplied by the user mid-session) — every prior phase's explain.md notes say verification was
ruff/mypy/static-only because no DB was reachable in the sandbox. Getting a live DB immediately surfaced two real bugs invisible to static checks: (1) a test-suite bug —app/core/db.pycaches the async engine/pool as a process-global singleton, but pytest-asyncio's default is a new event loop per test function; every test after the first tried to reuse pooled connections bound to a dead loop ("Future attached to a different loop" /asyncpg.exceptions._base.InternalClientError: got result for unknown protocol state 3"). Fixed by pinning bothasyncio_default_fixture_loop_scopeandasyncio_default_test_loop_scopeto"session"inpyproject.tomlso the whole test session shares one loop, matching the session-scoped_schemafixture that creates the engine. (2) **A real production bug** — Pydantic v2 puts the raised exception object itself underctx.errorin.errors()output for a failedmodel_validator(e.g.validate_challenge_config'sValueErrorfor an unknown challenge type or badrule_config); that's not JSON-serializable, soapp/core/errors.py::handle_validation_errorcrashedjson.dumpsand turned every such 422 into an unhandled 500. Fixed with_sanitize_validation_errors(), which stringifiesctx.errorbefore it reaches the response body — the only non-serializable field pydantic ever puts in.errors(). Also fixed a trivialHTTP_422_UNPROCESSABLE_ENTITY→HTTP_422_UNPROCESSABLE_CONTENTStarlette deprecation warning noticed while in that file. After both fixes: all 63 tests (58 pre-existing + 5 new for the rate limiter) pass,ruffandmypy --strictare clean, and the seed script plus the full event→job→progress→reward chain were manually verified end-to-end against the live DB (acomment_posted` event moved four seeded challenges' progress in one worker pass and correctly disbursed the "First Comment" reward on the first try) — the strongest verification any phase in this project has had. The lesson worth stating out loud in review: static checks (ruff/mypy) and a green test suite are not the same claim as "runs against a live database" — this project spent eight phases only able to make the former claim, and both bugs found here were exactly the class of thing that only shows up under the latter.
- Built:
frontend/src/lib/admin-challenges-api.ts(types + CRUD calls mirroringChallengeCreate/ChallengeUpdate/ChallengeOutfield-for-field, plus theCHALLENGE_TYPES/EVENT_TYPES/CHALLENGE_STATUSES/CHALLENGE_PERIODSoption lists the form's dropdowns render from),hooks/use-admin-challenges.ts(list + single-item queries) andhooks/use-admin-challenge-mutations.ts(create/update/archive — no optimistic writes, unlike the forum mutations, since this is an admin tool where the re-fetched row matters more than perceived latency).components/admin/challenge-form.tsxis one shared form for both create and edit, switching its one type-dependent field's label ("Target count" vs "Streak length") off thetypedropdown. Three routes:app/(app)/admin/challenges/{page,new/page,[id]/edit/page}.tsx, gated by a second layout (app/(app)/admin/layout.tsx) stacked on top of the existing auth gate — it only adds therole === "admin"check and redirects to/feedotherwise, reusing the same skeleton-while-unknown shape as(app)/layout.tsx. Added a plaincomponents/ui/native-select.tsx(styled<select>) rather than pulling in Base UI'sSelectcomposition, since the form's five dropdowns didn't need combobox behavior. Sidebar gained a conditional "Admin" nav item (user?.role === "admin"). - Key decision (two-layout gating, no new backend): The Phase 4 admin API (
/admin/challengesCRUD) was already complete and untouched — this phase is frontend-only. Gating is two stacked layouts rather than one combined check:(app)/layout.tsxproves "logged in at all" (existing, Phase 6),(app)/admin/layout.tsxadditionally proves "and is an admin" — same pattern as the backend'sCurrentUser→AdminUserdependency chain (app/api/deps.py), so the frontend's authorization shape mirrors the backend's instead of inventing a new one.CHALLENGE_TYPES = ["count", "streak"]is hardcoded in the frontend rather than fetched from the backend'sSTRATEGIESregistry — this doesn't violate invariant #3 (noif type ==in domain/business logic): it's a form's dropdown options, not evaluator branching, and the backend still rejects an invalidtypeon submit regardless of what the dropdown offered. - Notes: Incidental bug found while live-testing this phase (Playwright against a real
next dev+uvicorn, JWTs minted directly viaapp.core.security.create_access_tokenso a valid session could be exercised even with the sandbox's Postgres unreachable — see Phase 3–9's recurring note): every unhandled 500 was reaching the browser as an opaque CORS failure, not a readable error, because Starlette's@app.exception_handler(Exception)— unlike theAppError/RequestValidationError/StarletteHTTPExceptionhandlers beside it — is invoked byServerErrorMiddleware, which sits outsideCORSMiddlewarein the stackapp/main.pybuilds. This is a real, general bug (affects every endpoint, not just the new admin ones) that just happened to surface here because this was the first time a page was driven against a genuinely broken backend call live in a browser rather than checked viaisErrorassertions. Fixed inapp/core/errors.py::_cors_headers_for_unhandled_exception, which replicatesCORSMiddleware's origin check by hand for that one handler; regression-tested intests/test_errors.py(a bareRuntimeErrorroute, asserting the header is present for an allowed origin and absent for a disallowed one). Also fixed a real Base UI accessibility warning caught in the same pass:Button'srenderprop refuses to be pointed at an<a>(Base UI's own docs say link-styled-as-button should style the<a>directly, not compose throughButton) — switched both "New challenge" and "Edit" from<Button render={<Link/>}>to<Link className={buttonVariants(...)}>. Same DB-availability constraint as every prior phase: the sandbox's Postgres (Supabase, over its session pooler) is unreachable here (TLS interception rewrites the chain with a self-signed cert, which asyncpg's handshake rejects), sotests/test_errors.pyand the full suite couldn't be executed against a live DB in this session — verified instead viaruff/mypy --strict(both clean) and the Playwright pass described above, plus a directcurlcomparison of a 200 vs. a genuinely-unhandled 500's response headers against the running (DB-broken) server, which is what actually caught and confirmed the CORS bug and its fix.
- The engine is decoupled; the forum only emits events; there is one road in.
- The evaluator is data-driven;
typeis switched on in exactly one place (the strategy registry). - Idempotency is in two places: event replay (
unique event_id, return stored response) and reward once-only (unique (user, challenge, completion_key)). - The job is durable (Postgres outbox, same-txn enqueue,
SKIP LOCKED, retries) → at-least-once + idempotent writes = effectively-once.BackgroundTaskswas rejected because it isn't durable. - One timezone, fixed as a constant, drives both streak-day bucketing and the Monday weekly reset. Per-user TZ is the anticipated extension.
- Frontend: optimistic with real rollback, URL-as-state, skeletons not spinners, charting-lib data-viz, ~30s polling (justified).