Skip to content

feat: consent-first, role-aware Bedrock tutoring uplift (#8–#11)#13

Merged
OriNachum merged 40 commits into
mainfrom
feat/consent-tutoring-uplift
Jul 11, 2026
Merged

feat: consent-first, role-aware Bedrock tutoring uplift (#8–#11)#13
OriNachum merged 40 commits into
mainfrom
feat/consent-tutoring-uplift

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

The consent-tutoring uplift — one coherent phase over #8#11

Turns agentculture.org/learn from an open sign-in that persists identity with
no consent into a consent-first, role-aware tutoring service. Specced with
devague /think, planned with /spec-to-plan (converged on PR #12), and built
via /assign-to-workforce18 tasks, 8 dependency waves, one worktree per
task, every merge TDD-gated
(tests green before and after).

Closes #8 (experience), #9 (content), #10 (consent gate), #11 (Terms + Privacy).

What ships

⚠️ One spec correction needs your confirmation (this gate)

c24 (and the "Model access is Bedrock-direct" decision) say Nova Pro tutoring
points INFERENCE_URL at Bedrock's OpenAI-compatible endpoint
(…/openai/v1). A live probe on 2026-07-11 proved that endpoint returns
model_not_found for Nova Pro in every region tried, and /openai/v1/models
isn't even an operation. The native Bedrock Converse API
(POST /model/us.amazon.nova-pro-v1:0/converse, Bearer Bedrock API key) does
return Nova Pro completions and tolerates the broker's learner stamp — so
h11 still holds (config-only, no SDK); only the endpoint URL/shape changes.
The shipped code already targets Converse; the spec carries a post-convergence
correction note
on c24 (annotating, not rewriting the converged claim) plus
frame q1. Please confirm the retarget so I can fold it into the claim.

Test evidence (all green)

  • Worker suite: 168/168 (node --test, in-memory KV/D1, zero-network).
  • Python suite: 568 passed, 12 launch-gate e2e skipped (hermetic default).
  • Site: npm run build + npm run check green (static-auth 4-way whitelist,
    export-pages, 16 tutor-logic checks).
  • consent_walk.mjs LOCAL 10/10; LIVE 9/10 (baseline, pre-deploy).
  • black / isort / flake8 / bandit clean; rubric teken cli doctor --strict
    PASS; markdownlint 0.

Not in this PR — the supervised deploy (auto-mode blocks it)

Merging auto-deploys Pages (site) via deploy-site.yml. The Worker +
AWS still need a supervised, outside-auto-mode step: wrangler deploy from
merged main, wrangler secret put GITHUB_CLIENT_ID (from .env
GITHUB_APP_CLIENT_ID), flip INFERENCE_URL to the Converse URL + set
INFERENCE_TOKEN, then VOICE_TOKEN_SECRET + sam deploy for voice. Only
after the policy docs are live and consented may the first learner
(gh-id 20955789) be approved (c20). The green
LIVE_ORIGIN=https://agentculture.org launch-gate run — recorded while the
public nav link is live — is the gate the uplift ships behind. Runbook:
workers/learn-api/README.md + tools/launch-gate/BASELINE-2026-07-11.md.

  • learn-cli (Claude)

OriNachum and others added 30 commits July 11, 2026 10:07
…cret 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
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
…ERMS_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.
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
…c 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
…ssion, 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
…panish 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
…ck, 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
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
…reak (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
…-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
…rner 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
…st surface, default-private visibility, account panel
…PROVED

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
…hly 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
…iring (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
…grade/next-step/cloze-gen), zero broker code change (h11)

Conflicts with t16 resolved as unions: wrangler.toml secret notes,
README For-t15/For-t16 sections (both SHIPPED), check-static-auth's
four-way whitelist union (learner/consent/voice/tutor) with tutor
renumbered section 6; test_tutor_page's union pin updated to four REs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpvZY8K67Q9uGA6keFhTz
…ce/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
…(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
@OriNachum

Copy link
Copy Markdown
Contributor Author

/agentic_review

@gitguardian

gitguardian Bot commented Jul 11, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@qodo-code-review

qodo-code-review Bot commented Jul 11, 2026

Copy link
Copy Markdown

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

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
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Consent-first, role-aware Bedrock tutoring uplift

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Gate sign-in behind explicit Terms/Privacy consent; enforce re-consent on version bumps.
• Add admin allow-list + approval tier so tutoring/voice spend is impossible without approval.
• Ship self-serve export/delete, cloze (pick-the-right-word), and end-to-end launch-gate checks.
Diagram

graph TD
u(["Learner"]) --> site["Astro learn site"] --> api["Learn API Worker"] --> d1[("D1 ledger")]
api --> gh{{"GitHub OAuth"}}
api --> br{{"Bedrock APIs"}}
site --> api
api --> vb["Voice bridge WS"] --> ddb[("DynamoDB")]
vb --> br
subgraph Legend
  direction LR
  _actor(["User"]) ~~~ _svc["Service"] ~~~ _db[("Database")] ~~~ _ext{{"External"}}
end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Stamp terms_version into session token (avoid per-request D1 read)
  • ➕ Reduces D1 reads on requireConsented-gated routes
  • ➕ Keeps consent enforcement purely stateless per request
  • ➖ Requires every session issuer/refresh path to correctly propagate the claim
  • ➖ Higher risk of silent under/over-grant if any issuer is missed
2. FK consents -> learners (record learner before consent)
  • ➕ Stronger relational integrity and simpler joins
  • ➖ Breaks the core guarantee of zero D1 writes before consent
  • ➖ Makes pending-consent flows persist identity prematurely
3. Cloudflare-only voice bridge (Durable Objects/WebSockets)
  • ➕ Single-provider deployment footprint
  • ➕ Could reuse Worker auth primitives more directly
  • ➖ More uncertainty around Bedrock bidirectional streaming integration and cost guardrails
  • ➖ Loses the explicit AWS Budgets + SAM pattern adopted for voice

Recommendation: The PR’s strategy is sound: record consent before any learner/ledger writes, keep admin enforcement server-side via allow-list, and hard-gate all token-spending routes behind the consent+approval ladder. The extra D1 read in requireConsented is a reasonable correctness-first choice at this stage; revisit only if it becomes a measured hotspot. The only strategic open point is the spec’s Nova Pro endpoint wording: targeting native Bedrock Converse (vs OpenAI-compatible) is supported by live probes and still preserves the ‘no Worker SDK’ constraint.

Files changed (94) +15271 / -140

Enhancement (26) +4815 / -43
__init__.pyExpose new admin CLI command module +2/-0

Expose new admin CLI command module

• Wires the new admin CLI noun into the CLI package exports.

learn/cli/init.py

admin.pyAdd learn admin learners/approve/revoke commands +220/-0

Add learn admin learners/approve/revoke commands

• Adds an admin-only CLI surface calling the Worker’s admin endpoints to list learners and approve/revoke tutoring access.

learn/cli/_commands/admin.py

__init__.pyExpose admin API helpers +9/-2

Expose admin API helpers

• Exports new profile API helpers used by the admin CLI commands.

learn/profile/init.py

_api.pyAdd profile API calls for admin endpoints +44/-0

Add profile API calls for admin endpoints

• Implements client helpers for GET /api/admin/learners and POST /api/admin/{approve,revoke}.

learn/profile/_api.py

Footer.astroAdd policy links in footer +12/-0

Add policy links in footer

• Adds Terms/Privacy (and related) links to the site footer for discoverability.

site-astro/src/components/Footer.astro

LearnerPanelOverview.astroAdd account panel UI (visibility/export/delete/admin roster) +271/-0

Add account panel UI (visibility/export/delete/admin roster)

• Introduces richer learner account panel UI elements that learner.js hydrates (visibility, export/delete, and admin-only roster).

site-astro/src/components/LearnerPanelOverview.astro

TutorPanel.astroAdd tutor panel UI for approved learners +279/-0

Add tutor panel UI for approved learners

• Adds the tutoring UI shell and data hooks consumed by tutor.js/tutor-core.js, gated by approval state.

site-astro/src/components/TutorPanel.astro

stories-culture-guide.jsonUpdate exported Culture Guide stories (incl. cloze) +141/-0

Update exported Culture Guide stories (incl. cloze)

• Refreshes exported story content consumed by the site, including cloze-capable shapes.

site-astro/src/content-export/stories-culture-guide.json

stories-french.jsonUpdate exported French stories (incl. cloze) +265/-0

Update exported French stories (incl. cloze)

• Refreshes exported story content consumed by the site, including cloze-capable shapes.

site-astro/src/content-export/stories-french.json

stories-spanish.jsonUpdate exported Spanish stories (incl. cloze) +217/-0

Update exported Spanish stories (incl. cloze)

• Refreshes exported story content consumed by the site, including cloze-capable shapes.

site-astro/src/content-export/stories-spanish.json

index.astroAdjust subject landing to include new panels +2/-0

Adjust subject landing to include new panels

• Minor page updates to accommodate new learner/tutor panels and associated scripts.

site-astro/src/pages/[subject]/index.astro

index.astroRender interactive cloze blanks inline (no network) +153/-11

Render interactive cloze blanks inline (no network)

• Adds story rendering for pick-the-right-word cloze exercises (text split on {{id}} placeholders) and emits markup consumed by learner.js for client-only checking.

site-astro/src/pages/[subject]/stories/[id]/index.astro

index.astroAdd consent notice page and state container +263/-0

Add consent notice page and state container

• Adds the consent page that explains storage/retention, renders version/effective date, and provides Accept/Decline affordances for pending sessions.

site-astro/src/pages/consent/index.astro

index.astroAdd voice practice page with gate states +207/-0

Add voice practice page with gate states

• Introduces the voice page with signed-out/consent-needed/not-approved/ready/error states and loads voice.js.

site-astro/src/pages/voice/index.astro

consent.jsImplement consent page state machine + API wiring +165/-0

Implement consent page state machine + API wiring

• Adds client JS to call /api/me and /api/consent, and to POST accept/decline while toggling the consent page UI states.

site-astro/src/scripts/consent.js

learner.jsWire account panel + admin actions + export/delete + cloze picker +307/-3

Wire account panel + admin actions + export/delete + cloze picker

• Adds account panel hydration for visibility, export/delete, and admin roster/approve/revoke; adds client-only cloze picker wiring without any fetch calls.

site-astro/src/scripts/learner.js

tutor-core.jsAdd pure tutor payload building and response parsing +332/-0

Add pure tutor payload building and response parsing

• Implements payload builders and parsers for grading/next-step/cloze tutoring flows to keep DOM wiring thin and testable.

site-astro/src/scripts/tutor-core.js

tutor.jsAdd tutoring UI wiring and approved-only fetches +317/-0

Add tutoring UI wiring and approved-only fetches

• Adds DOM wiring for tutor interactions; calls only /api/tutor, /api/progress/:subject, and /api/record and returns early with zero fetches when not approved.

site-astro/src/scripts/tutor.js

voice.jsAdd voice client: token mint + WebSocket audio loop +387/-0

Add voice client: token mint + WebSocket audio loop

• Implements the voice session client that POSTs /api/voice/token then opens a single WebSocket using the minted URL, streaming LPCM audio and handling limits/states.

site-astro/src/scripts/voice.js

admin.jsAdd server-side admin allow-list role check +63/-0

Add server-side admin allow-list role check

• Implements isAdmin/requireAdmin using env.ADMIN_GITHUB_IDS layered on requireConsented, never trusting client-supplied claims.

workers/learn-api/src/admin.js

auth.jsAdd requireConsented (pending + stale-version rejection) +56/-1

Add requireConsented (pending + stale-version rejection)

• Adds requireConsented to reject pending-consent sessions and force re-consent after TERMS_VERSION bumps with structured 403 bodies.

workers/learn-api/src/auth.js

consent.jsAdd centralized consent policy helpers +60/-0

Add centralized consent policy helpers

• Adds consentSatisfiesCurrentTerms, currentTermsVersion override seam for tests, and the consentRequirement payload returned to clients.

workers/learn-api/src/consent.js

db.jsAdd consents + export/delete + admin roster + state mutators +251/-3

Add consents + export/delete + admin roster + state mutators

• Adds D1 helpers for reading/writing consent rows, exporting full learner data, deleting all learner data via batch(), listing learners for admin UI, and persisting visibility/approval/voice usage in learner state.

workers/learn-api/src/db.js

index.jsWire consent gate, re-consent, export/delete, roles/approval, tutor/voice gates +657/-21

Wire consent gate, re-consent, export/delete, roles/approval, tutor/voice gates

• Adds consent-first sign-in behavior (pending-consent sessions), consent accept/decline routes, requireConsented gating on ledger routes, self-serve export/delete, approval-gated tutor broker, admin roster + approve/revoke, visibility updates, and voice token minting with budget enforcement.

workers/learn-api/src/index.js

session.jsSupport pending-consent sessions and shorter TTL +22/-2

Support pending-consent sessions and shorter TTL

• Extends session token payload to include pending_consent marker and adds a dedicated TTL and helper to detect pending sessions.

workers/learn-api/src/session.js

voice.jsAdd voice token minting + monthly budget helpers +113/-0

Add voice token minting + monthly budget helpers

• Adds Worker-side voice token minter and helpers for month keys, usage accounting, and env parsing used by POST /api/voice/token.

workers/learn-api/src/voice.js

Refactor (2) +27 / -2
content.tsAdd cloze blank typing to content model +15/-0

Add cloze blank typing to content model

• Extends TypeScript content types to represent cloze blanks for safe story rendering.

site-astro/src/lib/content.ts

util.jsAllow HttpError to attach structured extra fields +12/-2

Allow HttpError to attach structured extra fields

• Extends HttpError to merge an extra object into JSON responses (used for consent-required reason + requirement payloads).

workers/learn-api/src/util.js

Tests (30) +6149 / -13
conftest.pyAdd pytest fixtures for uplift tests +21/-0

Add pytest fixtures for uplift tests

• Adds shared fixtures/config used by new policy/consent/voice/launch-gate tests.

tests/conftest.py

cloze_broken_subject.pyAdd broken cloze subject fixture +213/-0

Add broken cloze subject fixture

• Fixture subject that emits malformed cloze items to assert the cloze-items doctor check fails correctly.

tests/fixtures/subjects/cloze_broken_subject.py

cloze_conformant_subject.pyAdd conformant cloze subject fixture +231/-0

Add conformant cloze subject fixture

• Fixture subject that emits valid cloze items to assert the cloze-items doctor check passes.

tests/fixtures/subjects/cloze_conformant_subject.py

voice_token_cross_language.jsonAdd golden voice token fixture for parity tests +11/-0

Add golden voice token fixture for parity tests

• Provides a deterministic cross-language token fixture used to pin JS mint and Python verify behavior.

tests/fixtures/voice_token_cross_language.json

test_admin_cli.pyAdd tests for admin CLI commands +300/-0

Add tests for admin CLI commands

• Adds coverage for admin learners listing and approve/revoke behavior including error handling and output modes.

tests/test_admin_cli.py

test_consent_page.pyAdd static tests for consent page wiring and copy +321/-0

Add static tests for consent page wiring and copy

• Ensures consent page structure, shared-version wiring, and key claims stay intact.

tests/test_consent_page.py

test_launch_gate_invariants.pyAdd always-on boundary invariant tests +246/-0

Add always-on boundary invariant tests

• Adds hermetic checks asserting no email/password persistence, static-auth audit is wired, no provider SDK in Worker, no subject prose in learn-cli, and policy/schema consistency.

tests/test_launch_gate_invariants.py

test_policy_pages.pyAdd tests for Terms/Privacy pages and shared-version wiring +247/-0

Add tests for Terms/Privacy pages and shared-version wiring

• Validates version rendering, processor naming, and alignment with schema and code-level claims.

tests/test_policy_pages.py

test_site_export.pyAdd tests for site export completeness +41/-0

Add tests for site export completeness

• Adds checks ensuring the static export includes the expected pages and content structure.

tests/test_site_export.py

test_subject_doctor.pyExtend tests for cloze-items doctor check +160/-1

Extend tests for cloze-items doctor check

• Adds tests that the new cloze semantic validation passes/fails with the provided fixtures.

tests/test_subject_doctor.py

test_tutor_page.pyAdd tests for tutor gating and audited fetch surface +333/-0

Add tests for tutor gating and audited fetch surface

• Adds static/structural tests for tutor UI gating and whitelisted endpoint usage.

tests/test_tutor_page.py

test_voice_bridge_handler.pyAdd tests for bridge handler gate and routing +184/-0

Add tests for bridge handler gate and routing

• Tests token verification, connection refusal paths, concurrency cap handling, and event routing under injected deps.

tests/test_voice_bridge_handler.py

test_voice_bridge_relay.pyAdd tests for voice relay behavior +156/-0

Add tests for voice relay behavior

• Tests frame ordering, session deadlines, and upstream/downstream relay message shapes.

tests/test_voice_bridge_relay.py

test_voice_bridge_template.pyAdd template/config drift tests for voice bridge stack +297/-0

Add template/config drift tests for voice bridge stack

• Ensures SAM template defaults and caps match Python config constants to prevent silent drift.

tests/test_voice_bridge_template.py

test_voice_bridge_tokens.pyAdd tests for voice token verification strictness +180/-0

Add tests for voice token verification strictness

• Validates that only properly shaped, unexpired, approved voice tokens verify successfully.

tests/test_voice_bridge_tokens.py

test_voice_page.pyAdd tests for voice page state machine and safety ordering +324/-0

Add tests for voice page state machine and safety ordering

• Asserts voice.js only creates WebSocket connections after successful token mint and that page states exist.

tests/test_voice_page.py

test_voice_token_cross_language.pyAdd cross-language voice token parity tests +95/-0

Add cross-language voice token parity tests

• Pins JS minting and Python verification to the same committed token fixture.

tests/test_voice_token_cross_language.py

admin.test.jsAdd unit tests for admin allow-list enforcement +185/-0

Add unit tests for admin allow-list enforcement

• Tests that admin routes require server-side allow-list and cannot be bypassed by forged client claims.

workers/learn-api/test/admin.test.js

approval.test.jsAdd unit tests for approval-gated tutoring and c20 approval rules +420/-0

Add unit tests for approval-gated tutoring and c20 approval rules

• Proves non-approved tutor calls return 403 with zero inference; ensures approval check precedes config checks; enforces consent-current precondition on approve.

workers/learn-api/test/approval.test.js

consent.test.jsAdd unit tests for consent gate flows +343/-0

Add unit tests for consent gate flows

• Verifies pending-consent issuance, no D1 writes before accept, consent row precedes learner row, and structured consent_required responses.

workers/learn-api/test/consent.test.js

db.test.jsExtend DB tests for consents/export/delete/admin roster +339/-0

Extend DB tests for consents/export/delete/admin roster

• Adds coverage for new D1 helpers including consent history, listAllRecords, deleteLearnerData batch semantics, and learner roster aggregation.

workers/learn-api/test/db.test.js

export-delete.test.jsAdd tests for /api/export and /api/delete semantics +319/-0

Add tests for /api/export and /api/delete semantics

• Ensures export returns full learner/records/consents and delete requires confirm=uid, erases all tables, and revokes the session.

workers/learn-api/test/export-delete.test.js

helpers.jsAdd test helpers for consent seeding and env overrides +149/-10

Add test helpers for consent seeding and env overrides

• Adds helpers to seed consent rows, mint pending tokens, and simulate terms-version bumps in tests.

workers/learn-api/test/helpers.js

reconsent.test.jsAdd tests for re-consent gating after version bump +269/-0

Add tests for re-consent gating after version bump

• Verifies stale consent yields 403 consent_required on gated routes while /api/me reports reconsent_required and /api/consent/accept remains reachable.

workers/learn-api/test/reconsent.test.js

terms.test.jsAdd tests for shared terms version import +23/-0

Add tests for shared terms version import

• Asserts Worker-side terms constants are sourced from the shared module and used consistently in consent logic.

workers/learn-api/test/terms.test.js

tutor-converse.test.jsAdd tests for Bedrock Converse-shaped tutor forwarding +118/-0

Add tests for Bedrock Converse-shaped tutor forwarding

• Validates the broker forwards a Converse-shaped payload verbatim (plus learner stamp) and passes upstream status/body through unchanged.

workers/learn-api/test/tutor-converse.test.js

validate.test.jsAdd tests confirming cloze-originated records validate unchanged +37/-0

Add tests confirming cloze-originated records validate unchanged

• Ensures record validation remains compatible with cloze results without requiring schema changes in the Worker validator.

workers/learn-api/test/validate.test.js

visibility.test.jsAdd tests for visibility default and updates +255/-0

Add tests for visibility default and updates

• Validates default-private behavior and persistence of learner visibility within the state JSON blob.

workers/learn-api/test/visibility.test.js

voice.test.jsAdd tests for voice token minting and budget gates +296/-0

Add tests for voice token minting and budget gates

• Tests consent+approval gating, configuration errors, monthly budget enforcement, and token parity properties for /api/voice/token.

workers/learn-api/test/voice.test.js

worker.test.jsUpdate baseline worker tests for expanded routing/gates +36/-2

Update baseline worker tests for expanded routing/gates

• Extends Worker tests to cover new endpoints and the strengthened resource-gate invariants.

workers/learn-api/test/worker.test.js

Documentation (11) +1610 / -40
CHANGELOG.mdAdd release notes for consent/roles/tutoring uplift +25/-0

Add release notes for consent/roles/tutoring uplift

• Adds a changelog entry summarizing the consent gate, approval tiers, export/delete, voice, and cloze changes in this phase.

CHANGELOG.md

2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.mdAdd post-convergence Bedrock Converse correction note +25/-0

Add post-convergence Bedrock Converse correction note

• Documents a correction from Bedrock OpenAI-compatible endpoints to native Converse for Nova Pro, pending confirmation.

docs/specs/2026-07-11-agentculture-org-learn-is-now-a-consent-first-role.md

subject-plugin-contract.mdDefine cloze pick-the-right-word variant and rules +115/-3

Define cloze pick-the-right-word variant and rules

• Extends the subject-plugin contract to specify the cloze text+blanks variant ({{blank_id}} placeholders), plus validation and rendering expectations.

docs/specs/subject-plugin-contract.md

SPIKE.mdDocument voice bridge feasibility and cost spike +163/-0

Document voice bridge feasibility and cost spike

• Adds spike results and constraints used to size the serverless voice bridge and its caps/budget posture.

infra/SPIKE.md

catalog.pyUpdate explain catalog for new learn surfaces +95/-0

Update explain catalog for new learn surfaces

• Adds/updates explainable catalog entries to reflect the consent/roles/tutoring uplift surfaces.

learn/explain/catalog.py

index.astroAdd versioned Privacy Policy page +207/-0

Add versioned Privacy Policy page

• Adds the Privacy Policy page naming processors and matching data minimization claims (no email/password).

site-astro/src/pages/privacy/index.astro

index.astroAdd versioned Terms of Use page +193/-0

Add versioned Terms of Use page

• Adds the Terms page versioned via shared TERMS_VERSION/TERMS_EFFECTIVE_DATE.

site-astro/src/pages/terms/index.astro

BASELINE-2026-07-11.mdRecord pre-deploy baseline for consent_walk probes +98/-0

Record pre-deploy baseline for consent_walk probes

• Adds baseline results showing current prod fails the new probes prior to deployment.

tools/launch-gate/BASELINE-2026-07-11.md

README.mdDocument consent_walk launch-gate flows +68/-10

Document consent_walk launch-gate flows

• Extends launch-gate documentation to cover LOCAL vs LIVE consent/approval/voice checks and how to run them.

tools/launch-gate/README.md

README.mdExpand Worker runbook for consent/approval/voice and deployment +612/-27

Expand Worker runbook for consent/approval/voice and deployment

• Adds detailed documentation for consent gate behavior, approval/admin routes, Bedrock Converse config, voice bridge integration, and supervised deployment steps.

workers/learn-api/README.md

validate.jsDocument why cloze needs no record validator change +9/-0

Document why cloze needs no record validator change

• Adds commentary clarifying cloze result recording remains compatible with the existing record validation schema.

workers/learn-api/src/validate.js

Other (25) +2670 / -42
requirements.txtAdd Python deps for voice bridge +14/-0

Add Python deps for voice bridge

• Introduces Python dependency pins needed for the voice bridge runtime and tests.

infra/requirements.txt

template.yamlAdd AWS SAM voice bridge stack (WebSocket + Lambda + Budget) +397/-0

Add AWS SAM voice bridge stack (WebSocket + Lambda + Budget)

• Adds a serverless WebSocket bridge stack with concurrency/session caps, DynamoDB session storage, and a $20/month AWS Budgets ceiling.

infra/template.yaml

__init__.pyAdd voice_bridge package initializer +12/-0

Add voice_bridge package initializer

• Creates the package initializer for the voice bridge Lambda code.

infra/voice_bridge/init.py

config.pyAdd voice bridge config constants and env keys +38/-0

Add voice bridge config constants and env keys

• Centralizes max session duration, concurrency caps, model id, and environment variable names used by handler/template/tests.

infra/voice_bridge/config.py

handler.pyImplement WebSocket routing + token gate + session launch +275/-0

Implement WebSocket routing + token gate + session launch

• Implements $connect/$default/$disconnect behavior, verifies Worker-minted voice tokens, enforces concurrency caps, persists sessions/frames, and self-invokes the session relay path.

infra/voice_bridge/handler.py

relay.pyImplement Bedrock bidirectional stream relay +266/-0

Implement Bedrock bidirectional stream relay

• Adds the streaming relay loop for Nova Sonic voice sessions, handling ordered frames and streaming audio/text responses back to the client.

infra/voice_bridge/relay.py

tokens.pyAdd voice token mint/verify primitives (Python) +115/-0

Add voice token mint/verify primitives (Python)

• Defines the HMAC base64url token format and strict verification rules used by the voice bridge to accept only approved voice tokens.

infra/voice_bridge/tokens.py

practice.jsonExtend cloze schema with text+blanks variant +34/-1

Extend cloze schema with text+blanks variant

• Adds optional text/blanks fields and a cloze_blank definition so pick-the-right-word cloze exercises validate without breaking legacy payloads.

learn/contract/schemas/practice.json

story.jsonExtend story exercise schema with cloze blanks +33/-0

Extend story exercise schema with cloze blanks

• Adds optional text/blanks fields and a cloze_blank definition for cloze exercises embedded in stories.

learn/contract/schemas/story.json

conformance.pyAdd cloze-items semantic conformance check +177/-4

Add cloze-items semantic conformance check

• Adds a new doctor check that reads all stories and validates pick-the-right-word cloze semantics (placeholder/blank consistency, options/answer rules, unique ids).

learn/subjects/conformance.py

pyproject.tomlUpdate Python tooling configuration for new tests/modules +4/-1

Update Python tooling configuration for new tests/modules

• Adjusts project/test tooling settings to accommodate new CLI/admin and voice bridge modules and tests.

pyproject.toml

terms-version.mjsAdd shared TERMS_VERSION and effective date source +30/-0

Add shared TERMS_VERSION and effective date source

• Introduces a single source of truth for the published Terms/Privacy version/date used by both the site and Worker.

shared/terms-version.mjs

package.jsonWire new check scripts for static auth and tutor logic +2/-1

Wire new check scripts for static auth and tutor logic

• Updates site scripts to run expanded static-auth checks and tutor-logic checks as part of npm run check.

site-astro/package.json

check-export-pages.mjsUpdate export-page checks for new pages/content +7/-1

Update export-page checks for new pages/content

• Adjusts export-page verification to account for new page shapes and exported content integrity expectations.

site-astro/scripts/check-export-pages.mjs

check-static-auth.mjsExpand static auth audit for new per-page scripts/endpoints +343/-22

Expand static auth audit for new per-page scripts/endpoints

• Extends the static analysis whitelist to cover consent/export/delete/visibility/admin/tutor/voice endpoints and ensures signed-out pages still cannot trigger protected fetches.

site-astro/scripts/check-static-auth.mjs

check-tutor-logic.mjsAdd deterministic tests for tutor-core logic +341/-0

Add deterministic tests for tutor-core logic

• Adds a pure-logic script test harness validating tutor-core payload building and parsing behavior.

site-astro/scripts/check-tutor-logic.mjs

terms.tsRe-export shared terms version into Astro site +10/-0

Re-export shared terms version into Astro site

• Imports shared/terms-version.mjs and re-exports constants for policy/consent pages.

site-astro/src/lib/terms.ts

api-server.mjsUpdate local topology harness for new endpoints/pages +5/-1

Update local topology harness for new endpoints/pages

• Updates the in-process topology harness that runs Worker + built site under one origin for end-to-end launch-gate checks.

tools/launch-gate/api-server.mjs

consent_walk.mjsAdd end-to-end consent/approval/tutor/voice/delete walk +416/-0

Add end-to-end consent/approval/tutor/voice/delete walk

• Introduces an end-to-end runner validating the uplift’s key guarantees locally with real tokens and probing live unauthenticated surfaces for deploy gating.

tools/launch-gate/consent_walk.mjs

report.pyUpdate report generation for consent_walk output +20/-1

Update report generation for consent_walk output

• Extends launch-gate reporting to summarize the additional consent_walk checks.

tools/launch-gate/report.py

run.shWire consent_walk into launch-gate runner +23/-0

Wire consent_walk into launch-gate runner

• Updates the run script to execute consent_walk and capture results as part of the gate.

tools/launch-gate/run.sh

schema.sqlAdd consents table for consent-first persistence +32/-2

Add consents table for consent-first persistence

• Adds an idempotent consents table keyed by (github_user_id, terms_version) with deliberate no-FK design to allow consent-before-learner writes.

workers/learn-api/schema.sql

terms.jsRe-export shared terms version into Worker tree +15/-0

Re-export shared terms version into Worker tree

• Adds a local module re-export of shared terms version constants for consistent Worker imports.

workers/learn-api/src/terms.js

wrangler.signedout.tomlAdd signed-out wrangler profile for probe runs +5/-0

Add signed-out wrangler profile for probe runs

• Adds a wrangler config variant intended for signed-out or baseline probe scenarios.

workers/learn-api/wrangler.signedout.toml

wrangler.tomlUpdate vars/secrets and document Bedrock Converse + voice settings +56/-8

Update vars/secrets and document Bedrock Converse + voice settings

• Moves GITHUB_CLIENT_ID from repo vars to deploy-time secret; documents Bedrock Converse INFERENCE_URL; adds ADMIN_GITHUB_IDS and voice bridge vars and secret requirements.

workers/learn-api/wrangler.toml

@qodo-code-review

qodo-code-review Bot commented Jul 11, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 9 rules

Grey Divider


Action required

1. Deletion leaves sessions valid ✓ Resolved 🐞 Bug ⛨ Security
Description
POST /api/delete only tombstones the current token’s sid, so other unexpired sessions for the same
uid remain authenticated and can still access requireAuth routes after deletion. This weakens the
expected “delete logs me out” behavior and reduces containment if a token was stolen.
Code

workers/learn-api/src/index.js[R592-609]

+async function handleDelete(request, env) {
  const session = await requireAuth(request, env);
+  const body = await readJson(request);
+  if (String(body.confirm || "") !== String(session.uid)) {
+    throw new HttpError(
+      400,
+      "confirmation_required",
+      "Deletion requires confirm to equal your own github_user_id.",
+      'GET /api/me to read your github_user_id, then POST /api/delete with { "confirm": "<that id>" }.',
+    );
+  }
+  const deleted = await deleteLearnerData(env, session.uid);
+  await revokeSession(env, session);
+  return jsonResponse(
+    200,
+    { ok: true, status: "deleted", deleted },
+    { "Set-Cookie": cookie("session", "", { maxAge: 0 }) },
+  );
Evidence
handleDelete only calls revokeSession(env, session) for the current request’s session;
revocation checks in requireAuth only consult revoked:<sid>, and /api/me can mint refreshed
tokens without revoking earlier ones—so other sessions remain usable after deletion.

workers/learn-api/src/index.js[592-609]
workers/learn-api/src/index.js[918-923]
workers/learn-api/src/auth.js[27-52]
workers/learn-api/src/index.js[430-435]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`POST /api/delete` deletes learner data but only revokes the *current* session (`revoked:<sid>`). Any other unexpired session tokens for the same `uid` remain valid and can continue to call `requireAuth`-gated endpoints (e.g., `/api/me`, `/api/consent/accept`, `/api/delete`).

## Issue Context
Revocation is currently keyed only by `sid`, and session refresh (`/api/me`) can mint new tokens without revoking old ones, so multiple valid sessions per user are expected.

## Fix Focus Areas
- workers/learn-api/src/index.js[592-609]
- workers/learn-api/src/index.js[430-435]
- workers/learn-api/src/index.js[918-923]
- workers/learn-api/src/auth.js[27-52]

## Suggested fix
1. Add a per-user revocation marker in KV, e.g. `revoked_uid:<uid> = <epoch_seconds>` (or an incrementing `session_epoch`).
2. In `requireAuth`, after verifying the token signature/expiry, reject tokens whose `iat` is <= the stored `revoked_uid` time/epoch.
3. In `handleDelete`, write the per-user revocation marker (with TTL long enough to outlive any issued token), in addition to revoking the current `sid`.
4. Consider whether `/api/me` refresh should also propagate this model (it will automatically if it sets `iat` > revoked time).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Connect scans Dynamo table ✓ Resolved 🐞 Bug ➹ Performance
Description
The voice bridge’s concurrency gate uses a DynamoDB Scan to count active sessions, which still reads
every item in the table (including high-volume FRAME items) before filtering. This makes $connect
latency/cost scale with frame volume and can become a bottleneck even at low max concurrency.
Code

infra/voice_bridge/handler.py[R136-145]

+    def count_active(self, now: int) -> int:
+        # A Scan is O(table), which is fine *because* the concurrency cap
+        # keeps this table at a handful of rows by construction; a GSI would
+        # be added capacity for no measurable gain at cap=2.
+        result = self._table.scan(
+            Select="COUNT",
+            FilterExpression="SK = :meta AND expires_at > :now",
+            ExpressionAttributeValues={":meta": "METADATA", ":now": now},
+        )
+        return int(result.get("Count", 0))
Evidence
count_active is implemented as a Scan with a filter on SK, and the infra template explicitly
states the same table buffers inbound frames (thousands of writes per session), so the Scan will
traverse frame items too.

infra/voice_bridge/handler.py[123-145]
infra/template.yaml[314-322]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`DynamoSessionStore.count_active()` uses `table.scan(... FilterExpression='SK = :meta ...')`. Because sessions *and* frames share the same table, the Scan reads all items (frames included) and then filters, so the cost/latency of $connect grows with the number of frame items.

## Issue Context
The CloudFormation template documents that frames are the hot path with thousands of writes per session; those items live in the same table as the session metadata.

## Fix Focus Areas
- infra/voice_bridge/handler.py[136-145]
- infra/template.yaml[314-322]

## Suggested fix
Replace the Scan-based active-session count with a Query-able design, for example:
- Store metadata items under a constant partition key (e.g. `PK='ACTIVE'`, `SK=<connectionId>`) with TTL; then `count_active` can `query(Select='COUNT', KeyConditionExpression='PK = :pk')` and optionally filter by `expires_at`.
- Alternatively add a GSI that allows querying only metadata items (e.g., GSI partition on `SK` or a dedicated attribute like `kind='METADATA'`), then use Query on the index.
- Or maintain an atomic counter (with careful decrement on disconnect/TTL cleanup), though the PK approach is usually simplest.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Voice budget race condition ✓ Resolved 🐞 Bug ☼ Reliability
Description
Voice token minting checks the monthly cap using used from the learner state and then writes an
updated state blob, but the update is not atomic. Concurrent mint requests can both pass the cap
check and both write based on the same used value, allowing the cap to be exceeded.
Code

workers/learn-api/src/index.js[R709-735]

+  const maxSessionSeconds = positiveIntVar(
+    env.VOICE_MAX_SESSION_SECONDS,
+    DEFAULT_VOICE_MAX_SESSION_SECONDS,
+  );
+  const monthlyCap = positiveIntVar(
+    env.VOICE_MONTHLY_SECONDS_CAP,
+    DEFAULT_VOICE_MONTHLY_SECONDS_CAP,
+  );
+  const now = nowSeconds();
+  const month = voiceMonthKey(now);
+  const used = voiceSecondsUsed(learner, month);
+  if (used + maxSessionSeconds > monthlyCap) {
+    throw new HttpError(
+      429,
+      "voice_budget_exhausted",
+      "Your monthly voice allowance is used up.",
+      "The meter resets at the start of next month (UTC). Text tutoring is unaffected.",
+      { month, monthly_seconds_cap: monthlyCap, monthly_seconds_used: used },
+    );
+  }
+
+  const { token, payload } = await mintVoiceToken(env, session.uid, { now });
+  // Book AFTER the mint succeeded, BEFORE the token leaves the Worker — a
+  // failed write must not hand out unmetered tokens.
+  const secondsMinted = used + maxSessionSeconds;
+  await setLearnerVoiceUsage(env, session.uid, { month, seconds_minted: secondsMinted });
+
Evidence
handleVoiceToken enforces the cap using a previously read used value and then persists
seconds_minted by overwriting the state blob; there’s no transactional/conditional update tying
the check to the write.

workers/learn-api/src/index.js[709-735]
workers/learn-api/src/db.js[294-301]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The monthly voice budget is enforced via a read-then-write of `learners.state.voice_usage`. Two simultaneous `/api/voice/token` requests can observe the same `used` value and both succeed, exceeding `VOICE_MONTHLY_SECONDS_CAP`.

## Issue Context
`handleVoiceToken` computes `used` from the fetched learner row and writes the new total via `setLearnerVoiceUsage`, which re-reads and overwrites `state`.

## Fix Focus Areas
- workers/learn-api/src/index.js[709-735]
- workers/learn-api/src/db.js[294-301]

## Suggested fix
Make the cap check + increment atomic. Options:
1. Add a dedicated D1 table `voice_usage(github_user_id, month, seconds_minted, PRIMARY KEY(github_user_id, month))` and update with an atomic UPSERT/increment, e.g. `INSERT ... ON CONFLICT DO UPDATE SET seconds_minted = seconds_minted + ?` plus a guarded `WHERE` to enforce the cap.
2. Use optimistic concurrency on `learners.state` (compare-and-swap): update only if `state` (or `updated_at`) still matches what was read; on 0-row update, re-read and retry.
Either approach prevents concurrent mints from bypassing the cap.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread workers/learn-api/src/index.js
Comment thread infra/voice_bridge/handler.py
Comment thread workers/learn-api/src/index.js
…e 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
OriNachum and others added 7 commits July 11, 2026 18:41
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.
… 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
…oice 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
@sonarqubecloud

Copy link
Copy Markdown

@OriNachum OriNachum merged commit 7832b46 into main Jul 11, 2026
9 checks passed
@OriNachum OriNachum deleted the feat/consent-tutoring-uplift branch July 11, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Learn experience: role-based visibility + approved-user LLM tutoring (Bedrock Nova Sonic 2 / Nova Pro)

1 participant