OmniTutor
▸ contracts & schema v2 · perpetual reference · all phases consume this

Contracts & schema · v2

The stable rules of the system. Locked once in P0, consumed by every phase after. If anything here changes, every later phase pays a rewrite cost — so we don't change it casually. For the P0-specific work that produced this doc (the isolation cutover + acceptance gate), see p0_lld.html.
▸ axiom
OmniTutor is a fully isolated stack. omnitutor service · omnitutor database · omnitutor-assets bucket. Zero shared state with Canvas A · Physolympiad · DhyanHQ · DreamBook · or any other agent on this infra.
▸ schema v2 · extensibility pass · 2026-05-10

v1 was right for M1. v2 adds the seams that, if added later, would force migrations: multi-tenancy · canonical subjects/topics · plan revision history · asset dedup · idempotency · soft delete. Plus M2 stubs (subscriptions · mastery_records) so the FKs land cleanly when M2 starts.

▸ M1-critical additions
  • users.org_id nullable · multi-tenancy seam
  • subjects + topics · canonical · FK from lessons
  • plan_revisions · history of plan iterate / adjust
  • assets.content_hash unique · TTS dedup
  • idempotency_keys · client retries on SSE reconnect
  • deleted_at on user-data tables · soft delete
▸ M2 stubs · created empty
  • subscriptions · Stripe foundation
  • mastery_records · spaced repetition foundation
▸ M3 deferred
  • paths · path_lessons · user_path_progress · notifications · i18n locale

1.Tech stack

LayerChoiceWhy this
BackendFastAPI · Python 3.12Matches Canvas A pattern · fast iteration · async streaming · type-checked with pydantic
FrontendVanilla HTML/CSS/JSv12 mockup is already vanilla · zero build-step pain in M1 · React migration deferred to M2 if needed
DatabasePostgres 16Local on devbox for M1 · RDS in M2 · ULID primary keys · JSONB for content blobs
CachePostgres tablesSame DB · single connection pool · upgrade to Redis only when latency demands it
Object storeS3 · own bucketTTS audio · generated diagrams · Make-me artifacts · CDN via CloudFront
StreamingServer-Sent EventsSimpler than WebSockets · works through every proxy · one-way is all we need
LLMAnthropic APIHaiku 4.5 (first response) · Sonnet 4.6 (streaming beats) · Opus 4.7 (hard derivations only)
TTSElevenLabs streaming~200ms TTFB · streamed bytes drive owl mouth lip-sync amplitude
AuthAnonymous + CookieM1: anon session token in HttpOnly cookie · email-only "save my work" optional · full Google sign-in in M2
HostingEC2 (devbox-1) → ECS laterM1 on existing devbox · M2 moves behind ALB + ECS Fargate
Domainomnitutor.aiAlready owned · MerakiLabs Cloudflare zone · own A-record
DeployGitHub Actions → SSH → systemdOwn pipeline · zero coupling to other agents' deploys
Loggingstructlog → CloudWatchJSON logs · trace_id per request · sampled to S3 for replay

2.Data model

Minimal entity set for M1. Every entity has a ULID primary key (sortable, time-encoded) and created_at/updated_at timestamps.

EntityOwnsReferencesNotes
useranon_id · email? · settings_jsonAnonymous-by-default in M1. Email optional (for "save my work"). Full auth in M2.
sessionstarted_at · ended_at · client_meta_jsonuserA browser sitting at the page. Closes on tab-close or 30 min idle.
lessonsubject · topic · status · intent_json · plan_json · level_at_startsession, userOne lesson = one Plan modal lock + everything that follows. Status: draft · active · complete · abandoned.
beatorder · kind · content_json · narration_text · audio_url · viz_urllessonThe atom. Kind: concept · derivation · problem · test · free.
interactiontype · payload_json · response_json · tslesson, beatAnything the student does mid-lesson: ask, hint, adjust, visualize, animate, attempt, advance, etc.
cachekey (hash) · model · payload_json · hits · ttl_atModel output cache. Key = hash(model + topic + intent_signature + level + …).
assettype · url · mime · size_byteslesson?, beat?TTS clips · diagrams · Make-me outputs (flashcards/notes/slides/video). On S3.
eventtype · payload_json · tssession, lesson?Analytics stream. See §9 for type catalog.
model_runmodel · prompt_tokens · completion_tokens · cost_usd · latency_ms · statuslesson, beat, interactionOne row per LLM call. Cost · latency · errors tracked from day one. Drives daily spend cap (§10).
magic_linktoken · email · consumed_at · expires_atuserEmail-based auth for "save my work" in M1. 15-min TTL · single-use.
rate_bucketscope (ip|user|key) · key · count · window_startSliding-window rate limit counters. See §8.
subject [v2]code · name · archetypeCanonical subjects (physics · math · history · english · …). Lessons FK to here.
topic [v2]name · canonical (bool)subjectCanonical topics under a subject. canonical=false for user-typed free-form topics.
plan_revision [v2]revision_num · plan_json · triggered_by · feedback_textlessonHistory of plan iterations. triggered_by: initial | iterate | adjust. Lets students undo + we replay decisions.
idempotency_key [v2]key · request_hash · response_json · status_code · expires_atClient-supplied Idempotency-Key header. Prevents double-charging on SSE reconnect / network retry. 24h TTL.
subscription [v2 · M2 stub]plan_code · status · stripe_customer_id · stripe_sub_id · period_enduserEmpty in M1. M2 populates when Stripe lands. FK exists from day one so we don't migrate users later.
mastery_record [v2 · M2 stub]last_seen_at · score · attempts · correctuser, topicEmpty in M1. M2 spaced-repetition reads/writes here. Per (user_id, topic_id).

Modifications to v1 entities: users.org_id nullable · users.deleted_at nullable · lessons.subject_id + lessons.topic_id FK + lessons.deleted_at · beats.deleted_at · interactions.deleted_at · assets.content_hash + assets.deleted_at.

▸ schema_v0.sql · the Postgres DDL (abridged)

-- omnitutor schema · v0 · M1 minimum
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

CREATE TABLE users (
  id            TEXT PRIMARY KEY,           -- ULID
  anon_id       TEXT UNIQUE NOT NULL,
  email         TEXT UNIQUE,
  settings_json JSONB NOT NULL DEFAULT '{}',
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE sessions (
  id          TEXT PRIMARY KEY,
  user_id     TEXT NOT NULL REFERENCES users(id),
  started_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  ended_at    TIMESTAMPTZ,
  client_meta_json JSONB NOT NULL DEFAULT '{}'
);

CREATE TABLE lessons (
  id              TEXT PRIMARY KEY,
  session_id      TEXT NOT NULL REFERENCES sessions(id),
  user_id         TEXT NOT NULL REFERENCES users(id),
  subject         TEXT NOT NULL,           -- physics, math, history, english, ...
  topic           TEXT NOT NULL,
  status          TEXT NOT NULL DEFAULT 'draft',  -- draft|active|complete|abandoned
  level_at_start  TEXT NOT NULL,           -- pop|hs|ug|grad
  intent_json     JSONB NOT NULL,          -- output of setup modal
  plan_json       JSONB,                   -- output of plan modal lock
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  completed_at    TIMESTAMPTZ
);
CREATE INDEX idx_lessons_session ON lessons(session_id);

CREATE TABLE beats (
  id              TEXT PRIMARY KEY,
  lesson_id       TEXT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
  ord             INT NOT NULL,
  kind            TEXT NOT NULL,            -- concept|derivation|problem|test|free
  content_json    JSONB NOT NULL,
  narration_text  TEXT,
  audio_url       TEXT,
  viz_url         TEXT,
  status          TEXT NOT NULL DEFAULT 'pending', -- pending|streaming|ready|failed
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (lesson_id, ord)
);

CREATE TABLE interactions (
  id          TEXT PRIMARY KEY,
  lesson_id   TEXT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
  beat_id     TEXT REFERENCES beats(id) ON DELETE SET NULL,
  type        TEXT NOT NULL,           -- ask, hint, attempt, adjust, visualize, animate, advance, ...
  payload_json JSONB NOT NULL,
  response_json JSONB,
  ts          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE cache (
  key         TEXT PRIMARY KEY,         -- sha256(model + intent_signature + ...)
  model       TEXT NOT NULL,
  payload_json JSONB NOT NULL,
  hits        INT NOT NULL DEFAULT 0,
  last_hit_at TIMESTAMPTZ,
  ttl_at      TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cache_model_ttl ON cache(model, ttl_at);

CREATE TABLE assets (
  id          TEXT PRIMARY KEY,
  lesson_id   TEXT REFERENCES lessons(id) ON DELETE CASCADE,
  beat_id     TEXT REFERENCES beats(id) ON DELETE CASCADE,
  type        TEXT NOT NULL,           -- audio|diagram|flashcards|notes|slides|video
  url         TEXT NOT NULL,
  mime        TEXT NOT NULL,
  size_bytes  BIGINT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE events (
  id          TEXT PRIMARY KEY,
  session_id  TEXT REFERENCES sessions(id) ON DELETE CASCADE,
  lesson_id   TEXT REFERENCES lessons(id) ON DELETE SET NULL,
  type        TEXT NOT NULL,
  payload_json JSONB NOT NULL DEFAULT '{}',
  ts          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_events_session_ts ON events(session_id, ts);

CREATE TABLE model_runs (
  id            TEXT PRIMARY KEY,
  trace_id      TEXT NOT NULL,
  lesson_id     TEXT REFERENCES lessons(id) ON DELETE SET NULL,
  beat_id       TEXT REFERENCES beats(id) ON DELETE SET NULL,
  model         TEXT NOT NULL,        -- haiku-4-5 | sonnet-4-6 | opus-4-7 | elevenlabs
  purpose       TEXT NOT NULL,        -- plan | beat | ask | adjust | tts | viz | makeme
  prompt_tokens INT,
  completion_tokens INT,
  cost_usd      NUMERIC(10,5),
  latency_ms    INT,
  status        TEXT NOT NULL,        -- ok | rate_limited | refused | error | timeout
  error_code    TEXT,
  ts            TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_model_runs_ts ON model_runs(ts);
CREATE INDEX idx_model_runs_trace ON model_runs(trace_id);

CREATE TABLE magic_links (
  token       TEXT PRIMARY KEY,         -- random 32-byte hex
  user_id     TEXT NOT NULL REFERENCES users(id),
  email       TEXT NOT NULL,
  consumed_at TIMESTAMPTZ,
  expires_at  TIMESTAMPTZ NOT NULL,    -- 15 min from issue
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE rate_buckets (
  scope        TEXT NOT NULL,         -- ip | user | api_key
  key          TEXT NOT NULL,
  bucket       TEXT NOT NULL,         -- plan | ask | viz | global
  count        INT NOT NULL DEFAULT 0,
  window_start TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (scope, key, bucket, window_start)
);

▸ schema_v2.sql · extensibility additions

-- v2 · ALTER existing tables for soft-delete + multi-tenancy + asset dedup
ALTER TABLE users
  ADD COLUMN org_id     TEXT,                  -- nullable · M3 classroom seam
  ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE sessions     ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE lessons      ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE beats        ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE interactions ADD COLUMN deleted_at TIMESTAMPTZ;
ALTER TABLE assets       ADD COLUMN content_hash TEXT,
                              ADD COLUMN deleted_at   TIMESTAMPTZ;

-- TTS dedup · same narration_text + voice_id never stored twice
CREATE UNIQUE INDEX idx_assets_content_hash
  ON assets(content_hash) WHERE content_hash IS NOT NULL;

-- Subjects · canonical (physics, math, history, english, ...)
CREATE TABLE subjects (
  id          TEXT PRIMARY KEY,
  code        TEXT UNIQUE NOT NULL,    -- physics | math | history | english
  name        TEXT NOT NULL,
  archetype   TEXT NOT NULL,           -- science | humanities | language | skill | curiosity
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Topics · canonical lessons land here, free-form get canonical=false
CREATE TABLE topics (
  id          TEXT PRIMARY KEY,
  subject_id  TEXT NOT NULL REFERENCES subjects(id),
  name        TEXT NOT NULL,
  canonical   BOOLEAN NOT NULL DEFAULT FALSE,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_topics_subject ON topics(subject_id);

-- Lessons now FK to subjects + topics (keep free-form fallback)
ALTER TABLE lessons
  ADD COLUMN subject_id TEXT REFERENCES subjects(id),
  ADD COLUMN topic_id   TEXT REFERENCES topics(id);
  -- existing 'subject' / 'topic' text columns retained as fallback display

-- Plan revisions · history of every plan version (initial, iterate, adjust)
CREATE TABLE plan_revisions (
  id              TEXT PRIMARY KEY,
  lesson_id       TEXT NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
  revision_num    INT NOT NULL,
  plan_json       JSONB NOT NULL,
  triggered_by    TEXT NOT NULL,           -- initial | iterate | adjust
  feedback_text   TEXT,                       -- chip clicked or free-text
  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (lesson_id, revision_num)
);

-- Idempotency · prevents double-bill on retry / SSE reconnect
CREATE TABLE idempotency_keys (
  key           TEXT PRIMARY KEY,         -- client-supplied via Idempotency-Key header
  request_hash  TEXT NOT NULL,           -- sha256 of request body · catches mismatch
  user_id       TEXT REFERENCES users(id),
  status_code   INT NOT NULL,
  response_json JSONB NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at    TIMESTAMPTZ NOT NULL      -- 24h from creation · purged by cron
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);

-- M2 STUBS · created empty · populated in M2 ─────────────────────────────

CREATE TABLE subscriptions (
  id                     TEXT PRIMARY KEY,
  user_id                TEXT NOT NULL REFERENCES users(id),
  plan_code              TEXT NOT NULL,    -- free | starter | pro | classroom
  status                 TEXT NOT NULL,    -- trialing | active | past_due | canceled
  stripe_customer_id     TEXT,
  stripe_subscription_id TEXT,
  current_period_start   TIMESTAMPTZ,
  current_period_end     TIMESTAMPTZ,
  created_at             TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at             TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_user ON subscriptions(user_id);

CREATE TABLE mastery_records (
  user_id      TEXT NOT NULL REFERENCES users(id),
  topic_id     TEXT NOT NULL REFERENCES topics(id),
  last_seen_at TIMESTAMPTZ,
  score        NUMERIC(4,3),                 -- 0.000–1.000
  attempts     INT NOT NULL DEFAULT 0,
  correct      INT NOT NULL DEFAULT 0,
  next_review_at TIMESTAMPTZ,                -- spaced-rep schedule
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (user_id, topic_id)
);
CREATE INDEX idx_mastery_next_review ON mastery_records(user_id, next_review_at);

-- Convention: every read query filters WHERE deleted_at IS NULL.
-- Soft-delete cascade is enforced in app code, not DB triggers.

3.API contracts

The endpoints every later phase depends on. JSON in, JSON out (or SSE). Everything goes through /v1/. Auth via session cookie.

▸ idempotency [v2]: any state-changing POST may include Idempotency-Key: <client-uuid>. Server stores response in idempotency_keys for 24h. Retries with same key + matching body return the cached response. Mismatched body returns 409 conflict. Mandatory on /v1/plan · /v1/lesson/:id/adjust · /v1/lesson/:id/makeme · /v1/lesson/:id/visualize · /v1/lesson/:id/animate.

EndpointMethodPurposeNotes
/v1/healthzGETliveness probe · returns {ok:true, ts}P0
/v1/sessionPOSTcreate or refresh anonymous session · sets cookieP0
/v1/planPOSTsetup intent → plan + Beat 1 stub (Haiku)P3/P4
/v1/lesson/:id/streamGET (SSE)stream Beats 2–7 (Sonnet) · see §5 eventsP5
/v1/lesson/:idGETread full lesson (cached or live)P5
/v1/lesson/:id/askPOSTstudent question mid-lesson · returns answer + audio URLP6
/v1/lesson/:id/adjustPOSTadjust-level rework · returns new plan, re-streams beatsP9
/v1/lesson/:id/attemptPOSTCoach answer submission · returns verdict + feedbackP7
/v1/lesson/:id/visualizePOSTgenerate SVG diagram for current beatP8
/v1/lesson/:id/animatePOSTgenerate mini-simulationP8
/v1/lesson/:id/makemePOSTgenerate output (flashcards, notes, slides, video, etc.)P9
/v1/lesson/:id/whiteboardPOSTsubmit student drawing · returns Tutor commentP8
/v1/eventPOSTanalytics ingestion · client batchesP1
/v1/auth/magic-linkPOSTissue magic link to email · used for "save my work"P1/P9
/v1/auth/consumeGETconsume magic link · upgrade anonymous user · sets cookieP1/P9
/v1/lessonsGET"my library" · paginated list of user's lessonsP9
/v1/lesson/:id/abandonPOSTexplicit abandon · marks lesson, frees beat-stream resourcesP6

▸ Example · POST /v1/plan

// Request
{
  "subject": "physics",
  "topic": "Hooke's law & SHM",
  "intent": {
    "why": "homework",
    "level": "first time",
    "time": "30 min",
    "style": "problem-driven",
    "free_text": "Skip Lagrangian, focus on the physical intuition.",
    "advanced": { "pace": "balanced", "tone": "friendly", "outputs": ["flashcards"], "anchor": "none" }
  }
}

// Response · 200
{
  "ok": true,
  "lesson_id": "01HZX...",
  "plan": {
    "summary": "30 min · undergrad · problem-driven · friendly tone",
    "beats": [
      { "ord": 1, "kind": "concept", "title": "The spring at rest", "est_min": 3 },
      { "ord": 2, "kind": "concept", "title": "What restoring force means", "est_min": 4 },
      /* … */
    ],
    "after": "damped oscillators"
  },
  "beat1": { "id": "01HZX...", "content_json": { /* … */ }, "narration_text": "…", "audio_url": "https://cdn.omnitutor.ai/audio/…" },
  "trace_id": "req_…"
}

4.Streaming protocol

SSE on GET /v1/lesson/:id/stream. The client consumes events in order, hydrates the right rail plan tree, and renders beats as they arrive.

EventFires whenPayload
plan_readyPlan finalized · sent immediately on connect{ plan, total_beats }
beat_partialPartial beat content (every ~500ms while a beat generates){ ord, content_delta, status:"streaming" }
beat_completeBeat finished · narration + audio URL ready{ ord, content_json, narration_text, audio_url }
audio_readyTTS clip ready for an existing beat{ beat_ord, audio_url }
lesson_completeAll beats ready · stream closes{ duration_ms, model_costs }
errorRecoverable or fatal error{ code, message, recoverable, retry_after_ms? }
heartbeatEvery 15s · keeps connection alive{ ts }
// example wire transcript on /v1/lesson/01HZX/stream
event: plan_ready
data: {"plan":{"beats":[…]},"total_beats":7}

event: beat_partial
data: {"ord":2,"content_delta":"In a system where…","status":"streaming"}

event: beat_complete
data: {"ord":2,"content_json":{…},"narration_text":"…","audio_url":"…"}

event: heartbeat
data: {"ts":1715389200000}

// … repeats for beats 3–7 …

event: lesson_complete
data: {"duration_ms":12450,"model_costs":{"haiku":0.0021,"sonnet":0.0387}}

▸ reconnect / replay

If the client's connection drops mid-stream (mobile network · tab backgrounded · proxy timeout), the client reconnects with the standard SSE Last-Event-ID header. Server replays from the next event after that ID. Each event carries a monotonic id: field. No generated beat is ever lost — the server retains the stream buffer for 5 minutes after lesson_complete · cached beats are durable in DB.

// reconnect example
GET /v1/lesson/01HZX/stream HTTP/1.1
Last-Event-ID: 01HZX-evt-00007    // last event the client saw

// server response: stream resumes from event 8
id: 01HZX-evt-00008
event: beat_complete
data: {"ord":3,…}

5.Cache strategy

Same content, faster delivery. Cache key is deterministic from intent, so two students asking "Hooke's law for first-timer · 30 min · problem-driven" get the same cached lesson.

LayerKeyTTLNotes
plansha256(subject + topic + intent_signature)7 daysSetup-modal output. Re-uses across students with same intent.
beat contentsha256(plan_id + ord + level)30 daysEach beat cached individually. Student adjustments invalidate downstream beats.
TTS audiosha256(narration_text + voice_id)foreverVoice clips never change. Stored on S3, URL-keyed.
visualizationsha256(beat_id + viz_request)foreverGenerated SVGs / sims. Reusable across students.
Make-me assetsha256(lesson_id + output_type)foreverPer-lesson, per-output flashcards/slides/notes/video.

Pre-warming: a nightly job picks the top 100 most-asked plan keys and generates them at low priority. Cold-start for trending topics becomes hot-start.

▸ invalidation rules

TriggerWhat invalidatesWhat survives
adjust-level appliedall beats ord > current · planbeats ord ≤ current · TTS audio · viz
student edits intent (re-runs setup)entire lesson plan + beatsTTS audio (key by narration_text · still reusable)
beat regeneration explicitly requestedthat one beat onlyall other beats · audio · viz
cache TTL hitthat one roweverything else
global flush (admin only)everything in cache tableDB rows · S3 objects · audio

6.Auth model

StageWhat student doesWhat server does
M1 · defaultopens omnitutor.ai · no signupServer creates anonymous user with random ULID + sets ot_session HttpOnly cookie. All work tied to anon_id.
M1 · save my workenters email in "save my work" promptMagic-link email · clicking links email to anon_id. Library now persists across devices.
M2 · fullGoogle sign-inOAuth · existing anon work transferred to authenticated user · Stripe customer ID linked.

No PII required for M1. Cookies are first-party, HttpOnly, Secure, SameSite=Lax. Session inactivity expires at 30 days.

▸ soft delete [v2]: account deletion sets users.deleted_at + cascades app-side to sessions / lessons / beats / interactions / assets. Read queries default WHERE deleted_at IS NULL. Hard delete after 30 days via cron. users.org_id nullable today; M3 classroom flips it to required for org-bound users.

7.Naming conventions

SurfaceConventionExample
DB columns + JSON keyssnake_casecreated_at · narration_text · plan_json
URL pathskebab-case/v1/lesson/:id/visualize
Python vars + functionssnake_casedef build_plan(intent: Intent) -> Plan:
Frontend JS vars + functionscamelCasefunction loadBeat(beatId)
ConstantsSCREAMING_SNAKEHAIKU_TIMEOUT_MS = 5000
Subject codeslower-snakephysics · math · history · english
Beat kindslower-snakeconcept · derivation · problem · test · free
IDsULID (TEXT)01HZX5T6Y2C8P1Q9S3F4WJK7M · sortable, time-encoded
Trace IDsreq_ + ULIDpropagated through every log line for a single request

8.Rate limiting

Sliding-window counters per scope × bucket. Anonymous IPs throttle harder than authenticated users. Excess returns code:"over_quota" with drama-modal copy.

ScopeBucketM1 limitNotes
IP (anonymous)plan5 / hourprotect from drive-by abuse
IP (anonymous)ask60 / hourin-lesson questions
User (authenticated)plan30 / hourgenerous for real students
User (authenticated)ask300 / hourno realistic ceiling
Globalall$50 / dayspend cap · see §10 cost guardrail

9.Logging fields

Every log line carries the same dictionary. structlog emits JSON. CloudWatch indexes by trace_id for request-level replay.

{
  "ts":           "2026-05-10T08:14:23.421Z",
  "level":        "info",                  // debug | info | warn | error
  "msg":          "haiku call ok",
  "trace_id":     "req_01HZX...",            // every line of one request shares this
  "session_id":   "01HZX...",
  "user_id":      "01HZX...",
  "lesson_id":    "01HZX...",                // optional · null until lesson exists
  "beat_id":      null,
  "path":         "POST /v1/plan",
  "status":       200,
  "latency_ms":   1483,
  "model":        "haiku-4-5",                 // optional · only for LLM-touching calls
  "cost_usd":     0.0019,
  "cache":        "miss"                       // hit | miss | bypass
}

10.Safety & cost guardrails

Two rails the system never crosses, both checked before any LLM call.

▸ Cost guardrail

Daily spend tracked in model_runs. When daily total ≥ $50 (M1 cap), all new lesson generations return code:"over_quota" with drama-modal copy: "Tutor is full for the day · come back in <hours>." Already-active lessons finish streaming. Cap raises to $200/day in M2 with billing.

▸ Content moderation

Every student-supplied input (topic, free-text in setup, ask, adjust, whiteboard text) passes through a 200-token Haiku safety classifier before the main model call. If refused: code:"refused", reason:"safety", message:"…". Categories blocked: explicit harm, CSAM, self-harm encouragement, doxxing. Output also scanned for the same. Refusal is logged as a safety.refused event for review.

11.Error envelope

Every non-2xx response carries the same shape. Frontend can render it uniformly. Drama modal explains, never spinner.

{
  "ok": false,
  "code": "rate_limited",           // stable enum
  "message": "Tutor is thinking too hard. Try again in a sec.",
  "recoverable": true,
  "retry_after_ms": 1500,
  "trace_id": "req_01HZX..."
}
CodeMeaningUX
rate_limitedmodel API throttleOwl: "thinking hard, one more sec…" · auto-retry after retry_after_ms
model_unavailableAnthropic outageFall back to cached or to Haiku · Owl explains
invalid_inputsetup intent malformedField-specific error · re-open setup modal
not_foundlesson_id doesn't existRedirect to discovery · friendly toast
unauthorizedsession expiredRefresh anon session · retry once silently
over_quotarate-limit or daily cap hit (§8 §10)Drama modal: "Tutor is full · try in <X>." · no spinner
refusedcontent moderation blocked input or output (§10)Owl: "I can't teach that one. Want to try something else?"
internalunexpectedOwl: "I lost my place. Mind starting over?" · log trace_id

12.Event schema

Single stream. Every event has type, session_id, lesson_id?, ts, and payload_json. Used for analytics, replay, and quality monitoring.

session.start
Browser landed on omnitutor.ai for the first time this session
{ referrer, ua, viewport }
discovery.search
Student typed in the search bar
{ query, source }
lesson.invoke
Setup modal opened (free-form or tile)
{ subject, topic?, source }
setup.skip
Student hit "skip · just start"
{ }
setup.submit
Student locked the setup form
{ intent }
plan.shown
Plan modal rendered first time
{ lesson_id, time_to_render_ms }
plan.iterate
Student clicked an iterate chip or sent feedback
{ lesson_id, chip?, free_text? }
plan.locked
Send/Lock fired the transition veil
{ lesson_id, total_beats }
beat.advance
Student moved to next beat
{ lesson_id, from_ord, to_ord }
ask.fired
Student sent a mid-lesson question
{ lesson_id, beat_ord, q_chars }
attempt.submit
Coach answer or Test option submitted
{ lesson_id, beat_ord, verdict }
adjust.applied
Adjust-level apply fired
{ lesson_id, direction, axes[] }
visualize.fired
Visualize chip clicked
{ lesson_id, beat_ord }
animate.fired
Animate chip clicked
{ lesson_id, beat_ord }
makeme.fired
Make-me dropdown picked
{ lesson_id, output_type }
lesson.complete
Last beat finished · or student clicked done
{ lesson_id, duration_ms }
lesson.abandon
Tab closed mid-lesson
{ lesson_id, last_beat_ord }
cache.hit
A request was served from cache
{ key_kind, model? }
safety.refused
Moderation blocked input or output
{ stage:"input"|"output", category }
over_quota.hit
Rate limit or daily spend cap fired
{ scope, bucket, retry_after_ms }
error
Any error returned to client
{ code, trace_id }