The failure mode
Turkish universities plan each semester under a strict hierarchy. A course is not simply created — it is proposed by a department, reviewed at faculty level by the dean, approved at university level by the rector, and only then handed to Student Affairs for entry into the OBS student information system.
Run that in spreadsheets and email, and it breaks for reasons that have nothing to do with data volume.
Authority is field-level, not record-level. A dean legitimately edits some attributes of a course a department proposed, and not others. A rector can touch everything. An administrator owns a third set. A spreadsheet cannot express that, so control collapses into whoever currently has the file.
Approval state is invisible, and returns are routine. Nobody can answer what is waiting on me without asking someone. And a course sent back for correction has to re-enter the pipeline at the right stage with the reason attached — not start again from the beginning.
The hand-off is a cliff edge. Student Affairs must act on approved courses only, and track what it has already entered into OBS. Meanwhile an instructor gets assigned to a branch without anyone seeing their existing weekly hours, and the unbalanced load surfaces after term starts.
Why the obvious fix breaks
The instinct is to add a status column and a role check: if (user.role === 'DEAN') allowEdit(). Both halves fail.
A single status column cannot represent a returned course. Returned by whom, at which stage, for what reason, and what may happen to it next are four different facts, and flattening them into REJECTED means the course has to restart from a draft — which is why, in practice, people stop using the workflow and start emailing again.
A role check at the route is not the same as a permission model. It answers may this role do this kind of thing when the actual question is may this actor change this field on this record in this state right now. Sooner or later a screen and an endpoint disagree about the answer, and the one that is wrong is whichever one a reviewer is looking at.
And every check has an expiry the moment it is performed. A dean's queue is rendered, they read three rows, take a call, come back, and approve. In between, the department withdrew the course. Checking at render time and writing at click time is a race with a human-sized window.
The rule I settled on: the approval hierarchy is the domain model, not a permissions afterthought — and authority is re-established at the moment of writing, not the moment of asking.
The mechanism
Three decisions do the work.
Seven states, including the returns. Approval state is a first-class lifecycle rather than a status label, with separate dean-return and rector-return states. A returned course holds a real position in the workflow and has its own permitted transitions, so the reason travels with it and it resumes rather than restarts.
One authorization module, two consumers. Role hierarchy, field-level edit rights, scope visibility and transition rules live in a single module that both the API routes and the UI read from. Screens and endpoints cannot disagree, because there is only one answer to disagree about. A per-field endpoint checks that this role may edit this field on this course, then records the change to the audit log.
Authority is re-established inside the transaction. Approving or returning re-reads both the actor and the course, confirms the actor is still active and the course is still in the state the request assumed, re-checks authority, then writes the new state, the approval record and the notifications together:
// Everything before this point described a world that may have moved.
await db.transaction(async (tx) => {
const [actor, course] = await Promise.all([
tx.user.findActive(session.userId),
tx.course.findForUpdate(input.courseId),
]);
// The state the reviewer's screen was rendered against, not today's state.
if (course.status !== input.expectedStatus) {
throw new ConflictError("the course status has changed, the operation was cancelled");
}
assertMayTransition(actor, course, input.transition);
await tx.course.setStatus(course.id, next(input.transition));
await tx.approval.record(actor.id, course.id, input.reason);
await tx.notification.queueForNextActor(course);
});A course whose state moved under a reviewer's feet fails with a message a human can act on, rather than silently overwriting somebody else's decision.
The edge cases that shaped it
Each role gets its own workspace, not one screen with buttons hidden. Signing in routes each role to the surface matching its job — department head to their catalogue, dean to the faculty pool, rector to the university-wide pool, Student Affairs to the OBS panel. Navigation is filtered too: Student Affairs sees a two-item menu where an administrator sees the full management tree. Hiding a button on a shared screen teaches users that the system is arbitrary; giving them a smaller true surface does not.
Shared courses are structured data, not a text field. One course frequently serves several departments at several class levels. That is stored as explicit department-plus-class-level combinations, validated and de-duplicated on save, re-validated before approval, and editable only at dean and rector level — which is itself a field-level rule.
Instructor assignment is a decision made with the data in view. Each course splits into one to ten branches, each with its own instructor and weekly hours, enforced by a uniqueness constraint on (course, branch number). The assignment dialog shows each candidate's confirmed hours, pending hours, minimum load and expertise areas, so load balancing happens at the moment of choosing rather than in a report after term starts.
Notifications must not be able to block work. Email dispatch runs after the transaction commits and is logged on failure. A mail outage delays somebody learning about an approval; it never stalls the approval itself.
Bulk import is a contract, not an upload. Academic staff arrive from Excel through a four-step wizard against a documented ten-column contract, committed in batches of 25 so a large import cannot exceed request limits. Institutional email addresses are generated rather than typed.
Read paths were shaped for institution-scale lists — twelve composite indexes on the course table alone, with virtualized rendering on the large tables.
Limits, and what I would do differently
No usage or time-saved figure is claimed. This was verified by exercising the running application across all four roles against a seeded database, plus source review. There was no access to production data, so any percentage improvement would be invented. What can be shown is capability: a working seven-state pipeline with functioning return paths, role scoping demonstrated concretely — Student Affairs' queue contained only fully approved courses and its navigation collapsed to the OBS panel alone — and approval-cycle instrumentation computed from live records.
No AI capability is claimed either. The forecasting in the performance report is arithmetic extrapolation, and the report says so.
Field-level rights are configuration that reads like code. They live in one module, which is the right call, but adding a field still means editing that module. An institution that wants to add a planning attribute cannot do it without a developer. The honest design promotes the field matrix to data with a migration path — which is a larger change than it sounds, because the audit log's shape depends on it.
The conflict message is correct and unhelpful. The course status has changed, the operation was cancelled tells a reviewer their action failed but not what happened instead. It should name the transition that beat them and who made it. The data is all in the approval record; it simply is not surfaced.
If your approvals run on spreadsheets and email — authority differing by role and by field, work returned as often as approved, a downstream team needing a clean hand-off — the lifecycle is the product. The permissions are not a layer on top of it.