Data Model
Status: v1. PostgreSQL + pgvector. Self-hosted, one instance per company.
Overview
organizations ─┬── users ──┬── user_sessions
│ └── chat_sessions ── chat_messages
├── documents ──── document_chunks (pgvector + tsvector)
├── skills ────── skill_categories
├── courses ──┬── modules ──┬── lessons ── exercises
│ │ └── skill_checkpoints
│ └── manuals
├── background_jobs
├── api_keys
├── webhooks ──── webhook_deliveries
└── audit_log
enrollments ─── exercise_attempts
user_skills
spaced_repetition
generation_jobs
course_feedback
All tables are scoped by org_id (directly or through parent FK). Single organization per deployment.
Schema
Organizations
One row per deployment. Exists for data scoping and future-proofing.
CREATE TYPE workspace_mode AS ENUM ('organization', 'individual');
CREATE TABLE organizations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
workspace_mode workspace_mode NOT NULL DEFAULT 'organization',
settings jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
workspace_mode (migration 0017) is the deployment’s audience mode — see
docs/design/audience-modes.md. It is a stable per-deployment capability, set
once when the organization row is created (from WORKSPACE_MODE, default
organization), never inferred from the number of users. In organization the
row represents a company/team/class; in individual it is one person’s personal
space. Existing deployments upgrade to organization, so nothing changes for
them. The collective, organization-only endpoints (employees, talent, stats,
course assignment, skills) return 404 in an individual workspace.
Users
CREATE TYPE user_role AS ENUM ('admin', 'employee');
CREATE TYPE learning_profile AS ENUM ('standard', 'focus', 'fast');
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
email text NOT NULL,
hashed_password text NOT NULL,
full_name text NOT NULL,
role user_role NOT NULL DEFAULT 'employee',
learning_profile learning_profile NOT NULL DEFAULT 'standard',
accessibility jsonb NOT NULL DEFAULT '{}',
hired_at date,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (org_id, email)
);
accessibility stores flags the employee opts into during onboarding:
{"tea": false, "tdah": true, "dislexia": false}
The frontend reads these to adapt rendering. The backend never uses them for logic.
Documents (uploaded source material)
CREATE TYPE document_status AS ENUM ('pending', 'processing', 'ready', 'error');
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
uploaded_by uuid REFERENCES users(id) ON DELETE SET NULL,
title text NOT NULL,
storage_path text NOT NULL,
file_type text NOT NULL,
page_count int,
size_bytes bigint,
full_text text,
embedding_model text,
embedding_dim int,
status document_status NOT NULL DEFAULT 'pending',
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Document chunks (RAG with pgvector)
CREATE TABLE document_chunks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
content text NOT NULL,
embedding vector(384) NOT NULL,
chunk_index int NOT NULL,
search_vector tsvector GENERATED ALWAYS AS (to_tsvector('spanish', content)) STORED,
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (document_id, chunk_index)
);
CREATE INDEX idx_chunks_embedding ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 10);
CREATE INDEX idx_chunks_search ON document_chunks USING gin(search_vector);
metadata holds position info from the source document:
{"page": 3, "section": "Devoluciones", "heading": "Plazo"}
Embedding dimension: 384 for multilingual-e5-small. Change to 1024 if using multilingual-e5-large. The vector(N) declaration and index must match the model.
IVFFlat lists: rule of thumb is sqrt(num_rows). Start with 10, increase as the chunk count grows past a few thousand.
Skills taxonomy
CREATE TABLE skill_categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
name text NOT NULL,
position int NOT NULL DEFAULT 0,
UNIQUE (org_id, name)
);
CREATE TABLE skills (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
category_id uuid REFERENCES skill_categories(id),
name text NOT NULL,
description text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (org_id, name)
);
Example taxonomy:
Ventas (category)
├── devoluciones (skill)
├── atencion_cliente
└── cierre
Tecnologia (category)
├── html_css
└── excel
Courses
CREATE TYPE content_status AS ENUM ('draft', 'published', 'archived');
CREATE TABLE courses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
source_document_id uuid REFERENCES documents(id),
title text NOT NULL,
description text,
outcome text,
status content_status NOT NULL DEFAULT 'draft',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
outcome is what the employee will be able to do after completing the course. Required before publishing.
Modules
CREATE TABLE modules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
title text NOT NULL,
summary text,
position int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Lessons
CREATE TABLE lessons (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
title text NOT NULL,
content text NOT NULL,
position int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Exercises
CREATE TYPE exercise_type AS ENUM (
'test', 'true_false', 'fill_blank',
'order_steps', 'practical_case', 'dialogue'
);
CREATE TABLE exercises (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
lesson_id uuid NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
type exercise_type NOT NULL,
content jsonb NOT NULL,
position int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
The content jsonb varies by type:
// test
{
"question": "How many days for returns?",
"options": ["14", "30", "60", "90"],
"correct": 1,
"explanation": "Manual, p.3: '30 natural days'"
}
// true_false
{
"statement": "Bank statement is valid as proof of purchase",
"correct": true,
"explanation": "Manual, p.5"
}
// fill_blank
{
"template": "The product must be ___ and with ___",
"blanks": ["unused", "tags"],
"explanation": "..."
}
// order_steps
{
"instruction": "Order the return process steps",
"steps": ["Verify product", "Scan", "Register", "Refund"],
"correct_order": [0, 1, 2, 3],
"explanation": "..."
}
// practical_case
{
"context": "Friday 18:45. Customer with a 45-day old coffee maker...",
"question": "What do you do?",
"rubric": [
{"criteria": "Mentions 30-day policy doesn't apply", "required": true},
{"criteria": "Offers manufacturer warranty", "required": true}
],
"explanation": "..."
}
// dialogue
{
"context": "Angry customer, third visit this week...",
"system_prompt": "You are an angry customer...",
"max_turns": 4,
"evaluation_criteria": ["friendly tone", "concrete solution"]
}
Skill checkpoints
Maps module completion to skill level changes. When an employee completes a module, their skill level updates automatically.
CREATE TYPE skill_level AS ENUM ('low', 'medium', 'high');
CREATE TABLE skill_checkpoints (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
target_level skill_level NOT NULL,
UNIQUE (course_id, skill_id, module_id)
);
Example: course “Returns” teaches skill “devoluciones”:
- Complete module 3 -> level = medium
- Complete module 5 -> level = high
Manuals (reference material)
Always generated alongside a course. Can also exist standalone.
CREATE TABLE manuals (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
created_by uuid NOT NULL REFERENCES users(id),
source_document_id uuid REFERENCES documents(id),
course_id uuid REFERENCES courses(id),
title text NOT NULL,
content jsonb NOT NULL,
status content_status NOT NULL DEFAULT 'draft',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Rule: a course always has a manual. A manual can exist without a course.
Enrollments
CREATE TYPE enrollment_status AS ENUM ('assigned', 'in_progress', 'completed');
CREATE TABLE enrollments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_id uuid NOT NULL REFERENCES courses(id),
assigned_by uuid REFERENCES users(id) ON DELETE SET NULL,
status enrollment_status NOT NULL DEFAULT 'assigned',
deadline date,
started_at timestamptz,
completed_at timestamptz,
score real,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, course_id)
);
Exercise attempts
CREATE TABLE exercise_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
exercise_id uuid NOT NULL REFERENCES exercises(id),
answer jsonb NOT NULL,
score real NOT NULL CHECK (score >= 0 AND score <= 1),
passed boolean NOT NULL,
feedback text,
attempted_at timestamptz NOT NULL DEFAULT now()
);
Multiple attempts per exercise allowed. The latest attempt is the current state.
User skills (the skill graph)
CREATE TABLE user_skills (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES skills(id),
level skill_level NOT NULL DEFAULT 'low',
source text NOT NULL DEFAULT 'checkpoint',
last_assessed_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, skill_id)
);
source: 'checkpoint' (system set it via module completion) or 'manual' (admin assigned it directly).
Level never decreases from checkpoints. Admin can override to any level.
Spaced repetition (HLR)
CREATE TABLE spaced_repetition (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
exercise_id uuid NOT NULL REFERENCES exercises(id),
half_life_days real NOT NULL DEFAULT 7.0,
review_count int NOT NULL DEFAULT 0,
last_reviewed_at timestamptz,
next_review_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, exercise_id)
);
Algorithm:
- Initial half-life: 7 days
- Correct answer:
half_life *= 2 - Wrong answer:
half_life /= 2 - Review scheduled when:
P(forget) = 1 - exp(-elapsed / half_life) > 0.3
Generation jobs
Tracks the multi-step content generation pipeline. The admin sees progress in real time.
CREATE TYPE generation_output AS ENUM ('course_and_manual', 'manual_only');
CREATE TYPE generation_step AS ENUM (
'pending', 'extracting', 'structuring',
'generating', 'reviewing', 'published', 'failed'
);
CREATE TABLE generation_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
triggered_by uuid NOT NULL REFERENCES users(id),
source_document_id uuid REFERENCES documents(id),
output_type generation_output NOT NULL,
status generation_step NOT NULL DEFAULT 'pending',
langgraph_thread_id text,
progress jsonb NOT NULL DEFAULT '{}',
result_course_id uuid REFERENCES courses(id),
result_manual_id uuid REFERENCES manuals(id),
error_message text,
cancelled_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Course feedback
Post-course survey: 3 questions that generate a revision report for the course creator.
CREATE TABLE course_feedback (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
course_id uuid NOT NULL REFERENCES courses(id),
responses jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, course_id)
);
User sessions (auth tokens)
CREATE TABLE user_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL,
ip_address text,
user_agent text,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Audit log
CREATE TABLE audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
actor_id uuid NOT NULL REFERENCES users(id),
action text NOT NULL,
target_type text,
target_id uuid,
detail jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
Background jobs
CREATE TABLE background_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
type text NOT NULL CHECK (type IN (
'document_ingestion',
'spaced_repetition_recalc',
'bulk_user_import',
'bulk_course_assign'
)),
status text NOT NULL DEFAULT 'pending',
payload jsonb DEFAULT '{}',
result jsonb,
error_message text,
attempt_count int DEFAULT 0,
max_attempts int DEFAULT 3,
scheduled_at timestamptz DEFAULT now(),
started_at timestamptz,
completed_at timestamptz,
locked_by text,
locked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Chat sessions
CREATE TABLE chat_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id uuid NOT NULL REFERENCES organizations(id),
agent_type text NOT NULL CHECK (agent_type IN ('tutor', 'admin')),
title text,
summary text,
summary_covers_until int DEFAULT 0,
course_id uuid REFERENCES courses(id),
is_active boolean DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
Chat messages
CREATE TABLE chat_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
session_id uuid NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content text NOT NULL,
metadata jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
API keys
CREATE TABLE api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
created_by uuid NOT NULL REFERENCES users(id),
name text NOT NULL,
key_hash text NOT NULL,
scopes text[] NOT NULL,
is_active boolean DEFAULT true,
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
Webhooks
CREATE TABLE webhooks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
url text NOT NULL,
events text[] NOT NULL,
secret text NOT NULL,
is_active boolean DEFAULT true,
failure_count int DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now()
);
Webhook deliveries
CREATE TABLE webhook_deliveries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id uuid NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
event text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
response_code int,
attempt_count int DEFAULT 0,
next_retry_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
Key queries
Skills matrix (admin view)
SELECT u.full_name, s.name AS skill, us.level
FROM user_skills us
JOIN users u ON u.id = us.user_id
JOIN skills s ON s.id = us.skill_id
WHERE u.org_id = $1
ORDER BY u.full_name, s.name;
“What’s due today” (employee dashboard)
-- Spaced repetition reviews due
SELECT e.id, e.content, sr.next_review_at
FROM spaced_repetition sr
JOIN exercises e ON e.id = sr.exercise_id
WHERE sr.user_id = $1
AND sr.next_review_at <= now()
ORDER BY sr.next_review_at
LIMIT 3;
-- Active enrollments with nearest deadline
SELECT c.title, en.status, en.deadline,
COUNT(DISTINCT m.id) AS total_modules
FROM enrollments en
JOIN courses c ON c.id = en.course_id
JOIN modules m ON m.course_id = c.id
WHERE en.user_id = $1
AND en.status != 'completed'
ORDER BY en.deadline NULLS LAST
LIMIT 3;
Semantic search (RAG)
SELECT dc.content, dc.metadata,
1 - (dc.embedding <=> $2) AS similarity
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
WHERE d.org_id = $1
ORDER BY dc.embedding <=> $2
LIMIT 5;
$2 is the embedding vector of the user’s question.
Mentor matching
SELECT
mentor.full_name AS mentor,
mentee.full_name AS mentee,
s.name AS skill
FROM user_skills us_high
JOIN users mentor ON mentor.id = us_high.user_id
JOIN user_skills us_low ON us_low.skill_id = us_high.skill_id
AND us_low.level = 'low'
JOIN users mentee ON mentee.id = us_low.user_id
JOIN skills s ON s.id = us_high.skill_id
WHERE us_high.level = 'high'
AND mentor.org_id = $1
AND mentor.id != mentee.id;
Indexes
Beyond the pgvector index on document_chunks.embedding:
-- Lookups by org
CREATE INDEX idx_users_org ON users(org_id);
CREATE INDEX idx_documents_org ON documents(org_id);
CREATE INDEX idx_courses_org ON courses(org_id);
CREATE INDEX idx_skills_org ON skills(org_id);
-- Enrollment lookups
CREATE INDEX idx_enrollments_user ON enrollments(user_id);
CREATE INDEX idx_enrollments_course ON enrollments(course_id);
-- Exercise attempts for progress tracking
CREATE INDEX idx_attempts_user ON exercise_attempts(user_id);
CREATE INDEX idx_attempts_exercise ON exercise_attempts(exercise_id);
-- Spaced repetition scheduling
CREATE INDEX idx_sr_next_review ON spaced_repetition(user_id, next_review_at);
-- User skills for matrix queries
CREATE INDEX idx_user_skills_user ON user_skills(user_id);
CREATE INDEX idx_user_skills_skill ON user_skills(skill_id);
-- User sessions
CREATE INDEX idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX idx_user_sessions_token ON user_sessions(token_hash);
CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at);
-- Audit log
CREATE INDEX idx_audit_log_org ON audit_log(org_id);
CREATE INDEX idx_audit_log_actor ON audit_log(actor_id);
CREATE INDEX idx_audit_log_target ON audit_log(target_type, target_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at);
-- Background jobs
CREATE INDEX idx_background_jobs_org ON background_jobs(org_id);
CREATE INDEX idx_background_jobs_status ON background_jobs(status);
CREATE INDEX idx_background_jobs_scheduled ON background_jobs(scheduled_at);
-- Chat sessions and messages
CREATE INDEX idx_chat_sessions_user ON chat_sessions(user_id);
CREATE INDEX idx_chat_sessions_org ON chat_sessions(org_id);
CREATE INDEX idx_chat_messages_session ON chat_messages(session_id);
-- API keys
CREATE INDEX idx_api_keys_org ON api_keys(org_id);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
-- Webhooks and deliveries
CREATE INDEX idx_webhooks_org ON webhooks(org_id);
CREATE INDEX idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id);
CREATE INDEX idx_webhook_deliveries_status ON webhook_deliveries(status);
LLM usage log
Operational logging for token usage tracking and cost analysis. Not a core domain table — exists for observability.
CREATE TABLE llm_usage_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id),
user_id uuid REFERENCES users(id),
job_id uuid REFERENCES generation_jobs(id),
use_case text NOT NULL,
model text NOT NULL,
tokens_in int NOT NULL,
tokens_out int NOT NULL,
duration_ms int NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_llm_usage_log_org ON llm_usage_log(org_id, created_at);
CREATE INDEX idx_llm_usage_log_user ON llm_usage_log(user_id, created_at);
Extensions required
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector
Notes
- 27 tables total across core domain, learning tracking, content generation, chat, and platform infrastructure. See the overview diagram for the full hierarchy.
- UUID primary keys everywhere. No auto-increment integers. Clean for distributed systems and API exposure.
org_idon all top-level tables. Even with single-tenant deployment, this keeps queries explicit and makes future multi-tenancy possible without schema changes.jsonbfor flexible fields. Exercise content, accessibility flags, organization settings, document chunk metadata. Avoids schema changes when adding new exercise types or config options.- Soft delete not implemented. For MVP, hard deletes with CASCADE. If audit trail is needed later, add
deleted_atcolumns. - Timestamps are
timestamptz. Always UTC in the database, converted to local time in the frontend. One exception survived until migration0006:user_skills.last_assessed_atcame from0003astimestamp without time zone, while both of its writers pass aware datetimes. asyncpg refuses that combination, so raising an existing skill row crashed.0006converts it withAT TIME ZONE 'UTC'. The other naive columns from0003are only ever filled server-side bynow()/onupdateand were deliberately left alone.
Appendix: the v2 schema (dynamic courses)
This document describes the v1 schema and is not rewritten here. Everything below is still
accurate and still what a v1 course (any course not on the dynamic+validated path) runs on
in production.
v2 (dynamic courses) adds a substantial amount of schema on top, and its design of record is
v2-dynamic-courses.md §3, not this file. Go there for column-level
detail, the cache_key composition, the retention rules and the reasoning.
The shape of the addition, so you know whether you need to look:
- Migration
0005creates 13 tables and 8 enums, adds 6 columns tocourses(includingdelivery_mode,schema_status,schema_versionandintent_density) and adds 2 values to thegeneration_stepenum. It is hand-written, not autogenerated, and creation order matters — there are two forward references in the v2 schema. - The new tables cover: the course graph (
course_nodes,course_node_prerequisites), generated screens and who saw them (node_renders,node_render_views), per-learner state (learner_profiles,learner_node_states,node_probes,node_attempts), feedback and telemetry (node_feedback,learning_events,llm_usage_log,term_explanations) andaudit_log. - Nothing in v1 changes shape. The v2 columns on
coursesall have defaults that reproduce v1 behaviour, which is what lets the flag default tooff. - Two properties worth knowing before you query any of it:
node_rendershas nouser_id(the cache is shared per bucket, and the per-user read trail lives innode_render_views), andanswer_keyis a separate column that no response schema includes.
downgrade() on 0005 drops the 13 tables, the 6 columns and the 8 enums, but leaves
schema_proposing and schema_proposed orphaned in generation_step — PostgreSQL cannot remove
a value from an enum. That is documented rather than fixed, and asserted by
tests/integration/test_migration_0005.py.
Learning preferences and personalization revision
Migration 0011_learner_preferences adds the first declared preference that can modify a
dynamic render:
learner_profiles.learning_preferences: a closed, versioned JSONB with presentation (balanced|visual|textual|interactive), detail (concise|standard|detailed), and images (when_useful|prefer|avoid);learner_profiles.personalization_revision: a monotonic revision that changes when the bundle actually changes;learner_node_states.pinned_personalization_revision: the revision the render was pinned to.
The declared preference is not mixed with format_vector (inferred evidence) or with
users.accessibility (functional needs). Before it can influence prompts it is normalized to a
non-identifying canonical bucket that becomes part of cache_key. Saving a different bundle
bumps the revision and clears that learner’s pins without deleting the shared history. The pin
guard prevents a generation started with a stale revision from becoming the current render again
after a concurrent change.
Prepared pedagogical dossiers
Migration 0012_node_knowledge_packs adds node_knowledge_packs, a table of immutable
snapshots keyed by (node_id, source_fingerprint, generator_version). Each row belongs to an
organization, course, and node, and records schema_version, state
(pending|ready|review_required|stale|failed),
reviewable Markdown, the full JSON contract (pack_payload), a compact atom view, provenance,
hashes, tokens, duration, and error.
source_fingerprint includes the pedagogically relevant fields of the node plus the hash of the
source context. A new snapshot marks the previous ones as stale; a worker can only complete the
row that is pending with the fingerprint it claimed. The Markdown is not re-imported: for
selection, audit, and caching, the authority is pack_payload + pack_hash. review_required
keeps the payload and the Markdown for inspection, but only ready can feed OpenUI.
Pack states and the ready criterion (grounded). The persisted PackStatus enum
(knowledge_pack/contracts.py) is DRAFT, READY, REVIEW_REQUIRED, REJECTED; stale
and failed are runner outcomes, not row statuses (knowledge_pack/runner.py
_RETRYABLE_OUTCOMES = {"failed", "stale", "review_required"}). A pack becomes ready only
when generator.py::_build_pack finds all three: at least one must_preserve atom, at least
one required evidence_spec, and no blocking missing-data gap
(usable = bool(must_preserve) and has_required_evidence and not blocking_gap). Uncovered
source units are recorded non-blocking; a pack that is not usable is stored as
REVIEW_REQUIRED with its payload and Markdown preserved for inspection.
Learning note (free-text personalization)
Migration 0018_learner_learning_note adds learner_profiles.learning_note, a nullable
Text column holding the learner’s free-text “how I like to learn” note. It steers the
form of an explanation, never the facts (see personalization.md). It
is length-capped at the Pydantic layer (LEARNING_NOTE_MAX_CHARS = 500,
src/personalization/learning_note.py), normalized on write, and its 12-char sha1
learning_note_fingerprint partitions the render cache key
(node_render_service.build_render_key): an empty note leaves every existing key untouched;
two learners with the same note share a render. Writing it sets personalization_changed,
dropping that learner’s render pins.
Media artifacts
Two org-scoped tables back the generated media (see media-artifacts.md).
media_artifacts(src/models/media_artifact.py) — one generated media asset.kindenumMediaKind:podcast, slides, infographic, video, mindmap, report, cover_image.statusenumMediaArtifactStatus:pending -> running -> done | error(the failure state iserror, not “failed”). Columns:org_id,course_id,node_id(nullable),kind,status,spec_json(JSONB, holds thescope—node|course|standalone— the personalizationnote, citations and sub-asset refs),asset_path(nullable),content_hash(sha256 dedup key),error. There is noscopecolumn: scope lives insidespec_json. Org-scoped, not per-user.course_artifact_generators(src/models/course_artifact_generator.py) — composite PK(course_id, user_id). Records who, besides admins, may generate course-level media.