Skip to main content
Audience: anyone (Brandon, a contractor, a new agent) about to build on the internal training system. Read this before writing any training-related code. Last verified: 2026-07-18 against live migrations + TABLE-LIST.md row counts.

The one-paragraph version

We have a built, live, in-use system for authoring and publishing app training content. An admin writes a task-based training doc (title, summary, ordered steps, screenshots/video), it lives in four Postgres tables, and it surfaces in two places: the in-app reader at /knowledge/app/*, and — on an explicit publish action — as MDX pages on the Mintlify docs site. There are 90 docs, 118 steps, and 124 assets in there today. What we do not have is a learning-management system. Nothing tracks a person. There are no courses, enrollments, assignments, quizzes, scores, completions, due dates, or certificates. Every one of those is greenfield, and all of it is additive — it builds on top of what exists, it does not replace it. That distinction is the whole point of this document:
Content authoring: built. Learner tracking: not built.

The four tables

All in the PM APP Supabase project (lgveqfnpkxvzbnnwuled), domain support, status live.

training_docs — one row per task/article (90 rows)

training_doc_steps — ordered instructions (118 rows)

training_doc_id (CASCADE), step_order, title, instruction_markdown, expected_result, source_url, action_metadata, screenshot_asset_id → assets.

training_doc_assets — media (124 rows)

storage_bucket (default documents), storage_path, asset_type (screenshot | image | video), caption, alt_text, step_order. Unique on (storage_bucket, storage_path).

training_doc_relations — the training map (18 rows)

source_doc_idtarget_doc_id, relation_type (related | prerequisite | next), sort_order. Constrained against self-reference and duplicates. Migrations (supabase/migrations/): 20260627022000_create_training_docs.sql, 20260629204500_allow_training_doc_image_assets.sql, 20260630000500_create_training_doc_steps.sql, 20260630193000_allow_training_doc_video_assets.sql, 20260630210000_extend_training_docs_relations_tool_qa.sql.

RLS — read this before you build any employee-facing feature

All four tables are admin-only via current_is_app_admin(), plus a service_role full-access policy. A normal employee cannot read training_docs directly. The in-app reader gets around this by using a service client on the server:
That is fine for read-only published content rendered server-side. It is not a pattern to copy for anything per-user — the moment a feature needs “what has this person done,” it needs real RLS policies keyed to the user, not a service-role bypass. Any employee-training feature therefore needs a deliberate RLS decision first. See docs/architecture/RLS-ACCESS-LEVEL-DECISION.md.

How content flows

Two things worth internalizing:
  1. The database is the source of truth. The published MDX is a derived export, not the runtime source. The in-app reader queries Postgres.
  2. Publishing is an explicit action, not a sync. POST /api/admin/training-docs/[docId]/publish renders MDX, writes assets, and registers nav in docs.json. Nothing happens automatically on save.

Where the code lives

Nav entries: frontend/src/lib/navigation-config.ts (Task Training, Training Docs, Training Map, Knowledge Sources).

Three things named “training” that are NOT this

Do not confuse these — they share a word and nothing else.

What is confirmed absent

No table and no code anywhere for: courses, lessons, modules, quizzes, curriculum, enrollments, certifications, learning_paths, training_assignments, training_completions. No /academy, /university, /learn, or /courses route.

If you’re adding employee training — build it as a layer, not a rewrite

The existing schema is content. What’s missing is learner state. Add it alongside; do not restructure training_docs. The seam is already there: training_docs.task_key and training_docs.id are the natural join targets, and user_profiles.id is the person. A progress layer would look roughly like:
Before writing the migration, settle these — they’re the decisions that are expensive to change later:
  1. Unit of assignment. A single doc? Or a new training_paths grouping (which training_doc_relations with relation_type='prerequisite' already half-models — check whether that’s enough before adding a table).
  2. RLS model. Employees read published content and read/write only their own progress. This is a real policy design, not a service-client bypass.
  3. Content read access. training_docs is admin-only today. Either relax the read policy for status='published', or keep server-side rendering as the only read path. Pick one deliberately.
  4. Completion semantics. Self-attested checkbox, quiz score, or observed-in-app? These have very different schemas and very different levels of effort.

Ground rules for building here

  • Reuse before you add. REUSE-FIRST-GATE.md applies. The editor, publish pipeline, asset handling, and reader surface all exist and are tested — compose them.
  • Table pages use UnifiedTablePage. See .claude/rules/TABLE-PAGE-GATE.md.
  • Detail pages use the alleato-detail-page skill. See .claude/rules/DETAIL-PAGE-GATE.md.
  • Run npm run db:types before writing any query, and confirm columns against frontend/src/types/database.types.ts.
  • Any new table must be added to docs/architecture/tables.yaml, then run npm run db:inventory to regenerate TABLE-LIST.md. A drift check fails otherwise.
  • Branch + PR. Never commit to main. See CLAUDE.md “Git Workflow”.

Historical context and prior decisions live in docs/ops/tasks/: 2026-06-27-brandon-email-training-queue.md, 2026-06-29-ai-training-doc-generation.md, 2026-06-30-repeatable-training-doc-skill.md, 2026-06-30-training-docs-app-knowledge.md, 2026-07-01-training-docs-control-plane-fields.md, 2026-07-17-training-docs-skill-gates.md.