Guardian SIS — Implementation Guide
Onboarding an institution, end to end
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.
| Phase | What it establishes |
|---|---|
| Foundation | Tenant, campuses, settings, hierarchy, awards, grading and the calendar. Nothing academic can be created until these exist. |
| Academics | Programmes, courses, curriculum, sections and the money that attaches to them. |
| People & operations | Roles, 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.
scripts/_build_bruno_collection.py, with one folder per step and a status assertion on every request.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 need | Why |
|---|---|
| Platform super-admin access | step 1 creates a tenant, which is a platform operation |
| The tenant administrator account | steps 2–21 are all tenant-scoped configuration |
| Read access to the database | the verification queries in each step read the tables directly — a scoped application connection will not see across tenants, which is by design |
Foundation
Tenant, campuses, settings, hierarchy, awards, grading and the calendar. Nothing academic can be created until these exist.
/super-admin/institutions/new) — or POST /api/v1/super-admin/institutions/onboardTwo 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
| Field | Value | Notes |
|---|---|---|
| Institution name | Guardian Demo University | appears on the console and in the tenant's own UI |
| Institution code | GUARDIANDE3D70 | A–Z, 0–9 and underscore only; this is the key every SQL block in this guide filters on |
| Domain | dev.guardiansis.com | the hostname this tenant's public pages answer on |
| Compliance profile | ferpa | drives which compliance surfaces the tenant sees |
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.
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.
}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
{
"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.
select institution_id, institution_code, institution_name, domain, is_trial
from institutions
where institution_code = 'GUARDIANDE3D70';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/institutionsreturns 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).
/admin/institution-settings) — or POST /api/v1/admin/campusesA campus is where a section physically meets and where capacity lives. One is enough to start; the model supports many.
What to enter
| Field | Value | Notes |
|---|---|---|
| Campus code | MAIN | appears in the audit trail and on registers |
| Campus name | Main Campus | user-facing |
| City | Springfield | used on official documents |
| Mark as main campus | Yes | exactly 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.
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.
}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
{
"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.
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).
/admin/institution-settings) — or PATCH /api/v1/admin/institutionRegional 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
| Field | Value | Notes |
|---|---|---|
| Default timezone | America/New_York | all term dates are interpreted in it |
| Date format | YYYY-MM-DD | used on screens and documents |
| Theme | slate | the 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.
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.
}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
{
"timezone": "America/New_York",
"date_format": "YYYY-MM-DD",
"theme_key": "slate"
}GET /api/v1/admin/institutionVerify 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.
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.
/admin/departments) — or POST /api/v1/admin/departmentsThe 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
| Field | Value | Notes |
|---|---|---|
| Top level — colleges | COB · COH · CLA · CTE | Colleges are top-level units; they do not take a parent |
| Under COB | FIN · MGT · MKT | departments inside the College of Business |
| Under COH | NUR · HCA | departments inside the College of Health Sciences |
| Under CLA | ENG · HIS · PSY | departments inside the College of Liberal Arts |
| Under CTE | CSC | departments 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.
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.
}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
{
"department_code": "COB",
"department_name": "College of Business",
"unit_type": "college"
}{
"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.
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;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).
/config/academics/degrees) — or POST /api/v1/config/academics/degreesA 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
| Field | Value | Notes |
|---|---|---|
| Associate awards | AA — Associate of Arts · AAS — Associate of Applied Science | 2 years |
| Bachelor awards | BA · BS · BBA · BSN | 4 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.
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.
}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
{
"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.
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).
/config/academics/grade-scales) — or POST /api/v1/config/academics/grade-scales then its /entries endpointThe scale is where marks become letters and letters become GPA points. It is the arithmetic behind every transcript the institution issues.
What to enter
| Field | Value | Notes |
|---|---|---|
| Scale code | STD_LETTER | the institution's primary scale |
| Scale type | letter | letter · pass_fail · numeric · custom |
| Default | Yes | exactly one scale should be the default |
| Entries | A+ 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.0 | the 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:
| Scale | Type | Used for |
|---|---|---|
| STD_LETTER — Standard Letter 4.0 (+/-) (default) | letter | the institution's ordinary grading |
| STRAIGHT_LETTER — Straight Letter, no +/- | letter | institutions that do not split grades |
| PASS_NOPASS | pass_fail | electives, transfer credit |
| S_U — Satisfactory / Unsatisfactory | pass_fail | graduate and professional programmes |
| NUMERIC_100 — Numeric Bands 0–100 | numeric | institutions that publish percentages |
| WEIGHTED_LETTER — Honors / AP-IB | letter | secondary and dual-credit work |
| COMPETENCY — Mastery, 4 level | custom | competency-based programmes |
| INT_BANDED — International / banded numeric | numeric | non-US grading conventions |
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.
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
{
"scale_code": "STD_LETTER",
"scale_name": "Standard Letter 4.0 (+/-)",
"scale_type": "letter",
"is_default": true
}{
"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.
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;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.
/admin/terms) — or POST /api/v1/config/academics/termsThe 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
| Field | Value | Notes |
|---|---|---|
| Current term | 2026FA — Fall 2026 | status active |
| Following terms | 2026SU · 2027SP · 2027FA | status planned |
| Drop deadline | October 30 | after this, a drop is a withdrawal |
| Withdrawal deadline | November 14 | the last day to leave a course |
| Census date | September 8 | the date enrolment is frozen for reporting |
| Grade deadline | December 19 | when final grades are due |
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:
| Date | What it governs |
|---|---|
| Drop deadline | before it, a student drops; after it, they withdraw — different financial and academic consequences, and the product distinguishes them (see the worked example) |
| Withdrawal deadline | the last day a student may leave a course at all |
| Census date | the moment enrolment is frozen for regulatory reporting |
| Grade deadline | when 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.
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.
}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
{
"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"
}{
"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.
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;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;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.
Academics
Programmes, courses, curriculum, sections and the money that attaches to them.
POST /api/v1/config/academics/ranks, POST /api/v1/config/academics/shiftsRanks 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
| Field | Value | Notes |
|---|---|---|
| Ranks | PROFESSOR · ASSOCIATE_PROFESSOR · ASSISTANT_PROFESSOR · INSTRUCTOR · LECTURER · ADJUNCT_PROFESSOR · CLINICAL_PROFESSOR · VISITING_PROFESSOR · EMERITUS · TEACHING_FELLOW | 10 ranks — a full academic ladder |
| Shift code | DAY | shifts 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.
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.
}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
{
"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.
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.
/admin/programs) — or POST /api/v1/programsThe 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
| Field | Value | Notes |
|---|---|---|
| Programme code | BBA-20 | a year suffix is conventional — it dates the version |
| Programme name | Bachelor of Business Administration | user-facing |
| Degree awarded | BBA | selected from step 5 |
| Owning department | COB | selected from step 4 |
| Total credits | 120 | drives 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.
| Code | Programme | Degree | Owning college |
|---|---|---|---|
BBA-20 | Bachelor of Business Administration | BBA | COB |
BSN-20 | Bachelor of Science in Nursing | BSN | COH |
BALA-20 | Bachelor of Arts Liberal Arts | BA | CLA |
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.
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.
}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
{
"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.
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.
/admin/courses) — or POST /api/v1/coursesA 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
| Field | Value | Notes |
|---|---|---|
| Course code | ACC201 | subject prefix + number: ACC201, NUR101, CSC330 |
| Course title | Financial Accounting | user-facing |
| Credits | 3 | drives GPA weighting and degree progress |
| Owning department | FIN | from 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).
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.
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.
}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
{
"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.
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;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.
POST /api/v1/catalog-years, POST /api/v1/programs/{id}/versionsThis 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
| Field | Value | Notes |
|---|---|---|
| Catalogue year | TR20-2026 | status published |
| Programme version | 2026-2027 Catalog | one per programme |
| Requirement categories | (per programme) | core, major, general education |
| Version state | draft → published | students 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.
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.
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.
}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
{
"year_code": "TR20-2026",
"year_name": "2026-2027 Catalog",
"start_date": "2026-07-01",
"end_date": "2027-06-30",
"status": "draft"
}returns the year with status = publishedVerify 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.
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');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.
/admin/sections) — or POST /api/v1/admin/sectionsA 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
| Field | Value | Notes |
|---|---|---|
| Section number | 01 | identifies the offering within its course |
| Course | ACC201 — Financial Accounting | from step 10 |
| Term | 2026FA | from step 7 |
| Instructor | a staff member | from step 16 — do not leave this blank |
| Capacity | 35 | registration refuses beyond it |
| Room / schedule | (optional) | used for timetabling |
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.
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.
}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
{
"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.
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;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;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_enrollmentagrees 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.
POST /api/v1/billing/fee-structures, POST /api/v1/billing/payment-plansA 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
| Field | Value | Notes |
|---|---|---|
| Tuition per credit | $450.00 · type tuition | 1098-T eligible — this is what the tax statement reports |
| Technology Fee | $150.00 · type fee | not 1098-T eligible, deliberately |
| Billing frequency | term | term · semester · annual · one-time |
| Payment plan | 3 instalments, monthly | created 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.
- 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.
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.
}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
{
"name": "Tuition per credit",
"fee_type": "tuition",
"amount": 450.00,
"billing_frequency": "term",
"is_1098_t_eligible": true,
"is_active": true
}{
"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.
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;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.
POST /api/v1/config/finance/tax-codes, POST /api/v1/config/finance/bank-accountsThese 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
| Field | Value | Notes |
|---|---|---|
| Tax code | EXEMPT for tuition | US higher ed is typically exempt; set what your jurisdiction requires |
| Transaction code | TUITION | categorises the charge for reporting |
| Bank account | the institution's operating account | payments 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.
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.
}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
{
"code": "EXEMPT",
"name": "Tuition — tax exempt",
"rate": 0.0,
"is_active": true
}{
"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.
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.
People & operations
Roles, staff, registration windows, compliance configuration and the verification pass.
/admin/roles) — or POST /api/v1/admin/roles, PUT /api/v1/admin/roles/{id}/action-permissionsRoles 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
| Field | Value | Notes |
|---|---|---|
| Seeded roles | 14 | System Admin · Registrar · Faculty · Student · Bursar · Financial Aid · Advisor · Dept Chair · Auditor · IR Officer · Admissions Officer · Title IX Coordinator · Veterans SCO · Disability Services |
| Permission rows | 53 | the 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.
Measured on the demonstration tenant, permissions per role run from 17 down to zero:
| Role | Grants |
|---|---|
| Registrar | 17 — the broadest tenant role |
| System Admin | 14 — configuration and user management, and no education-record access (FERPA-correct by design) |
| Faculty · Bursar · Dept Chair | 3 each — scoped to their own domain |
| Financial Aid · Student · Admissions Officer · Disability Services · Veterans SCO | 2 each |
| Advisor · Auditor · Title IX Coordinator | 1 each |
| IR Officer | 0 — 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.
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
{
"name": "Enrollment Coordinator",
"description": "Registration and enrollment operations"
}{
"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.
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;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.
/admin/users) — or POST /api/v1/admin/usersStaff 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
| Field | Value | Notes |
|---|---|---|
| Email format | firstname.lastname@your-domain | must be a valid domain — see the warning below |
| Role | from step 15 | selected, not typed |
| Department | e.g. FIN | scopes what they can grade and see |
| Rank | e.g. ASSOCIATE_PROFESSOR | from step 8; carries the default teaching load |
| Department head | a switch, not a role | any non-student role may be a head |
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.
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.
}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
{
"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.
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;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.
/registration) — or POST /api/v1/registration/periodsRegistration 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
| Field | Value | Notes |
|---|---|---|
| Window name | Fall 2026 Open Registration | user-facing |
| Start / end | 2026-09-10 → 2026-10-24 | the window must cover today |
| Active | Yes | a window with the wrong dates and active=true is still closed |
| Term | 2026FA | the term the window opens |
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.
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.
}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
{
"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
}{
"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.
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');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;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.
GET/PUT /api/v1/config/ferpa-configFERPA 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
| Field | Value | Notes |
|---|---|---|
| FERPA contact | ferpa_contact_email | the address students are told to write to |
| Directory information items | directory_info_items | which fields the institution treats as directory information |
| Opt-out default | opt_out_default | whether students are opted out until they say otherwise |
| Consent lifetime | consent_lifetime_days | how 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:
| What | Where it lives | |
|---|---|---|
| Enforced by design | A 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 policy | Which fields are directory information; whether opt-out is the default; how long a consent lasts; who the contact is | these 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.
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.
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.
}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
{
"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.
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');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.
POST /api/v1/config/status-workflowsA 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
| Field | Value | Notes |
|---|---|---|
| Workflow name | e.g. Applicant pipeline | one per record type that moves |
| States | e.g. inquiry → applied → in_review → decision → enrolled | the product refuses a transition not on this list |
| Terminal states | enrolled · withdrawn · rejected | a 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.
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
{
"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.
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.
POST /api/v1/communication/templatesTemplates 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
| Field | Value | Notes |
|---|---|---|
| Template name | e.g. Application received | internal label |
| Subject / body | with merge fields | rendered per recipient at send time |
| Channel | email · in-app | the product exposes both |
| Trigger | (where applicable) | templates can be sent by an event or by hand |
Two tables, two purposes — do not confuse them
| Table | What it holds |
|---|---|
transcript_templates | the 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_templates | the 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.
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.
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.
}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
{
"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.
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;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.
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
| Field | Value | Notes |
|---|---|---|
| Schema at head | the DB stamp must match the code | the app refuses to start otherwise |
| Seeded config | all seeds report ok | surfaced on /health |
| Curriculum health | no orphaned requirements | an analytics endpoint, not a guess |
| Payment-plan health | instalments reconcile to plans | an 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
| # | Check | How |
|---|---|---|
| 1 | The application started at all | GET /api/v1/health — a schema mismatch refuses startup, so a 200 here means the schema matches the code |
| 2 | Reference data seeded | the same health payload reports each seeder's status |
| 3 | Nobody is locked out | sign in as each role that must operate the institution |
| 4 | A student can be enrolled | attempt a real registration inside the open window |
| 5 | A grade can travel the full chain | enter, submit, approve — the whole path, not the first step |
| 6 | The money reconciles | an invoice, a payment, and a 1098-T export that agrees with both |
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.
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.
}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
"status": "healthy", "version": "...", "db": "ok", "seed_status": { "...": {"status": "ok"} }returns a list of detected inconsistenciesreturns orphaned requirements and programmes without versionsreturns plans whose instalments do not reconcileVerify 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.
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;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/healthreports 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.