Architecture review — LUCT CMS / Portal compatibility

Pilot project: Lesotho (Working Group, 3 Oct 2026) — first acceptance campus; newer CMS screenshots are the feature benchmark; see Lesotho feature analysis and lesotho-pilot.md. Guidance also incorporates Cyberjaya LUCT CMS and Student Portal operational experience. Keep the canonical domain clean; concentrate legacy complexity in the CMS anti-corruption boundary (CAP-53). This phase (confirmed 23 Sep 2026; pilot campus updated 3 Oct): existing campus CMS family remains System of Record; portals via Portal API; do not duplicate operational desks. Longer term: progressive unified one-stop CMS on verified gaps + migration strategy (target delivery). Ideal (proposed): one product, distributed country databases, white-label and feature flags. Portals never talk to CMS / Postgres / files directly.

Pilot: Lesotho (M4) This phase: CMS = SoR Clean canonical inside Ugly legacy translation outside M5+: progressive unified CMS
SDD & ADR catalogueDesign docs and decisions Distributed CMSCurrent → ideal (one page) Six instructionsCore rules for authors Target shapePortals → domains → ACL → CMS CMS ACL (CAP-53)Anti-corruption layer Source of truthCMS vs Postgres owners Academic modelVersion, campuses, results Active studentNamed status queries Integration opsIdempotency, DQ, audit Doc priorityCritical → later Vercel + Neon PoCDemo host only — not ADR-2/3

Software Design Documents and ADRs

Product baseline for Working Group CMS 2026. Hub: sdd/ · Nomenclature: SDD-15.

Architecture Decision Records

ID Decision Summary Recorded in
ADR-1 Existing CMS as initial backend Campus CMS family remains SoR this phase; portals via Portal API; do not duplicate operational desks. Pilot acceptance campus = Lesotho (3 Oct 2026). Progressive unified CMS is M5+ on verified gaps. SDD-02
ADR-2 Singapore, on UltaHost Host on the existing UltaHost VDS estate in Singapore. Machine size follows the launch stage, not the largest machine already owned. SDD-12
ADR-3 Kubernetes, starting at the minimum Application workloads on k3s. Existing CMS and mail stay off Kubernetes. Start single-node; grow by stage. SDD-12

Software Design Documents

ID Document What it covers
SDD-00 Document control Status, sources, glossary, status meanings
SDD-01 System overview Product intent, portals, constraints, current vs target
SDD-02 Architecture and integration Existing CMS as backend, Portal API, auth, mobile, data
SDD-03 Delivery milestones Admissions-first sequence and release conditions (M1–M5)
SDD-04 Applicant portal Apply → evidence → track → offer → enrol
SDD-05 Student portal Learning, records, fees, support (current v1 frontend)
SDD-06 Lecturer portal Publish, attendance, marking, class communication
SDD-07 Admin / staff CMS Registry, Faculty, Bursary, QA, Marketing, remaining desks
SDD-08 Shared platform Integration spine, security, ops, training
SDD-09 Requirements traceability Product decisions, campus requests, country evidence, UI baseline
SDD-10 Open questions Decisions still required before Live
SDD-11 Capability catalogue All 112 CAP rows from the detailed checklist
SDD-12 Deployment architecture Repositories, VDS hosts, public names, request path
SDD-13 Progress and timeline Latest sheet snapshot, completion, sequence, remaining tasks
SDD-14 Existing CMS vs Raisd portals Cyberjaya LUCT CMS inventory mapped to CAP IDs
SDD-15 Nomenclature Quick links for every CAP-* and M1–M5

Six instructions for documentation authors

  1. Keep the canonical academic model clean — do not copy the legacy schema.
  2. Upgrade CAP-53 from “CMS database adapter” to a documented anti-corruption / integration layer.
  3. Make historical academic state immutable/auditable and calculations reproducible.
  4. Explicitly model statuses, curriculum versions, repeats, transfers, exemptions, and credit/CGPA rules — do not hide them in SQL.
  5. One authoritative owner per domain; cross-system operations must be idempotent, recoverable, and reconcilable.
  6. Assume legacy data is imperfect — detect and expose inconsistencies rather than silently guessing.

Suggested target architecture

Clean domain inside; legacy translation outside. Incremental migration — not a big-bang CMS rewrite.

Portals → Portal API → domains → ACL → CMS

Staff keep using existing CMS UI where it works; new staff screens fill verified gaps only.

flowchart TB
  subgraph Portals
    ST[Student]
    LE[Lecturer]
    SF[Staff]
    AP[Applicant]
  end

  API[Portal API]

  subgraph Domains
    AC[Academic domain]
    FI[Finance domain]
    AD[Admissions domain]
  end

  CAN[(Canonical domain model)]
  PG[(Postgres owned data)]

  subgraph ACL["CMS Anti-Corruption Layer CAP-53"]
    MAP[Mapping]
    VAL[Validation]
    OPS[Operations]
  end

  CMS[(Existing CMS SoR)]

  ST --> API
  LE --> API
  SF --> API
  AP --> API
  API --> AC
  API --> FI
  API --> AD
  AC --> CAN
  FI --> CAN
  AD --> CAN
  CAN --> PG
  CAN --> ACL
  MAP --> CMS
  VAL --> CMS
  OPS --> CMS
          
Cross-cutting: Identity & Authorization · Audit · Idempotency · Reconciliation · Data Quality · Monitoring · Files. Migration grows new domains beside the legacy CMS rather than replacing it in one cutover.

Core academic path (keep this readable)

Developers must understand this without opening legacy SQL.

flowchart TB
  SP[StudentProfile] --> PE[ProgrammeEnrolment]
  PE --> PV[ProgrammeVersion]
  PV --> CM[CurriculumModules]
  PE --> STU[StudyPeriod]
  STU --> MR[ModuleRegistration]
  MR --> RES[ModuleResult]

  MOD[Module] --> MO[ModuleOffering]
  MO --> CAM[Campus]
  MO --> AT[AcademicTerm]
  MO --> TA[TeachingAssignment]

  PV --> APS[AcademicProgressService]
  CM --> APS
  MR --> APS
  RES --> APS
  APS --> TR[TermResult / Progress snapshots]
  TR --> PUB[ResultPublication]
  PUB --> TX[Transcript presentation]
          

CMS Adapter = Anti-Corruption Layer (CAP-53)

Not a 1:1 table access layer. Legacy meaning lives across tables, views, stored procedures, and old PHP. Domain handlers must not embed arbitrary legacy SQL.

Responsibilities

Example: getCurrentStudyPeriod(student) may walk r_student → r_stdprogram → r_stdsemester → f_term → r_stdmodule and return a canonical StudyPeriod.

ResponsibilityIntent
Translate legacy → canonicalCMS records become domain entities
Translate commands → CMS opsDomain writes become required CMS operations
Resolve legacy status meaningsMap ambiguous ACTIVE / registered / current flags
Map legacy IDs ↔ canonical IDsVia LegacyEntityMap — never reuse CMS IDs as PK
Handle campus/schema differencesCampusCmsAdapter where schemas differ
Validate inconsistent legacy recordsRaise DataQualityIssue, do not silently guess
Safe transactional writesDefined boundaries; no partial “success”
Idempotent writesIntegrationOperation + idempotency key
Report mapping / sync failuresSurface to ops and reconciliation
Support reconciliationCounts and domain integrity checks

CMS vs Postgres — one authority per domain

Never leave “CMS says ACTIVE / Postgres says DEFERRED” without a winner.

DomainCurrent authorityFuture authority
Student identityCMSMigration decision TBD
Programme enrolmentCMSTBD
Study periodsCMSTBD
Module registrationCMSTBD
Official resultsCMSTBD
Finance ledgerCMSTBD
Application portal stateDecide explicitly (Portal/Postgres or CMS)Postgres/CMS
Portal sessionPortalPortal
Uploaded file bytesFile storeFile store
Legacy ID maps / DQ issues / auditPortal Postgres (planned)Portal Postgres

Academic model decisions

Keep ProgrammeVersion

Enrolment is pinned to the version under which the student enrolled (e.g. BBA 129 vs 122 credits). Do not resolve requirements from “current” programme config.

ProgrammeCampusOffering (preferred over Faculty-as-campus)

Same programme may be delivered at several campuses while sharing academic identity. Faculty remains organisational; offering is explicit.

flowchart LR
  P[Programme] --> PV[ProgrammeVersion]
  PV --> PCO[ProgrammeCampusOffering]
  PCO --> B[Botswana]
  PCO --> U[Uganda]
  PCO --> S[Sierra Leone]
          
Mirrors ModuleOffering (module × term × campus). See ERD curriculum. ProgrammeEnrolment.campusId remains the student’s study campus.

Credits earned ≠ credits in CGPA

Retain ModuleResult.includedInCgpa. Pass/elective/exemption cases must be representable without special-case transcript SQL.

Valid example: Grade PASS · Credits earned 3 · CGPA points 0 · Included in CGPA false.

Also support: normal, repeat, exemption, credit transfer, pass/fail, audit/non-credit, replacement, additional elective, transferred programme credit — via registration type + result flags, not ad-hoc SQL.

Calculations are reproducible snapshots

Authoritative inputs: ModuleRegistration + ModuleResult + CurriculumModule + ProgrammeVersion + grading rules. TermResult is a calculated snapshot (calculationVersion, calculatedAt), not primary truth.

ResultPublication versions published academic history

Publication is not only publishedAt on the current row. Corrections create a new publication that supersedes the previous; prior versions remain auditable.

AcademicTerm ≠ student’s programme semester

Keep explicit: calendar AcademicTerm (e.g. 2026-03) vs StudyPeriod.programmeSemesterNumber (Semester 5 for one student, Semester 3 for another).

Attempts are never overwritten

ModuleRegistration.attemptNumber + registrationType. Failed then repeat keeps both rows; policy decides CGPA/credit effect.

AcademicProgressService — policy not transcript SQL

calculateModuleOutcome, credits, term GPA, CGPA, graduation progress, prerequisites. Transcript is a presentation of calculated history. One service — not portal/registry/transcript/stored-proc each inventing rules.

History is never rewritten

Defer / return / transfer / withdraw / re-enrol create or transition records. Use EnrolmentStatusHistory (and similar) for auditability.

Define “active student” properly

EMGS active ≠ CMS registered ≠ current semester ≠ current active report. Avoid one universal student.status = ACTIVE.

LayerStatus lives on
IdentityStudentProfile
EnrolmentProgrammeEnrolment
Academic periodStudyPeriod
RegistrationModuleRegistration
ImmigrationStudentVisaPass / ImmigrationCase
FinanceFinanceAccount / account issues

Named queries: isCurrentlyEnrolled(), isAcademicallyActive(), hasCurrentStudyPeriod(), isRegisteredForModules(), hasActiveImmigrationStatus(), isEligibleForStudentPortal(). Every report must state which definition it uses. Demo draft of the cohort definition and open Registry questions: canonical schema confirmations (CS-01–CS-03; Schema v3 policy Demo seeded).

Integration operations

LegacyEntityMap

Canonical UUID ↔ sourceCampus + legacyTable + legacyId. Supports overlapping numeric IDs across campuses.

DataQualityIssue + reconciliation

Expect imperfect legacy data. Flag CURRENT_ENROLMENT_WITHOUT_STUDY_PERIOD, LEGACY_PASSPORT_MISMATCH, etc. CAP-53 reports CMS vs mapped counts and domain integrity checks.

Idempotency — IntegrationOperation

Register module, accept offer, create enrolment, submit marks, publish results, invoice, payment, forms — all keyed. Retries must not double-write.

Transaction / workflow states

Registration → CMS → invoice must define failure modes. UI success only when the agreed boundary completes.

flowchart LR
  P[PENDING] --> PR[PROCESSING]
  PR --> C[CMS_COMMITTED]
  C --> F[FINANCE_PENDING]
  F --> OK[COMPLETED]
  PR --> FAIL[FAILED]
  C --> REC[REQUIRES_RECONCILIATION]
  F --> REC
          

AuditEvent

Who / what / when / why / before / after for marks, grades, registrations, enrolments, finance, publications, visa — not only application logs.

Migration: preserve raw legacy

Legacy source → raw import → mapping/validation → canonical. Never silently “fix” historical academic data on import.

Campus differences = configuration

Effective-dated campus policy tables (Schema v3 Demo: status mapping, academic-status, credit, CGPA, document numbering, adjustments) plus CampusCmsAdapter. Canonical model stays consistent; no if (campus === "botswana") scattered in domain services.

Identity ≠ legacy password behaviour

IdP → stable subject → Person/Student/Staff mapping. Legacy password/email login stays behind a temporary adapter — not canonical design.

RBAC scopes

Beyond role × method × campus: faculty, programme, module offering, operation. A Botswana lecturer does not automatically see every Botswana student’s marks.

ResultAccessPolicy

Rule-based (campus, funding, balance, holds, term) → allowed + reasonCode. Not hardcoded if (balance < 1000) in the portal.

Integrity constraints (document intentionally)

Examples: unique studentNumber where applicable; unique StudyPeriod(enrolment, term); ModuleResult 1:1 registration; TermResult 1:1 study period; controlled registration uniqueness by attempt; payment allocation limits; offer accept must not create two first enrolments.

Integration health

Beyond API/DB up: adapter latency, failed writes, retry queue, unmapped records, reconciliation mismatches, stuck registrations, file/metadata orphans.

Documentation priority

PriorityChange
CriticalCMS Adapter as anti-corruption layer
CriticalCMS/Postgres source-of-truth ownership
CriticalLegacy-ID mapping
CriticalTransaction + retry/idempotency behaviour
CriticalDeterministic academic calculation rules
CriticalExplicit active/enrolled/registered statuses
HighResult/calculation versioning
HighAudit trail
HighMigration reconciliation / data quality
HighProgramme campus offering independently
HighRepeats / exemptions / transfers / pass-fail
HighCampus-specific configuration strategy
MediumIntegration monitoring
MediumResult-access / finance policy
MediumRBAC scopes beyond campus
LaterFully retire legacy CMS procedures/views
Do not over-engineer the new system because the old CMS is complicated. Complexity belongs at the boundary: clean canonical domain inside; ugly legacy translation outside. Related diagrams: Architecture · ERD · DFD · LUCT CMS analysis.