Guardian SIS — Implementation Guide

Onboarding an institution, end to end

Written for implementation engineers and institutional IT. 21 steps · worked example: Guardian Demo University (GUARDIANDE3D70)

How to use this. Each step is a procedure — where to do it (UI path and API endpoint), what to enter, what to verify, and what breaks if you skip it. The values shown are the ones used in the Guardian Demo University tenant, so every SQL block in this guide returns real rows and can be run as you go.
Substitute your own code. Every SQL block filters on institution_code = 'GUARDIANDE3D70'. Replace it with your institution's code — and check the code first, because step 1 explains how a wrong one makes these queries match nothing, silently.

How to use this

Work the steps in order. The phases build on each other: nothing academic can be created before the institutional foundation exists, and nothing operational before the academic structure does.

PhaseWhat it establishes
FoundationTenant, campuses, settings, hierarchy, awards, grading and the calendar. Nothing academic can be created until these exist.
AcademicsProgrammes, courses, curriculum, sections and the money that attaches to them.
People & operationsRoles, staff, registration windows, compliance configuration and the verification pass.

This is Pass 1 of 3 — the Foundation phase. The remaining steps are appended as they are verified against the live tenant, so every step you read here has been executed rather than described.

Run it, do not retype it. Every step below carries its requests as Bruno .bru files — the URL, the auth and the body together, so they can be pasted into Bruno and executed. The whole 21-step collection is generated by scripts/_build_bruno_collection.py, with one folder per step and a status assertion on every request.
Why the assertion matters. Bruno reports PASS on a 403 when a request carries no assertion — its PASS means "the request executed", not "the API is healthy". Every request in this guide asserts a 200, so a run can genuinely fail.

Before you start

You needWhy
Platform super-admin accessstep 1 creates a tenant, which is a platform operation
The tenant administrator accountsteps 2–21 are all tenant-scoped configuration
Read access to the databasethe verification queries in each step read the tables directly — a scoped application connection will not see across tenants, which is by design
On reading the database. Tenant tables are protected by row-level security, and the wall applies even to the table owner. An unscoped query therefore returns zero rows — which looks exactly like an empty table. Every verification query below is written to be run as a superuser, or with the tenant scope set explicitly. If a query returns 0, suspect the scope before suspecting the data.

Foundation

Tenant, campuses, settings, hierarchy, awards, grading and the calendar. Nothing academic can be created until these exist.

Step 1 · Foundation
Create the tenant
WherePlatform console → Institutions → New Institution (/super-admin/institutions/new) — or POST /api/v1/super-admin/institutions/onboard
Depends onNothing. This is the root — every other step acts inside the tenant this one creates.
BlocksEverything.
Why this step matters

Two creation paths exist and they produce different tenants. Only one of them lets you choose the institution code that every verification query in this guide filters on.

What to enter

FieldValueNotes
Institution nameGuardian Demo Universityappears on the console and in the tenant's own UI
Institution codeGUARDIANDE3D70A–Z, 0–9 and underscore only; this is the key every SQL block in this guide filters on
Domaindev.guardiansis.comthe hostname this tenant's public pages answer on
Compliance profileferpadrives which compliance surfaces the tenant sees
The one thing this step decides: the institution CODE

Every verification query in this guide filters on the institution code. If the code is not what you expect, those queries match zero rows and look like they are working — the failure is silent.

Only the onboard path can set the code. The form at /super-admin/institutions/new has a Code field and its endpoint accepts and uses it verbatim.

The provision path cannot set it at all. It generates one — the first characters of the name plus a random suffix — so “Guardian Demo University” becomes something like GUARDIANDE3D70 rather than anything you chose.

Two things this path also creates, which the other does not do consistently

The role templates. Onboarding seeds the tenant's roles with their permission matrix, so day-one access control is already correct. Verify it in step 15.

The tenant administrator. An account is created for the person who will configure the institution. If you provision instead, note that the tenant's domain is left empty — see step 17 for why that matters.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

List Institutions (verify the code).bru
meta {
  name: List Institutions (verify the code)
  type: http
  seq: 4
  tags: [
    1-create-the-tenant
  ]
}

get {
  url: {{baseUrl}}/api/v1/super-admin/institutions
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 1 verification. Confirm the tenant exists with the code you chose.

  ⚠️ THIS ENDPOINT REQUIRES A PLATFORM SUPER-ADMIN. A tenant administrator gets 403, correctly — it is a platform operation, not a tenant one. Use a super-admin token for step 1 and a tenant-admin token from step 2 onward. Measured.
}
Onboard Institution.bru
meta {
  name: Onboard Institution
  type: http
  seq: 3
  tags: [
    1-create-the-tenant
  ]
}

post {
  url: {{baseUrl}}/api/v1/super-admin/institutions/onboard
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "name": "Guardian Demo University",
    "code": "GUARDIANDE3D70",
    "domain": "dev.guardiansis.com",
    "institution_type": "us_university",
    "admin_email": "admin@your-institution.edu"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 1. The `code` you set here is what every SQL block in the guide filters on. The /provision path CANNOT set a code — it generates one — and a generated code makes every verification query match zero rows, silently.
}

Request bodies

POST /api/v1/super-admin/institutions/onboard — request body
{
  "name": "Guardian Demo University",
  "code": "GUARDIANDE3D70",
  "domain": "dev.guardiansis.com",
  "institution_type": "us_university",
  "admin_email": "admin@your-institution.edu"
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the tenant exists with the code you chose
select institution_id, institution_code, institution_name, domain, is_trial
  from institutions
 where institution_code = 'GUARDIANDE3D70';
Confirm the role templates were seeded (step 15 verifies the permissions)

6 degrees are defined in step 5, but 14 roles should already exist here.

select count(*) as roles
  from roles
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70');

Verify this step

  • The console lists the institution with the code you chose.
  • GET /api/v1/super-admin/institutions returns it.
  • The tenant administrator can sign in.

If you skip it

  • Wrong or generated code → every SQL block in this guide silently matches nothing.
  • No domain set → the tenant's public catalogue cannot resolve by host (step 17 covers the fallback).
↑ back to top
Step 2 · Foundation
Campuses
WhereTenant admin → Institution Settings → Campuses (/admin/institution-settings) — or POST /api/v1/admin/campuses
Depends onStep 1.
BlocksSections and terms reference a campus, so create at least one before either.
Why this step matters

A campus is where a section physically meets and where capacity lives. One is enough to start; the model supports many.

What to enter

FieldValueNotes
Campus codeMAINappears in the audit trail and on registers
Campus nameMain Campususer-facing
CitySpringfieldused on official documents
Mark as main campusYesexactly one campus should be the main one

Set the main campus deliberately

The product asks which campus is the main one, and it is worth answering rather than accepting a default: documents and defaults resolve against it. Exactly one campus should carry the flag.

The demonstration tenant has a single campus, MAIN, and it is the main one. Institutions with several locations create each and flag one — overlapping or missing main-campus flags are a common misconfiguration.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Campus.bru
meta {
  name: Create Campus
  type: http
  seq: 5
  tags: [
    2-campuses
  ]
}

post {
  url: {{baseUrl}}/api/v1/admin/campuses
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "campus_code": "MAIN",
    "campus_name": "Main Campus",
    "city": "Springfield",
    "is_main": true
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 2. Exactly one campus should carry is_main. Sections and terms reference a campus.
}
List Campuses.bru
meta {
  name: List Campuses
  type: http
  seq: 6
  tags: [
    2-campuses
  ]
}

get {
  url: {{baseUrl}}/api/v1/admin/campuses
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 2 verification. Check the main-campus flag on the returned rows.
}

Request bodies

POST /api/v1/admin/campuses — request body
{
  "campus_code": "MAIN",
  "campus_name": "Main Campus",
  "city": "Springfield",
  "is_main": true
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm exactly one campus is flagged main

If this returns more than one is_main = true, fix it before continuing.

select campus_code, campus_name, is_main, city
  from campuses
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by campus_id;

Verify this step

  • The campus appears in the Campuses table with an active badge.
  • The institution card reflects the campus count.

If you skip it

  • No campus → sections cannot be created cleanly (step 12).
↑ back to top
Step 3 · Foundation
Institutional settings
WhereTenant admin → Institution Settings (/admin/institution-settings) — or PATCH /api/v1/admin/institution
Depends onStep 1.
BlocksTerm dates, date formats and document rendering all read from here.
Why this step matters

Regional and identity settings decide how dates render, what timezone terms open in, and how the institution is named on the documents it issues.

What to enter

FieldValueNotes
Default timezoneAmerica/New_Yorkall term dates are interpreted in it
Date formatYYYY-MM-DDused on screens and documents
Themeslatethe institution's own palette
Contact details(optional)printed on official documents when set

Two settings that are easy to skip and expensive to change later

The timezone. Term start, census and deadline dates are stored as dates but shown in the institution's timezone. Getting this wrong shifts every deadline by hours — enough to matter at a census date.

The date format. It governs every date the institution prints. An institution that expects day-first dates should set it before generating any transcript, because those documents are issued, not regenerated.

The institution code is read-only here by design: changing it would break the audit chain and every external reference, so the field exists to be seen, not edited.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Read Institution Settings.bru
meta {
  name: Read Institution Settings
  type: http
  seq: 7
  tags: [
    3-institutional-settings
  ]
}

get {
  url: {{baseUrl}}/api/v1/admin/institution
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 3. Shows what the product stores: timezone, date format, theme, identity.
}
Update Institution Settings.bru
meta {
  name: Update Institution Settings
  type: http
  seq: 8
  tags: [
    3-institutional-settings
  ]
}

patch {
  url: {{baseUrl}}/api/v1/admin/institution
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "timezone": "America/New_York",
    "date_format": "YYYY-MM-DD",
    "theme_key": "slate"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 3. Timezone governs how term deadlines render; date format governs every document the institution issues.
}

Request bodies

PATCH /api/v1/admin/institution — request body
{
  "timezone": "America/New_York",
  "date_format": "YYYY-MM-DD",
  "theme_key": "slate"
}
Confirm what the product stores (read-only view)
GET /api/v1/admin/institution

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the settings persisted
select institution_name, institution_code, timezone, date_format, theme_key
  from institutions
 where institution_code = 'GUARDIANDE3D70';

Verify this step

  • Save the form and confirm the values persist after a reload.
  • Confirm the institution name renders on a document preview.

If you skip it

  • Wrong timezone → deadlines off by hours at census.
  • Wrong date format → issued documents carry the wrong convention.
↑ back to top
Step 4 · Foundation
Departments (the academic hierarchy)
WhereTenant admin → Academic Structure → Departments (/admin/departments) — or POST /api/v1/admin/departments
Depends onStep 3.
BlocksProgrammes, courses and staff appointments all hang off departments.
Why this step matters

The hierarchy decides who owns a course, which department head approves a grade, and how workload is reported. It is worth getting the shape right before courses exist, because moving a unit later is a data migration.

What to enter

FieldValueNotes
Top level — collegesCOB · COH · CLA · CTEColleges are top-level units; they do not take a parent
Under COBFIN · MGT · MKTdepartments inside the College of Business
Under COHNUR · HCAdepartments inside the College of Health Sciences
Under CLAENG · HIS · PSYdepartments inside the College of Liberal Arts
Under CTECSCdepartments inside the College of Technology & Eng

The rule the product enforces: a college is top-level

A college sits at the top of the hierarchy and cannot have a parent. A department belongs inside a college. Divisions sit inside departments.

This is enforced rather than advisory: the product refuses a college with a parent, and refuses a department without one. That is deliberate — the shape governs grade-approval routing and workload reporting, and a malformed tree produces approvals that go nowhere.

In the demonstration tenant the hierarchy resolves to 4 colleges and 9 departments, and every department resolves to a college. A useful check before you continue to step 9: every department should have a parent, and no college should.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Department (inside a college).bru
meta {
  name: Create Department (inside a college)
  type: http
  seq: 10
  tags: [
    4-departments
  ]
}

post {
  url: {{baseUrl}}/api/v1/admin/departments
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "department_code": "FIN",
    "department_name": "Finance",
    "unit_type": "department",
    "parent_department_code": "COB"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 4. The product REFUSES a college with a parent and a department without one — that rule is what keeps grade-approval routing coherent.
}
Create Department (top-level college).bru
meta {
  name: Create Department (top-level college)
  type: http
  seq: 9
  tags: [
    4-departments
  ]
}

post {
  url: {{baseUrl}}/api/v1/admin/departments
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "department_code": "COB",
    "department_name": "College of Business",
    "unit_type": "college"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 4. A college sits at the top of the hierarchy and takes no parent.
}

Request bodies

POST /api/v1/admin/departments — a top-level college
{
  "department_code": "COB",
  "department_name": "College of Business",
  "unit_type": "college"
}
POST /api/v1/admin/departments — a department inside it
{
  "department_code": "FIN",
  "department_name": "Finance",
  "unit_type": "department",
  "parent_department_code": "COB"
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Read the whole hierarchy in one query — the check that matters

Every college should read [top level]; every department should name its college.

select d.department_code || ' (' || d.unit_type || ')' ||
       case when p.department_code is not null
            then ' under ' || p.department_code
            else ' [top level]' end as placement
  from departments d
  left join departments p on p.department_id = d.parent_department_id
 where d.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by d.department_id;
Prove the rule is enforced, not advisory — count violations

Must be 0. The product refuses this at write time; a non-zero count means something wrote around it.

select count(*) as colleges_with_a_parent
  from departments d
 where d.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and d.unit_type = 'college'
   and d.parent_department_id is not null;

Verify this step

  • The department list shows the parent/child relationship.
  • Attempt to create a college with a parent — the product should refuse it.

If you skip it

  • Courses need a department (step 10).
  • Grade approval routes through the department head (step 16).
↑ back to top
Step 5 · Foundation
Degrees (the awards you may confer)
WhereTenant admin → Academic Structure → Degrees (/config/academics/degrees) — or POST /api/v1/config/academics/degrees
Depends onStep 4.
BlocksProgrammes reference a degree; transcripts name it when conferred.
Why this step matters

A degree is what the institution is authorised to award. It is a different fact from the programme a student is enrolled on — one is what you may confer, the other is what a student is studying.

What to enter

FieldValueNotes
Associate awardsAA — Associate of Arts · AAS — Associate of Applied Science2 years
Bachelor awardsBA · BS · BBA · BSN4 years, 120 credits

Degrees versus programmes — the distinction that trips people up

A degree is the award: “Bachelor of Science”. A programme is the course of study that leads to it: “BSc Nursing”. Several programmes can lead to the same degree, and a programme can be revised over time without the degree changing.

Define the degrees first, because the programme form in step 9 selects from this list — and so does the transcript that is eventually issued in the student's name.

The demonstration tenant defines 6 degrees: AA, AAS, BA, BS, BBA and BSN, spanning associate and bachelor levels with their credit totals.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Degree.bru
meta {
  name: Create Degree
  type: http
  seq: 12
  tags: [
    5-degrees
  ]
}

post {
  url: {{baseUrl}}/api/v1/config/academics/degrees
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "degree_code": "BSN",
    "degree_name": "Bachelor of Science in Nursing",
    "degree_level": "bachelor",
    "duration_years": 4,
    "total_credits_required": 120
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 5. A degree is what the institution may CONFER; a programme (step 9) is what a student is ENROLLED on. Define degrees first.
}
List Degrees.bru
meta {
  name: List Degrees
  type: http
  seq: 13
  tags: [
    5-degrees
  ]
}

get {
  url: {{baseUrl}}/api/v1/config/academics/degrees
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 5 verification. The demonstration tenant defines 6: AA, AAS, BA, BS, BBA, BSN.
}

Request bodies

POST /api/v1/config/academics/degrees — request body
{
  "degree_code": "BSN",
  "degree_name": "Bachelor of Science in Nursing",
  "degree_level": "bachelor",
  "duration_years": 4,
  "total_credits_required": 120
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the award set

6 awards are expected: AA, AAS, BA, BS, BBA, BSN.

select degree_code, degree_name, degree_level, total_credits_required
  from degrees
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by degree_id;

Verify this step

  • The degree list shows each award with its level and credit total.

If you skip it

  • Programmes cannot declare their award (step 9).
↑ back to top
Step 6 · Foundation
Grade scales
WhereTenant admin → Academic Structure → Grade Scales (/config/academics/grade-scales) — or POST /api/v1/config/academics/grade-scales then its /entries endpoint
Depends onStep 4.
BlocksEvery grade entered against a section resolves through a scale, and GPA is computed from the scale's points.
Why this step matters

The scale is where marks become letters and letters become GPA points. It is the arithmetic behind every transcript the institution issues.

What to enter

FieldValueNotes
Scale codeSTD_LETTERthe institution's primary scale
Scale typeletterletter · pass_fail · numeric · custom
DefaultYesexactly one scale should be the default
EntriesA+ 4.0 · A 4.0 · A- 3.7 · B+ 3.3 · B 3.0 · B- 2.7 · C+ 2.3 · C 2.0 · C- 1.7 · D+ 1.3 · D 1.0 · D- 0.7 · F 0.0the default scale's full entry set

The product ships eight scales, and one of them must be the default

A new tenant is provisioned with a working vocabulary of scales rather than an empty list — which matters, because a section cannot accept a grade until a scale resolves for it:

ScaleTypeUsed for
STD_LETTER — Standard Letter 4.0 (+/-) (default)letterthe institution's ordinary grading
STRAIGHT_LETTER — Straight Letter, no +/-letterinstitutions that do not split grades
PASS_NOPASSpass_failelectives, transfer credit
S_U — Satisfactory / Unsatisfactorypass_failgraduate and professional programmes
NUMERIC_100 — Numeric Bands 0–100numericinstitutions that publish percentages
WEIGHTED_LETTER — Honors / AP-IBlettersecondary and dual-credit work
COMPETENCY — Mastery, 4 levelcustomcompetency-based programmes
INT_BANDED — International / banded numericnumericnon-US grading conventions
Choose the default deliberately

The default scale is what a section falls back to when nothing else resolves. If it is wrong or unset, grades can be entered against the wrong arithmetic — and the resulting GPA is quietly incorrect rather than visibly broken.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

List Grade Scales.bru
meta {
  name: List Grade Scales
  type: http
  seq: 14
  tags: [
    6-grade-scales
  ]
}

get {
  url: {{baseUrl}}/api/v1/config/academics/grade-scales
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 6. Eight scales are seeded with the tenant; exactly one must be the default. The default is what a section falls back to, so a wrong default makes GPA quietly incorrect.
}

Request bodies

POST /api/v1/config/academics/grade-scales — request body
{
  "scale_code": "STD_LETTER",
  "scale_name": "Standard Letter 4.0 (+/-)",
  "scale_type": "letter",
  "is_default": true
}
POST /api/v1/config/academics/grade-scales/{scale_id}/entries — one entry
{
  "grade_label": "A-",
  "numeric_min": 90,
  "numeric_max": 92,
  "grade_points": 3.7,
  "is_passing": true,
  "sort_order": 3
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm exactly ONE default scale

Eight scales are seeded with the tenant. Exactly one must be is_default = true.

select scale_code, scale_name, scale_type, is_default
  from grade_scales
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by is_default desc, scale_id;
Read the default scale's entries — this is the GPA arithmetic

Expect A+ through F, 13 entries, descending from 4.0 to 0.0.

select e.grade_label, e.grade_points, e.is_passing
  from grade_scale_entries e
  join grade_scales s on s.scale_id = e.scale_id
 where s.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and s.is_default
 order by e.sort_order;

Verify this step

  • The scale list marks exactly one default.
  • The default scale's entries cover the full letter range, including F.

If you skip it

  • No default scale → a section cannot resolve a grade (step 12).
  • Wrong points → every GPA derived from it is wrong.
↑ back to top
Step 7 · Foundation
Terms and the academic calendar
WhereTenant admin → Academic Structure → Terms (/admin/terms) — or POST /api/v1/config/academics/terms
Depends onStep 2 (campus).
BlocksSections, registration windows, invoices and aid years are all dated against a term.
Why this step matters

The term is the unit of time everything else hangs off. Its dates carry the deadlines that actually bind: census, drop, withdrawal and grade deadlines.

What to enter

FieldValueNotes
Current term2026FA — Fall 2026status active
Following terms2026SU · 2027SP · 2027FAstatus planned
Drop deadlineOctober 30after this, a drop is a withdrawal
Withdrawal deadlineNovember 14the last day to leave a course
Census dateSeptember 8the date enrolment is frozen for reporting
Grade deadlineDecember 19when final grades are due
Two term endpoints exist — pick one and stay with it

The product exposes both /api/v1/config/academics/terms and /api/v1/terms. They are different writers. Choose one path for your implementation and use it consistently, because creating the same term through both will produce duplicates and any report that groups by term will then double-count.

The UI at Academic Structure → Terms is the safer route, precisely because it is one path that cannot be double-used by accident.

Define the terms ahead of the one you need

An institution that cannot see next year's terms before this year ends cannot plan registration. Create the current term active and at least the next one planned — the product supports terms existing before they begin, and step 17 opens registration against one of them.

The four dates that bind

These are not decoration; each one changes what the product permits:

DateWhat it governs
Drop deadlinebefore it, a student drops; after it, they withdraw — different financial and academic consequences, and the product distinguishes them (see the worked example)
Withdrawal deadlinethe last day a student may leave a course at all
Census datethe moment enrolment is frozen for regulatory reporting
Grade deadlinewhen final grades must be entered and approved

The demonstration tenant carries four terms — 2026FA active, with 2026SU, 2027SP and 2027FA planned — and the deadlines above.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Term (current).bru
meta {
  name: Create Term (current)
  type: http
  seq: 15
  tags: [
    7-terms
  ]
}

post {
  url: {{baseUrl}}/api/v1/config/academics/terms
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "term_code": "2026FA",
    "term_name": "Fall 2026",
    "start_date": "2026-08-24",
    "end_date": "2026-12-12",
    "census_date": "2026-09-08",
    "drop_deadline": "2026-10-30",
    "withdrawal_deadline": "2026-11-14",
    "grade_deadline": "2026-12-19",
    "status": "active"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 7. TWO term endpoints exist in this product (/config/academics/terms and /terms). Use ONE of them consistently — using both produces duplicate terms, and any report grouped by term then double-counts.
}
List Terms (check for duplicates).bru
meta {
  name: List Terms (check for duplicates)
  type: http
  seq: 16
  tags: [
    7-terms
  ]
}

get {
  url: {{baseUrl}}/api/v1/terms
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 7 verification. Exactly one term should be active. A duplicated term_code here is the failure the warning above describes.
}

Request bodies

POST /api/v1/config/academics/terms — the CURRENT term
{
  "term_code": "2026FA",
  "term_name": "Fall 2026",
  "start_date": "2026-08-24",
  "end_date": "2026-12-12",
  "census_date": "2026-09-08",
  "drop_deadline": "2026-10-30",
  "withdrawal_deadline": "2026-11-14",
  "grade_deadline": "2026-12-19",
  "status": "active"
}
POST /api/v1/config/academics/terms — a FUTURE term
{
  "term_code": "2027SP",
  "term_name": "Spring 2027",
  "start_date": "2027-01-11",
  "end_date": "2027-05-07",
  "status": "planned"
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the terms and which one is live

Exactly one term should be active; the rest planned.

select term_code, term_name, status, start_date, census_date,
       drop_deadline, withdrawal_deadline, grade_deadline
  from terms
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by start_date;
Check for duplicates created by using BOTH term endpoints

Must return zero rows. A duplicate here is the failure this step warns about, and it makes any report grouped by term double-count.

select term_code, count(*) as occurrences
  from terms
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 group by term_code
 having count(*) > 1;
Confirm the four binding dates are set, not blank

Blank dates do not error — they silently permit the wrong operation on the wrong day.

select count(*) as terms_missing_a_binding_date
  from terms
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and (census_date is null or drop_deadline is null
        or withdrawal_deadline is null or grade_deadline is null);

Verify this step

  • The term list shows the current term as active and later terms as planned.
  • Open a term and confirm all four binding dates are set, not blank.

If you skip it

  • No term → sections, invoices and aid years have nothing to attach to.
  • Wrong census/drop dates → the product permits the wrong operation on the wrong day.
↑ back to top

Academics

Programmes, courses, curriculum, sections and the money that attaches to them.

Step 8 · Academics
Shifts, ranks and honours
WhereTenant admin → Academic Structure → Configuration — or POST /api/v1/config/academics/ranks, POST /api/v1/config/academics/shifts
Depends onStep 4 (departments).
BlocksStaff appointments reference a rank; sections reference a shift.
Why this step matters

Ranks are the academic ladder staff are appointed on, and each carries a default teaching load. That default is what makes workload reporting meaningful before anyone has entered a load by hand.

What to enter

FieldValueNotes
RanksPROFESSOR · ASSOCIATE_PROFESSOR · ASSISTANT_PROFESSOR · INSTRUCTOR · LECTURER · ADJUNCT_PROFESSOR · CLINICAL_PROFESSOR · VISITING_PROFESSOR · EMERITUS · TEACHING_FELLOW10 ranks — a full academic ladder
Shift codeDAYshifts define the teaching day a section sits in
Honour thresholds(optional)GPA bands that name honours at graduation

The rank is not decoration — it carries a workload default

Each rank stores a default standard load and a default contract length. The product uses them when a staff member is appointed (step 16), which is what lets workload reporting work on day one rather than after a term of data entry.

The demonstration tenant defines all 10 ranks. Shifts and honours are optional at this stage and can be added when a section needs one.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Rank.bru
meta {
  name: Create Rank
  type: http
  seq: 17
  tags: [
    8-ranks-and-shifts
  ]
}

post {
  url: {{baseUrl}}/api/v1/config/academics/ranks
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "rank_code": "ASSOCIATE_PROFESSOR",
    "rank_name": "Associate Professor",
    "rank_abbr": "Assoc Prof",
    "default_standard_load": 12,
    "default_contract_length": 9
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 8. A rank carries a default teaching load, which is what makes workload reporting meaningful before anyone has entered a load by hand.
}
List Ranks.bru
meta {
  name: List Ranks
  type: http
  seq: 18
  tags: [
    8-ranks-and-shifts
  ]
}

get {
  url: {{baseUrl}}/api/v1/config/academics/ranks
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 8 verification. 10 ranks are seeded — a full academic ladder.
}

Request bodies

POST /api/v1/config/academics/ranks — request body
{
  "rank_code": "ASSOCIATE_PROFESSOR",
  "rank_name": "Associate Professor",
  "rank_abbr": "Assoc Prof",
  "default_standard_load": 12,
  "default_contract_length": 9
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the academic ladder is complete

10 ranks are expected. A missing rank means a staff appointment cannot name it in step 16.

select rank_code, rank_name, default_standard_load, default_contract_length
  from ranks
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by sort_order;

Verify this step

  • The rank list shows the full academic ladder with each rank's default load.
  • A new staff appointment can name a rank (step 16).

If you skip it

  • No ranks → staff appointments cannot declare a rank, so workload reporting falls back to nothing.
  • Missing rank names → appointment is refused or defaults silently.
↑ back to top
Step 9 · Academics
Programmes
WhereTenant admin → Academic Structure → Programs (/admin/programs) — or POST /api/v1/programs
Depends onSteps 4 (department), 5 (degree).
BlocksCourses belong to a programme; students enrol on one; curriculum versions hang off it.
Why this step matters

The programme is the course of study a student is enrolled on. It names the degree it leads to, the department that owns it, and the credits required to complete it.

What to enter

FieldValueNotes
Programme codeBBA-20a year suffix is conventional — it dates the version
Programme nameBachelor of Business Administrationuser-facing
Degree awardedBBAselected from step 5
Owning departmentCOBselected from step 4
Total credits120drives degree-progress percentages

Three programmes, because the shape matters more than the count

The demonstration tenant carries a business, a nursing and a liberal-arts programme — deliberately different in structure, so anything built against one is tested against the others.

CodeProgrammeDegreeOwning college
BBA-20Bachelor of Business AdministrationBBACOB
BSN-20Bachelor of Science in NursingBSNCOH
BALA-20Bachelor of Arts Liberal ArtsBACLA
Programmes have VERSIONS, and that is the point

A programme is not a fixed list of requirements. It has versions, each bound to a catalogue year (step 11). A student is pinned to the version current when they were admitted — which is how an institution changes its requirements without rewriting the rules under students already studying.

Do not treat the version as an afterthought. It is what makes a degree audit defensible.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Programme.bru
meta {
  name: Create Programme
  type: http
  seq: 19
  tags: [
    9-programmes
  ]
}

post {
  url: {{baseUrl}}/api/v1/programs
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "program_code": "BBA-20",
    "program_name": "Bachelor of Business Administration",
    "degree_code": "BBA",
    "department_code": "COB",
    "total_credits_required": 120
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 9. The programme is the course of study. It has VERSIONS (step 11), and a student is pinned to the version current at admission — which is how requirements change without rewriting the rules under students already studying.
}
List Programmes.bru
meta {
  name: List Programmes
  type: http
  seq: 20
  tags: [
    9-programmes
  ]
}

get {
  url: {{baseUrl}}/api/v1/programs
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 9 verification. Capture a program_id from the response — step 11 needs it.
}

Request bodies

POST /api/v1/programs — request body
{
  "program_code": "BBA-20",
  "program_name": "Bachelor of Business Administration",
  "degree_code": "BBA",
  "department_code": "COB",
  "total_credits_required": 120
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the programmes and what each leads to

3 programmes expected: a business, a nursing and a liberal-arts programme.

select program_code, program_name, total_credits_required
  from programs
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by program_id;

Verify this step

  • The programme list shows each programme with its degree and credit total.
  • Opening a programme offers a Versions tab (populated in step 11).

If you skip it

  • No programme → students cannot be enrolled, and courses have no owner.
  • Wrong credit total → every degree-progress percentage is wrong.
↑ back to top
Step 10 · Academics
Courses
WhereTenant admin → Academic Structure → Courses (/admin/courses) — or POST /api/v1/courses
Depends onStep 4 (department).
BlocksSections are instances of a course; curriculum requirements reference courses.
Why this step matters

A course is the definition — code, title, credits, owning department. A section (step 12) is one offering of it in one term. Keeping those apart is what lets a catalogue be reused across terms.

What to enter

FieldValueNotes
Course codeACC201subject prefix + number: ACC201, NUR101, CSC330
Course titleFinancial Accountinguser-facing
Credits3drives GPA weighting and degree progress
Owning departmentFINfrom step 4 — governs who approves its grades

24 courses across the three programmes

The demonstration tenant defines a realistic catalogue: business core (ACC, BUS, FIN, MKT, MGT), nursing (NUR), computing (CSC), and general education (ENG, GEN, HIS, PSY).

Bulk import is the realistic path

An institution arrives with a catalogue, not with twenty-four courses typed one at a time. The product exposes a single create, a bulk import (POST /api/v1/courses/import) and a matching export.

Validate before committing. The useful question about any import is what happens when SOME rows are bad — whether the good rows land or the whole batch is discarded. Confirm that against your own file before you commit it.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Course.bru
meta {
  name: Create Course
  type: http
  seq: 21
  tags: [
    10-courses
  ]
}

post {
  url: {{baseUrl}}/api/v1/courses
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "course_code": "ACC201",
    "course_name": "Financial Accounting",
    "credits": 3,
    "department_code": "FIN",
    "is_active": true
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 10. A course is the DEFINITION; a section (step 12) is one offering of it in one term. Keeping those apart is what lets a catalogue be reused across terms.
}
List Courses.bru
meta {
  name: List Courses
  type: http
  seq: 22
  tags: [
    10-courses
  ]
}

get {
  url: {{baseUrl}}/api/v1/courses
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 10 verification. 24 courses are seeded across roughly ten subject prefixes.
}

Request bodies

POST /api/v1/courses — request body
{
  "course_code": "ACC201",
  "course_name": "Financial Accounting",
  "credits": 3,
  "department_code": "FIN",
  "is_active": true
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the catalogue and its distribution

24 courses expected across roughly ten subject prefixes.

select split_part(course_code, '1', 1) as subject_prefix, count(*) as courses
  from courses
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 group by 1
 order by 1;
Courses with no department — a silent configuration hole

Should be 0. A course with no department has no grade-approval route, and the failure appears later, at approval time.

select count(*) as courses_without_a_department
  from courses
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and department_id is null;

Verify this step

  • The course list shows the catalogue with credits and owning departments.
  • An export produces a file that matches the import format.

If you skip it

  • No courses → sections cannot exist (step 12), so nothing can be registered.
  • A course with no department → its grades have no approval route, discovered only at approval time.
↑ back to top
Step 11 · Academics
Curriculum — catalogue year and programme versions
WhereTenant admin → Academic Structure → Catalog Publish / Curriculum Manager — or POST /api/v1/catalog-years, POST /api/v1/programs/{id}/versions
Depends onSteps 9 (programmes), 10 (courses).
BlocksDegree progress cannot compute until a programme version carries its requirement categories.
Why this step matters

This is where the product stops being a list of courses and becomes a degree. A catalogue year is the container; a programme version is the requirement set; categories are the buckets a student must fill.

What to enter

FieldValueNotes
Catalogue yearTR20-2026status published
Programme version2026-2027 Catalogone per programme
Requirement categories(per programme)core, major, general education
Version statedraft → publishedstudents pin to a published version

The lifecycle that protects students already studying

A programme version moves draft → published → archived. Publishing is the moment a version can be pinned by students, and the typing is deliberate: a published version with students enrolled on it resists being returned to draft, because that would change their requirements mid-degree.

Why this step carries the product's central claim

The worked example in the evaluation reference follows a student flagged at risk. That flag is computed here: a student's completed courses are measured against the categories of the version they are pinned to. Get the version wrong and every degree audit in the institution is wrong — quietly, because a partially-satisfied requirement set reports as "in progress" rather than as an error.

Publish, then verify

A draft catalogue year is invisible to students and to the public catalogue. If registration refuses everyone in step 17, check here first — an unpublished catalogue year is the most common cause.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Catalogue Year.bru
meta {
  name: Create Catalogue Year
  type: http
  seq: 23
  tags: [
    11-curriculum
  ]
}

post {
  url: {{baseUrl}}/api/v1/catalog-years
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "year_code": "TR20-2026",
    "year_name": "2026-2027 Catalog",
    "start_date": "2026-07-01",
    "end_date": "2027-06-30",
    "status": "draft"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 11. Create as DRAFT, add requirements, then publish. A draft catalogue year is invisible to students and to the public catalogue.
}
List Catalogue Years.bru
meta {
  name: List Catalogue Years
  type: http
  seq: 25
  tags: [
    11-curriculum
  ]
}

get {
  url: {{baseUrl}}/api/v1/catalog-years
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 11 verification. Status must read 'published', not 'draft'.
}

Request bodies

POST /api/v1/catalog-years — request body
{
  "year_code": "TR20-2026",
  "year_name": "2026-2027 Catalog",
  "start_date": "2026-07-01",
  "end_date": "2027-06-30",
  "status": "draft"
}
PATCH /api/v1/catalog-years/{id}/publish
returns the year with status = published

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the catalogue year is PUBLISHED, not draft

Must read 'published'. A draft year is invisible to students and to the public catalogue, and registration will refuse everyone in step 17.

select year_code, status
  from catalog_years
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70');
Confirm every programme has a version on that year

Every programme should show at least 1. A programme with 0 versions cannot be enrolled onto and has no degree requirements.

select p.program_code, count(v.program_version_id) as versions
  from programs p
  left join program_versions v on v.program_id = p.program_id
 where p.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 group by p.program_code
 order by p.program_code;

Verify this step

  • The catalogue year reads published, not draft.
  • Every programme reports at least one version.
  • Opening a programme version lists its requirement categories.

If you skip it

  • Unpublished catalogue year → students see no catalogue and registration refuses everyone in step 17.
  • No programme version → the programme cannot be enrolled onto and has no degree requirements, so degree progress reports nothing.
  • Missing categories → a degree audit cannot say what is outstanding.
↑ back to top
Step 12 · Academics
Sections (the classes that actually run)
WhereTenant admin → Academic Records → Sections (/admin/sections) — or POST /api/v1/admin/sections
Depends onSteps 10 (courses), 7 (terms), 2 (campuses), 16 (staff, for the instructor).
BlocksRegistration enrols students into sections; grades attach to them; rosters build from them.
Why this step matters

A section is a course offered in a term, at a campus, with an instructor and a capacity. It is what students register for and what teachers grade.

What to enter

FieldValueNotes
Section number01identifies the offering within its course
CourseACC201 — Financial Accountingfrom step 10
Term2026FAfrom step 7
Instructora staff memberfrom step 16 — do not leave this blank
Capacity35registration refuses beyond it
Room / schedule(optional)used for timetabling
A section with no instructor is a silent failure, not a nuisance

It is the most common half-finished configuration, and it does not error: the section exists, students can register, and the roster builds.

The failure surfaces later and elsewhere — at grade time. Grade entry needs an instructor to attribute the entry to, and approval routes through the department. With no instructor the roster is real but nobody owns it, and the approval chain has nowhere to start.

Live in the demonstration tenant: 3 of 8 sections have no instructor. That is not a fault in the product — it is what a half-configured term looks like, and the query below finds it in one line.

Capacity is enforced, so set it deliberately

current_enrollment is derived from the enrolments, and registration refuses a student beyond max_enrollment. A section left at 0 capacity silently blocks everyone; a capacity far above any real room overstates the timetable. The demonstration tenant shows both ends: one section at 6 of 6 — full, and therefore waitlisted in step 17 — and others at 0 of 35.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Section.bru
meta {
  name: Create Section
  type: http
  seq: 26
  tags: [
    12-sections
  ]
}

post {
  url: {{baseUrl}}/api/v1/sections
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "course_code": "ACC201",
    "term_code": "2026FA",
    "campus_code": "MAIN",
    "section_number": "01",
    "instructor_email": "alice.grant@your-institution.edu",
    "max_enrollment": 35,
    "room": "B-204"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 12. Do NOT leave the instructor blank. A section with no instructor does not error — it fails later, at grade time, because the roster has no owner and the approval chain has nowhere to start.
}
List Sections.bru
meta {
  name: List Sections
  type: http
  seq: 27
  tags: [
    12-sections
  ]
}

get {
  url: {{baseUrl}}/api/v1/sections
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 12 verification. Check each section has an instructor and a sane capacity.

  ⚠️ NOTE THE PATH: the UI reaches sections at /admin/sections, but the API path is /api/v1/sections. UI route and API path are NOT the same thing in this product, and /admin/sections returns 404 to an API client. Measured.
}

Request bodies

POST /api/v1/admin/sections — request body
{
  "course_code": "ACC201",
  "term_code": "2026FA",
  "campus_code": "MAIN",
  "section_number": "01",
  "instructor_email": "alice.grant@your-institution.edu",
  "max_enrollment": 35,
  "room": "B-204"
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Find sections with NO instructor — run this before the term opens

Should be 0 before registration opens. A non-zero result means some rosters have no owner and their grades have no approval route.

select s.section_id, c.course_code, s.section_number
  from sections s
  join courses c on c.course_id = s.course_id
 where s.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and s.instructor_id is null
 order by s.section_id;
Confirm capacity was set, and how full each section is

A section at 0 capacity blocks every enrolment silently. A section at capacity is what drives the waitlist in step 17.

select c.course_code, s.section_number, s.current_enrollment, s.max_enrollment
  from sections s
  join courses c on c.course_id = s.course_id
 where s.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by c.course_code;
Confirm the derived counter agrees with the enrolments

Must return zero rows. current_enrollment is denormalised and must be DERIVED from the enrolments, never incremented — the same discipline the invoice balance needs.

select s.section_id, s.current_enrollment,
       (select count(*) from course_enrollments e
         where e.section_id = s.section_id and e.enrollment_status = 'ENROLLED') as actual
  from sections s
 where s.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and s.current_enrollment <> (select count(*) from course_enrollments e
         where e.section_id = s.section_id and e.enrollment_status = 'ENROLLED');

Verify this step

  • Every section has an instructor assigned (the query above returns 0 rows).
  • current_enrollment agrees with the enrolments for every section.
  • Opening a section shows its roster.

If you skip it

  • No instructor → the roster exists but has no owner, and the grade-approval chain has nowhere to start.
  • Capacity left at 0 → registration silently blocks every student.
  • Section counter drifting from the enrolments → capacity checks and waitlists both act on a wrong number.
↑ back to top
Step 13 · Academics
Fee structures and payment plans
WhereTenant admin → Student Financials → Fee Structures / Payment Plans — or POST /api/v1/billing/fee-structures, POST /api/v1/billing/payment-plans
Depends onStep 14 (tax codes) should follow this.
BlocksInvoices are generated from fee structures; awards are packaged against them.
Why this step matters

A fee structure is what the institution charges. It is the template every invoice line is produced from, so getting it right here means invoices are right for every student afterwards.

What to enter

FieldValueNotes
Tuition per credit$450.00 · type tuition1098-T eligible — this is what the tax statement reports
Technology Fee$150.00 · type feenot 1098-T eligible, deliberately
Billing frequencytermterm · semester · annual · one-time
Payment plan3 instalments, monthlycreated against a student's balance

The flag that decides whether a student's tax statement is correct

Each fee structure carries 1098-T eligibility. Tuition is eligible; a technology fee is not. That single flag is what the 1098-T export sums into Box 1 — and it is set here, on the fee, not at filing time.

Verified on the demonstration tenant: tuition is flagged eligible and the technology fee is not, and the generated export reflects exactly that, per student.

A plan attaches to a balance — and the balance must be current first
  • The invoice must exist first, with its balance derived. A plan created against a stale balance spreads the wrong figure across every instalment.
  • The invoice's stored balance is denormalised. It must be recomputed from the payment ledger, never incremented — a rule the application enforces in its own payment flow, and which any import or script must honour too.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Fee Structure.bru
meta {
  name: Create Fee Structure
  type: http
  seq: 28
  tags: [
    13-fees-and-plans
  ]
}

post {
  url: {{baseUrl}}/api/v1/billing/fee-structures
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "name": "Tuition per credit",
    "fee_type": "tuition",
    "amount": 450.00,
    "billing_frequency": "term",
    "is_1098_t_eligible": true,
    "is_active": true
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 13. The is_1098_t_eligible flag is set HERE, on the fee — not at filing time. It is what the 1098-T export sums into Box 1.
}
Create Payment Plan.bru
meta {
  name: Create Payment Plan
  type: http
  seq: 29
  tags: [
    13-fees-and-plans
  ]
}

post {
  url: {{baseUrl}}/api/v1/billing/payment-plans
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "student_id": 210,
    "invoice_id": 34,
    "installment_count": 3,
    "frequency": "monthly",
    "start_date": "2026-10-01"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 13. A plan splits an existing invoice balance. The invoice must exist first, with its balance derived from the payment ledger — the balance is denormalised and must be recomputed, never incremented.
}

Request bodies

POST /api/v1/billing/fee-structures — request body
{
  "name": "Tuition per credit",
  "fee_type": "tuition",
  "amount": 450.00,
  "billing_frequency": "term",
  "is_1098_t_eligible": true,
  "is_active": true
}
POST /api/v1/billing/payment-plans — request body
{
  "student_id": 210,
  "invoice_id": 34,
  "installment_count": 3,
  "frequency": "monthly",
  "start_date": "2026-10-01"
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the fee set and which fees feed the tax statement

Tuition should be eligible; operational fees should not. An incorrect flag here produces an incorrect 1098-T for every student.

select name, fee_type, amount, is_1098_t_eligible
  from fee_structures
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by fee_id;
Confirm every payment plan's instalments sum to its total

instalments_sum must equal total_amount. Rounding is the usual cause when it does not — the remainder belongs on the final instalment.

select p.plan_id, p.total_amount,
       (select coalesce(sum(i.amount), 0) from payment_plan_installments i where i.plan_id = p.plan_id) as instalments_sum
  from payment_plans p
 where p.invoice_id in (select invoice_id from invoices
                       where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70'));

Verify this step

  • Fee structures exist with 1098-T eligibility set deliberately on each.
  • A payment plan's instalments sum exactly to its plan total.
  • Generating an invoice from a fee structure produces correct line items.

If you skip it

  • No fee structure → invoices cannot be generated, so billing cannot start.
  • Wrong 1098-T flag → every student's tax statement is wrong, and it is filed as issued.
  • Plan against a stale balance → the wrong figure is spread across every instalment.
↑ back to top
Step 14 · Academics
Tax codes and bank accounts
WhereTenant admin → Student Financials → Configuration — or POST /api/v1/config/finance/tax-codes, POST /api/v1/config/finance/bank-accounts
Depends onStep 13 (fee structures reference a tax code).
BlocksInvoices reference a tax code; payments reference a bank account.
Why this step matters

These are the two references the money side needs to be auditable: what tax treatment a charge carries, and where a payment landed.

What to enter

FieldValueNotes
Tax codeEXEMPT for tuitionUS higher ed is typically exempt; set what your jurisdiction requires
Transaction codeTUITIONcategorises the charge for reporting
Bank accountthe institution's operating accountpayments reference it, which is what makes reconciliation possible

Why these belong in the foundation, not a later tidy-up

Neither is glamorous, and both are easy to skip when the first invoice happens to work without them. The cost appears later:

  • An invoice line with no tax code cannot be reported by tax treatment, and retro-fitting one across a term of invoices is a data change, not a setting.
  • A payment with no bank account cannot be reconciled to a statement, which turns an accounting task into a manual exercise.

Financial configuration is jurisdiction-specific and deliberately left to the implementer. The structure is the same everywhere; the values are not.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Bank Account.bru
meta {
  name: Create Bank Account
  type: http
  seq: 31
  tags: [
    14-tax-and-bank
  ]
}

post {
  url: {{baseUrl}}/api/v1/config/finance/bank-accounts
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "account_name": "Operating Account",
    "account_number_last4": "4471",
    "is_active": true
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 14. A payment with no bank account cannot be reconciled to a statement.
}
Create Tax Code.bru
meta {
  name: Create Tax Code
  type: http
  seq: 30
  tags: [
    14-tax-and-bank
  ]
}

post {
  url: {{baseUrl}}/api/v1/config/finance/tax-codes
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "code": "EXEMPT",
    "name": "Tuition — tax exempt",
    "rate": 0.0,
    "is_active": true
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 14. An invoice line with no tax code cannot be reported by tax treatment, and retro-fitting one across a term of invoices is a data change rather than a setting.
}

Request bodies

POST /api/v1/config/finance/tax-codes — request body
{
  "code": "EXEMPT",
  "name": "Tuition — tax exempt",
  "rate": 0.0,
  "is_active": true
}
POST /api/v1/config/finance/bank-accounts — request body
{
  "account_name": "Operating Account",
  "account_number_last4": "4471",
  "is_active": true
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the financial references exist before the first invoice

Both should be non-zero before invoicing begins. Zero here is not an error at invoice time — it is a reporting gap discovered later.

select 'tax_codes' as reference, count(*) as rows
  from tax_codes
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 union all
select 'bank_accounts', count(*)
  from bank_accounts
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70');

Verify this step

  • Both reference tables return a non-zero count.
  • Creating an invoice line accepts a tax code, and recording a payment accepts a bank account.

If you skip it

  • No tax codes → invoice lines cannot be reported by tax treatment, and retro-fitting is a data change rather than a setting.
  • No bank accounts → payments cannot be reconciled to a bank statement.
↑ back to top

People & operations

Roles, staff, registration windows, compliance configuration and the verification pass.

Step 15 · People & operations
Roles and permissions
WhereTenant admin → Administration & Governance → Users & Roles (/admin/roles) — or POST /api/v1/admin/roles, PUT /api/v1/admin/roles/{id}/action-permissions
Depends onStep 1 (onboarding seeds the templates).
BlocksEvery permission decision downstream — who can grade, who can bill, who can see a record at all.
Why this step matters

Roles are how a FERPA-shaped institution delegates access. The product seeds a working set at onboarding, so this step is normally verification and adjustment rather than construction.

What to enter

FieldValueNotes
Seeded roles14System Admin · Registrar · Faculty · Student · Bursar · Financial Aid · Advisor · Dept Chair · Auditor · IR Officer · Admissions Officer · Title IX Coordinator · Veterans SCO · Disability Services
Permission rows53the matrix the roles resolve to
Custom roles(optional)built in the Role Designer when a built-in role grants too much or too little

The seeded set is a starting point, not a blank sheet

Onboarding creates the tenant's roles with their permission matrix already resolved, so day-one access control is correct rather than deliberately empty. Your job here is to verify the grants match your institution's intent and add custom roles where they do not.

Verify the grants — the seeded set is not uniform, and one role has none

Measured on the demonstration tenant, permissions per role run from 17 down to zero:

RoleGrants
Registrar17 — the broadest tenant role
System Admin14 — configuration and user management, and no education-record access (FERPA-correct by design)
Faculty · Bursar · Dept Chair3 each — scoped to their own domain
Financial Aid · Student · Admissions Officer · Disability Services · Veterans SCO2 each
Advisor · Auditor · Title IX Coordinator1 each
IR Officer0 — no grants at all

The narrow grants are correct-by-design: a student sees only their own record, faculty grade only within their department, a bursar only money. That is the FERPA posture working, not a gap.

⚠️ But IR Officer holding zero permissions is a real finding, recorded as item 62's family. An institution that appoints an Institutional Research Officer — the role whose job is producing regulatory reporting — will find they can reach nothing. Check this before appointing one, and grant what the role needs through the Role Designer rather than assuming the seeded set covers it.

Two structural facts worth knowing before you edit anything

The matrix is per-entity. A grant is a row of (entity, permission, can_view/can_add/can_edit/can_delete, scope) — it says "this role may edit this entity", not "this role has this capability everywhere". Role Designer changes take effect immediately, so test with a real login after each change.

Some screens gate on a role string, not on a permission. Where that is true, a permission grant alone will not open the screen. The most visible case is financial aid: the aid screens check for an aid-staff role or financial:view_all, and a platform administrator is refused by design.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

List Roles.bru
meta {
  name: List Roles
  type: http
  seq: 32
  tags: [
    15-roles
  ]
}

get {
  url: {{baseUrl}}/api/v1/admin/roles
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 15. Read the grants carefully. They run from 17 down to ZERO, and IR Officer holds none — a role that can log in and reach nothing, which presents as a broken account rather than a permissions decision.
}

Request bodies

POST /api/v1/admin/roles — request body
{
  "name": "Enrollment Coordinator",
  "description": "Registration and enrollment operations"
}
PUT /api/v1/admin/roles/{role_id}/action-permissions — grant a permission
{
  "grants": [
    { "entity_name": "students", "permission": "student:view_all",
      "can_view": true, "scope": "INSTITUTION" }
  ]
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Read the whole permission matrix — the check that matters

14 roles expected. Read the far end of this list: any role with 0 grants can log in and reach nothing, which looks like a broken account rather than a permissions decision.

select r.name as role, count(rp.permission_id) as grants,
       coalesce(string_agg(distinct rp.permission, ', ' order by rp.permission), '(none)') as permissions
  from roles r
  left join role_permissions rp on rp.role_id = r.role_id
 where r.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 group by r.name
 order by count(rp.permission_id) desc, r.name;
Prove a role cannot escalate beyond its scope — colleges as a worked case

Faculty and Dept Chair should hold grade:edit_dept (their department only). If either holds grade:manage, it reaches the whole institution and the scoping is wrong.

select r.name, rp.permission, rp.scope
  from role_permissions rp
  join roles r on r.role_id = rp.role_id
 where r.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and r.name in ('Faculty', 'Dept Chair', 'Advisor')
 order by r.name, rp.permission;

Verify this step

  • The role list shows all 14 roles with their grants.
  • A user assigned to a role can sign in and reach that role's surfaces.
  • A role with 0 grants is identified before anyone is appointed to it.

If you skip it

  • Unverified grants → someone holds access they should not, or nothing they need.
  • Appointing an IR Officer without checking → a role that can reach nothing, which presents as a broken account rather than a permissions decision.
↑ back to top
Step 16 · People & operations
Staff users
WhereTenant admin → Administration & Governance → Users & Roles (/admin/users) — or POST /api/v1/admin/users
Depends onSteps 15 (roles), 4 (departments), 8 (ranks).
BlocksSections need an instructor (step 12); grade approval routes to a department head.
Why this step matters

Staff accounts are what make the structure operate. The product separates the role a person holds from the appointments they carry — a department head is an appointment, not a role.

What to enter

FieldValueNotes
Email formatfirstname.lastname@your-domainmust be a valid domain — see the warning below
Rolefrom step 15selected, not typed
Departmente.g. FINscopes what they can grade and see
Ranke.g. ASSOCIATE_PROFESSORfrom step 8; carries the default teaching load
Department heada switch, not a roleany non-student role may be a head
The email domain must be a REAL, valid domain — this is not cosmetic

The product generates tenant email domains, and an earlier version generated domains containing an underscore. An underscore is illegal in a domain label, so the browser's own <input type="email"> validation rejected every address the platform had issued — and blamed the operator's typing.

Measured: a brand-new trial customer could not add a single staff member through the product's own add-user form. The address is the one field where a platform's generated value and the browser's validation rule must agree.

What this means for your implementation: use addresses on a domain you control and that the product treats as valid — letters, digits and hyphens only in each label. Verified on the demonstration tenant, where all staff addresses resolve under gdu.guardiansis.com and every one passes the browser rule.

The head appointment is separate from the role

A department head is an appointment recorded as a flag on the user, not a role in itself. Any non-student role may hold it — a registrar or a bursar can be a head. Students are refused explicitly, because the flag grants approval authority.

The demonstration tenant holds 16 staff accounts spanning all 14 roles, which is what makes it possible to demonstrate every role's own view of the product — including the student's.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Staff User.bru
meta {
  name: Create Staff User
  type: http
  seq: 33
  tags: [
    16-staff
  ]
}

post {
  url: {{baseUrl}}/api/v1/admin/users
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "email": "alice.grant@your-institution.edu",
    "password": "<meets your policy>",
    "first_name": "Alice",
    "last_name": "Grant",
    "role": "faculty",
    "department_code": "FIN",
    "rank_code": "ASSOCIATE_PROFESSOR",
    "can_be_department_head": false
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 16. The email domain must be VALID: letters, digits and hyphens in each label. The browser's own <input type=email> validation rejects an underscore in a domain label, and an earlier platform version generated exactly that — which made a new customer unable to add ANY staff member.
}
List Users.bru
meta {
  name: List Users
  type: http
  seq: 34
  tags: [
    16-staff
  ]
}

get {
  url: {{baseUrl}}/api/v1/admin/users
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 16 verification. Every role that must operate the institution needs a holder.
}

Request bodies

POST /api/v1/admin/users — request body
{
  "email": "alice.grant@your-institution.edu",
  "password": "<a password meeting your policy>",
  "first_name": "Alice",
  "last_name": "Grant",
  "role": "faculty",
  "department_code": "FIN",
  "rank_code": "ASSOCIATE_PROFESSOR",
  "can_be_department_head": false
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm every role has at least one holder

A role with 0 holders cannot be demonstrated and cannot operate. If you intend to show every role, every role needs someone.

select r.name as role, count(u.user_id) as users
  from roles r
  left join users u on u.role = case r.name
      when 'System Admin' then 'system_admin' when 'Registrar' then 'registrar'
      when 'Faculty' then 'faculty' when 'Student' then 'student'
      when 'Bursar' then 'bursar' when 'Financial Aid' then 'financial_aid'
      when 'Advisor' then 'advisor' when 'Dept Chair' then 'dept_chair'
      when 'Auditor' then 'auditor' when 'IR Officer' then 'ir'
      when 'Admissions Officer' then 'admissions'
      when 'Title IX Coordinator' then 'title_ix'
      when 'Veterans SCO' then 'veterans_sco'
      when 'Disability Services' then 'disability' end
   and u.institution_id = r.institution_id and u.deleted_at is null
 where r.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 group by r.name order by count(u.user_id), r.name;
Confirm no staff address sits on an invalid domain

Must return zero rows. Any address here would be REJECTED by the product's own add-user form, which is how the underscore defect was found.

select email
  from users
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and deleted_at is null
   and email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$';

Verify this step

  • Every role that must operate the institution has at least one holder.
  • Staff addresses all pass the validity check (the query returns zero rows).
  • A staff member can sign in and sees only their own role's surfaces.

If you skip it

  • No instructor on a section → the roster has no owner and grades have nowhere to start (step 12).
  • An address on an invalid domain → the product's own add-user form rejects it, which is how the underscore defect was found.
↑ back to top
Step 17 · People & operations
Registration windows — the step most likely to go wrong
WhereTenant admin → Admissions & Registration → Registration (/registration) — or POST /api/v1/registration/periods
Depends onStep 7 (terms), step 12 (sections exist to register into).
BlocksWithout an open window, registration refuses everyone — and the refusal looks like an error rather than a closed window.
Why this step matters

Registration is gated by a dated window, deliberately: enrolment is open by decision, not by default. This step is where an implementation most often appears broken when it is merely closed.

What to enter

FieldValueNotes
Window nameFall 2026 Open Registrationuser-facing
Start / end2026-09-10 → 2026-10-24the window must cover today
ActiveYesa window with the wrong dates and active=true is still closed
Term2026FAthe term the window opens
A closed window refuses everyone, and does not look like a bug

If registration appears broken — students cannot enrol, no obvious error — check the window first. Three ways it is wrong, and all three present identically:

  • No window exists.
  • The window's dates do not cover today. A window three months out is a correctly configured window that is simply not open.
  • The window is inactive.

The symptom is silence: registration is refused and nothing says why. Order the checks in that sequence.

Holds block registration, and they are working when they block

A registration hold is placed against a student — a financial hold, an advising hold — and while active it prevents enrolment. It is an auditable object with a reason, a placer and a date, not a free-text note.

In the demonstration tenant one active financial hold exists, deliberately: it makes it possible to show what a blocked student sees, which is more informative than a clean run. A hold that is not cleared will silently keep blocking after the reason is resolved, so clear holds rather than removing them.

Capacity and waitlists follow from step 12

A section at capacity produces a waitlist rather than an overflow. The waitlist is ordered and the product supports promotion and auto-enrolment — POST /api/v1/registration/waitlist/promote/{section_id}. The demonstration tenant has one full section with students waiting, so the waitlist alerts on the registration screen are populated rather than empty.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Registration Window.bru
meta {
  name: Create Registration Window
  type: http
  seq: 35
  tags: [
    17-registration-windows
  ]
}

post {
  url: {{baseUrl}}/api/v1/registration/periods
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "term_id": 65,
    "name": "Fall 2026 Open Registration",
    "start_date": "2026-09-10T00:00:00Z",
    "end_date": "2026-10-24T23:59:59Z",
    "is_active": true
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 17 — the step most likely to go wrong. A closed window refuses EVERYONE and does not look like a bug. Three failure modes present identically: no window, dates that do not cover today, or inactive. Check them in that order.
}
List Holds.bru
meta {
  name: List Holds
  type: http
  seq: 37
  tags: [
    17-registration-windows
  ]
}

get {
  url: {{baseUrl}}/api/v1/registration/holds
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 17 verification. Know every active hold before demonstrating registration, or a blocked student looks like a bug.
}

Request bodies

POST /api/v1/registration/periods — request body
{
  "term_id": 65,
  "name": "Fall 2026 Open Registration",
  "start_date": "2026-09-10T00:00:00Z",
  "end_date": "2026-10-24T23:59:59Z",
  "is_active": true
}
POST /api/v1/registration/holds — place a hold
{
  "student_id": 210,
  "hold_type": "financial",
  "reason": "Unpaid balance on Fall 2026 invoice"
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm a window is OPEN TODAY — the check that saves an afternoon

The row must exist, be active, and have covers_today = true. All three matter, and registration gives the same silent refusal if any one fails.

select name, start_date, end_date, is_active,
       (now() between start_date and end_date) as covers_today
  from registration_periods
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70');
Confirm the sections that registration must serve are ready

Cross-check with step 12. Registration will succeed into a section with no instructor, and the failure appears later at grade time.

select count(*) as sections_without_an_instructor
  from sections
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and instructor_id is null;
Confirm active holds, so a blocked student is explained rather than mysterious

Each active hold blocks that student from registering. Explain them before you demonstrate registration, or a blocked student looks like a bug.

select s.student_id, s.first_name, s.last_name, h.hold_type, h.reason, h.placed_date
  from registration_holds h
  join students s on s.student_id = h.student_id
 where h.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and h.is_active
 order by s.student_id;

Verify this step

  • A registration window exists, is active, and covers today.
  • A student can be registered into a section inside the window.
  • Active holds are known and explainable before demonstrating registration.

If you skip it

  • No window, or a window that does not cover today → registration refuses everyone silently, and it looks like a fault.
  • An uncleared hold → a student stays blocked after the reason is resolved.
  • Capacity left at 0 → enrolment is silently refused.
↑ back to top
Step 18 · People & operations
FERPA configuration
WhereTenant admin → Administration & Governance → Configuration — or GET/PUT /api/v1/config/ferpa-config
Depends onStep 16 (there must be someone to name as the FERPA contact).
BlocksDirectory-information rules, opt-outs and disclosure authorisations.
Why this step matters

FERPA is not a module bolted on; it is the shape of the product's access model. This step records the institution's own policy so the product can enforce it rather than assume it.

What to enter

FieldValueNotes
FERPA contactferpa_contact_emailthe address students are told to write to
Directory information itemsdirectory_info_itemswhich fields the institution treats as directory information
Opt-out defaultopt_out_defaultwhether students are opted out until they say otherwise
Consent lifetimeconsent_lifetime_dayshow long a disclosure authorisation remains valid

What is actually enforced, and what is configuration

Two different things live under FERPA in this product, and it is worth separating them:

WhatWhere it lives
Enforced by designA system administrator cannot browse student education records. A student sees only their own. Financial-aid data requires an aid role.the API gates — not configurable, and testable
Institution policyWhich fields are directory information; whether opt-out is the default; how long a consent lasts; who the contact isthese settings

The first column is the part worth demonstrating. A platform administrator attempting to open a student's grades is refused by the API, not by a convention — which is exactly what a compliance reviewer asks for. It is also why the aid screens refuse a super-admin: the denial is the feature.

Directory information is a decision your institution must make

FERPA permits an institution to designate certain fields as directory information and release them without consent. What counts varies between institutions and is often constrained by state law.

Set it deliberately rather than accepting a default. The product will enforce whatever you configure — including a choice you did not mean to make.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Read FERPA Config.bru
meta {
  name: Read FERPA Config
  type: http
  seq: 38
  tags: [
    18-ferpa
  ]
}

get {
  url: {{baseUrl}}/api/v1/config/ferpa-config
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 18. Zero rows is a valid answer — it means no policy is recorded yet, which is the state to fix, not an error to investigate.
}
Update FERPA Config.bru
meta {
  name: Update FERPA Config
  type: http
  seq: 39
  tags: [
    18-ferpa
  ]
}

put {
  url: {{baseUrl}}/api/v1/config/ferpa-config
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "ferpa_contact_email": "registrar@your-institution.edu",
    "directory_info_items": ["name", "enrollment_status", "dates_of_attendance", "degrees_awarded"],
    "opt_out_default": false,
    "consent_lifetime_days": 365
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 18. What counts as directory information is an INSTITUTION decision, often constrained by state law. The product enforces whatever you configure — including a choice you did not mean to make.
}

Request bodies

PUT /api/v1/config/ferpa-config — request body
{
  "ferpa_contact_email": "registrar@your-institution.edu",
  "directory_info_items": ["name", "enrollment_status", "dates_of_attendance", "degrees_awarded"],
  "opt_out_default": false,
  "consent_lifetime_days": 365
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the FERPA configuration exists at all

If this returns NO ROWS the institution has no FERPA policy recorded — nothing is broken, but the surface is unconfigured. Zero rows is the state to fix, not an error to investigate.

select ferpa_contact_email, opt_out_default, consent_lifetime_days,
       array_length(directory_info_items, 1) as directory_items
  from ferpa_config
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70');
Confirm who is DENIED education records — the enforced half

These accounts are denied student education records BY DESIGN. Test one: sign in and attempt to open a student's grades. The refusal is the feature, and it is worth demonstrating rather than apologising for.

select u.email, u.role
  from users u
 where u.institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and u.role in ('system_admin', 'admin')
   and u.deleted_at is null;

Verify this step

  • A FERPA configuration row exists with a contact and directory-information items.
  • A system administrator is refused a student's education record — the enforced half, testable in one attempt.

If you skip it

  • No configuration → no FERPA policy is recorded, so the institution's own choices are unspecified.
  • Default directory information unchallenged → fields may be released that the institution did not intend to release.
↑ back to top
Step 19 · People & operations
Status lifecycles
WhereTenant admin → Administration & Governance → Configuration — or POST /api/v1/config/status-workflows
Depends onStep 4 (departments own the records that move through a workflow).
BlocksAny surface that moves a record through states — applications, processes, approvals.
Why this step matters

A status lifecycle is a state machine with permitted transitions. Defining it deliberately is what stops a record reaching a state it cannot legally leave.

What to enter

FieldValueNotes
Workflow namee.g. Applicant pipelineone per record type that moves
Statese.g. inquiry → applied → in_review → decision → enrolledthe product refuses a transition not on this list
Terminal statesenrolled · withdrawn · rejecteda terminal state has no way out

The product's own funnel is the worked example

The applicant pipeline ships as a nine-state funnel, and it is a good template for the shape:

inquiry → applied → in_review → decision_pending → admitted / waitlisted / rejected → enrolled, with withdrawn as an exit.

Why a defined list matters: the product refuses a transition that is not in the workflow. That is what prevents an applicant jumping from inquiry to enrolled without a decision — and it is why a missing state, rather than a wrong one, is the usual defect. If a record cannot reach a state your institution uses, the state is missing from the lifecycle, not broken.

Status workflows and processes are not seeded by default in the demonstration tenant — the applicant funnel is built into the admissions module rather than configured as a workflow. Configure lifecycles here for any record type your institution moves through custom states.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Status Workflow.bru
meta {
  name: Create Status Workflow
  type: http
  seq: 40
  tags: [
    19-lifecycles
  ]
}

post {
  url: {{baseUrl}}/api/v1/config/status-workflows
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "name": "Applicant pipeline",
    "nodes": [
      { "state": "inquiry", "is_initial": true },
      { "state": "applied" },
      { "state": "in_review" },
      { "state": "admitted" },
      { "state": "enrolled", "is_terminal": true }
    ],
    "edges": [
      { "from": "inquiry", "to": "applied" },
      { "from": "applied", "to": "in_review" },
      { "from": "in_review", "to": "admitted" },
      { "from": "admitted", "to": "enrolled" }
    ]
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 19. The product REFUSES a transition not on the list — so a MISSING state, not a wrong one, is the usual defect. If a record cannot reach a state your institution uses, the state is missing from the lifecycle.
}

Request bodies

POST /api/v1/config/status-workflows — request body
{
  "name": "Applicant pipeline",
  "nodes": [
    { "state": "inquiry", "is_initial": true },
    { "state": "applied" },
    { "state": "in_review" },
    { "state": "admitted" },
    { "state": "enrolled", "is_terminal": true }
  ],
  "edges": [
    { "from": "inquiry", "to": "applied" },
    { "from": "applied", "to": "in_review" },
    { "from": "in_review", "to": "admitted" },
    { "from": "admitted", "to": "enrolled" }
  ]
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm which lifecycles are configured (zero is a valid, expected answer)

Zero means nothing is configured yet — not an error. Configure a lifecycle for every record type your institution moves through custom states, and check a refused transition names the missing state rather than failing opaquely.

select count(*) as workflows
  from status_workflows
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70');

Verify this step

  • Any workflow your institution uses is configured, and a refused transition names the missing state rather than failing opaquely.

If you skip it

  • No lifecycle for a record type that moves → records cannot reach states your institution uses, and the refusal does not say why.
↑ back to top
Step 20 · People & operations
Communication templates
WhereTenant admin → Administration & Governance → Communication Templates — or POST /api/v1/communication/templates
Depends onStep 3 (institution identity, which the templates render).
BlocksNotifications the institution sends in its own name.
Why this step matters

Templates are how the institution speaks to students and applicants. They are also the point where an implementation becomes visible to the outside world — the first message a prospect receives is a template.

What to enter

FieldValueNotes
Template namee.g. Application receivedinternal label
Subject / bodywith merge fieldsrendered per recipient at send time
Channelemail · in-appthe product exposes both
Trigger(where applicable)templates can be sent by an event or by hand

Two tables, two purposes — do not confuse them

TableWhat it holds
transcript_templatesthe documents an institution issues — official and unofficial transcripts, verification letters. These are seeded (2 exist in the demonstration tenant) because a transcript must render correctly from day one
notification_templatesthe messages it sends — application received, decision ready, payment due

Write the student-facing message before the term, not during it

The temptation is to defer templates until something needs sending — which is always a moment when nobody has time to write carefully. A decision letter that reaches an applicant with an unrendered merge field or the wrong institution name is a reputational cost with no upside.

Send one to yourself first

Every template should be sent to a real address before it is relied on. Merge fields either resolve or they do not, and the difference is invisible until the message is in someone's inbox. This is the same discipline as verifying a SQL result rather than trusting the query.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Create Notification Template.bru
meta {
  name: Create Notification Template
  type: http
  seq: 41
  tags: [
    20-templates
  ]
}

post {
  url: {{baseUrl}}/api/v1/communication/templates
  body: json
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

body:json {
  {
    "name": "Application received",
    "channel": "email",
    "subject": "We received your application to {{institution_name}}",
    "body": "Dear {{first_name}},\n\nThank you for applying to {{institution_name}}. Your reference is {{application_uid}}.\n\nRegards,\n{{institution_name}} Admissions"
  }
}

assert {
  res.status: eq 200
}

docs {
  STEP 20. Send every template to a REAL address before relying on it — merge fields either resolve or they do not, and the difference is invisible until the message is in someone's inbox.
}
List Transcript Templates.bru
meta {
  name: List Transcript Templates
  type: http
  seq: 42
  tags: [
    20-templates
  ]
}

get {
  url: {{baseUrl}}/api/v1/config/transcript-templates
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 20. These are the DOCUMENTS (transcripts, letters), seeded 2 per tenant — distinct from notification_templates, which are the MESSAGES.
}

Request bodies

POST /api/v1/communication/templates — request body
{
  "name": "Application received",
  "channel": "email",
  "subject": "We received your application to {{institution_name}}",
  "body": "Dear {{first_name}},\n\nThank you for applying to {{institution_name}}. Your application reference is {{application_uid}}.\n\nRegards,\n{{institution_name}} Admissions"
}

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

Confirm the document templates exist (a transcript must render from day one)

At least one must exist and exactly one should be the default. Without one, an official transcript cannot be produced — and transcripts are issued, not regenerated.

select name, is_default
  from transcript_templates
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
 order by is_default desc;
Confirm which notification templates are configured

Zero is common on a fresh tenant and is not an error — but every message the institution intends to send needs one, so treat this count as a to-do rather than a status.

select count(*) as notification_templates
  from notification_templates
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70');

Verify this step

  • At least one transcript template exists and exactly one is the default.
  • A template sent to a real address renders its merge fields correctly.

If you skip it

  • No transcript template → an official transcript cannot be produced, and transcripts are issued rather than regenerated.
  • An untested merge field → the first message a prospect receives is malformed.
↑ back to top
Step 21 · People & operations
Verify the whole set
WhereRead-only. Run these as a superuser, or with the tenant scope set.
Depends onSteps 1–20.
BlocksNothing — this is the pass that decides whether the tenant is ready for students.
Why this step matters

Twenty steps of configuration can each look right in isolation and still leave the tenant unable to do the one thing it exists for. This step asks the product's own health surfaces rather than trusting the steps.

What to enter

FieldValueNotes
Schema at headthe DB stamp must match the codethe app refuses to start otherwise
Seeded configall seeds report oksurfaced on /health
Curriculum healthno orphaned requirementsan analytics endpoint, not a guess
Payment-plan healthinstalments reconcile to plansan analytics endpoint

Ask the product, not the implementer

The final pass is deliberately not a checklist of what you did — it is a set of questions put to the running product. That is the difference between "I configured it" and "it works".

The six checks, in the order that fails fastest

#CheckHow
1The application started at allGET /api/v1/health — a schema mismatch refuses startup, so a 200 here means the schema matches the code
2Reference data seededthe same health payload reports each seeder's status
3Nobody is locked outsign in as each role that must operate the institution
4A student can be enrolledattempt a real registration inside the open window
5A grade can travel the full chainenter, submit, approve — the whole path, not the first step
6The money reconcilesan invoice, a payment, and a 1098-T export that agrees with both
The standard to hold it to

A tenant is ready when a person who did not configure it can use it. Every check above can be run by someone else, without reading this guide, which is the point — configuration that only its author can operate is not finished.

Run it in Bruno

Paste into a Bruno collection, or generate the whole one with scripts/_build_bruno_collection.py. Every request carries a status assertion, so a run can genuinely fail — without one, Bruno reports PASS on a 403.

Curriculum Health.bru
meta {
  name: Curriculum Health
  type: http
  seq: 45
  tags: [
    21-verify
  ]
}

get {
  url: {{baseUrl}}/api/v1/analytics/curriculum-health
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 21, check 3. Orphaned requirements and programmes without versions.
}
Data Integrity.bru
meta {
  name: Data Integrity
  type: http
  seq: 44
  tags: [
    21-verify
  ]
}

get {
  url: {{baseUrl}}/api/v1/analytics/data-integrity
  auth: bearer
}

auth:bearer {
  token: {{token}}
}

assert {
  res.status: eq 200
}

docs {
  STEP 21, check 2. Structural drift detected by the product rather than by a checklist.
}

Request bodies

GET /api/v1/health — the first check
"status": "healthy", "version": "...", "db": "ok", "seed_status": { "...": {"status": "ok"} }
GET /api/v1/analytics/data-integrity — structural drift
returns a list of detected inconsistencies
GET /api/v1/analytics/curriculum-health
returns orphaned requirements and programmes without versions
GET /api/v1/analytics/payment-plan-health
returns plans whose instalments do not reconcile

Verify in the database

Each query states what a correct result looks like. Run them as a superuser, or with the tenant scope set — see the note before step 1 about why an unscoped query returns zero rows.

One query for the whole configuration — what exists and what does not

Compare against the values throughout this guide. A zero where a number is expected is the fastest route to what is missing — and note that a zero is a fact, not necessarily a fault: some entities are legitimately empty on a fresh tenant.

with inst as (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
select 'roles' as entity, count(*) from roles where institution_id = (select institution_id from inst)
union all select 'staff users', count(*) from users where institution_id = (select institution_id from inst) and deleted_at is null
union all select 'campuses', count(*) from campuses where institution_id = (select institution_id from inst)
union all select 'departments', count(*) from departments where institution_id = (select institution_id from inst)
union all select 'degrees', count(*) from degrees where institution_id = (select institution_id from inst)
union all select 'programmes', count(*) from programs where institution_id = (select institution_id from inst)
union all select 'courses', count(*) from courses where institution_id = (select institution_id from inst)
union all select 'catalogue years', count(*) from catalog_years where institution_id = (select institution_id from inst)
union all select 'terms', count(*) from terms where institution_id = (select institution_id from inst)
union all select 'sections', count(*) from sections where institution_id = (select institution_id from inst)
union all select 'fee structures', count(*) from fee_structures where institution_id = (select institution_id from inst)
union all select 'open registration windows', count(*) from registration_periods where institution_id = (select institution_id from inst) and is_active
order by 1;
Confirm no section is left without an owner — the most common readiness failure

Should be 0 before students arrive. This single number is the best predictor of a term that will go smoothly, because the failure it represents is silent until grade time.

select count(*) as sections_without_an_instructor
  from sections
 where institution_id = (select institution_id from institutions where institution_code = 'GUARDIANDE3D70')
   and instructor_id is null;

Verify this step

  • GET /api/v1/health reports healthy with every seeder ok.
  • The configuration census returns no unexpected zero.
  • No section is left without an instructor.
  • A person who did not configure the tenant can use it.

If you skip it

  • Skipping this pass → twenty steps that each looked right in isolation, and a tenant that cannot do the one thing it exists for.
↑ back to top