feat: the learn platform — three faces, three subjects, signed split, launch gate (learn uplift)#4
Conversation
…as, validator, story fixtures (t2) Ship the contract's machine-readable half as package data: - learn/contract/schemas/*.json — 11 versioned JSON Schemas: the eight tutor verbs (overview, progress, advice, story list/read, lesson, practice, record, doctor), the shared story content schema, and the stderr error shape. Every payload requires schema_version (^1\.[0-9]+$); doctor pins contract_version. record's raw-result object structurally forbids score/grade/points — subjects report raw results, learn computes scores. - learn/contract/_validate.py — stdlib-only validator (runtime deps stay empty) covering exactly the keyword subset the schemas use, with an unsupported-keyword guard. - learn/contract/__init__.py — CONTRACT_VERSION, shared vocabularies (mastery ladder, results, story levels), load_schema/validate API loadable from the installed wheel for t3's conformance gate. - tests/fixtures/stories/ — the acceptance fixtures: a french graded story, a spanish graded story, and a culture-guide narrative scenario, all validating against story.json. - tests/fixtures/payloads/ — one golden example payload per schema. - 109 new tests (validator semantics, schema inventory/versioning, golden payloads, story acceptance + negative cases). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
The human/agent-readable contract doc: the portal/subject/driver model and directive pattern, the normative state division (subjects own content + per-learner mastery; learn owns the deterministic motivation layer; drivers tutor and record), invocation/stream/exit-code conventions, the eight tutor verbs with their payload schemas, shared vocabularies, the schema_version policy, the story schema with its three reference fixtures, and the registration-not-fork procedure for adding a subject. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
… 109 tests) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…toring broker (t11) The thin dynamic layer behind agentculture.org/learn (spec c22): auth sessions, the cross-subject learner ledger (KV + D1), and the tutoring broker. Everything lives under workers/learn-api/ plus docs/hosting.md; disjoint from the Python package. - GitHub OAuth, web + device flow, issuing short-lived HMAC-signed session tokens (Bearer for CLI/MCP, cookie for web). Only the GitHub id + display name are persisted — no password, no email (scope read:user; email never read). - Progress read/write mirroring the subject-plugin contract v1.0: POST /api/record validates the `recorded` shape and rejects score/grade/points exactly like record.json; GET /api/progress/:subject derives a progress-shaped payload from the append-only ledger. - POST /api/tutor brokers to env.INFERENCE_URL (a served cloudai/ec2bedrock endpoint — never a bespoke provider SDK). Auth middleware runs before the broker route, so signed-out requests can NEVER trigger a model call — proven by test (zero inference calls when signed out). - docs/hosting.md: the cost-when-busy note (honesty condition h8). - 34 tests via node --test with in-memory KV/D1 stubs — no network, no wrangler. markdownlint passes on all new docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ker) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
… conformance gate (t3) learn-cli becomes a real portal: which subjects exist is pure DATA (learn/subjects/registry.json, overridable via LEARN_SUBJECTS_REGISTRY), and subjects are driven ONLY as external subprocesses over --json — never imported. Registers french/spanish/culture-guide with the pinned argv_prefix shapes (culture-guide mounts contract verbs under a `subject` noun). Registry API (learn.subjects): SubjectEntry (metadata-only; unknown keys are rejected so content/progression can never leak in), load_registry, get_subject, resolve_executable, is_available. New CLI surface: - `learn subjects [--json]` — list registered subjects with availability. - `learn subject overview` — describe the noun (rubric: overview on any noun with verbs). - `learn subject doctor <name> [--json]` — the conformance gate: spawns the subject's verbs and validates each --json payload against the contract schemas (executable-found, verb-doctor + contract-version pin, overview/progress/ advice/story-list, and an error/exit-contract probe on a bad invocation). Emits the standard doctor payload (itself a valid subject_doctor payload). Exit 0 conforms, 2 drift/uninstalled (environment), 1 unknown subject (user). Acceptance criteria met: 1. doctor validates verbs/schema_version/JSON payloads/exit codes and FAILS on drift (proven by a drifted fixture: payload + error-contract drift caught). 2. a dummy 4th subject registers and PASSES with zero new platform code (data entry + external conformant fixture only). 3. deleting a registry entry removes the subject from every face; tests enforce learn-cli ships no subject content/progression logic. Tests: +24 (155 total). Coverage 92.7%. black/isort/flake8/bandit/markdownlint and `teken cli doctor . --strict` (26/26) all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…rmance gate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…, adaptive what-next (t9) The motivation layer learn-cli owns per the contract's state division: subjects report raw pass|partial|fail observations and own their mastery ladder; this pure package derives all *motivation* from the recorded-result ledger alone — no LLM, no wall clock. Every time-dependent function takes an explicit `now: datetime` (UTC-normalized), so identical history yields identical numbers on web, CLI, MCP. Package (learn/motivation/, zero new runtime deps): - score_exercise / score_session / score_lesson / mean_score — 0-100 scores (result base 0/60/100 blended 0.6*result + 0.4*accuracy when countable). - streaks — per-subject and cross-subject day streaks; day boundary = UTC midnight; alive until a full day is missed. - decayed_mastery — exponential retention on top of the stored ladder (10-day half-life; base strengths unknown/intro/practiced/mastered = 0/.4/.7/1). - build_state / review_queue — fold the ledger into per-item state, surface touched items decayed below 0.5 (urgent < 0.35), most-decayed first. - what_next — one typed Recommendation, never None: the completed-track (progress.done) case switches to maintenance + depth mode, so a learner who mastered every item of every track always has a meaningful next action. Tests (+90): golden-history fixtures freeze exact scores/streaks/queue/what-next (constant changes become visible diffs); a 40-cycle completed-track simulation proves the never-ending invariant; AST guards enforce no wall-clock/network/LLM imports. Motivation package at 100% coverage; all lint/rubric gates green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…view queues, what-next Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Builds the /learn/ surface on org/site-astro's design system verbatim (global.css tokens, Layout/Header/Footer/PageHero/Mark, both fonts; zero new tokens) — a landing page (subject catalog), one sub-page per subject (modules + story ladder), and a story reader per story, generated via getStaticPaths over committed content-export fixtures authored from the real french-cli/spanish-cli/culture-guide story and curriculum content. Adds scripts/check-export-pages.mjs (npm run check) to fail the build if the export and the built pages ever drift apart, and .github/workflows/deploy-site.yml (build, check, then wrangler pages deploy to a new "agentculture-learn" Cloudflare Pages project — production on main, preview otherwise). The deploy step wraps dist/ under a learn/ folder so Astro's base-prefixed asset hrefs resolve correctly on a plain static host; hand-written page links stay relative so the site doesn't care where it's ultimately mounted. Also widens .markdownlint-cli2.yaml's node_modules ignore to a recursive pattern now that site-astro/ has its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…flow, export-page check) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ger + sync (t12) Adds learn/profile/ (stdlib-only, XDG-style local state: ledger.jsonl, auth.json 0600, sync.json) and the learner-facing verbs it backs: - `learn auth login|logout|status|overview` — GitHub device-flow sign-in via the learn API (t11), linking the CLI to the same learner account the web uses. Sign-in is additive only; every verb works fully offline with no session. - `learn progress [subject]` — drives each registered+installed subject's own `progress --json` and blends the facts with the local ledger via learn.motivation for per-subject score/streak/item-history plus an overall streak. - `learn next` — renders learn.motivation.what_next() over the full ledger. - `learn record <subject> --item --result ...` — proxies to the subject's own `record` verb (source of truth for mastery), ledgers the ack locally, and best-effort syncs unsynced rows when signed in (never fails the command on a network error). Sync is one-way push only for v1 (POST /api/record per unsynced row, cursor tracked in sync.json) — documented in learn/profile/_sync.py's docstring. learn/subjects/driver.py is the new runtime subprocess driver (the conformance gate's read-only counterpart) these verbs use to call subjects. Extends the conformant_subject.py test fixture with a `record` verb so the new proxy path has something conformant to drive in tests. New tests cover the profile store, the device-flow login (via a real localhost http.server fixture), and progress/next/record including the anonymous-never-touches- network guarantee. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ore rule The Python-template .gitignore's 'lib/' pattern silently excluded site-astro/src/lib/ from t13's commit, and the ignored file was deleted with the worktree. Scope the rule to the repo root (/lib/) and reconstruct the typed content-export loader. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…rn site export' (t10) Derive learn-cli's three faces from one agentfront App (learn/front/): a parallel registry whose 9 tools wrap the same operations the hand-rolled CLI uses (subject registry + subprocess driving, never importing subject code), plus 3 generated docs. The CLI, MCP, and HTTP faces enumerate identically — proven in CI by agentfront's surfaces_agree. - Depend on agentfront[mcp]>=0.20 (runtime dep; updates the "zero third-party dependencies" note in CLAUDE.md). agentfront 0.20.0 is on PyPI with the mcp extra. - learn/front/: build_app() -> App; _driver.py drives subject contract verbs over subprocess --json (generalizes the conformance gate); _docs.py generates the portal/contract/subjects doc pages from package data (deterministic); _export.py writes the pinned static-content bundle. - New CLI verbs (hand-rolled CLI, register() pattern + explain catalog entries): 'learn mcp serve' (stdio single-'run' MCP server), 'learn mcp overview', 'learn site serve' (WSGI markdown site), 'learn site export --out <dir>', 'learn site overview'. - 'learn site export' emits the pinned format t13's Astro site consumes: meta.json, subjects.json, stories-<subject>.json (available subjects only, full story objects from story list+read), docs/<slug>.md. Deterministic (sorted keys, no wall clock); never fails on a missing subject. - Extend the conformant_subject fixture with story read / lesson / practice / record so the App tools and export are exercised end to end without french/ spanish installed (via LEARN_SUBJECTS_REGISTRY). - Ignore .venv in markdownlint config (new transitive deps ship LICENSE.md). Gates: 297 tests pass, coverage 96%, black/isort/flake8/bandit/markdownlint clean, teken rubric 26/26. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ve/export, surfaces-agree gate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze # Conflicts: # .markdownlint-cli2.yaml
…rogress/next/record verbs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze # Conflicts: # learn/cli/__init__.py # learn/explain/catalog.py # tests/fixtures/subjects/conformant_subject.py
…lic export Subject repos ship 'dev-' stories as in-repo test fixtures (t4/t5/t6 authored them so code and content tasks could land in parallel). They are not learner content: the raw story list (CLI/MCP faces) still shows them, but 'learn site export' — the public web content — filters them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Replaces t13's hand-authored fixtures: spanish now shows its 3 modules (the fixtures predated t5's port), dev- stories are excluded by the exporter, and the export docs/ pages ride along per the pinned format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Pages can't be mounted at a path, so the worker takes the whole /learn route: /learn/api/* normalizes to the API, everything else proxies read-only to PAGES_ORIGIN (the agentculture-learn Pages project, which serves under /learn/ per the deploy wrapping). One worker deploy = the whole same-origin mount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…t14)
Implements the spec's resource gate on site-astro/: signed-out visitors get
a provably pure-static site (zero calls to the learn API, hence zero model
calls), while signed-in visitors get progress/streak/next-lesson panels and
per-exercise result recording, hydrated client-side after a GET /api/me
session check.
- src/lib/api.ts: the single API_BASE constant (default same-origin
/learn/api), imported by Header.astro's static "Sign in" href and every
fetch() in the new client script.
- src/scripts/learner.js: the one piece of client JS beyond org's reveal
script. Calls GET /api/me once; a 401/network error stamps
data-auth="out" and stops (no further request of any kind); a 200 stamps
data-auth="in" and only then hydrates learner panels
(GET /api/progress/:subject), wires story-exercise recording
(POST /api/record), and sign-out (POST /api/auth/logout).
- global.css: one shared `.signedin-only { display: none; }` default; each
component pairs it with its own `html[data-auth="in"] ...` show/hide
override, built entirely from existing tokens.
- Header.astro: an auth slot (quiet "Sign in" link vs. name + sign out).
- LearnerPanelSubject.astro / LearnerPanelOverview.astro: one card in both
states so there's no layout shift; signed-out is an invitation, signed-in
shows mastered/touched counts (honestly labeled as ledger coverage, not
curriculum totals) and a "last active" chip standing in for a day-based
streak the API doesn't expose.
- Story reader: signed-out keeps t13's CTA line; signed-in swaps in
pass/partial/fail recording buttons per exercise, optimistic with a quiet
inline status on failure.
- scripts/check-static-auth.mjs (npm run check:static-auth / test:static,
now part of npm run check): proves the zero-API invariant statically —
no built page's <html> carries data-auth, the CSS actually hides
.signedin-only by default, and learner.js's fetch() calls are confined to
the /api/me|/progress/|/record|/auth/* whitelist and lexically gated
behind a confirmed session (checked against both source and the built,
minified bundle).
npm run build / npm run check green (29 pages, unchanged); Python suite
untouched (353 passed); teken cli doctor --strict green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
… zero-API static proof Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…wnlint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…le launch bar (t16) Add tools/launch-gate/ — one entrypoint `bash tools/launch-gate/run.sh` that runs the whole pre-launch gate against the REAL production topology locally (no cloud, no wrangler) and prints a machine-checked PASS/FAIL table. Topology (api-server.mjs): wraps workers/learn-api's real default export with its own test stubs (KVStub/D1Stub/makeEnv/mintToken from test/helpers.js) and a loopback static server for the built site, with the worker's PAGES_ORIGIN pointed at it — so the /learn/* zone-mount proxy is exercised for real and the browser sees ONE origin (/learn static + /learn/api served by the worker). The walk (walk.mjs, Playwright, phone 390x844 + desktop 1280x800): - signed-out: 3 cards, story body+glossary, html[data-auth=out], invitation + CTA, and the network invariant (only GET /api/me — no progress/record/tutor). - signed-in: html[data-auth=in] + display name; per subject (french, spanish, culture-guide) records a pass via the story button, then a CLI parity bridge (`learn record` signed in with the same session, synced to the local API) proves the synced row appears identically in the rendered panel and the API. - degrades gracefully to a fetch/DOM fallback (browser checks SKIPPED, never a silent pass) if chromium can't launch. One acceptance test per audience: web (walk.mjs), CLI (tests/e2e/test_cli_golden.py), agent (tests/e2e/test_mcp_harness.py). The pytest audiences are gated by RUN_LAUNCH_GATE=1 so the normal suite stays fast (353 passed, 12 skipped). The measurable launch bar (launch_bar.py): 3x `learn subject doctor` healthy, content counts from the real content (>=10 stories x >=3 levels for each language, >=3 culture-guide scenarios, dev-* excluded), the zero-API static check (site-astro npm run check), and zero new design tokens vs org's global.css (CSS token subset). report.py folds every producer's NDJSON results into the final table and exit code. Not wired into CI (runs pre-launch, by hand). playwright is the gate package's own devDependency, not added to site-astro. No version bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…h bar (66 checks) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
|
/agentic_review |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
PR Summary by QodoLearn uplift: contract v1.0, subject registry+doctor, three faces, site+worker, gate
AI Description
Diagram
High-Level Assessment
Files changed (153)
|
Code Review by Qodo
Context used✅ Compliance rules (platform):
8 rules 1. CLI bypasses agentfront registry
|
Cognitive complexity (S3776):
- contract/_validate.py: split _check into one small predicate per JSON-Schema
keyword (ref/type/enum-const/string/numeric/object/array/anyOf/not), and
split _walk_keywords's recursion by value-shape (map/list) helpers.
- cli/_commands/auth.py: split cmd_auth_login into _start_device_flow,
_poll_once/_await_device_confirmation, and _emit_login_success.
- front/_export.py: split _subject_stories's per-item logic into
_read_full_story and _export_story.
Duplicated literals (S1192):
- cli/_commands/{mcp,site,auth,subject}.py: add a per-module _JSON_HELP
constant for the repeated "--json" help string.
- contract/__init__.py: add _SUFFIX = ".json".
- contract/_validate.py: add _DEFS = "$defs".
Others:
- front/_driver.py: drop the redundant json.JSONDecodeError from an
`except (json.JSONDecodeError, ValueError)` — it's already a ValueError.
- profile/_api.py: replace the "http://"/"https://" startswith-tuple scheme
guard with urlparse(url).scheme in _ALLOWED_SCHEMES, removing the flagged
hardcoded http:// literal without changing which schemes are accepted.
- motivation/_models.py: merge `endswith("Z") or endswith("z")` into one
`endswith(("Z", "z"))` call.
- contract/_validate.py: merge the nested `if negated is not None: if not
...` into a single `if negated is not None and not ...`.
No behavior change: full suite stays 353 passed / 12 skipped, public APIs
(validate, unsupported_keywords, cmd_auth_login, export_site, drive, etc.)
are unchanged. Verified: pytest -n auto, black --check, isort --check-only,
flake8, bandit, and teken cli doctor . --strict all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
…ons + constants) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze
|
|
PUSHBACK — deliberate, spec-confirmed decision. The 'zero third-party dependencies' rule was the template scaffold's state, not a standing constraint: the converged uplift spec (docs/specs/, claim c23, user-confirmed) makes agentfront>=0.20 THE runtime dependency the three faces derive from, and this PR updated CLAUDE.md's Commands section to state exactly that. Resolving.
|
|
PUSHBACK — defused by the dispatch guard:
|
|
PUSHBACK — documented architecture, not drift: the hand-rolled argparse CLI predates this PR, is tuned to pass the teken agent-first rubric (a CI gate), and stays; the agentfront App registry backs the MCP/HTTP/export faces, with
|
|
FIX — real finding, thanks. The zone-mount proxy now strips
|
|
FIX —
|
|
FIX —
|
|
FIX —
|
…0600 + corruption tolerance (#5) Addresses the four real findings from PR #4's review threads (3562990747, 3562990748, 3562990752, 3562990757). Claude-Session: https://claude.ai/code/session_01Jkvh4uxSi6AsBJYPjiurze Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* chore(worker): GITHUB_CLIENT_ID out of wrangler.toml — deploy-time secret from .env The GitHub-App client id (technically public) is no longer hardcoded in the repo: [vars] entry removed; it becomes a wrangler secret sourced from the repo-root .env GITHUB_APP_CLIENT_ID, alongside GITHUB_CLIENT_SECRET. README runbook updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(worker): D1 consent schema + db helpers (t2) Add a consents table (github_user_id, terms_version, granted_at; PK on the first two) to schema.sql, idempotent and FK-light so consent can be recorded before a learners row exists (the pending-consent flow, t5). Add getConsent/recordConsent/deleteLearnerData to db.js, following the existing prepared-statement/ON CONFLICT/ISO-8601 style, with unit tests against an extended D1Stub (consents storage + batch()). No route wiring — index.js/auth.js/session.js untouched, that's t5/t7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(t1): versioned Terms of Use + Privacy Policy pages with shared TERMS_VERSION Adds /learn/terms/ and /learn/privacy/ (site-astro), both rendering a visible version + effective date and linked from the /learn footer. The Privacy Policy names the three processors (GitHub OAuth, Cloudflare hosting, AWS Bedrock Nova Sonic 2 / Nova Pro for the approved tutoring tier — with an explicit speech/text-leaves-to-the-model-provider callout), states what's collected (GitHub id + display name, the learning ledger, a session cookie; no email, no password), why, retention (kept while the account exists, hard-deleted on delete/withdraw), and the learner's rights (access/export/delete/withdraw). Terms of Use covers GitHub-based accounts, acceptable use, "as is" service, the approved AI-tutoring tier, data ownership, termination/suspension, changes-to-terms -> version bump -> re-consent, and a simple contact/governing-law section (GitHub issues, no published email). shared/terms-version.mjs is the single TERMS_VERSION/TERMS_EFFECTIVE_DATE source of truth, started at 1.0.0 / 2026-07-11, re-exported (not copied) by both site-astro/src/lib/terms.ts and workers/learn-api/src/terms.js — proven importable under both Vite/astro build and Node's own --test runner. tests/test_policy_pages.py cross-checks the pages' data claims against workers/learn-api/schema.sql (no email/password column) and proves the rendered version can't drift from the shared source. check-export-pages.mjs and check-static-auth.mjs are updated to treat terms/ and privacy/ as known non-subject, non-learner-panel pages so the new routes don't trip the orphan-page or signed-out-invitation checks. * feat(contract): add pick-the-right-word cloze exercises (t3, c16/h8) Extends the existing `cloze` exercise type (unchanged since contract 1.0) with two OPTIONAL fields — `text` (passage with `{{blank_id}}` placeholders) and `blanks` ({id, options, answer}) — for a multi-blank, closed-option variant a reader can check without a driver or model call. The legacy single-blank free-text cloze shape (prompt + answer) is untouched. - learn/contract/schemas/{practice,story}.json: additive `cloze_blank` $def + exercise.text/blanks, open payloads so nothing predating them breaks. - learn/subjects/conformance.py: new `cloze-items` doctor check — reads every declared story and verifies well-formed blanks (options include the answer, blank ids unique + match text placeholders 1:1, item_id present), passing trivially for subjects that declare none. - No changes needed to record.json or workers/learn-api/src/validate.js: `recorded` carries no exercise-shape field, so a cloze result (its multi-blank tally lands in the pre-existing correct/total counters) was already contract-valid — proven by new tests, not just asserted. - site-astro: renders a pick-the-right-word cloze passage as inline button groups per blank with instant right/wrong feedback, entirely client-side (wireClozeExercises() in learner.js, zero fetch calls, works signed-out). - tests/test_site_export.py locks the export byte-stable for a subject declaring no cloze items (acceptance criterion 5). docs/specs/subject-plugin-contract.md §3.6.1 documents the shape and the least-invasive design decisions for the subject-CLI follow-up tasks (french-cli, spanish-cli, culture-guide) to author against. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(infra): voice-bridge serverless spike + SAM template — Nova Sonic 2 over API GW WebSocket (t4) Spike verdict (infra/SPIKE.md, run live against us-east-1, read-only): InvokeModelWithBidirectionalStream is SigV4-only — Bedrock API keys get 403 "This operation does not support API Keys" — but a plain Python 3.12 process holding the stream via the experimental aws_sdk_bedrock_runtime SDK completed a FULL audio round trip (Polly speech in, transcript + 19 audioOutput events back), so the Lambda relay authenticates with its execution role and python3.12/arm64 stays (league's convention, no Node). Bonus t15 intel: the bearer key serves Nova Pro on /converse (200 "Pong.") but NOT on /openai/v1/chat/completions (model_not_found in us-east-1). infra/template.yaml mirrors league-of-agents-platform's conventions: SAM, one arm64 Lambda (router + self-invoked session holder), long-form intrinsics parseable as plain YAML, capacity caps as Parameters cross-checked against voice_bridge/config.py by test, an AWS Budgets alarm pinned to a 20 USD/month ceiling with every sizing choice commented against it, zero idle cost. h7 groundwork: the bridge accepts ONLY short-lived voice tokens (session.js-symmetric HMAC format, scope=voice, approved=true) minted by learn-api (t16 mints; verification implemented + unit-tested here). No token, no upstream Bedrock connection — enforced structurally at $connect and proven by test. 65 new tests (tokens 21, handler 12, relay 6, template 26, incl. sam validate when the CLI is present); suite 420 passed. TDD: tests written red first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(worker): consent gate on both sign-in paths — pending-consent session, accept/decline endpoints, zero D1 writes pre-consent (t5) Implements spec c9/h1 + decision c19: an unconsented sign-in (web /api/auth/callback AND device-flow poll) no longer writes anything to D1. It mints a short-lived (10 min) pending-consent session — the marker lives in the stateless signed token, so nothing is persisted — and lands the user on the consent surface: the web callback 302s to /learn/consent/ with a pending cookie; the device poll returns status "consent_required" plus a pending Bearer token and the consent requirement so the CLI can prompt. Pending sessions can only GET /api/me (reports pending_consent + consent_required incl. TERMS_VERSION, never sliding-refreshes), use the consent endpoints, and log out; progress/record/tutor reject them with a structured 403 consent_required before touching D1 or inference — extending the resource-gate invariant one level (signed-out AND pending-consent traffic can never trigger a model call). New endpoints: - GET /api/consent public: terms_version, effective_date, policy links - POST /api/consent/accept records consent (exact shared TERMS_VERSION) FIRST, THEN upserts the learner (the consents table's missing FK makes that order possible), revokes the pending token, upgrades to a full session (Set-Cookie + body Bearer, symmetric across faces) - POST /api/consent/decline pending only: revokes the session, clears the cookie, zero rows ever written; a full session gets 409 already_consented (withdrawal = deletion is t7) The consent-satisfaction check is factored into consent.js#consentSatisfiesCurrentTerms for t6 to extend with the version-mismatch (re-consent) rule; re-consent itself is deliberately NOT implemented here. TDD: test/consent.test.js was written red-first — the h1 pair (fresh sign-in on BOTH paths asserts zero D1 writes) failed against the previous Worker with a logged learners insert, then the gate made it green. The D1 test stub now logs every write statement so "zero writes" and write ORDER (consents before learners before records) are asserted literally. The two pre-existing sign-in tests now seed a consented user, preserving their intent (post-consent sign-in still upserts + issues a full session). Tests: workers/learn-api 74/74 (was 58); pytest 458 passed, 12 skipped; markdownlint clean on the README (route table + t10/t12 endpoint shapes documented). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(t14): re-export content from released subjects — french 0.6.0, spanish 0.7.0, culture-guide 0.10.0 (cloze in, dev- excluded) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(worker): re-consent on TERMS_VERSION bump — stale-consent D1 check, structured 403, /api/me additive field (t6) Extends t5's consentSatisfiesCurrentTerms (t5 scope: consent != null) to require an exact terms_version match against the currently published version (consent.js#currentTermsVersion, an env.TERMS_VERSION_OVERRIDE test seam mirroring util.js#outboundFetch — production never sets it). - requireConsented (auth.js) adds a getConsent D1 read for full sessions and rejects a stale match with 403 consent_required { reason: "stale_version", consent_required }, alongside the existing pending-session 403 (now { reason: "pending", ... } for the same shape). HttpError gained an additive `extra` body-fields param to carry this. - Both sign-in paths (handleCallback, handleDevice) and handleConsentAccept now resolve the version through currentTermsVersion(env) instead of a bare TERMS_VERSION import, so a bump reroutes a previously-consented learner's next sign-in to pending-consent (zero new D1 writes) and re-accept always stamps the current version. - /api/me never 403s on stale consent — it reports an additive reconsent_required field (+ consent_required when true), keeping identity checks reachable while other routes reject. Design: D1-read-per-request over a claims-based (session-payload) cache — simple and always correct now; documented tradeoff in README "Storage". Tests: new test/reconsent.test.js (10 tests: predicate/seam, h2 stamping, both sign-in paths re-routing on a simulated bump, the live-token 403 wall with zero inference calls, the full v1->bump->403->re-accept->200 cycle, /api/me's additive shape). 5 existing worker.test.js tests updated to seed a matching consent row now that requireConsented checks D1, not just the token's pending marker. 74 baseline + 10 new = 84/84 green; 458 Python tests green; README route table + endpoint shapes updated additively. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(site): consent UI page wired to the pending-consent API (t10) Add site-astro/src/pages/consent/ (spec c9/c19) presenting the WHAT/WHY/ RETENTION/WHO-CAN-SEE-IT notice and a dedicated src/scripts/consent.js that drives it against workers/learn-api's pending-consent contract: GET /api/me + GET /api/consent to render live state, POST /api/consent/accept to upgrade into the signed-in hub, POST /api/consent/decline to confirm nothing was stored. Handles pending, expired (401, 10-min TTL), already-signed-in, declined, and error states. Extend check-export-pages.mjs's KNOWN_NON_SUBJECT_DIRS and check-static-auth.mjs's NOT_A_LEARNER_PANEL_PAGE for the new /consent/ route, and add a precise (not blanket) CONSENT_ALLOWED_SUFFIX_RE fetch whitelist for consent.js, separate from learner.js's own. Fix the built-bundle whitelist check to collect API_BASE aliases globally across dist/ instead of per-file, since Vite now code-splits src/lib/api.ts into its own shared chunk now that two scripts import it. tests/test_consent_page.py locks the page's key claims (no email/no password, imports TERMS_VERSION rather than hardcoding it, links both policy pages, all five states present) the same way test_policy_pages.py does for Terms/Privacy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * fix(worker): deterministic getConsent on granted_at ties — rowid tiebreak (flaky reconsent full-cycle test) Two consent rows inserted in the same millisecond made ORDER BY granted_at DESC ambiguous; the newest insertion now wins (rowid DESC), stub mirrors it, regression test pins the tie case. Found by the t6 merge gate (2/5 runs red). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(worker): self-serve export + delete — consent withdrawal = whole-learner erasure (t7) GET /api/export (requireConsented) returns the learner's complete data as self-describing JSON: identity row, every recorded result across every subject (db.js#listAllRecords), and full consent history (db.js#listConsents). POST /api/delete (requireAuth — deliberately not requireConsented, so a stale-consent learner can still erase without re-accepting terms first) requires a { confirm: "<github_user_id>" } body, calls the existing deleteLearnerData batch, revokes the current session (KV tombstone), and clears the cookie — h3 verbatim: no row survives in learners/records/consents and the old token 401s on the next call. c18 holds: the ledger stays append-only for everyone else (isolation-tested), whole-learner erasure is the only deletion path, and a deleted learner signing in again is indistinguishable from a brand-new one (full-cycle tested). No site-astro change: no existing account/profile surface exists to hang a destructive, confirmation-guarded control on without inventing new UI — both routes are CLI/agent-ready today; the web affordance is deferred to t8 (roles + visibility), which already needs a real account-scoped surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(t8): roles + visibility — ADMIN_GITHUB_IDS allow-list, admin learner list, self-serve visibility Adds spec c12/h4 to the consent-first uplift: server-side-only admin enforcement (src/admin.js#isAdmin/requireAdmin, backed by the new ADMIN_GITHUB_IDS Worker var), GET /api/admin/learners (a three-query, no-N+1 roster with per-subject record counts), default-private learner visibility living in the existing state blob (no schema change) with a POST /api/me/visibility toggle, and additive is_admin/ visibility fields on GET /api/me. h4 is proven with a forged client-side admin claim test and a cross-learner isolation audit. The site-astro account panel (LearnerPanelOverview's signed-in hub) gains the visibility toggle plus the "Export my data"/"Delete my data" web affordance t7 deferred here, and an admin-only learner list gated on is_admin. The CLI gets `learn admin learners` following the existing learn.profile authenticated-API pattern (learn auth), with an explain-catalog entry and a green agent-first rubric. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(t9): the approval gate — signed-out < signed-in < consented < APPROVED The tutoring tier is now admin-granted per learner (spec c13/h5, decision c20 enforced in code): - Worker: `approved` flag in learners.state (no schema change, absent == not approved), POST /api/admin/approve + POST /api/admin/revoke behind t8's requireAdmin; handleTutor 403s approval_required BEFORE the INFERENCE_URL check with zero outbound inference (h5, proven by extending the ordering test one level in test/approval.test.js); approve 409s consent_stale unless the target's consent covers the CURRENT terms (c20); a terms bump blocks tutoring even for an approved learner (both gates independent); deletion erases the flag — a re-signup is not approved. Additive: learner.approved on GET /api/me, approved on the admin roster. - CLI: learn admin approve/revoke <github_user_id> (profile API client + catalog entries + rubric green); admin learners renders tutoring status. - Site: account panel shows the caller's own tier; the admin list gains per-learner approve/revoke buttons; check-static-auth's admin whitelist is now exactly enumerated (learners|approve|revoke). Worker tests 136 -> 151; pytest 500 green; teken doctor --strict green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(t16): voice session end-to-end — approval-gated token mint, monthly voice budget, site voice UI (c15/h7) The learn-api half of the voice approval gate plus the web face; the live bridge deploy stays a supervised launch-gate step (t4 handoff #4). Worker (POST /api/voice/token): - Same learner-side gates as /api/tutor, same order: requireConsented -> the SAME approvedOf check (reused in place per t9's hook-point note, not duplicated) -> config 503 not_configured (VOICE_BRIDGE_URL + VOICE_TOKEN_SECRET, mirroring INFERENCE_URL) -> budget 429. h7: no token in any non-200 body, ever — signed-out/pending/stale/unapproved proven 401/403/403/403 with the approval 403 firing before the config 503. - src/voice.js mints the session.js-shaped token infra/voice_bridge/tokens.py verifies: {v, scope:"voice", uid, approved:true, iat, exp, sid}, unpadded base64url, HMAC-SHA256 over the encoded body, TTL 120s (mint->connect window; session length is the bridge's own cap). - Cross-language pin: tests/fixtures/voice_token_cross_language.json is a committed JS-minted token — test/voice.test.js asserts the minter still reproduces it byte-for-byte, tests/test_voice_token_cross_language.py asserts tokens.py still verifies it. Neither side can drift alone. - Per-learner monthly budget (t4 handoff #3): each mint books the full bridge session cap (300s) against learners.state.voice_usage; the mint that would exceed VOICE_MONTHLY_SECONDS_CAP (default 1800s = 30 min, sized against the $20 Budgets ceiling in a src/voice.js comment) 429s voice_budget_exhausted and books nothing; month rollover is free (stale month reads as zero). Honest scope documented in README: caps MINTED seconds, not actual streamed seconds — bridge caps + Budgets are the cost backstops; tightening needs bridge->worker reporting (follow-up). Site (own page dir + own script, disjoint from t15's tutor/learner files): - site-astro/src/pages/voice/index.astro: five gate states (signed-out visible with no JS, consent-needed / not-approved / ready / error), mic start/stop, session timer, granted limits, transcript. - site-astro/src/scripts/voice.js: t4's client audio contract — mic -> downsample -> 16kHz/16-bit/mono LPCM -> {"seq", "audio"} frames (seq ordering-authoritative); 24kHz LPCM downstream via WebAudio; 4001 unauthorized + connect-failure + budget/consent/approval mint errors all surfaced; client-side stop at max_session_seconds. - h7 client half is structural and audited: exactly ONE new WebSocket(), inside the injectable openBridgeSocket seam, whose only call site sits after the token mint's non-ok early return — no token, no WebSocket. check-static-auth.mjs gains a precise VOICE_ALLOWED_SUFFIX_RE (/me, /voice/token) + the documented WebSocket allowance; check-export-pages learns the voice page; tests/test_voice_page.py locks all of it with pure file reads. Verified end-to-end without AWS: a token minted by the real Worker route passes the real bridge $connect gate (handler.lambda_handler) and a forged token is refused 401. Deferred to launch gate: the live Nova Sonic exchange — operator runbook (sam deploy VoiceTokenSecretValue=same secret as the Worker's VOICE_TOKEN_SECRET, BudgetAlertEmail; then VOICE_BRIDGE_URL + secret on the Worker) recorded in workers/learn-api/README.md. Tests: worker 151 -> 166; pytest 500 -> 533 (+ cross-language contract, voice page); site build + check green; black/isort/flake8/bandit/ markdownlint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(t15): Nova Pro tutoring through the existing broker — Converse wiring (config only), approved-tier tutor surface, personalized cloze to the ledger The Worker diff is ZERO code lines (h11 verbatim): t15 ships as config + docs + a client surface, with the four-level gate and the forward-verbatim broker untouched. Wiring (c24, corrected target): Bedrock's OpenAI-compat surface does NOT serve Nova Pro in any probed region (model_not_found in us-east-1/us-west-2/ eu-west-1/eu-central-1) — the live-verified target is the native Converse API. wrangler.toml records the exact URL commented out (model us.amazon.nova-pro-v1:0, region us-east-1, INFERENCE_TOKEN = Bedrock API key via wrangler secret put, sourced from .env AWS_BEDROCK_API_KEY_SECRET; cost-when-busy: per-token only, zero idle). README "For t15" documents the NO-GO/GO probe results incl. the sanitized live re-verification (HTTP 200, latencyMs 491). test/tutor-converse.test.js (additive, worker suite now 153) proves the unchanged broker forwards a Converse payload verbatim + the learner stamp and relays responses/errors untranslated. Tutor surface (site-astro, approved learners only): TutorPanel.astro on every subject page + src/scripts/tutor.js (DOM) over src/scripts/ tutor-core.js (pure: prompts, Converse builders, defensive parsers, §3.6.1 validator, record builder). Three flows through POST /api/tutor — GRADE (strict rubric + strict-JSON pinned in the system prompt, pass/partial/fail + explanation), NEXT-STEP (adaptive from /api/progress weak items, weakest-first), CLOZE-GEN (a personalized pick-the-right-word story, validated client-side against §3.6.1 INCLUDING item_id reuse from an existing weak item as the stable join key; played inline with the story reader's interaction pattern; tally recorded via the existing POST /api/record with correct/total — no new record fields). Gate honored end-to-end: the panel is signedin-only (signed-out sees nothing, zero fetches); non-approved learners see the "tutoring is admin-approved" note and tutor.js returns before wiring anything; learner.js gains only the two-line learn:me hook. check-static-auth.mjs extended precisely: an anchored TUTOR_ALLOWED_SUFFIX_RE (/tutor$ | /progress/ | /record$), the built-bundle union, a hydrateTutorPanel gate check, and the pre-t15 blanket "no /tutor anywhere" rule replaced by "referenced ONLY through whitelisted fetch templates" (fetch templates, quoted tutor-script paths, and // comments stripped; anything else still fails) — all three mutation-tested. Tests: tests/test_tutor_page.py (25, consent-page pattern) locks the gate note, whitelist precision, prompt pinning, and the config-only wiring; scripts/check-tutor-logic.mjs (16 checks, wired into npm run check) executes tutor-core.js's pure logic — builders, fence/prose-tolerant parsers, every §3.6.1 violation, the join-key rule, tally-to-record shape. Deliberately untestable without live inference: real Nova Pro output quality (t17's gate drives that live). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * feat(t17): extended launch gate — consent/approval/deletion/tutor/voice/cloze success signals + boundary invariants The uplift's success signals (spec c8) as an extended launch gate that FAILS against pre-uplift prod and passes post-deploy (h17), plus the mechanical boundary invariants (h16). New: - tools/launch-gate/consent_walk.mjs — the consent -> approval -> deletion -> tutoring/voice signals as HTTP flows over a real origin. LOCAL mode drives the FULL authed flows against the in-process topology (10/10): zero D1 writes until consent (consent row before learner row), re-consent on version bump, self-serve delete + revoke, tutor 403 with zero outbound inference, admin approve/revoke, voice gate order (approval before config), policy/consent/voice pages + cloze content served. LIVE mode probes prod's unauthenticated surface (routes must 401 not 404, pages must 200 with markers). - tests/test_launch_gate_invariants.py — always-on mechanical invariants (h16): no email/password column, no provider SDK in the Worker (dep or import), static-auth check stays wired, no subject prose authored in learn-cli, privacy policy names every processor and its data claims match the schema. - tools/launch-gate/BASELINE-2026-07-11.md — the recorded pre-uplift LIVE baseline: 9/10 consent_walk LIVE checks fail against today's prod (routes/pages 404), the h17 evidence. Changed: - run.sh — two new steps: the Worker unit suite (168, the authoritative authed-flow proof) and consent_walk.mjs (honors LIVE_ORIGIN). - api-server.mjs — fix ERR_HTTP_HEADERS_SENT on Set-Cookie responses (setHeader after writeHead); the harness could not serve consent accept/login/logout/delete e2e. Surfaced by the new consent walk. - report.py — labels for the new audiences. Gates: pytest 568 passed / 12 e2e-skipped; worker 168/168; site build + npm run check green; consent_walk LOCAL 10/10, LIVE 9/10 (baseline); black/isort/flake8/bandit clean; rubric doctor PASS; markdownlint 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * chore: bump 0.6.0, changelog, and record the c24 Converse correction (pending user confirmation) - version-bump minor 0.5.4 -> 0.6.0 with the full uplift changelog - spec: post-convergence correction note on c24 (OpenAI-compat -> native Converse), annotating not rewriting the converged claim; captured as q1 in the frame questions store for user confirmation at this PR gate. h11 (config-only, no provider SDK) still holds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * chore: gitignore .devague/questions (devague working-state, auto-added) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * chore: scope GitGuardian ignore to the t16 voice-token test fixture The cross-language contract fixture (tests/fixtures/voice_token_cross_ language.json) is a deterministic HMAC of a PUBLIC payload with a fake, committed test secret — it pins the JS minter / Python verifier agreement byte-for-byte and cannot change. GitGuardian's JWT detector matches its shape; this narrowly-scoped ignore (that one path + the known fake secret string) records it as a documented false positive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * spec: apply the user-confirmed c24 correction (OpenAI-compat -> native Converse) User confirmed 2026-07-11. c24 and the Model-access decision now name the native Bedrock Converse API (POST /model/us.amazon.nova-pro-v1:0/converse, Bearer Bedrock API key); the addendum is retained as the evidence trail and frame question q1 is resolved. h11 holds — config only, no provider SDK. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * fix: reduce cognitive complexity of cloze conformance checks (S3776) SonarCloud flagged _validate_cloze_exercise (28) and _probe_cloze_items (37) as CRITICAL cognitive-complexity issues, both over the allowed 15 (python:S3776). Extract each distinct validation/probing concern into a small, docstringed helper so both top-level functions become short orchestrators: - _validate_cloze_exercise: _validate_cloze_top_level_fields (text/item_id checks), _validate_cloze_blank_id (id presence/uniqueness/placeholder match), _validate_cloze_blank_answer (options/answer checks), _validate_cloze_blank (per-blank shape + id + answer), and _validate_cloze_orphan_placeholders (text placeholders with no blank). - _probe_cloze_items: _read_story_exercises (one story's story-read + parse), _check_cloze_exercise_id_reuse (cross-story id dedup), _validate_cloze_in_story (one exercise's validation + dedup), _probe_story_cloze_items (one story's exercise loop), and _summarize_cloze_check (final check assembly). Behavior-preserving: identical error strings, error ordering, and return shapes throughout — no test needed to change. Full suite stays green (568 passed, 12 skipped) plus black/isort/flake8/bandit and the `teken cli doctor . --strict` rubric gate all pass. * fix(voice-bridge): Query the concurrency gate instead of Scan-ing the table Qodo finding (BUG 2, performance): DynamoSessionStore.count_active() used table.scan(FilterExpression=...) to count active sessions for the $connect concurrency gate. Sessions and inbound audio frames share one DynamoDB table, and frames are the hot path (thousands of writes per session), so a Scan read every frame item before filtering — $connect latency/cost grew with frame volume instead of staying bounded by the (small, cap-limited) session count. Fix: move session-metadata items to a constant partition key (PK="ACTIVE", SK=<connectionId>) instead of PK=SESSION#<connection>/SK=METADATA. Frame items keep their existing PK=SESSION#<connection> shape untouched. count_active() now does table.query(Select="COUNT", KeyConditionExpression="PK = :active", ...) against that one partition, which never touches frame items. put_session/get_session/delete_session updated to the new key shape; no template.yaml schema change needed (PK/SK stay String HASH/RANGE on the base table — no GSI required). Test-first: added FakeDynamoTable (a boto3 Table-resource stand-in that records scan()/query() calls) and four new tests against DynamoSessionStore directly, including test_count_active_queries_the_constant_pk_and_ignores_frame_items, which seeds 3 metadata items alongside 1000 frame items and asserts the count is exactly 3, scan() is never called, and query() is called once keyed on PK="ACTIVE". 568 -> 572 passed, 12 skipped. black/isort/flake8 clean; bandit findings unchanged (4 pre-existing low-severity, none introduced by this change). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * fix(worker): close two Qodo review findings — delete session leak + voice budget race BUG 1 (security): POST /api/delete only revoked the calling session's own sid tombstone, so a learner's other still-valid sessions (another tab, a CLI token, a session /api/me had refreshed) survived a self-delete. handleDelete now also stamps a per-uid `revoked_uid:<uid>` KV marker with the delete epoch second; auth.js#requireAuth rejects any token for that uid whose `iat` predates it (strict `<`, not `<=`, so a resignup landing in the same epoch-second as the delete is never false-revoked — see that function's comment for why). session.js#issueSession gained an optional `opts.now` override (mirroring voice.js#mintVoiceToken's own test-only override) so tests can pin `iat` deterministically instead of relying on wall-clock luck. BUG 3 (reliability): handleVoiceToken's monthly voice-budget cap was a plain read-check-write — two concurrent mints could both read the same `used`, both pass the cap check, and both write, minting two sessions' worth of budget for one booking. db.js#setLearnerVoiceUsage is now a compare-and-swap on the learner row's `updated_at` (`WHERE github_user_id = ? AND updated_at = ?`); index.js#bookVoiceUsage retries (bounded, 3 attempts) against a fresh read on a lost CAS and refuses the mint — without leaking the already-minted token — rather than risk exceeding VOICE_MONTHLY_SECONDS_CAP. test/helpers.js's D1 stub models the conditional UPDATE so the race is provably closed without wrangler or real concurrency. New tests: test/auth.test.js (deterministic requireAuth revocation-boundary unit tests), test/export-delete.test.js (multi-session delete revocation + cross-learner isolation), test/db.test.js (setLearnerVoiceUsage CAS unit tests), test/voice.test.js (real concurrent-mint Promise.all test — verified to fail against the pre-fix code, both mints landing 200 and overshooting the cap). 168 -> 183 worker tests, all green; pytest stays 568 passed / 12 skipped. README.md and tools/launch-gate/ docs updated to match (test count, revocation model, atomic-booking behavior). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz * docs: log the four Qodo/Sonar review fixes under 0.6.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>



What
The learn platform — the full learn-uplift build (spec + plan landed in #3, tracked cross-repo in french-cli#2, spanish-cli#2, culture-guide#10, org#7). Built by a 16-task workforce in 5 TDD-gated waves; suite 22 → 353 tests (+12 gated E2E), coverage ~96%.
learn subjects,learn subject doctor <name>; french/spanish/culture-guide all healthy (8/8 checks each), driven purely as subprocess--jsonruntimes.learn auth login(GitHub device flow), offline-first ledger + sync,progress/next/recordverbs.learn mcp serve,learn site serve,learn site export;surfaces_agreein CI. Upstream gaps filed: agentfront#52, #53.site-astro/: 29 static pages in org's design (zero new tokens — machine-checked subset of org'sglobal.css), signed-out/in split with a zero-API static proof, Pages deploy workflow (agentculture-learn).agentculture.org/learn/*zone mount: one worker serves/learn/api/*and proxies the static site.tools/launch-gate/run.sh: 66 machine-checked assertions (web via Playwright at phone+desktop, CLI golden--json, MCP harness, launch-bar numbers). Current result: GATE: PASS, 66/66. Live-mode rerun:LIVE_ORIGIN=… run.sh.Post-merge operator steps (documented in workers/learn-api/README.md + site-astro/README.md)
CLOUDFLARE_API_TOKEN+CLOUDFLARE_ACCOUNT_IDsecrets to this repo → re-run the deploy workflow.wrangler secret put(SESSION_SECRET, GITHUB_CLIENT_SECRET); provision KV/D1; deploy the worker with the zone route.agentculture.org/learn/resolves; thenLIVE_ORIGINlaunch-gate rerun.Version 0.4.2 → 0.5.0.