Skip to main content

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 — each StageApproval (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

  1. Submit — a bridge service ensures a WorkflowInstance exists (via startWorkflow), links the domain entity to the right stage with linkEntityToStage(stageInstanceId, entityId, entityType), then calls submitStage(stageInstanceId).
  2. Approver resolutionsubmitStage creates StageApproval records by resolving the stage's required_approvals roles. If a resolver is registered for the stage's entity_type in workflowApproverResolvers.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.
  3. Pending queuegetPendingApprovalsForPerson(personId) is the single unified queue. Sequential stages are gated: an approval is only visible/actionable once earlier sequence_order approvals are resolved. Notification emails are sent when an approval becomes actionable.
  4. DecisionrecordApproval(approvalId, status, approverId, comments) records approved, rejected (terminal — cancels the instance), or revision_requested (send-back; the student resubmits through the normal submit endpoint, which reuses the same instance/stage and voids stale approvals).
  5. Stage completion — when every approval for a stage is approved, the stage completes and the workflow advances to the next stage.
  6. Entity status sync — a stage-completion hook registered per entity_type (e.g. set AcademicPlan.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 legacy buildFullDocument pattern silently dropped fields added to the schema later.
  • Legacy-shaped reads. Pending-approval endpoints (/api/thesis/pending-approvals, /api/major-plan-approvals/..., etc.) map unified StageApproval rows back to the response shapes the existing frontend consumes, via workflowBridgeShared.ts helpers.

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:

  1. 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 in backend/src/index.ts, and mirror the definition in backend/scripts/seed-approval-workflows.js for 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;
  2. Register an approver resolver in workflowApproverResolvers.ts keyed by entity_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);
  3. Register a stage-completion hook with registerStageCompletionHook(entityType, hook) (exported from workflowService.ts) to sync the entity's status to approved on completion. Keep it idempotent — re-dispatch must be a no-op. Wire the registration call into boot.

  4. Write the thin bridge service — submit (ensure instance → link entity → submitStage), approve/reject/request-revision wrappers around recordApproval (syncing rejected / revision_requested entity statuses in the wrapper), and a getPendingApprovals read 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.