Workflow Engine API
Base URL: /api/v1/workflow-engine
The generic, configurable approval engine — distinct from Workflow, which models the fixed registration workflow. Here a definition describes a request type and its approval stages, and an instance is one person's request moving through them.
Staff call these "Requests", which is why the permission nodes are REQUESTS.* rather than WORKFLOW.*.
The stage model
A definition owns ordered steps. sort_order is the stage number, and steps sharing a sort_order form one concurrent stage — every approver in that stage must act before the instance advances. It is not a strict per-step sequence; two steps with the same sort_order run in parallel, not one after the other.
Endpoints Overview
Definitions (administration)
Gated on ADMIN.WORKFLOW_ENGINE.
| Method | Endpoint | Description |
|---|---|---|
| GET | /definitions | List workflow definitions |
| POST | /definitions | Create a definition |
| GET | /definitions/{definitionID} | Get a definition with its steps |
| PATCH | /definitions/{definitionID} | Update a definition |
| DELETE | /definitions/{definitionID} | Delete a definition |
| GET | /form-templates | List form templates available to definitions |
Starting a request
Gated on REQUESTS.START.
| Method | Endpoint | Description |
|---|---|---|
| GET | /startable | List the definitions the caller may start |
| GET | /subject-search?q= | Find the person a request is for, when the caller may file for others |
| GET | /definitions/{definitionID}/form | Get the form to fill in for a definition |
| POST | /instances | Start an instance (submit a request) |
Tracking a request
Gated on REQUESTS.LIST.
| Method | Endpoint | Description |
|---|---|---|
| GET | /instances | List instances visible to the caller |
| GET | /instances/{instanceID} | Get one instance with its step history |
| POST | /instances/{instanceID}/cancel | Cancel an instance |
| GET | /files/{fieldResponseID} | Download a file attached to a field response |
| GET | /instances/{instanceID}/enrollment-letter | Download the PDF letter of a completed ENROLLMENT_VERIFICATION request |
| POST | /instances/{instanceID}/enrollment-letter/resend | Email that letter again to its recipients; returns the delivery log |
Approving
Gated on REQUESTS.APPROVALS.
| Method | Endpoint | Description |
|---|---|---|
| GET | /approvals/pending | List steps awaiting the caller's approval |
| GET | /instances/{instanceID}/steps/{instanceStepID}/form | Get the form this step asks the approver to complete |
| POST | /instances/{instanceID}/steps/{instanceStepID}/approve | Approve a step |
| POST | /instances/{instanceID}/steps/{instanceStepID}/reject | Reject a step |
Step forms: asking the approver a question
There are two unrelated form slots in this engine, and confusing them is the easy mistake:
| Slot | Column | Who fills it | When |
|---|---|---|---|
| Intake form | workflow_definitions.form_template_id | the initiator | at POST /instances |
| Step form | workflow_steps.form_template_id | the approver | at .../approve |
Before migration 260 an approver's entire input surface was a free-text notes field. A step may now declare its own template, and each step gets its own blank copy of it — the completed response is workflow_instance_steps.form_response_id, written in the same transaction that closes the step.
Fetch, then submit. GET .../steps/{instanceStepID}/form returns the blank form; the answers go back as values on the approve call, keyed by field code:
POST /workflow-engine/instances/412/steps/908/approve
{
"notes": "Approved, credits confirmed against the sending transcript.",
"values": {
"transfer_courses": [
{ "course": "ENGL 101", "credits": 3, "equivalent": "ENGL 121" },
{ "course": "HIST 110", "credits": 3, "equivalent": "HIST 134" }
]
}
}values is validated field-by-field by the form-builder validation engine, so failures arrive as error.details in the same shape the intake form already produces — not as a single opaque message.
Rejecting skips the form
values is ignored by /reject. An approver turning a request down is never made to fill in a table first.
The response belongs to the subject, not the approver
form_responses.person_id on a step form is the instance subject, matching the intake response — the answers describe the subject, not the person who typed them. Who approved is recorded on the step itself.
Changing a step's form is asymmetric
| Change | Result |
|---|---|
| unset → set | Allowed, even with live instances |
| set → a different template | 409 |
| set → unset | 409 |
Adding a form to a step that has none cannot invalidate anything, because a step form's answers do not exist until the step completes — without this, adding a table to an existing workflow would need a hand-written migration per rollout. Re-pointing a step that already has one is refused because historical answers reference that template's field ids, and would be rendered under another template's labels.
Step forms are also the main consumer of the Table (GRID) field type.
Routing a step: assignee rules
workflow_steps.assignee_rule picks a step's approvers; NULL means anyone holding one of the step's approver roles. Every rule falls back to those roles when it resolves nobody.
| Rule | Approvers |
|---|---|
INITIATOR_PRIMARY_ADVISOR | Every current primary advisor of the subject (Colleague declares one; an advisor link added in the SIS can make more). It used to pick one arbitrarily |
SUBJECT_MAJOR_UNIT_ADMINS | The administrator(s) of the org unit owning the subject's current major(s), climbing to the parent unit when one has none |
FIELD_SECTION_INSTRUCTORS | The instructors of the section an answer names |
FIELD_COURSE_UNIT_ADMINS | The chair of the department owning the course (or section) an answer names, climbing to the dean; ownership is unit_course_codes |
FIELD_PERSON | The person an answer names |
ADVISOR as an approver role means the subject's primary advisor
A role-based step (no rule) that lists ADVISOR among its approver roles does not admit any advisor: only a current primary advisor of the request's subject may act on it. The check is the same in CompleteStep, in the per-instance "can act" answer and in /approvals/pending, which drops the ADVISOR role from items the caller is not primary advisor for. Other advisors may still read the request. "Primary" is Colleague's first-listed open advisement, imported nightly (migration 267).
The FIELD_* rules (migration 294) need assignee_field_code, which must name a field on the intake form or on the form of a step in an earlier stage -- one answered in the same or a later stage would always be blank when the rule runs, so the definition editor refuses it. The field must also draw from a source the rule understands (a section-, course- or person-sourced dropdown). assignee_field_code is ignored, and cleared, for every other rule.
Engine-sourced dropdowns
A SELECT field whose lookup_type_code names one of these sources gets its options from the database when the form is rendered, and a submitted value outside them is refused -- the completion hooks read the values back as ids.
| Source | Options (value) |
|---|---|
ACADEMIC_YEAR | Years not yet begun; all recent years for override roles (academic_year_id) |
REQUEST_TERMS | Terms running or starting within nine months (term_id) |
ACTIVE_COURSES | Active catalog courses (course_id) |
REQUEST_SECTIONS | Sections in terms running now or starting within two weeks (section_id) |
SUBJECT_SECTIONS | The subject's own registered sections in terms running or ended within 60 days (section_id) |
INSTRUCTORS | People who taught a section in the last two years (person_id) |
INDEPENDENT_STUDY_COURSES | Active <subject> 495 courses, one per subject (course_id) |
GET /definitions/{definitionID}/form takes subject_person_id so that a coordinator filing for a student sees the student's sections.
Start validators and completion hooks
Some workflow codes carry code beside their data. A start validator checks the answers at POST /instances and returns field-level errors; a completion hook runs inside the transaction that finalizes the request, so a refusal rolls the final approval back and the approver sees the reason. Hooks write nothing on a WRITE_MODE=restricted instance.
| Workflow code | Start validator | On final approval |
|---|---|---|
LEAVE_OF_ABSENCE | Puts the student on leave | |
WITHDRAWAL | Withdraws the student from open sections | |
NAME_CHANGE | New legal name; old one kept as FORMER | |
TRANSFER_COURSE_APPROVAL | Credits 0-30 | PRE_APPROVED transfer credit |
INCOMPLETE_GRADE | I/IP grade with deadline and default grade | |
DIRECTED_STUDY, INDEPENDENT_STUDY | Course and credits exist | One-student section, instructor, enrollment |
LATE_ADD | Add deadline passed, within week 5, credits in range | Enrollment past the deadline |
ENROLLMENT_VERIFICATION | Mailing address when mailed; email_copies at most 10 well-formed addresses | None in the transaction; after commit the letter is emailed (below) |
SPECIAL_REQUEST | None: the decision is the outcome |
The Records Office requests (migrations 294-297) are described for their users in Records Office Requests. Their hooks are keyed on the forms' field codes, so renaming a field breaks the hook.
The enrollment letter is available only once the request is COMPLETED (409 before), to anyone who may view the request. It is signed with the name of the registrar who approved it, under the ENROLLMENT_LETTER_SIGNATORY_TITLE setting.
After the approval commits, the engine emails the letter in the background (migration 307), one message per recipient with the PDF attached: ENROLLMENT_LETTER_REGISTRAR_EMAIL, the subject's primary email, the initiator's when they filed it on the subject's behalf, and the email_copies answer (email.ParseAddressList, which also validates it at submission). The message is the ENROLLMENT_LETTER_DELIVERY email template. Each attempt is a row in records.enrollment_letter_deliveries, returned as letter_deliveries on the instance detail of a completed request. can_resend_letter is true for a superuser or a viewer whose acting role approves one of the request's steps; it gates POST .../enrollment-letter/resend (403 otherwise), which re-renders and re-sends synchronously and returns the whole log, newest first.
A note on the permission levels
The action routes above — start, cancel, approve, reject — are gated at VIEW, not EDIT, even though they change state. That is deliberate rather than an oversight: whether this caller may act on this instance depends on the row (are they the requester? are they an assigned approver for this stage?), which no node-level grant can express. The node check answers "may you use the requests feature at all"; the service performs the real per-record check. Raising these to EDIT would lock out ordinary requesters without adding any protection.
Uploads
POST /instances accepts multipart bodies so a request can carry attachments. It is one of two paths exempted from the global 1 MB request-body cap (the other is photo upload); the effective ceiling comes from FILE_MAX_SIZE_MB, and the handler enforces the per-file limit itself.
Complete endpoint reference
Every route this domain registers, with the permission node that gates it. Generated from the route table, so it is complete by construction — the sections above explain behaviour, this one guarantees nothing is missing.
Paths are relative to /api/v1/workflow-engine. A node in italics is an exemption rather than a grant: selfservice acts only on the caller's own record, public needs no session at all, and custom carries its own wrapper.
| Method | Endpoint | Permission node | Description |
|---|---|---|---|
| GET | /approvals/pending | REQUESTS.APPROVALS | Get pending |
| GET | /definitions | ADMIN.WORKFLOW_ENGINE | List definitions |
| POST | /definitions | ADMIN.WORKFLOW_ENGINE | Create a definition |
| DELETE | /definitions/{definitionID} | ADMIN.WORKFLOW_ENGINE | Delete a definition |
| GET | /definitions/{definitionID} | ADMIN.WORKFLOW_ENGINE | Get one definition |
| PATCH | /definitions/{definitionID} | ADMIN.WORKFLOW_ENGINE | Update a definition |
| GET | /definitions/{definitionID}/form | REQUESTS.START | Get the definition's form |
| GET | /files/{fieldResponseID} | REQUESTS.LIST | Get one file |
| GET | /form-templates | ADMIN.WORKFLOW_ENGINE | List form templates |
| GET | /instances | REQUESTS.LIST | List instances |
| POST | /instances | REQUESTS.START | Create a instance |
| GET | /instances/{instanceID} | REQUESTS.LIST | Get one instance |
| POST | /instances/{instanceID}/cancel | REQUESTS.LIST | Cancel for a instance |
| GET | /instances/{instanceID}/enrollment-letter | REQUESTS.LIST | Download an enrollment verification letter (PDF) |
| POST | /instances/{instanceID}/enrollment-letter/resend | REQUESTS.LIST | Email an enrollment verification letter again (approving role only) |
| POST | /instances/{instanceID}/steps/{instanceStepID}/approve | REQUESTS.APPROVALS | Approve for a step |
| GET | /instances/{instanceID}/steps/{instanceStepID}/form | REQUESTS.APPROVALS | Get the step's approver form |
| POST | /instances/{instanceID}/steps/{instanceStepID}/reject | REQUESTS.APPROVALS | Reject for a step |
| GET | /startable | REQUESTS.START | List startable workflows |
| GET | /subject-search | REQUESTS.START | Search for the person a request is for |