Unified Workflows & Approvals
The unified WorkflowService (backend/src/services/workflowService.ts) is the only pattern for multi-stage workflows and approvals in PBS Knowledge. It drives PhD dissertation and Masters thesis milestones, mentoring forms, and all five undergraduate approval flows (award nominations, major plans, fellowships, independent research, honors thesis).
Do not create a new per-domain approval service with its own status map or read-modify-write document handling — that pattern was deleted in June 2026.
Data Model
Workflow definitions are data in TerminusDB, not code:
Workflow Definition: name, applicable person types, ordered stages
└─ WorkflowStage Subdocument: stage_id, sequence_order, stage_type,
entity_type, required_approvals (Set<ApproverRole>),
approval_order (sequential | parallel), requires_stages
WorkflowInstance One running workflow for a person/entity
(primary_entity_id + primary_entity_type)
└─ StageInstance One per stage; links a domain entity by string fields
entity_id + entity_type
└─ StageApproval One per required approver per round; records the decision
Three shared status enums (import from types/generated/schema-types.ts, never compare with ad-hoc string literals):
WorkflowStatus— the instance (active,completed,cancelled, ...)StageStatus— each stage (not_started,in_progress,pending_approval,revision_requested,completed, ...)ApprovalStatus— eachStageApproval(pending,approved,rejected,revision_requested)
Domain entities keep a minimal status field for display (e.g. AcademicPlan.approval_status). In-review entities carry the unified status submitted; the retired intermediate statuses (advisor_review, committee_review, advisor_endorsement, admin_review, ...) are legacy-only and must never be written by new code.
End-to-End Lifecycle
- Submit — a bridge service ensures a
WorkflowInstanceexists (viastartWorkflow), links the domain entity to the right stage withlinkEntityToStage(stageInstanceId, entityId, entityType), then callssubmitStage(stageInstanceId). - Approver resolution —
submitStagecreatesStageApprovalrecords by resolving the stage'srequired_approvalsroles. If a resolver is registered for the stage'sentity_typeinworkflowApproverResolvers.ts, it resolves approvers from the entity itself (e.g.ThesisRequest.primary_advisor, the "Undergraduate Committee" group); otherwise the default graduate-relationship resolution applies. When no person resolves, a role-only approval is created that any authorized caller can claim. - Pending queue —
getPendingApprovalsForPerson(personId)is the single unified queue. Sequential stages are gated: an approval is only visible/actionable once earliersequence_orderapprovals are resolved. Notification emails are sent when an approval becomes actionable. - Decision —
recordApproval(approvalId, status, approverId, comments)recordsapproved,rejected(terminal — cancels the instance), orrevision_requested(send-back; the student resubmits through the normal submit endpoint, which reuses the same instance/stage and voids stale approvals). - Stage completion — when every approval for a stage is
approved, the stage completes and the workflow advances to the next stage. - Entity status sync — a stage-completion hook registered per
entity_type(e.g. setAcademicPlan.approval_status = 'approved'+approved_at) fires after completion is committed. Hooks are failure-isolated and must be idempotent. Rejection/revision syncs happen in the bridge wrapper instead, because hooks only fire on completion.
The *WorkflowService Bridge Pattern
Each domain flow has a thin bridge service that owns submit/approve/reject/request-revision, entity status sync, and the legacy-shaped pending queue:
thesisWorkflowService.ts— honors thesis (sequential: advisor → second member → committee)independentResearchWorkflowService.ts,fellowshipWorkflowService.ts,awardNominationWorkflowService.ts,majorPlanWorkflowService.ts
Bridges are deliberately thin wrappers around WorkflowService:
// Approve = guard checks, then delegate
const guard = await this.guardAction(planId, approverId);
await this.workflowService.recordApproval(guard.approvalId, 'approved', approverId, comments);
// Entity sync to 'approved' happens in the stage-completion hook, not here
Two important bridge conventions:
- Minimal-field entity writes. Status syncs go through
partialUpdateDocument(re-fetch full document, merge only the changed fields). Never hand-maintain a full field list — the legacybuildFullDocumentpattern silently dropped fields added to the schema later. - Legacy-shaped reads. Pending-approval endpoints (
/api/thesis/pending-approvals,/api/major-plan-approvals/..., etc.) map unifiedStageApprovalrows back to the response shapes the existing frontend consumes, viaworkflowBridgeShared.tshelpers.
Adding a New Approval Flow
Copy an existing bridge: majorPlanWorkflowService.ts for a single-stage flow, or fellowshipWorkflowService.ts for sequential multi-approver. The four steps:
-
Seed the Workflow at boot. Define the workflow as a constant and write an idempotent
ensureXxxWorkflow(fastify)(insert-if-missing, keyed by workflow name, never overwrites). Call it from server boot inbackend/src/index.ts, and mirror the definition inbackend/scripts/seed-approval-workflows.jsfor manual re-runs:export const MY_WORKFLOW_DEFINITION = {
name: 'My Approval Flow',
applicable_person_types: ['UndergraduateStudent'],
active: true,
stages: [
{
'@type': 'WorkflowStage',
stage_id: 'committee_review',
sequence_order: 1,
stage_type: 'approval_only',
entity_type: 'MyEntity',
required_approvals: ['undergrad_committee'],
approval_order: 'sequential',
is_required: true,
},
],
} as const; -
Register an approver resolver in
workflowApproverResolvers.tskeyed byentity_type. It receives the resolution context (entity id, role) and returns the people who should approve — from entity fields, group membership, or both:registerApproverResolver('MyEntity', resolveMyEntityApprovers); -
Register a stage-completion hook with
registerStageCompletionHook(entityType, hook)(exported fromworkflowService.ts) to sync the entity's status toapprovedon completion. Keep it idempotent — re-dispatch must be a no-op. Wire the registration call into boot. -
Write the thin bridge service — submit (ensure instance → link entity →
submitStage), approve/reject/request-revision wrappers aroundrecordApproval(syncingrejected/revision_requestedentity statuses in the wrapper), and agetPendingApprovalsread built on the unified queue.
If a flow needs notification template overrides or extra notification data, use registerWorkflowNotificationTemplates / registerWorkflowNotificationDataProvider rather than sending email from the bridge.
Legacy *Approval Entities Are Read-Only History
The legacy per-domain approval services (thesisApprovalService.ts, independentResearchApprovalService.ts, fellowshipApprovalService.ts) and the aggregate /api/approvals/* endpoints were deleted in Phase 6 of the migration (last drain completed 2026-06-10). The legacy approval document types — ThesisRequestApproval, IndependentResearchApproval, FellowshipApplicationApproval, MajorPlanApproval — remain in the schema only as read-only history for entities that completed before the cutover. Bridge detail reads fall back to them when no workflow instance exists; no new records of these types are ever created.
All new approval records are StageApproval documents. See docs/implementation/unified-workflow-migration-plan.md for the full migration history, and the live integration suites in backend/tests/integration-live/workflows/ for executable examples of every flow.