Maintain CMS and LMS drift matrix¶
Problem¶
The CMS and LMS intentionally share a platform baseline while retaining different product responsibilities, dependencies, and runtime behavior. Those differences need one maintained contract so contributors can distinguish intentional divergence from accidental drift.
The canonical shared stack lives in the root AGENTS.md
Baselines. Its Workspace dependencies section explains hoisted
development tooling versus per-app runtime dependencies. The matrix below
records divergences only.
| Area | CMS (apps/cms) |
LMS (apps/lms) |
Notes |
|---|---|---|---|
| Role | Authoring (edx-create), workarea UI |
Multi-tenant learner + public marketing site + admin | Ports 3000 / 3001 |
| Design tokens | Imports @open-learning-hub/design-tokens; globals.css adds authoring-only @theme brand keys (--color-exe-*) and @source for widget renderers |
Imports design tokens; globals.css adds @source for site chrome + widget renderers and minimal base layer (for example font-sans) |
Semantic --success / --warning / --info live in packages/design-tokens |
| shadcn UI primitives | Workspace package @open-learning-hub/ui (same primitive source as LMS); app-local src/components/ui/ contains CMS composites like file-drop-zone, portable-text-editor, pagination |
Workspace package @open-learning-hub/ui; LMS has no local src/components/ui/ primitive tree |
Shared primitives (button, card, dialog, tabs) stay aligned with apps/cms/.cursor/rules/cms-system.mdc |
| Portable Text | Full editor stack (@portabletext/editor, toolbar, toolkit, keyboard-shortcuts, to-html, html) |
@portabletext/react for rendering only |
LMS does not ship the authoring editor |
| Authoring UX | @dnd-kit/* + react-resizable-panels for drag/resize workarea |
No @dnd-kit/*; no LMS usage of react-resizable-panels in learner/admin chrome |
LMS uses zustand for course-sidebar expansion state (src/lib/store/sidebar.ts) |
| Auth / learner DB | Auth.js v5 + Kysely for CMS API/admin model; password helpers re-exported from @open-learning-hub/auth-utils |
Auth.js v5 + Kysely for tenant-scoped LMS data; server-only boundaries |
Both apps have independent schemas/migrations and DB scripts |
| Sign-out confirmation UX | Branded /logout page, guarded redirect to / when unauthenticated, CTA set: sign out / back to workarea / cancel home; pages.signOut maps Auth.js fallback to this route |
Branded /learn/sign-out page, guarded redirect to / when unauthenticated, CTA set: sign out / back to dashboard / cancel home; pages.signOut maps Auth.js fallback to this route |
Parity achieved for branded sign-out confirmation patterns while preserving app-specific route naming (/logout vs /learn/sign-out). |
| Password policy | newPasswordSchema from @open-learning-hub/auth-utils/password-policy for reset + admin create; sign-in remains non-empty + bcrypt verify |
newPasswordSchema via src/schemas/auth.ts for sign-up/reset/invite/account set-change; sign-in remains non-empty + bcrypt verify |
Canonical policy: min 8, max 128, requires uppercase + lowercase + digit. Dev/e2e passwords resolve from SEED_DEMO_PASSWORD via @open-learning-hub/auth-utils/seed-demo-password (T-028) |
| Structured logger | src/lib/log.ts with import "server-only" |
src/lib/log.ts with import "@/lib/server-only" |
Byte-identical except the server-only import line; canonical contract: 091-structured-logging.mdc |
App-local CMS events, i18n templates, preferences, and email_events schema |
App-local LMS events, i18n templates, preferences, and email_events schema |
Both use server-only @open-learning-hub/email for React Email rendering and Resend/SMTP/console delivery; runtime event persistence is not wired yet |
|
| CMS content API | Implements and serves CMS APIs | src/lib/cms/ HTTP client to edX CMS |
LMS is a consumer |
| OpenAPI | openapi:generate via src/lib/api/generate-openapi.ts |
openapi:generate via scripts/openapi-generate.ts + @asteasolutions/zod-to-openapi in devDependencies |
|
| Markdown / XML / export | marked, fast-xml-parser, fflate, jsdom, @uiw/react-json-view |
Not in LMS runtime dependency set | CMS export/import and diagnostics tooling |
| E2E | Playwright suite (test:e2e) |
Playwright suite (test:e2e) under tests/e2e/ |
Both apps run e2e via workspace scripts |
| Docs site | Zensical under apps/cms/docs/zensical |
Zensical under apps/lms/docs/zensical: docs:sync mirrors into docs-source/, docs:build emits site/, docs:publish copies site/ to public/docs/ |
LMS mirror script: apps/lms/docs/zensical/scripts/sync_docs.py |
| Public docs nav link | shared-site-header-nav-docs in authenticated site header (Button + external <a href="/docs/">) |
main-nav-link-docs in MainNav when site hierarchy contains a docs node (text link + ExternalLink, same URL/new-tab behavior) |
Shared contract: i18n nav.docs, /docs/, target="_blank", rel="noopener noreferrer" |
| Admin nav link | shared-site-header-nav-admin when JWT roles[] includes admin, super-admin, or tenant-admin |
main-nav-link-admin only when CMS hierarchy includes admin and session role is tenant_admin, course_admin, or super_admin |
Both link to /admin; role naming intentionally differs (CMS hyphen slugs vs LMS underscore enums) |
| LMS components widgets | Catalogs lms-featured-courses / lms-blog-listing / lms-course-catalog; CMS surfaces render LmsComponentPlaceholderWidget |
Overrides those three types on SharedWidgetView with live section renderers fed by resolveLmsWidgetData + ResolvedLmsWidgetDataProvider |
Zero-config (empty content schema); see apps/lms/docs/guides/content-rendering.md |
| Lint/format | Biome + Prettier on *.{md,mdx,mdc} |
Same pattern | No .prettierrc; .prettierignore only |
| Component filenames | kebab-case (theme-provider.tsx) under src/components/; tests commonly in __tests__/ with matching kebab names |
kebab-case under src/components/ and src/app/**/_components/; co-located kebab-case.test.tsx |
Canonical policy: 031-file-naming.mdc; exports stay PascalCase |
| Module / lib filenames | kebab-case (feature-flag-registry.ts, data-dir.ts) under src/lib/**, src/auth/**, and peers |
kebab-case across src/lib/**, src/auth/**, src/db/**, src/types/**, and route-colocated action modules |
CMS minor outlier: tokenVersion.ts in src/lib/auth/; Next.js bracket params remain framework-standard |
| Test harness | __tests__/ + kebab names beside source |
tests/utils/render-with-theme.tsx (export renderWithTheme); remote unit tests under tests/unit/** use kebab-case.test.ts patterns |
LMS details in apps/lms/.cursor/rules/030-testing.mdc |
Both apps use Lucide only; the canonical policy is
035-icons-lucide.mdc.
Both apps use Next.js 16 proxy.ts, not middleware.ts, for cross-cutting
request handling.
CMS and LMS both use next-themes with attribute="class",
defaultTheme="dark", enableSystem, disableTransitionOnChange, and the
default storage key theme. This shared app chrome behavior is parity, not
drift.
Proposal¶
- Keep this matrix as the canonical cross-app divergence record.
- Update it in the same change whenever a shared dependency, UI primitive, runtime contract, or app-specific implementation changes.
- Link to the canonical shared rule or implementation rather than duplicating policy in this ticket.
- Treat undocumented differences found during review as drift until they are aligned or recorded here as intentional.
Acceptance criteria¶
- The matrix reflects current CMS and LMS dependencies and behavior.
- Every intentional divergence names its owning implementation or canonical policy where useful.
- Shared contracts are described as parity rather than duplicated as app-specific rules.
- Changes affecting both apps update this ticket in the same pull request.
- Both CMS and LMS documentation sites expose this ticket under
Platform. -
npm run checkpasses.
Out of scope¶
- Forcing CMS and LMS to use identical mutation transports, dependencies, or product features.
- Replacing the canonical root and app-specific
AGENTS.mdfiles. - Tracking implementation work that belongs in a narrower audit ticket.
Notes / decisions log¶
- 2026-07-16: Converted the living drift reference into an in-progress platform ticket while preserving the comparison matrix.
- 2026-07-17: Aligned CMS and LMS new-password validation to shared
@open-learning-hub/auth-utils/password-policyand standardized seed/e2e credentials to a shared seed password literal (no backward-compatibility dataset support). - 2026-07-17: Opened T-028 to replace hardcoded seed/e2e/test password literals with
SEED_DEMO_PASSWORDacross both apps. - 2026-07-31: Aligned LMS and CMS branded sign-out confirmation flows (guard behavior, CTA set, Auth.js
pages.signOutwiring, and auth-page test hooks).