[Spike] Completion-ratio unlocks for anonymous users — contribute to see more; account unlocks everything #329

Closed
opened 2026-07-02 00:51:39 +00:00 by spikerj · 3 comments
Owner

Spike under epic #323.

Concept: anonymous visitors earn visibility by doing: completing lessons/exercises unlocks the ability to see more content, ratio-style (complete N → the next M become visible). Creating an account removes the ratio entirely — everything is fully unlocked for authenticated users. The gate is an on-ramp/conversion mechanic, never a paywall for account holders.

Contribution currency (decided): lesson/exercise completions.

Current state (verified): sandbox mode is anonymous-open, but lesson mode hard-redirects to login (language-runner.ts auth gate) and every progress endpoint in LessonsController is [Authorize] (/progress, /{lessonNumber}/attempt, /progress/batch, /complete-tutorial, /submit). There is no entitlement/unlock concept anywhere in the platform — existing gating is prerequisites + roles only. So anonymous lesson play itself is new ground, not just the ratio.

Questions the spike must answer:

  • Anonymous identity — device-scoped token vs signed local completion ledger. Prior art: the progress-sync IndexedDB outbox (libraries/platform/progress-sync/) already queues completions offline for later verification; evaluate extending it with a server-issued anonymous id
  • Trust/verification — which endpoints get anonymous variants; how server-side grading verifies anonymous completions (attempt tokens like /{lessonNumber}/attempt but unauthenticated); abuse resistance (rate limits, replay, farming)
  • The ratio itself — starting free allotment per language; N completions → M unlocked; per-language vs global pool; interaction with existing prerequisite locks (ratio gates visibility, prerequisites gate order)
  • UX — how locked-but-earnable lessons render in the sidebar (distinct from prerequisite locks); the "create an account to unlock everything" moment (celebratory, not naggy); what happens to earned anonymous progress on signup (migrate the ledger into UserLessonProgress)
  • Content-layer interaction — do books (#326)/text (#327)/videos (#328) participate in the ratio or stay free as enticement? Recommendation to be made by the spike
  • Telemetry — funnel events for anonymous → contributing → account conversion

Deliverable: design doc comment on this issue + implementation sub-tickets. No production code in the spike.

Spike under epic #323. **Concept:** anonymous visitors earn visibility by doing: completing lessons/exercises unlocks the ability to see more content, ratio-style (complete N → the next M become visible). Creating an account removes the ratio entirely — **everything is fully unlocked for authenticated users**. The gate is an on-ramp/conversion mechanic, never a paywall for account holders. **Contribution currency (decided):** lesson/exercise completions. **Current state (verified):** sandbox mode is anonymous-open, but lesson mode hard-redirects to login (`language-runner.ts` auth gate) and every progress endpoint in `LessonsController` is `[Authorize]` (`/progress`, `/{lessonNumber}/attempt`, `/progress/batch`, `/complete-tutorial`, `/submit`). There is no entitlement/unlock concept anywhere in the platform — existing gating is prerequisites + roles only. So anonymous lesson play itself is new ground, not just the ratio. **Questions the spike must answer:** - [ ] **Anonymous identity** — device-scoped token vs signed local completion ledger. Prior art: the `progress-sync` IndexedDB outbox (`libraries/platform/progress-sync/`) already queues completions offline for later verification; evaluate extending it with a server-issued anonymous id - [ ] **Trust/verification** — which endpoints get anonymous variants; how server-side grading verifies anonymous completions (attempt tokens like `/{lessonNumber}/attempt` but unauthenticated); abuse resistance (rate limits, replay, farming) - [ ] **The ratio itself** — starting free allotment per language; N completions → M unlocked; per-language vs global pool; interaction with existing prerequisite locks (ratio gates *visibility*, prerequisites gate *order*) - [ ] **UX** — how locked-but-earnable lessons render in the sidebar (distinct from prerequisite locks); the "create an account to unlock everything" moment (celebratory, not naggy); what happens to earned anonymous progress on signup (migrate the ledger into `UserLessonProgress`) - [ ] **Content-layer interaction** — do books (#326)/text (#327)/videos (#328) participate in the ratio or stay free as enticement? Recommendation to be made by the spike - [ ] **Telemetry** — funnel events for anonymous → contributing → account conversion **Deliverable:** design doc comment on this issue + implementation sub-tickets. No production code in the spike.
spikerj added the enhancement label 2026-07-02 00:51:39 +00:00
Author
Owner

Design: completion-ratio unlocks for anonymous visitors

Spike deliverable. Grounded in the current code (auth gate in language-runner.ts, [Authorize] on all progress endpoints, attempt-token flow, progress-sync IDB outbox, RedisRateLimitMiddleware). Implementation sub-tickets follow in the next comment.

Principles (locked in at epic filing)

  1. Currency = lesson/exercise completions. 2. Account = everything unlocked, immediately and forever. 3. The ratio is an on-ramp, never a paywall.

What "see more" means (clarified)

GET /api/Lessons is already anonymous — the catalog listing is public today. What's gated is playing lessons (attempt/submit/complete are [Authorize], and the SPA hard-redirects lesson mode to login). So the ratio gates playability, not listing: every lesson title stays visible in the sidebar (motivating), but beyond-horizon lessons are ratio-locked. Books/text/videos content layers (#326/#327/#328) stay free as enticement — they advertise the depth an account gets.

1. Anonymous identity — server-issued anonymous session (recommended)

  • POST /api/Lessons/anonymous/session (anonymous, tightly rate-limited): issues an opaque anonId — GUID + server-side record with a 90-day idle TTL (Mongo TTL index). Client stores it in localStorage beside the existing sandbox state.
  • Rejected: pure client-side ledger (trivially forged; the whole point is earned server-verified visibility) and device fingerprinting (privacy-hostile, unreliable). Prior art to reuse: the progress-sync IDB outbox already queues completions for later server verification — the anon path is that machinery pointed at anon endpoints.
  • Privacy posture: no PII, no cross-device linking, explicitly not an account; expiring the id just resets the on-ramp.

2. Trust & verification — anonymous variants of the existing grading path

  • GET /api/Lessons/{n}/attempt/anonymous + POST /api/Lessons/{n}/submit/anonymous carrying the anonId: same strategy-generated per-attempt content, same server-side grading (or CodeExecution-worker re-verification like /progress/batch). Nothing about grading gets weaker for anon users — the attempt token already defeats copy-paste.
  • New Mongo collection anonymousLessonProgress keyed (anonId, lessonNumber) with TTL matching the session — UserLessonProgress shape minus identity.
  • Abuse resistance: the anon endpoints only serve lessons inside the current horizon (server-checked, not client-checked); Redis rate limits per anonId AND per IP on session-create + submit; daily anon completion cap (e.g. 20/day) — generous for a human, boring for a farmer. Completion farming earns nothing valuable anyway: the unlock is visibility, not certificates or skills (skills stay account-only, which is itself a conversion hook).

3. The ratio

  • Free allotment: Tier 0 + Tier 1 per language (≈ first 10–14 lessons — welcome + foundations). Enough to genuinely learn something before the first gate.
  • Formula: visibleCount = freeAllotment + floor(anonCompletions × 1.5), per language, over the display-number ordering. Complete 2 → see 3 more. The multiplier > 1 means the horizon accelerates — momentum feels rewarded, and an engaged visitor effectively never hits a wall until deep in the curriculum, by which point they've invested enough to want the account.
  • Prerequisites still gate order inside the horizon (existing isLessonUnlocked); the ratio gates the horizon itself. Two different lock semantics, two different icons (below).
  • Server is the source of truth: horizon computed backend-side from anonymousLessonProgress, returned by a tiny GET /api/Lessons/anonymous/horizon?language= (or piggybacked on the session-validate response).

4. Sign-in — the conversion moment

  • On first authenticated session with an anonId present: POST /api/Lessons/progress/claim migrates the anon ledger into UserLessonProgress (idempotent, then deletes the anon records) and triggers the existing skill auto-award check — your anonymous work retroactively earns the badges. That's the celebratory hook: "Signed in — 3 lessons claimed, Bronze C# badge earned."
  • UX: ratio-locked rows get a distinct icon + tooltip ("Complete 2 more lessons to reveal — or sign in and see everything"); each horizon-advance fires a small celebration snackbar; a quiet persistent footer line in the sidebar carries the sign-in CTA. Never a modal, never a nag — the product's tone is old-school generous.

5. Telemetry funnel

Existing activity-tracking domains, four events: anon-session-createdanon-lesson-completedunlock-milestonesignup-with-claim. That's the whole funnel; Seq/Grafana can chart conversion without new infrastructure.

Explicitly out of scope for v1

Cross-device anon continuity, anon leaderboards, gating the content layers, any change to sandbox mode (stays fully open), and skills for anonymous users (deliberate conversion incentive).

# Design: completion-ratio unlocks for anonymous visitors Spike deliverable. Grounded in the current code (auth gate in `language-runner.ts`, `[Authorize]` on all progress endpoints, attempt-token flow, `progress-sync` IDB outbox, `RedisRateLimitMiddleware`). Implementation sub-tickets follow in the next comment. ## Principles (locked in at epic filing) 1. Currency = **lesson/exercise completions**. 2. Account = **everything unlocked**, immediately and forever. 3. The ratio is an on-ramp, never a paywall. ## What "see more" means (clarified) `GET /api/Lessons` is already anonymous — the catalog *listing* is public today. What's gated is **playing** lessons (attempt/submit/complete are `[Authorize]`, and the SPA hard-redirects lesson mode to login). So the ratio gates **playability**, not listing: every lesson title stays visible in the sidebar (motivating), but beyond-horizon lessons are ratio-locked. Books/text/videos content layers (#326/#327/#328) stay **free** as enticement — they advertise the depth an account gets. ## 1. Anonymous identity — server-issued anonymous session (recommended) - `POST /api/Lessons/anonymous/session` (anonymous, tightly rate-limited): issues an opaque `anonId` — GUID + server-side record with a **90-day idle TTL** (Mongo TTL index). Client stores it in `localStorage` beside the existing sandbox state. - **Rejected:** pure client-side ledger (trivially forged; the whole point is *earned* server-verified visibility) and device fingerprinting (privacy-hostile, unreliable). Prior art to reuse: the `progress-sync` IDB outbox already queues completions for later server verification — the anon path is that machinery pointed at anon endpoints. - Privacy posture: no PII, no cross-device linking, explicitly not an account; expiring the id just resets the on-ramp. ## 2. Trust & verification — anonymous variants of the existing grading path - `GET /api/Lessons/{n}/attempt/anonymous` + `POST /api/Lessons/{n}/submit/anonymous` carrying the anonId: same strategy-generated per-attempt content, same server-side grading (or CodeExecution-worker re-verification like `/progress/batch`). **Nothing about grading gets weaker for anon users** — the attempt token already defeats copy-paste. - New Mongo collection `anonymousLessonProgress` keyed `(anonId, lessonNumber)` with TTL matching the session — `UserLessonProgress` shape minus identity. - **Abuse resistance:** the anon endpoints only serve lessons *inside the current horizon* (server-checked, not client-checked); Redis rate limits per anonId AND per IP on session-create + submit; daily anon completion cap (e.g. 20/day) — generous for a human, boring for a farmer. Completion farming earns nothing valuable anyway: the unlock is visibility, not certificates or skills (skills stay account-only, which is itself a conversion hook). ## 3. The ratio - **Free allotment:** Tier 0 + Tier 1 per language (≈ first 10–14 lessons — welcome + foundations). Enough to genuinely learn something before the first gate. - **Formula:** `visibleCount = freeAllotment + floor(anonCompletions × 1.5)`, per language, over the display-number ordering. Complete 2 → see 3 more. The multiplier > 1 means the horizon accelerates — momentum feels rewarded, and an engaged visitor effectively never hits a wall until deep in the curriculum, by which point they've invested enough to want the account. - **Prerequisites still gate order** inside the horizon (existing `isLessonUnlocked`); the ratio gates the horizon itself. Two different lock semantics, two different icons (below). - Server is the source of truth: horizon computed backend-side from `anonymousLessonProgress`, returned by a tiny `GET /api/Lessons/anonymous/horizon?language=` (or piggybacked on the session-validate response). ## 4. Sign-in — the conversion moment - On first authenticated session with an anonId present: `POST /api/Lessons/progress/claim` migrates the anon ledger into `UserLessonProgress` (idempotent, then deletes the anon records) and triggers the existing skill auto-award check — **your anonymous work retroactively earns the badges**. That's the celebratory hook: "Signed in — 3 lessons claimed, Bronze C# badge earned." - UX: ratio-locked rows get a distinct icon + tooltip ("Complete 2 more lessons to reveal — or sign in and see everything"); each horizon-advance fires a small celebration snackbar; a quiet persistent footer line in the sidebar carries the sign-in CTA. **Never a modal, never a nag** — the product's tone is old-school generous. ## 5. Telemetry funnel Existing activity-tracking domains, four events: `anon-session-created` → `anon-lesson-completed` → `unlock-milestone` → `signup-with-claim`. That's the whole funnel; Seq/Grafana can chart conversion without new infrastructure. ## Explicitly out of scope for v1 Cross-device anon continuity, anon leaderboards, gating the content layers, any change to sandbox mode (stays fully open), and skills for anonymous users (deliberate conversion incentive).
Author
Owner

Implementation sub-tickets filed: #331 (backend — anon sessions, graded anon progress, horizon computation, sign-in claim) and #332 (frontend — anon lesson mode, horizon lock UI, claim celebration, funnel events; depends on #331).

Spike deliverable complete: design doc above + tickets. Two decisions surfaced for your sign-off before implementation starts (defaults chosen in the doc, cheap to change now, annoying later):

  1. The ratio — free Tiers 0–1, then floor(completions × 1.5) horizon growth. Steeper (×1) converts harder; shallower (×2) converts softer.
  2. Skills stay account-only — anonymous completions earn visibility but badges only arrive via the sign-in claim. This is the main conversion hook; flag if you'd rather anon users earn badges live.

Leaving this spike open for your review of the design; close it if the direction looks right, or comment adjustments and I'll fold them into #331/#332.

Implementation sub-tickets filed: **#331** (backend — anon sessions, graded anon progress, horizon computation, sign-in claim) and **#332** (frontend — anon lesson mode, horizon lock UI, claim celebration, funnel events; depends on #331). Spike deliverable complete: design doc above + tickets. Two decisions surfaced for your sign-off before implementation starts (defaults chosen in the doc, cheap to change now, annoying later): 1. **The ratio** — free Tiers 0–1, then `floor(completions × 1.5)` horizon growth. Steeper (×1) converts harder; shallower (×2) converts softer. 2. **Skills stay account-only** — anonymous completions earn visibility but badges only arrive via the sign-in claim. This is the main conversion hook; flag if you'd rather anon users earn badges live. Leaving this spike open for your review of the design; close it if the direction looks right, or comment adjustments and I'll fold them into #331/#332.
Author
Owner

Spike delivered and fully implemented. Design doc approved; implementation shipped via #331 (backend, PR #56) and #332 (frontend, PRs #99 + #100), all merged to master. Closing the spike.

Spike delivered and fully implemented. Design doc approved; implementation shipped via #331 (backend, PR #56) and #332 (frontend, PRs #99 + #100), all merged to `master`. Closing the spike.
Sign in to join this conversation.