PMS Workflow Engine

Architecture Diagnosis Report

Generated: {{ $generatedAt }}
Source: codebase audit (facts from source only)
Project: {{ $projectRoot }}
Executive summary
The PMS does not have one workflow engine. At least six parallel mechanisms can touch the same pc_forms row: forward workflow, legacy counter workflow, meeting workspace overlay, draft/revision lifecycle, admin side-channels (bypass/recovery/rescue/reconcile), and PCN forward (separate entity). For PC-I/PC-II, PcForm::usesForwardWorkflow() infers forward mode from kind + status + state and does not require the uses_forward_workflow column — imported PCFMS rows can behave as forward PCs while missing workflow_stage and current_actor_id.

Table of contents

  1. Workflow engines
  2. Complete state machine
  3. Who owns a PC
  4. Permission engine
  5. Imported PC workflow
  6. Draft system
  7. Meeting workflow
  8. Revision system
  9. Duplicate logic
  10. Production risks
  11. Root causes
  12. Recommendations & risk assessment

1. Workflow engines

1.1 Forward workflow (PC-I / PC-II primary)

EntrySubmit → PcFormService::finalizePcFormForwardSubmit() → PcFormWorkflowService::finalizeSubmitAndForward()
ControllerPcFormsController: forward, decline, forwardRecipients, declineRecipients, workflowActions
ServicesPcFormWorkflowService, PcFormForwardActorResolver, PcFormWorkflowRecipientResolver, PcFormWorkflowStageResolver
Tablespc_forms, pc_form_forwards
EnterinitializeOnSubmit(): workflow_stage=ad_review, current_actor_id=submitter, uses_forward_workflow=true
Exitworkflow_stage=approved (AA auth / bypass) or state=rejected; meeting-linked locks pre-meeting actions
Actorcurrent_actor_id must match auth()->id() unless super-admin bypass

1.2 Legacy counter workflow (PC-III+ default; post-meeting)

EntrySubmit without forward init; MeetingWorkspaceMeetingController::approveLinkedPcForm → PcFormService::approve()
Tablespc_forms, pc_form_reviews
Progressionstate + check_count, approve_count, department_* counters
ActorNo current_actor_id — permission + scheme scope
Blocked whenusesForwardWorkflow() true without meeting context (approve() throws)

1.3 Meeting workflow (overlay)

EntryMeetingWorkspaceMeetingService::syncSubjectLinks()
EffectmarkMeetingLinked(): workflow_stage=meeting_linked, current_allowed_actions=null; does NOT change current_actor_id
Post-meetingpc_form_reviews + PcFormService::approve() / forum decisions / AA auth

1.4 Co-signatory workflow (MoM)

MwsSchemeMomCoSignatoryWorkflowService — gates meeting approval via MwsSchemeMomService::momSignatureGateState().

1.5 PCFMS import (partial reconstruction)

Commandpc:migrate-pcfms → PcfmsPcMigrationService
Setsstate (PC1StatusID), status, is_revised, parent_id, counters=1
Does NOT setworkflow_stage, current_actor_id, uses_forward_workflow, current_allowed_actions

1.6 Admin side-channels

Workflow BypassWorkflowBypassService — skip ladder to approved
Recovery CenterWorkflowRecoveryApplyService — manual stage/actor/meeting
Rescue CenterWorkflowRecoveryService — validated repairs + rollback
CLIpc-forms:reconcile-actors, pc-forms:assign-whitelisted-actor

1.7 Multiple engines on same PC?

Yes. Forward + legacy state sync, forward + meeting handoff, import gap + inferred forward, revision + forward, admin recovery + live workflow can all apply to one row.

2. Complete state machine

2.1 Legacy state (pc_forms.state)

ValueConstantNotes
draftSTATE_DRAFTUnsubmitted
requestedSTATE_REQUESTEDChecker / AD review sync
checkingSTATE_CHECKINGDeprecated — not written at runtime
department_approval_pendingSTATE_DEPARTMENT_APPROVAL_PENDINGLegacy dept / DDWP sync
under approvalSTATE_APPROVINGP&D / meeting-linked sync
approvedSTATE_APPROVEDTerminal
rejectedSTATE_REJECTEDTerminal (UI: Deferred)

2.2 Forward workflow_stage

StageTerminalLegacy sync
ad_draftNounchanged
ad_reviewNorequested
ad_approveNorequested
ddwp_department_handoffNodepartment_approval_pending
pnd_development_sectionNounder approval
pnd_internal_circulationNounder approval
meeting_linkedNounder approval
approvedYesapproved
declinedYesrejected (not set by main decline())

2.3 Forward flow (ASCII)

[draft] --submit--> ad_review (actor=creator)
ad_review --forward--> ad_review | ad_approve | ddwp_department_handoff
ad_approve --forward--> pnd_development_section | pnd_internal_circulation
pnd_* --forward--> pnd_* (circulation)
any in-progress --decline--> upstream stage (creator → ad_review)
any in-progress --link meeting--> meeting_linked (actions locked)
meeting_linked --AA auth / bypass--> approved (terminal)

Methods: PcFormWorkflowService::initializeOnSubmit, forward, decline, markMeetingLinked
DB: pc_forms + pc_form_forwards on each transition

2.4 Legacy counter flow (ASCII)

draft --submit--> requested
requested --checker approve (×N)--> department_approval_pending | under approval
department_approval_pending --dept approve (×N)--> under approval
under approval --approver approve (×N)--> approved
any in-flight --reject--> rejected

Methods: PcFormService::approve(), reject(); PcFormApprovalCountService

3. Who owns a PC

FieldAuthoritative for
current_actor_idForward/decline/edit (forward path) — primary runtime owner
created_byCreator identity; return-to-creator routing; draft edit
updated_byLast editor (audit)
pc_form_forwards trailDecline recipient resolution; visibility
ACL permissionsLegacy approve/reject when not forward-held
statusActive vs superseded revision; gates usesForwardWorkflow() for PC-I/II
Known routing defect: Return-to-creator requires created_by === recipient in PcFormForwardActorResolver. Imported PCs with created_by NULL break intended return-to-submitter routing.

4. Permission engine

No Laravel Policy for PcForm. Authorization is service-layer + can() ACL (config/permission.php, app/helpers.php).

MethodLocationLogic
userMayEditMainFormPcFormServicecan edit + forward: currentUserHoldsFormForEdit
currentUserMayActPcFormWorkflowServiceForward only; actor match OR super-admin bypass
currentUserMayReturnPcFormWorkflowServiceMay act AND actor ≠ created_by
currentUserMayForwardPcFormServiceDelegates to currentUserMayAct
currentUserMayApprovePcFormServiceLegacy counter + meeting variants
canBypassPermissionshelpers.phpUser::isSuperAdmin()

Duplicate paths: Route ACL + EnsurePermissionForRoute actor override + controller re-check + service actor check + UI modal canReviewCurrentCycle().

5. Imported PC workflow

PCFMS SQL → PcfmsPcMigrationService → pc_forms + pcfms_import_maps
  SET: state, status, is_revised, parent_id, counters=1
  NOT SET: workflow_stage, current_actor_id, uses_forward_workflow
       ↓
  reconcile-actors / assign-whitelisted-actor → bootstrap forwards + actor + stage
       ↓
  PcFormWorkflowService forward/decline → meeting link → legacy approve post-meeting

Lost on import: Full PCFMS approval ladder, per-step actors, workflow_stage history.

Reconstructed: Coarse state, revision chain, hardcoded counters.

6. Draft system

ActionResult
Save draftstate=draft on pc_forms; optional pc_form_drafts snapshot
Submitstate=requested; forward init for PC-I/II
Revision resubmitNew row status=1; old row status=0
ReturnPcFormWorkflowService::decline() — upstream actor
Post-approval workspacepost_approval_revision_workspace=true, status=false

Drafts can disappear via: resubmit deactivation, workspace purge, delete(), listing filters on status=0.

7. Meeting workflow

StepService / effect
LinkMeetingWorkspaceMeetingService::syncSubjectLinks → MwsMeetingSubjectLink
Stage lockPcFormWorkflowService::markMeetingLinked
MoM gateMwsSchemeMomService::momSignatureGateState
ApproveMeetingWorkspaceMeetingController::approveLinkedPcForm → PcFormService::approve or forum
CirculationMwsPrePdwpMomCirculationService

Meeting pauses forward (hasLinkedMeeting blocks currentUserMayAct) and hands off to legacy review/approve.

8. Revision system

FieldMeaning
parent_idPoints to root PC (flat chain)
is_revisedEnables "Revised (version N)" badge
status1=active live PC; 0=closed/superseded
Version N1-based index by id in chain (attachRevisionChainPositions)

Health rules: at least one active revision; at most one status=1 per chain.

9. Duplicate logic

10. Production risks (localhost vs production)

RiskWhy prod fails, local may not
Dual DB (pc + core)Missing scheme/project data on prod
Schema conditionalsMigrations not run on prod
Inferred forward without columnsImport + no reconcile
Lazy reconcile on viewFirst viewer mutates prod state
CLI reconciliation not runDev reconciled, prod not
Sync notificationsProd mail/DB slower; timeouts
Storage / signaturesstorage:link, permissions
Config/opcache cacheStale permission.php

11. Root causes (engine-level)

EngineProblem
A — Dual workflow modelstate + workflow_stage run together; post-meeting drops to legacy approve
B — Import gapForward inferred but stage/actor/trail missing
C — Ownership splitcurrent_actor_id vs permissions vs created_by null
D — Side-channel mutationReconcile, bypass, recovery, rescue all write workflow fields
E — Revision semanticsstatus, state, is_revised independent — confusing listings
F — Permission layeringRoute ACL vs actor vs service disagree

12. Recommendations & risk assessment

Immediate fixes

  1. Mandatory post-import reconcile before forward actions
  2. Initialize created_by on all imported active PCs
  3. Close inactive revisions with state=approved (not requested)
  4. Document which engine owns each PC (kind + usesForwardWorkflow + meeting + import map)

Medium-term

  1. Single ownership model for forward path
  2. Align usesForwardWorkflow() with uses_forward_workflow column
  3. Consolidate admin recovery into one service
  4. Single permission gate per action

Long-term redesign

  1. Explicit workflow enum + transition table (no dual state sync)
  2. Event-sourced workflow log with compensating admin events
  3. Import snapshot + explicit "activate workflow" step
  4. Separate read models for listing vs write model

Risk assessment

AreaSeverityLikelihood
Imported PC forward without actorCriticalHigh
Dual state columns divergeHighMedium
Admin side-channel overwriteHighMedium
created_by null routingHighHigh (imports)
Multiple active revisionsMediumMedium
Permission path mismatchMediumMedium