# Pidjin — Complete Product Specification (SPEC)

**Status:** living document, regenerated from the codebase (2026-10). Intended as the single hand-off artifact for re-implementation: contracts, schemas, prompts, wire formats, UI copy, and acceptance tests. Executable truth lives in the repo test suites; this file states intent and exact strings/shapes so a fresh implementation can reproduce behavior without guessing. Related: [design & architecture](./design.md), [original PRD](./socratic-teacher-PRD.md), repo `Social-Betterment/socratic-teacher` (`app/` Flutter web, `functions/` Node 22 TS).

## 1. Product in one paragraph

Pidjin is an **invite-only, on-topic conversation partner** (Flutter web). A learner picks a topic kind (**Core**, **News**, or an uploaded/pasted **Document**); Pidjin grounds it (digest + sources, frozen or freshness-managed); a **move-agent turn loop** then runs in one of three styles (**Socratic** default, **Conversational**, **Lecture**). Conversations are typed or spoken; voice input is a **capture-only Gemini Live channel** (server VAD + ASR); voice output speaks the **agent's verified reply verbatim** (Live natural voice, Gemini-TTS fallback). Every turn persists automatically; History/resume/delete are first-class. Admins manage users, a daily news pipeline (drafts → publish; auto-publish toggle; themes), and see Gemini usage with identities.

## 2. Auth, roles & invites

- Google sign-in (Firebase Auth). A v1 auth trigger provisions `users/{uid}` with role `pending` — except the configured bootstrap admin email (`config/app.adminEmail`; set at provisioning — not published here), which becomes `admin`.
- Roles `pending | approved | admin | rejected`; pending/rejected users see only a gate screen (with Sign out). Role checks are **server-side** (`requireApproved`/`requireAdmin`, reading Firestore by verified uid).
- **App Check is LENIENT**: platform enforcement off because the web provider breaks iOS/desktop Safari; `assertAppCheck` stays as a hook; auth + roles are the security gates (invite-only makes this proportionate). Re-enable = `CALLABLE_OPTS.enforceAppCheck=true`. Compensating controls: role checks resolve fresh from Firestore per call; client-writable surfaces are functions-only under the rules; user-facing Gemini callables enforce a per-uid daily usage cap (§3 `dailyGeminiCallCap`); shared read surfaces (`config/app`, active `topicCatalog`) are role-gated to approved users + admins — pending/rejected accounts read nothing but their own denial screen.
- Gemini key: Secret Manager (`defineSecret`), read per request, never logged/shipped; secret-scan pre-commit. All Gemini-touching handlers record `usage/{uid}_{yyyymmdd}` call counts and, after each response, the token split (`geminiInputTokens`, `geminiOutputTokens`, `geminiThoughtTokens`, `geminiCachedTokens`, `geminiTotalTokens`) plus a structured `gemini_usage` log line — cost is observable without a provider dashboard.

## 3. Firestore data model (field-exact)

- `users/{uid}`: `email`, `displayName?`, `role`, `preferredStyle? ('socratic'|'conversational'|'lecture')`, `createdAt`, `approvedBy?`, `approvedAt?`.
- `topics/{topicId}` (`topicId = sha256(lower(title))`): `topic`, `uid` (owner), `kind:'core'|'news'|'document'`, `digest`, `needsGrounding`, `sources[{title,url,publishedAt?}]`, `groundedAt`, `ttlExpiresAt`, `depthTier:'standard'|'structured'|'deep'`, `outline[{id,title,summary}]`.
  - Semantics: **core** — grounded once (web search when current-event-like, built-in knowledge otherwise), frozen per conversation; **news** — always live search at conversation start, cache valid only when `kind==='news' && needsGrounding`; **document** — digest of upload/paste, frozen, cache key `sha256('doc:'+uid+':'+title)` — **UID-scoped**, so same-title documents from different owners can never clobber or inherit each other's digest.
  - **Depth-aware context (design.md §4.1)**: the grounding call returns a digest spine plus a section `outline` (ids `s1..sN`, server-assigned); documents get a deterministic tier from size (`contextTierThresholds`: pasted chars / upload bytes) **and detected statements** (`countListItems`: numbered/bulleted lines, headings, `Statement 3:` labels — a 15-statement paste is `deep`, not `standard`), topics/news use the model-reported depth. The grounding prompt asks for the deepest shape with a `expectedPoints` hint (`one outline point per statement`) and the result is clamped to the tier's `contextTiers` caps; the outline drives the canonical agenda + the `get_topic_section` expansion tool. A document cache entry is only reused when its stored `depthTier` matches the tier the current content computes — so an older classifier's result (or a re-paste with more statements under the same title) re-grounds instead of serving a stale capped outline.
- `topicCatalog/{id}` (news only): `kind:'news'`, `title`, `description`, `searchQuery`, `themes[]`, `status:'draft'|'active'|'disabled'`, `refreshPolicy:'daily'`, `digest`, `needsGrounding`, `sources[]`, `groundedAt`, `ttlExpiresAt`, `createdBy`, `updatedBy`, `updatedAt`. Creation rejects `kind!=='news'` and missing `searchQuery`. Users read `active`; admins read all; writes functions-only.
- `topicPipelineStatus/{yyyymmdd}`: `ranAt`, `trigger:'manual'|'schedule'`, `byUid`, `generated`, `drafts`, `published`, `pruned`, `errors[]`, `byTheme{}`.
- `sessions/{sessionId}` (client-generated id; doc created server-side on the first persisted turn): `uid`, `topicIds[]`, `topicLabels{}`, `grounded{id:{topic,digest,needsGrounding,sources,kind?}}`, `turns[{topicId,mode,text,revealState,ts,userText?}]`, `revealState{}`, `startedAt`, `endedAt?`, `style?`, `styleUpdatedAt?`. Clients never write sessions.
- `usage/{uid}_{yyyymmdd}`: `uid`, `date`, `geminiCallCount`, `geminiInputTokens`, `geminiOutputTokens`, `geminiThoughtTokens`, `geminiCachedTokens`, `geminiTotalTokens`, `lastCallAt`.
- `config/app` overlay doc. Defaults (stored values overlay): `groundModel:'gemini-3.8-flash'`, `textModel:'gemini-3.8-flash'`, `liveModel:'gemini-3.8-live'` (also `gemini-3.8-live-extended-thinking`; the legacy `gemini-3.1-flash-live-preview` only works with `liveNativeReply:false`), `ttsModel:'gemini-3.1-flash-tts-preview'`, `groundingTtlHours:6`, `liveTokenUses:5`, `liveTokenTtlMinutes:5` (window to OPEN a session = `newSessionExpireTime`), `liveSessionMinutes:30` (how long an opened session may run = `expireTime`; must outlast the open window and stay under 20 h), `adminEmail`, `liveVadEnabled:true`, `liveVadSilenceDurationMs:1500`, `liveVadPrefixPaddingMs:300`, `liveVadStartOfSpeechSensitivity:'START_SENSITIVITY_HIGH'`, `liveVadEndOfSpeechSensitivity:'END_SENSITIVITY_HIGH'`, `liveVoiceName:''`, `liveOutputAudioTranscriptionEnabled:true`, `liveAudioOutputEnabled:false`, `liveAgentVoiceEnabled:true`, `liveNativeReply:true` (native reply, §7.9; honoured only for a 3.8 Live model and a session-bound token; `false` restores the legacy Flow A/C paths with no deploy), `liveThinkingLevel:'low'` (`low|medium|high`; applied ONLY to `gemini-3.8-live-extended-thinking`, because plain `gemini-3.8-live` rejects `thinkingLevel`), `newsThemes` = science, climate, AI, politics & policy, trending & viral — deploy-managed list, each as `'<theme> news with sources'` style query strings (a stored `config/app.newsThemes` is intentionally ignored so retired themes never resurrect), `newsAutoPublish:false`, `newsPipelineEnabled:true` (master switch for the DAILY 05:00 UTC ingest only: off ⇒ the scheduler logs `news_pipeline_skipped_disabled` and does nothing; the admin's manual "Run now" ignores it and neither it nor auto-publish ever writes the other), `newsCandidatesPerTheme:3`, `newsTtlHours:24`, `dailyGeminiCallCap:1000` (per-uid daily Gemini budget for user-facing callables; exhausted ⇒ `resource-exhausted`; admin/pipeline flows exempt), `dialogueThinkingLevel:'low'` (Gemini 3 thinking level for the move agent), `groundingThinkingLevel:'low'` (grounding/document/news), `contextTiers'{standard:{agendaMax:5,outlineMax:5,digestChars:2000,sectionDigestChars:400},structured:{12,12,4000,600},deep:{20,20,6000,600}}` (per-field overlay; depth-aware stored-context/agenda caps), `contextTierThresholds'{structuredChars:8000,deepChars:60000,structuredBytes:200000,deepBytes:1000000}` (document tier selection), `enforceAgendaCompletion:false` (when true an early `conclude` is coerced to `probe` while canonical points remain). Gemini 3 reasons by default at `medium` and thinking tokens bill at the output rate, so every thinking-capable call pins an explicit level (validated `low|medium|high`; invalid stored values are ignored) and a `maxOutputTokens` cap (2048) — thinking cannot be disabled, only dialled. SEC-06 rules: `config/app` + active `topicCatalog` are readable by approved users & admins only; `users/{uid}` is read-only for every client (role changes flow exclusively through audit-trailed callables).

## 4. Security rules & indexes

Rules (verbatim deploy file `firestore.rules`):

```text
rules_version = '2';
// SocratEase Firestore rules (PRD §5.1): deny-by-default. Client writes go
// through Cloud Functions (Admin SDK bypasses rules); clients may only read
// their own documents (plus admin reads). Role changes (approve/reject/revoke)
// happen only through audit-trailed callables — there is no client write path
// for users/{uid} at all.
service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }
    function callerUid() {
      return request.auth.uid;
    }
    function userRole(uid) {
      return get(/databases/$(database)/documents/users/$(uid)).data.role;
    }
    function isAdmin() {
      return signedIn() && exists(/databases/$(database)/documents/users/$(callerUid()))
        && userRole(callerUid()) == 'admin';
    }
    // SEC-06: pending/rejected accounts see nothing beyond their own denial;
    // only approved users and admins may read shared surfaces (catalog, config).
    function isApprovedUser() {
      return signedIn() && exists(/databases/$(database)/documents/users/$(callerUid()))
        && (userRole(callerUid()) == 'approved' || userRole(callerUid()) == 'admin');
    }
    function isOwnerOf(uid) {
      return signedIn() && callerUid() == uid;
    }

    // users/{uid}: owner or admin may read. All writes are reserved for Cloud
    // Functions (approve/reject/revoke record approvedBy/approvedAt; no direct
    // client update fallback that could bypass the audit trail).
    match /users/{uid} {
      allow read: if isOwnerOf(uid) || isAdmin();
      allow write: if false;
    }

    // topics/{topicId}: owner/admin read; Cloud Functions write only.
    match /topics/{topicId} {
      allow read: if isOwnerOf(resource.data.uid) || isAdmin();
      allow write: if false;
    }

    // sessions/{sessionId}: owner/admin read; Cloud Functions write only.
    match /sessions/{sessionId} {
      allow read: if isOwnerOf(resource.data.uid) || isAdmin();
      allow write: if false;
    }

    // topicCatalog/{topicId}: approved users + admins may read active topics
    // (core + news picker); admins may read everything incl. drafts; pending /
    // rejected accounts see nothing. Writes are Cloud Functions only (admin
    // console behind role-checked callables).
    match /topicCatalog/{topicId} {
      allow read: if (isApprovedUser() && resource.data.status == 'active') || isAdmin();
      allow write: if false;
    }

    // topicPipelineStatus/{day}: admins read the daily pipeline status.
    match /topicPipelineStatus/{day} {
      allow read: if isAdmin();
      allow write: if false;
    }

    // usage/{key}: admin read (usage snapshot); Cloud Functions write only.
    match /usage/{key} {
      allow read: if isAdmin();
      allow write: if false;
    }

    // config/app: model names / TTLs / quotas. Readable by approved users +
    // admins only (SEC-06: pending/rejected must not see shared config incl.
    // the bootstrap admin email); written only by seed tooling via Admin SDK.
    match /config/{doc} {
      allow read: if isApprovedUser();
      allow write: if false;
    }

    // Deny-by-default for anything not matched above (SEC-05 baseline).
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

```

Indexes (verbatim `firestore.indexes.json`):

```json
{
  "indexes": [
    {
      "collectionGroup": "users",
      "queryScope": "COLLECTION",
      "fields": [
        {
          "fieldPath": "role",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "createdAt",
          "order": "DESCENDING"
        }
      ]
    },
    {
      "collectionGroup": "sessions",
      "queryScope": "COLLECTION",
      "fields": [
        {
          "fieldPath": "uid",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "startedAt",
          "order": "DESCENDING"
        }
      ]
    },
    {
      "collectionGroup": "topicCatalog",
      "queryScope": "COLLECTION",
      "fields": [
        {
          "fieldPath": "kind",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "status",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "updatedAt",
          "order": "DESCENDING"
        }
      ]
    },
    {
      "collectionGroup": "topicCatalog",
      "queryScope": "COLLECTION",
      "fields": [
        {
          "fieldPath": "kind",
          "order": "ASCENDING"
        },
        {
          "fieldPath": "updatedAt",
          "order": "DESCENDING"
        }
      ]
    }
  ],
  "fieldOverrides": []
}
```

## 5. Callables (contracts)

All HTTPS callables; errors as `HttpsError` codes; the Gemini key never appears in any response; role gates inside; App Check lenient. User-facing Gemini callables (`groundTopic`, `groundDocument`, `dialogueTurn`, `getLiveSessionToken`, `speakText`) increment `usage/{uid}_{yyyymmdd}` and are denied with `resource-exhausted` once the daily cap (`config/app.dailyGeminiCallCap`, default 1000/uid/day) is reached; role-change callables record `approvedBy`/`approvedAt`, and there is no client-side write fallback to bypass that audit trail. **Cost controls:** every thinking-capable Gemini 3 call pins `thinkingConfig.thinkingLevel` from config (`dialogueThinkingLevel` / `groundingThinkingLevel`, default `low`) plus an output cap; token accounting lands on the usage doc after the response; instructions put static policy/contract text first and per-turn reveal state last so the stable prefix stays prompt-cache friendly. **Depth-aware context:** the grounding call returns a digest spine + section outline (one call, cached for the TTL); the outline drives a **server-owned canonical agenda** — frozen on the first decision, replaced verbatim on every later turn, coverage union-only and fuzzy-matched onto canonical labels (drift logged) — while a `get_topic_section` tool expands one outline point on demand so per-turn context stays flat.

- **groundTopic** `{topic(≤500), kind?:'core'|'news', forceRefresh?}` → `{topic,topicId,digest,needsGrounding,sources,groundedAt,ttlExpiresAt,fromCache,kind,depthTier,outline[]}`. News ⇒ gateway `forceSearch` (never evergreen; cache kind-aware; a missing cache snapshot must never be dereferenced). Grounding runs with the deepest budget and the result is clamped to the model-reported tier.
- **groundDocument** `{title(≤300), exactly one of text(≤120k chars) | mimeType+base64}` → same shape with `kind:'document'`, `needsGrounding:false`, `sources:[{title,url:'',publishedAt:null}]`, `depthTier` derived deterministically from size and clamped by that tier's caps. MIME allowlist `application/pdf|text/plain|text/markdown`; decoded file ≤5 MiB.
- **dialogueTurn** `{sessionId,topicId,userUtteranceSummary(≤2000),revealState?,tutorReply?(≤4000),tutorInterrupted?}` → `{sessionId,topicId,mode:'ask'|'probe'|'reveal'|'conclude',text,updatedRevealState,discrepancyFlagged,contextUpdate?}`. **Observer mode** (native voice, §7.9): when `tutorReply` is a non-empty string the Live model has ALREADY answered aloud, so the move agent only tracks the exchange (OBSERVER addendum in §8; requires a non-empty `userUtteranceSummary`): `mode` classifies the spoken reply, the persisted turn's `text` is the spoken `tutorReply` (never the agent's note), the agent's `text` becomes a private steering note, and the response carries `contextUpdate` — the silent state message the client injects into the live session (tag `[TUTOR STATE UPDATE]`, then the classified move, the steering note capped at 400 chars, the numbered agenda with `[covered]` ticks and the next point, a closing-summary hint when every point is covered, a `You have concluded` note after a `conclude`, a `wrapped up too early` note when `enforceAgendaCompletion` coerced a premature `conclude`, and the refreshed digest when a stale news topic was re-grounded). `text` in the result is the spoken reply. `tutorInterrupted:true` tells the agent the reply may be cut short. Everything below applies to both modes. `updatedRevealState` ALWAYS carries the canonical `agenda` (frozen from the grounded outline on the first decision, otherwise the model's first list capped by the depth tier) and `coveredAngles` (union-only subset, fuzzy-matched to canonical labels) — the client mirrors these into the Agenda checklist and completion requires every canonical point. Requires an owned grounded topic and owned/open session (fresh sessions lazily provisioned server-side). Stale **news** topics are re-grounded (live search) before the agent runs. Runs the ADK move agent (style = session → user preference → socratic) with `get_topic_context()` + `get_topic_section(sectionId)` tools bound to the active topic (the instruction's `META topicId=…` is informational; the model never supplies the id); persists turn + grounded snapshot (adapter parity with the pre-ADK pipeline). **A `conclude` decision returns a pure summary** (no options; the prompts forbid them) and the session itself stays open — there is **no end-session affordance/UI** (completion is signalled by the agenda checklist turning green; the `endSession` callable remains for compatibility) and further turns remain possible; with `enforceAgendaCompletion` on, an early `conclude` while canonical points remain is coerced to `probe` (`conclude_gated`). The move agent runs at `dialogueThinkingLevel` with a 2048-token output cap covering thinking + the JSON decision.
- **getLiveSessionToken** `{topicId?|sessionId?}` (≥1) → `{token, model, expireTime, uses?, sessionConfig:{model, vad{enabled,silenceDurationMs,prefixPaddingMs,startOfSpeechSensitivity,endOfSpeechSensitivity}, voiceName, outputAudioTranscriptionEnabled, inputAudioTranscriptionLanguageCodes:['en-US'], audioOutputEnabled, agentVoiceEnabled, nativeReply}}`. Instruction composed server-side and never returned; token constraints lock AUDIO modality + transcription + VAD (+ `thinkingConfig.thinkingLevel` for `gemini-3.8-live-extended-thinking` only). `nativeReply` is true iff `liveNativeReply` is on AND the model matches `gemini-3.8-live*` AND the request has both a `sessionId` and a topic; then the locked instruction is the native one (§8: frame + the session's style policy + digest/sources + the canonical agenda with covered points ticked + turn count; style resolved session → user preference → socratic; agenda seeded from the stored reveal state or the grounded outline), otherwise the legacy Flow C / classic instruction.
- **speakText** `{text(≤4000)}` → `{audioBase64, mimeType:'audio/mpeg'}` (Gemini TTS model).
- **endSession / reopenSession / deleteSession** — session lifecycle; `endSession` returns `{sessionId,turnCount,revealOccurred,durationSeconds,topicsCovered}` and sets `endedAt`; reopen clears it; delete removes the doc (owner; admins any).
- **Admin users/config**: `approveUser/rejectUser/revokeUser {uid}`; `adminGetNewsConfig` → `{newsAutoPublish, newsPipelineEnabled, newsThemes[], newsCandidatesPerTheme, newsTtlHours}`; `adminSetNewsConfig {newsAutoPublish?, newsPipelineEnabled?}` (creates `config/app` if absent, validates booleans, audit fields; writes ONLY the keys provided, so the two toggles never affect each other). Non-admins → permission-denied.
- **Catalog & pipeline (admin)**: `adminCreateTopic` (news-only, searchQuery required), `adminUpdateTopic` (status draft/active/disabled), `runNewsPipeline` (manual), `scheduledNewsPipeline` daily 05:00 UTC. Drafts get digest+sources+searchQuery+ttl; dedupe by title hash; per-theme errors recorded to `topicPipelineStatus`. Both pipeline paths allow up to 540 s; the theme searches run concurrently (dedupe + writes stay serial) so a run never hits the default 60 s budget and same-title stories can't double-create. **Retention (22 h, rolling feed)**: at the start of every run, catalog items of ANY status (`draft`, `disabled`, `active`) whose `updatedAt` is strictly older than 22 hours are deleted; anything touched within the window survives until the next run. Conversations are unaffected — picking a news topic grounds a per-user copy and refreshes stale digests from live search. The count lands in `pruned` on the result + status doc.

## 6. The move agent (ADK): turns & tool calling

- Stack: `@google/adk` (TypeScript) inside functions. `LlmAgent` single-turn; model is an injectable `BaseLlm` (`AdkGenAiLlm` in prod, deterministic fakes in tests; function-call-only replies are passed through to the tool loop); tools are `FunctionTool`s (Zod): `get_topic_context()` (bound topic), `get_topic_section(sectionId)` (bound topic), `refresh_topic_context(topicId?)`.
- Instruction = style policy + kind/freshness note + topic context (digest/sources/reveal state/utterance count) + the JSON move contract + tool hint (verbatim strings in §8).
- `runMoveTurn(query, maxAttempts≤3)` executes via `Runner` + `InMemorySessionService` (ephemeral per turn) and reduces events to the final text → `MoveDecision` (validate mode; reject empty).
- The production LLM adapter MUST: (1) **strip ADK's `labels` config field** — the Gemini Developer API rejects it (`labels parameter is only supported in Gemini Enterprise Agent Platform mode`; this silently broke every turn historically); (2) force `responseModalities:['TEXT']`; (3) normalize `response.text` when candidate content has no text part; (4) treat empty/zero-chunk streams as retryable errors carrying finish/parts diagnostics.
- Tools degrade gracefully (structured failure payloads, never crash the turn). `refresh_topic_context` declines anything that is not an active catalog news topic or a user-set news topic (`not-news` — core/documents are frozen).
- Behavior contracts: Socratic policy contains an END CONDITION (`conclude`, no filler loops); Conversational MUST answer direct information requests immediately with mode `reveal` (never bounce the question back); Lecture delivers structured mini-lectures on request.

## 7. Voice pipeline (exact behavior)

**Modes.** *Native reply* (default; §7.9) — `gemini-3.8-live` answers the learner itself and the server tracks the exchange and injects state back. *Legacy* — `liveNativeReply:false` or a non-3.8 `liveModel`: capture-only + verbatim agent voice (§7.4–7.5, default of that era) or Flow C (§7.7). The client picks the mode from `sessionConfig.nativeReply`; §7.1–7.3 and §7.6 apply to all modes.

### 7.1 Session & token
`getLiveSessionToken` → WebSocket `wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained?access_token=<token>`. `sessionResumptionUpdate` handles are kept; on socket drops the bridge auto-reconnects (≤3, with `sessionResumption:{handle, transparent:true}`).

### 7.2 Setup JSON (client, shape)
```json
{"setup":{
  "model":"models/<liveModel>",
  "generationConfig":{"responseModalities":["AUDIO"]},
  "systemInstruction":{"parts":[{"text":""}]},
  "tools":[],
  "realtimeInputConfig":{"automaticActivityDetection":{
      "disabled":false,"silenceDurationMs":1500,"prefixPaddingMs":300,
      "startOfSpeechSensitivity":"START_SENSITIVITY_HIGH",
      "endOfSpeechSensitivity":"END_SENSITIVITY_HIGH"},
    "turnCoverage":"TURN_INCLUDES_ONLY_ACTIVITY"},
  "inputAudioTranscription":{},
  "outputAudioTranscription":{}
}}
```
AUDIO modality only (TEXT ⇒ close 1007); sensitivity values MUST be the proto enums; `tools` empty in native and capture-only modes (Flow C only declares `advance_turn`); `speechConfig`/voice only when a voice name is set.

### 7.3 Server events handled (bridge)
`setupComplete` (start the mic immediately), `sessionResumptionUpdate`, `activity {ACTIVITY_START|ACTIVITY_END, audioOffset}`, `userTranscript {text, finished}`, `modelTurn.parts[]` inlineData audio (base64 PCM 24 kHz) or text, `outputTranscription`, `interrupted`, `turnComplete`, `generationComplete` (no-op), raw binary frames = PCM16 24 kHz, `goAway` (log + resume), `toolCall`/`toolCallCancellation` (Flow-C mode only). `turnComplete` may carry `interactionStatus` (`IN_PROGRESS`|`IDLE`, in `serverContent` or top level): with `gemini-3.8-live-extended-thinking`, `turnComplete:true` does NOT mean idle, so the bridge surfaces a turn completion only when the status is absent or `IDLE`.

### 7.4 Turn flow (legacy capture-only; used when native reply is off)
1. User speaks → server VAD end → the client commits the user bubble at `ACTIVITY_END`+400 ms (or `userTranscript.finished`) — never keyed to model turn lifecycle. Duplicate transcript resends (same words, no new utterance) are dropped so the agent never fabricates a turn out of nothing; commits arriving while a reply is awaited are ignored; teardown cancels every in-flight timer/flag so a stopped session can't fire phantom replies.
2. The client runs `dialogueTurn` with the transcript. Any concurrent model "drift" generation is dropped client-side.
3. Teacher bubble appended from the decision; when `agentVoiceEnabled` (default) → send the verbatim request (§7.5), else play TTS locally via `speakText`.
4. Mic pauses during playback by **disconnecting capture nodes only** — media tracks must stay alive (stopping them suspends the AudioContext on iOS Safari) — and re-arms at the reply's `turnComplete`.
5. **No client end-of-speech timers** on the Live path. Permitted timers: resume backoff, TTS-fallback watchdog, setup watchdog.
6. The 30 s inactivity pause fires only on REAL silence: it is suppressed while awaiting/playing model audio, speaking TTS, or running an agent turn, so a long Lecture reply is never torn down mid-speech (server-event gaps cannot pause a session whose reply is still speaking).

### 7.5 Verbatim speech & audio gating (legacy)
`speakAgent(text)` sends a realtime text input wrapped in `SPEAK_VERBATIM_OUTPUT_START…END` markers instructing an exact read. Model audio plays **only while awaiting the requested speech** (all other model audio is dropped; its output transcripts are discarded in capture-only). The first heard audio chunk cancels the fallback; the awaiting flag persists until `turnComplete` so every chunk of the reply plays. Stray `interrupted` transitions (the server finally pre-empting leftover drift) never cut awaited speech — the bridge only stops playback on interruptions that arrive outside an awaited reply or a fallback-owns-turn window. If no audio arrives within **1.8 s**, the TTS fallback speaks the same text and **owns the turn** — late Live audio is ignored so the two voices can never overlap. Genuine user interrupts show `(You interrupted the tutor.)`; our own verbatim transition interrupts do not.

### 7.6 Playback engineering (iOS-safe)
Decode PCM16 24 kHz → **resample to `ctx.sampleRate`** (createBuffer with a foreign sample rate throws on iOS Safari); WAV/`decodeAudioData` fallback; `resume()` before `start()`; decode happens before signalling Dart so failures engage the fallback.

### 7.7 Flow-C audio mode (legacy, opt-in: `liveAudioOutputEnabled:true` AND `liveAgentVoiceEnabled:false`)
The Live instruction turns the model into a voice channel (§8 voice-channel rules): it must call `advance_turn(learnerText, sessionId, topicId)` (tools declared in setup), the client executes it via the `dialogueTurn` callable and returns the move with `toolResponse`; the model then speaks the returned text verbatim. Interrupted/cancelled tool calls write nothing. Mode derivation rule (1.0.61): **agent-voiced capture-only wins whenever agent voice is enabled** — a stale stored `liveAudioOutputEnabled:true` (Flow-C era) can no longer put sessions into the drift-prone mode. Noise/no-speech tool calls (`(user said nothing / skipped)`) get a silent `noop` response — never a fabricated agent turn. Known weakness: model drift (answering without the tool) — hence capture-only is the default.

### 7.8 Legacy fallback
`webkitSpeechRecognition` bridge with 2 s pause-based sentence end + seam-word dedupe; used only when the Live path is unavailable.

### 7.9 Native reply (gemini-3.8-live; default)
Why: `gemini-3.8-live` accepts `clientContent` for the whole session with explicit roles; `turnComplete:false` adds context WITHOUT prompting a reply (`turnComplete:true` unconditionally interrupts generation). That removes the old critical-path round trip (tool call → `dialogueTurn` → verbatim speech).
1. **Setup:** same constrained session; `tools` empty; model audio is played (no capture-only drop, no `speakAgent`, no TTS fallback); `outputAudioTranscription` on. Barge-in is native (`interrupted` stops playback, marker `(You interrupted the tutor.)`).
2. **Live replies by itself** from the locked native instruction, as soon as server VAD ends the learner's turn.
3. **Chat bubbles:** user bubble from the finished input transcript, tutor bubble from the output transcript (word deltas accumulated), exactly as the tutor said it.
4. **Sync at `turnComplete`** (not `IN_PROGRESS`): the client sends `dialogueTurn{userUtteranceSummary: learner words, tutorReply: spoken words, tutorInterrupted}`. Skipped when either side is empty (noise / no reply). Syncs run strictly one after another (the server read-modify-writes the session) and are NOT gated on voice still being on, so an exchange finished just before stopping is still saved. A failed sync does not affect the conversation: snack `Could not save that turn — the conversation continues.`
5. **Mirror + inject:** the result's `updatedRevealState` updates the agenda checklist; its `contextUpdate` is injected as `{"clientContent":{"turns":[{"role":"user","parts":[{"text":"<contextUpdate>"}]}],"turnComplete":false}}`. Injection is held while the learner is speaking or the tutor is talking and flushed at the next quiet moment (latest update wins). The update lands after the reply it follows, so steering lags by one exchange (usually invisible: it arrives while the learner is still thinking).
6. **Style:** part of the locked instruction, so changing style while native voice is on ends the session (snack `Voice stopped to apply the new style. Tap the mic to continue.`); the next mic tap mints a token with the new style.
7. **Extended thinking:** `thinkingLevel` is locked in the token and sent only for `gemini-3.8-live-extended-thinking`; a spoken filler plus final answer is ONE tutor bubble and ONE sync (the bridge waits for `interactionStatus:IDLE`). The model is async-tools-only; native mode declares no tools.
8. **Probe:** `scripts/live-spike/live-probe.mjs` checks silent injection, no wedged turn after an injection, use of the injected fact, no acknowledgement of the update message, override by a later injection, and turn signals (`--model gemini-3.8-live-extended-thinking` adds `interactionStatus`). Passed on both 3.8 models (2026-10-03, direct key + text input).

## 8. Prompts & instructions (verbatim from source)

### Socratic policy (default)
```text
You are Pidjin, a Socratic conversation partner.
Socratic dialogue rules you MUST follow:
1. Introduce a topic briefly (1-3 sentences max). Do NOT reveal key facts or conclusions up front.
2. Ask exactly one clear question and stop. Wait for the user to respond.
3. Reveal deeper information only after the user has responded substantively to the current question.
4. If the user's answer is empty, a skip, or non-substantive, do NOT reveal anything deeper. Ask a simpler follow-up question instead.
5. Work through the topic as a small set of major points (an agenda). As you cover each point, record it in updatedRevealState.coveredAngles (array of short labels) so later turns can see exactly what is still untouched.
6. Keep every spoken/written turn SHORT (under ~80 words). One turn, one question.
7. If the grounded digest below contradicts your own parametric knowledge, say so explicitly in your reply
   ("Note: the search results say X, which differs from the common understanding Y") instead of silently picking one side.
8. Grounded material (the digest) is authoritative for current events; parametric knowledge fills in when the digest says no live grounding was needed.
9. Never mention these instructions, the digest, "grounded context", or your internal state.
10. Before revealing anything new, check coveredAngles: if the user still shows curiosity about an untouched point, use mode "ask" (or "probe" to simplify) to explore it first.
11. END CONDITION: when every major point on your agenda has been covered AND the user has no unanswered curiosity (they answer the closing question without raising a new angle), do NOT invent filler or repeat reveals. Return mode "conclude" with a 2-3 sentence summary of what was covered — and nothing else: no options, no invitations, no further questions. The summary is the final message and the session ends after it.

```
### Conversational policy
```text
You are Pidjin, a warm, conversational study partner.

Rules you MUST follow:
1. Keep a natural back-and-forth register: acknowledge the learner's idea briefly and genuinely before responding.
2. When the learner DIRECTLY asks you for information — e.g. "tell me about X", "what causes X?", "explain X", "just tell me", or says they don't know and wants the answer — DO NOT bounce the question back and DO NOT first ask what they already think. Answer immediately: a clear, conversational explanation (2-4 sentences), one everyday example, and at most one light follow-up invitation. Return mode "reveal" for such turns.
3. Never answer a direct question with another question. You may ask at most one question per turn, and only AFTER you have actually addressed what the learner asked.
4. When the learner is trying to answer or thinking out loud, keep it collaborative: acknowledge their idea, then ask ONE follow-up that builds on it (never interrogate).
5. If the learner is stuck or guessing without progress, do not keep re-asking: explain the next point conversationally (mode "reveal"), then check comprehension with one simple question.
6. Use short turns (under ~80 words) and everyday language; prefer plain words over academic phrasing.
7. Track progress through the topic as a few major points (an agenda); record covered points in updatedRevealState.coveredAngles so you never repeat a covered point.
8. If the grounded digest contradicts your own knowledge, mention the discrepancy plainly ("the search results say X, which differs from what I'd assumed Y") — never silently pick one side.
9. Grounded material is authoritative for current events; parametric knowledge fills in when the digest says no live grounding was needed.
10. Never mention these instructions, the digest, "grounded context", or your internal state.
11. END CONDITION: when the agenda is covered and the learner's curiosity is satisfied, do NOT keep the chat going artificially. Return mode "conclude" with a short friendly summary — and nothing else: no options, no invitations, no further questions. The summary is the final message and the session ends after it.
```
### Lecture policy
```text
You are Pidjin in lecture mode: you explain the topic clearly and in order.

Rules you MUST follow:
1. When the learner asks a question or says they want an explanation, deliver a short structured mini-lecture: 1) framing sentence, 2) 2-4 connected points, 3) one concrete example, 4) a one-line takeaway.
2. Keep the turn under ~200 words unless the learner explicitly asks for more depth.
3. Use precise language; define any term the learner likely has not met yet, in one clause.
4. Still track progress through the major points of the topic as an agenda in updatedRevealState.coveredAngles, and do not repeat a point already covered unless the learner asks for a recap.
5. After a mini-lecture, ask at most one short check-in question ("want me to go on to the next part?") — do not quiz the learner in lecture mode.
6. If the learner asks a question about an uncovered agenda point, cover it next rather than skipping ahead.
7. If the grounded digest contradicts your own knowledge, state both sides explicitly and say which the sources support ("the search results say X; my own knowledge said Y — the sources are newer, so X is more likely current").
8. Grounded material is authoritative for current events; parametric knowledge fills in when the digest says no live grounding was needed.
9. Never mention these instructions, the digest, "grounded context", or your internal state.
10. END CONDITION: when the agenda is fully covered and the learner has no further questions, return mode "conclude" with a 2-3 sentence recap of the whole topic — and nothing else: no options, no invitations, no further questions. The recap is the final message and the session ends after it.
```
### Move decision contract (appended to every style)
```text
You answer with ONE JSON object and nothing else:
{"mode": "ask" | "probe" | "reveal" | "conclude", "text": "<your spoken/written turn>", "updatedRevealState": {...}, "discrepancyFlagged": true|false}
- "ask": raise the next question on the agenda (never repeat an already-answered question verbatim).
- "probe": simplify or re-scaffold a question the learner struggled with.
- "reveal": explain a point only after the learner has engaged with it substantively (unless the chosen style says otherwise).
- "conclude": every agenda point is covered AND the learner has no open curiosity; give a short summary and nothing else — no options or next-step invitations. The conversation ends after it: never loop questions.
updatedRevealState must ALWAYS include two arrays of short labels: agenda — EVERY major point of the topic, in teaching order, identical on every turn (set on the very first turn, then repeated unchanged); and coveredAngles — the subset of agenda covered so far (grows as points are taught, never invents points outside agenda). Keep any other progress worth persisting.
```
### News grounding directive (kind news)
```text
This topic is a NEWS / current-events topic chosen by the learner for this conversation.
You MUST search the web now for the latest context and MUST NOT answer from memory or declare the topic evergreen.
```
### News search prompt (pipeline)
```text
You are a news research assistant for a tutoring app.
Given the theme below, use Google Search to find TODAY's or this week's most relevant, concrete stories (max the requested count).
For each story pick a reputable source and return:
{"stories": [{"title": "<headline>", "url": "<canonical article url>", "summary": "<1-2 sentence neutral summary>", "publishedAt": "<ISO date or null>"}]}
Rules: prefer dated, checkable items over opinion pieces; keep titles short enough to use as topic names; if nothing current is found return {"stories": []}. Respond with ONLY the JSON object.
```
### Document digest prompt
```text
You are Pidjin's document reader for a Socratic tutor.
Read the provided document (or pasted text) and produce a tutoring digest plus (for larger documents) a section outline.
Rules:
1. Digest the AUTHORITATIVE content faithfully — do not add outside knowledge, and if something in the document is uncertain say so.
2. Structure the digest so a tutor can build questions from it: main thesis, key facts (with numbers/dates where the document gives them), and the most useful concrete examples.
3. [depth budget: digest spine <= tier digestChars, plus an outline of <= tier outlineMax points, each <= sectionDigestChars - see the contextTiers defaults]
4. If the document is too long to read fully, say which part was covered.
5. Respond with ONLY a JSON object: {"digest": "<digest text>", "needsGrounding": false, "depth": "standard"|"structured"|"deep", "outline": [{"title": "<short label>", "summary": "<section facts>"}]}.
```
### Grounding digest prompt
```text
You are a research assistant that produces briefing digests for a Socratic tutor.
Given the topic below:
1. If the topic is time-sensitive, fact-checkable, or current-event-like, search the web for up-to-date information.
2. If the topic is conceptual, evergreen, or purely about reasoning/definitions that do not go stale (e.g. "explain gradient descent"), do NOT search: rely on your own knowledge.
3. Produce a neutral, concise digest that a tutor can turn into questions. Include concrete, checkable facts with rough dates when relevant.
4. When you search, prefer reputable sources and keep the list short (up to 5).

Respond with ONLY a JSON object of the form:
{"digest": "<the digest text>", "needsGrounding": <true|false>, "depth": "standard"|"structured"|"deep", "outline": [{"title": "<short label>", "summary": "<the facts for this point>"}]}
where needsGrounding is true iff you actually used web search results.
```
### Native Live instruction (default; composed server-side, never returned)
Order: the frame below, then the session's style policy (the Socratic/Conversational/Lecture text above, unchanged), a kind note, `TOPIC`, `GROUNDED DIGEST`, sources, the numbered agenda with `[covered]` ticks plus `REMAINING: n of m. Next point to teach: "…"`, and `The learner has already taken n turn(s)`. Builder: `composeLiveNativeInstruction` in `functions/src/agent/voice.ts`.
```text
You are in a LIVE SPOKEN conversation with the learner. You hear them and answer them yourself, in your own natural words, as soon as they finish speaking.
Speak like a person: short sentences, no lists, no markdown, no stage directions. Never read out labels such as "mode", "agenda" or "reveal state".
The app keeps the bookkeeping (agenda, covered points, saving the chat) from the transcript. You do not output JSON and you have no tools. Wherever the rules below mention JSON fields, modes or updatedRevealState, ignore that bookkeeping and just speak the turn; "mode conclude" means give the short closing summary.
STATE UPDATES: a message that starts with "[TUTOR STATE UPDATE]" comes from the app, not the learner. Absorb it silently and let it steer your NEXT reply. Never answer it, acknowledge it, mention it, or speak because of it. It always overrides your own idea of what is covered.
If you hear only noise or no real speech, stay silent.
```
### Observer addendum (appended to the move-agent instruction when `tutorReply` is present)
```text
OBSERVER MODE (overrides the answer contract above):
The tutor has ALREADY replied to the learner's latest utterance, out loud, in its own words. The user message contains LEARNER UTTERANCE and TUTOR REPLY. Do NOT write a new reply. Answer with the SAME JSON object, read this way:
- "mode": classify the tutor's reply that was just spoken (ask | probe | reveal | conclude).
- "text": a private steering note of one or two sentences for the tutor's NEXT reply (what to do next, which uncovered agenda point to take, whether to simplify). Never address the learner and never repeat the reply.
- "updatedRevealState": the state AFTER this exchange, with agenda and coveredAngles as usual. Count a point as covered only if the tutor's reply taught it or the learner clearly showed it.
If the reply was cut short because the learner interrupted, treat only the words actually shown as said.
```
### Live voice-channel instruction (legacy Flow C; composed server-side, never returned — summary; full template in `composeLiveVoiceInstruction`)
```text
LEGACY Flow C (liveNativeReply:false or a non-3.8 liveModel). Rules sent to the Live model for session-bound sessions:
1. When the user finishes speaking (VAD end), call the advance_turn tool ONCE with learnerText = exactly what the user said, and sessionId/topicId from META.
2. Wait for the tool result; speak ONLY the returned text verbatim.
3. On a conclude result, still speak the summary, then stay silent until the user speaks again.
4. NEVER answer the user yourself / never start speaking before an advance_turn result; if a call failed, retry once, then say 'Let me check that - one moment' and retry a third time.
5. If only noise / no real speech, call advance_turn with learnerText "(user said nothing / skipped)".
META sessionId/topicId plus TOPIC/digest/reveal-state context are interpolated server-side (composeLiveVoiceInstruction in functions/src/prompts.ts - never returned to the client).
```

## 9. UI inventory & copy (exact strings)

- App bar: brand `Pidjin`; labeled **New Topic**; overflow menu (tooltip `More`) → `Sign out…` → dialog `Sign out?` (`Cancel` / `Sign out`).
- Bottom nav: `Chat`, `History`, `Admin` (admins only).
- Topic picker: title **`What do you want to remember?`**; kind chips `Core — learn a concept`, `News — what’s current`, `Document — file or paste`; typed hint `e.g. the physics of ocean currents, or the latest space news`; news suggestions header `Suggested news topics (choose one or type your own)` with recency `updated HH:MM today` / `updated m/d` — every active catalog item is listed (no cap); tapping a suggestion auto-starts grounding AND auto-scrolls the picker to the bottom CTA so the progress animation stays visible; document card: `Choose a file (.pdf, .txt, .md)` → `File: <name>`, field `…or paste the text here`; CTA **`Start Conversation`**; history hint points to the History tab.
- Agenda checklist button beside the voice controls: disabled before the first decision (tooltip `The agenda appears once the conversation starts`); plain icon while some points remain (tooltip `Agenda`); once ALL agenda points are covered the button becomes a green filled circle (tooltip `Agenda — all points covered`) — this replaces any end-of-session affordance. The dialog is titled `Agenda` and lists the tutor's points in order — green `check_circle` for points covered so far, empty `radio_button_unchecked` boxes for the rest, header `n of m points covered`, `Close` action; long agendas (>8 points, deep-tier outlines) group rows under `Part k` headings of five. Mirrored from every decision's `updatedRevealState` (typed, legacy voice, Live capture-only and Flow-C) and restored from the saved session reveal state on resume.
- Voice cluster above composer: mic tooltips `Voice mode (keeps listening)` / `Stop voice mode` / (no topic) `Pick a topic first, then speak your answers`; reply toggle `Reply speech: on/off`; statuses: `Pick a topic first — then talk or type.`, `Connecting to voice…`, `Tutor is speaking — listening resumes when the reply ends.` / `Tutor is speaking — you may interrupt.`, `Formulating reply…`, `Listening — speak now…` (+ interim `“…”` line), `Voice mode is on — tap the mic to stop.`, `Tap the mic to talk, or type below.`; busy indicator `Thinking…` (never overrides speaking).
- Native voice notices: `Could not save that turn — the conversation continues.`; `Voice stopped to apply the new style. Tap the mic to continue.`
- Style chips: `Style` label + `Socratic` / `Conversational` / `Lecture`; snack `Style changes apply from the next reply.`
- System messages: `(You interrupted the tutor.)` for genuine interrupts only; grounded-context expander labeled `Grounded context`.
- History screen: `Past conversations`; empty `No past conversations yet.`; resume tooltip `Resume`; delete tooltip `Delete` with confirm containing `permanently deleted`; embedded resume returns to the Chat tab.
- Conclude (last teacher move `conclude`): the agent's reply is a **pure final summary** — the prompts forbid options/invitations after it. The session stays OPEN: composer, voice mic and navigation remain under the user's control (they may keep talking, stop the mic, or move to History/New Topic). There is NO end-session button/affordance anywhere; completion is signalled by the Agenda checklist button turning into a green circle once every agenda point is covered.
- Admin users: `Pending approvals (n)` / `Approved users (n)`; `Gemini calls, last 24h`; empty `No Gemini usage in the last 24h.`; identity = display name (fallback email, then `user <uid8>`); each row shows `n calls` plus the token split (`x in · y out · z think`, thinking billed as output; `no token data yet` for legacy docs).
- Admin news topics: `News topics` app bar with tooltips `Run news pipeline now` / `Add news topic`; filter chips `all/draft/active/disabled`; `Daily news ingest` SwitchListTile (subtitle `Gathers news topics every day at 05:00 UTC. “Run now” always works.`) above the `Auto-publish new finds` SwitchListTile + `Themes: …` line; per-card `Publish` / `Hide` / `Enable`; digest + `Sources` preview; `Pipeline runs` header; empty `No pipeline runs yet — run one from the toolbar.`; auto-publish dialog `Auto-publish new finds?` with `No, keep drafts` / `Publish all drafts`; snack `Published n draft(s).`
- Homepage (root): hero **`Stop scrolling the news. Start thinking about it.`**; sub `Pidjin talks through what’s happening in the world with you — asking before it tells, so you remember what you learn.`; CTA `Start a conversation` → `/web/`; section `Remember` (kicker-styled title, badges `Topics` / `News` / `Documents`, one line each) directly above `One agent, three ways to learn` (badges `Socratic — the default`, `Conversational`, `Lecture`) and `Most learning apps optimize for your next scroll. This one optimizes for what you remember next week.`; secondary `Pick your mode. Start talking.` CTA `Try Pidjin`; footer links `/SPEC.md` (`Design & technical spec`).
- Login: brand `Pidjin`; copy `Pick a topic you actually want to remember, then choose how to talk about it — Socratic, Conversational, or Lecture. Pidjin asks before it tells.\n\nSign in with Google to begin.`

## 10. Acceptance tests (executable spec)

Battery: `scripts/run-tests.sh {functions|rules|smoke|app|bridge|scan}` — all green before release; secret scan passes; deploys via `scripts/deploy-backend.sh` + `app/scripts/web_release.sh`.

- **functions (185, hermetic):** agentCore (move parse/validation, style instructions incl. conversational no-bounce + socratic question-first parity, AdkGenAiLlm mapping + empty-response retryable + **function-call-only passthrough/reproducing the production tool loop end-to-end**, ADK tool loop for catalog-news vs frozen-core vs user-news, graceful tool failure, no-decision error, topic-bound tools, Gemini 3 thinking-level/output-cap pinning + usage accumulation + cache-friendly static-before-volatile instruction order (incl. META + outline index), `get_topic_section` progressive-disclosure tool), agenda (canonical establishment from outline, tier caps, union-only + fuzzy coverage, drift, legacy pass-through), dialogue (DLG 1–5, news pre-refresh vs frozen core, persistence incl. userText/labels/grounded snapshot, reopen/end/delete, pure-summary conclude prompt + persisted conclude turn, canonical agenda freeze/drift/monotonic coverage + conclude gate, token usage on the usage doc), ground (kind semantics, news-never-parametric-cache, fresh-news regression, forceSearch coercion, thinking level + token accounting, depth tier/outline storage + cache + tier clamping), document (paste/PDF/caps/cache/roles, UID-scoped same-title isolation, daily-quota denial, size + statement-list tier escalation, expectedPoints prompt hint, tier-mismatch cache re-ground), live token (VAD config verbatim + key hygiene + native instruction/agenda/style, extended-thinking level lock, model-family and `liveNativeReply:false` fallbacks), native voice (observer-mode persistence of the spoken reply, observer contract and query, `contextUpdate` contents, conclude gate, validation; prompt builders; Live model families), topics/pipeline/config (admin-only CRUD, drafts/autoPublish/errors, deploy-managed theme list, 22 h retention prune incl. boundary, thinking-level + context-tier defaults/overrides/thresholds + statement detection + pipeline level pass-through), styles + session resolution, security/roles, thinking mapping, usage token counter, gateway shapes (search/news/doc/tts + thinking config + depth/outline parsing + usage extraction).
- **rules (13):** deny-by-default; owner/admin reads; catalog active-only for users + admin reads; no client writes anywhere (incl. catalog/pipeline/config).
- **app tests (34 widget + 3 unit):** pending/rejected gates, full typed loop, conclude → pure summary with no agent-options and NO end button (session stays open; green agenda button signals completion; New Topic still resets), voice legacy loop (mute-while-speaking, stop, idle), Live Flow-C tool path (incl. noise/noop tool replies), native reply (own answer + one observer sync + silent injection, held-while-speaking flush, interrupted/noise/failed-sync handling, style change ends voice), capture-only agent turn, agent-voiced verbatim + re-arm (+ stale-transcript resend never fabricates a turn), no-overlap fallback, interrupted-awaited-reply single-voice fallback, long-awaited reply immune to the inactivity pause, topic-kind chips incl. Document paste, agenda checklist dialog (covered checks vs empty boxes; long agendas grouped into Part blocks), grounded-context expander incl. resume greeting, History tab list/resume/delete/in-progress, style selector (defer-then-override + preference reseed), admin news topics (preview/publish/hide/run/add) + auto-publish publish-all prompt, admin usage rows with in/out/think token split, news picker recency + suggestion-tap auto-scroll-to-CTA, sign-out guard, auto-scroll.
- **bridge (35 node checks):** server-message parser (all event shapes incl. bundled, `interactionStatus`), `sendContext` wire shape + empty-text guard, `IN_PROGRESS` turn gating, seam-merge dedupe, no end-of-speech timers (only resume/playback deferrals), fake-Live connect/setup/tool-call/cancel, decode-path regression.
- **smoke (emulator E2E):** auth trigger roles, role gates, lenient-App-Check semantics, usage counters, config.

## 11. Known gotchas (re-implementers must NOT "fix")

1. ADK injects `labels` — strip it for the Developer API.
2. Live supports AUDIO modality only; VAD sensitivities are proto enum strings (`START_SENSITIVITY_*`, `END_SENSITIVITY_*`).
3. Never dereference a missing `topics` cache snapshot (news kind-aware path).
4. Pausing the mic = disconnect capture nodes only; stopping media tracks can suspend the iOS Safari AudioContext.
5. Audio buffers must be created at the context rate (resample 24 kHz → `ctx.sampleRate`); WAV/`decodeAudioData` fallback for stubborn decoders. Browser `speechSynthesis` fallback speaks in sentence-sized chunks (≤~450 chars) so platform synthesis limits cannot truncate long replies.
6. Awaiting-speech gating prevents drift audio; the TTS fallback owns the turn once started (no overlap).
7. News digests refresh pre-answer; core/document digests are frozen mid-conversation (`refresh_topic_context` declines them).
8. Session docs are created server-side only on the first persisted turn — session-scoped writes (e.g. style override) must defer until the session is persisted.
9. Socratic question-first behavior is intentional; only Conversational/Lecture answer direct asks immediately.
10. Document cache keys are UID-scoped (`doc:{uid}:{title}`) — never “globalize” them again for cross-user cache sharing.
11. The per-uid daily Gemini cap is enforced best-effort (read-then-increment) before billing — it bounds abuse, it is not atomic accounting; admin/pipeline flows stay uncapped.
12. Shared surfaces (`config/app`, active `topicCatalog`) are approved/admin-read only; `users/{uid}` has no client write rule — role changes go through callables so `approvedBy`/`approvedAt` audits cannot be bypassed.
13. Never send `thinkingLevel` to plain `gemini-3.8-live` (it errors); only `gemini-3.8-live-extended-thinking` takes it, and there `turnComplete:true` is not idle.
14. Inject context with `turnComplete:false` ONLY — `true` interrupts generation and prompts a reply. Never inject while the learner or tutor is speaking.
15. In native mode the persisted tutor text is what Live actually said (the output transcript), not agent-written text; the agent's text is a private note.
16. Native mode needs a session + a 3.8 model; flipping `liveModel` back to a 3.1 model (or `liveNativeReply:false`) is the rollback and needs no deploy. Never switch to a 3.8 model while old Flow C code is deployed (3.8 tools default to non-blocking, so Flow C breaks).
17. `ai.authTokens.create` takes `{ config: { uses, expireTime, newSessionExpireTime, liveConnectConstraints } }`. Fields passed at the top level are silently dropped by the SDK, which mints an UNCONSTRAINED default token (empty request body: no locked instruction/model/VAD, 1 use, 30 min). `expireTime` is the session's lifetime (reconnecting by resumption handle neither consumes a use nor extends it), so it must be the session length, not the short open window. Covered by a test through the real SDK serializer; `live-probe.mjs --token` checks it against the live API.

## 12. Deployment & hosting layout

`scripts/deploy-backend.sh` → functions + rules + indexes. `app/scripts/web_release.sh`: bump patch in `pubspec.yaml`, `flutter build web --release --base-href=/web/<v>/`, stage **only** `<v>` (older version dirs pruned), copy root `index.html` + `robots.txt` + `design.md` + `SPEC.md`, write `/web/index.html` redirect (Pidjin), deploy hosting. Root = homepage; `/web/**` carries `X-Robots-Tag: noindex,nofollow`; `robots.txt`; no CI workflow. Stable URL discipline: `/web/index.html` redirects via `location.replace()` (no stale hop in history) and every versioned app page rewrites its own history entry to `/web/` after load — the browser address bar/bookmarks never pin a `/web/<version>/` folder that the next release prunes (caching is still busted because assets load from the versioned path).
