Workflow policy modeling#
Workflow behavior is configurable when a company, tenant, process, document type, country, date, or administrator can change it. Status transitions, approval routes, reason codes, routing weights, thresholds, outcomes, and retry decisions belong in policy tables, then resolver functions and services use those rows to make decisions.
Do not hide workflow policy in string-literal branches. A command should resolve policy first, fail closed if required policy is missing or ambiguous, and mutate durable state only after all required policy checks pass.
Modeling slice#
An auditable workflow rule has these source files:
| File | Contains |
|---|---|
.fields.bl | Status, action, reason, process, route, and scope field types |
.tables.bl | Transition, reason, route, and outcome policy rows |
.messages.bl | Declared failures for missing config, ambiguous config, denied actions, and invalid reasons |
.functions.bl | Exactly-one resolvers and command helpers |
.services.bl | Integration-facing workflow operations |
| e2e tests | Configured success, missing config, ambiguous config, invalid action, invalid reason, and mutation preservation |
Fields#
Keep status and action values as typed fields, not enums, when tenants can add, rename, retire, or effective-date values.
field CompanyCode: string {
max_length: 10;
}
field WorkflowProcessCode: string {
max_length: 40;
}
field WorkflowStatusCode: string {
max_length: 40;
}
field WorkflowActionCode: string {
max_length: 40;
}
field WorkflowReasonSetCode: string {
max_length: 40;
}
field WorkflowReasonCode: string {
max_length: 40;
}
field ApprovalRouteCode: string {
max_length: 80;
}Use enums only for true invariants. A debit/credit indicator can be an enum; approval statuses, actions, reason codes, and route outcomes are usually policy data.
Transition policy#
A transition row owns the allowed movement from current status and action to next status. It also owns whether a reason is required and which reason set is valid.
table WorkflowTransitionPolicy {
field company_code: CompanyCode key;
field process_code: WorkflowProcessCode key;
field from_status_code: WorkflowStatusCode key;
field action_code: WorkflowActionCode key;
field valid_from: date key;
field valid_to: date optional;
field active: bool required;
field to_status_code: WorkflowStatusCode required;
field reason_required: bool required;
field reason_set_code: WorkflowReasonSetCode optional;
field route_code: ApprovalRouteCode optional;
field outcome_code: string required;
constraint primary key (
company_code,
process_code,
from_status_code,
action_code,
valid_from
);
constraint check (valid_to is null || valid_to >= valid_from);
index workflow_transition_resolution on (
company_code,
process_code,
from_status_code,
action_code,
valid_from
) {
unique: false;
}
}The resolver must filter by every scope column, the active flag, and effective date. If more than one row matches, the configuration is ambiguous and the workflow must stop.
Reason policy#
Reason codes are configurable when administrators can require, localize, scope, or retire them.
table WorkflowReasonPolicy {
field company_code: CompanyCode key;
field reason_set_code: WorkflowReasonSetCode key;
field reason_code: WorkflowReasonCode key;
field valid_from: date key;
field valid_to: date optional;
field active: bool required;
field display_key: string required;
constraint primary key (
company_code,
reason_set_code,
reason_code,
valid_from
);
constraint check (valid_to is null || valid_to >= valid_from);
index workflow_reason_resolution on (
company_code,
reason_set_code,
reason_code,
valid_from
) {
unique: false;
}
}Validate two different failures: the reason is missing when policy requires it, and the supplied reason is not active in the configured reason set.
Declared failures#
Every user-facing workflow failure needs a declared message. Missing configuration is a custom("config") problem. A configured rule that rejects an action is a custom("policy") problem.
message workflow_transition_required {
code: "WF-ERR-2001";
severity: error;
category: custom("config");
params: {
company_code: CompanyCode;
process_code: WorkflowProcessCode;
from_status_code: WorkflowStatusCode;
action_code: WorkflowActionCode;
action_date: date;
};
message: {
en: "Workflow transition policy is required.";
};
}
message workflow_transition_ambiguous {
code: "WF-ERR-2002";
severity: error;
category: custom("config");
params: {
company_code: CompanyCode;
process_code: WorkflowProcessCode;
from_status_code: WorkflowStatusCode;
action_code: WorkflowActionCode;
action_date: date;
};
message: {
en: "More than one workflow transition policy matches the action.";
};
}
message workflow_reason_required {
code: "WF-ERR-2003";
severity: error;
category: custom("policy");
params: {
company_code: CompanyCode;
process_code: WorkflowProcessCode;
action_code: WorkflowActionCode;
};
message: {
en: "A workflow reason is required for this action.";
};
}
message workflow_reason_invalid {
code: "WF-ERR-2004";
severity: error;
category: custom("policy");
params: {
company_code: CompanyCode;
reason_set_code: WorkflowReasonSetCode;
reason_code: WorkflowReasonCode;
};
message: {
en: "The supplied workflow reason is not valid for the configured reason set.";
};
}
error WorkflowPolicyError;The params should include the resolution scope an operator needs to fix the row. Avoid inline string errors; they cannot be localized, cataloged, or tested consistently.
Transition resolver#
The count helper and selected row must use the same filters. This prevents accidental success when overlapping windows or duplicate rows exist.
private function count_workflow_transitions(
company_code_param: CompanyCode,
process_code_param: WorkflowProcessCode,
from_status_code_param: WorkflowStatusCode,
action_code_param: WorkflowActionCode,
action_date_param: date
): int =>
select count(*) as match_count
from WorkflowTransitionPolicy
where WorkflowTransitionPolicy.company_code = company_code_param
and WorkflowTransitionPolicy.process_code = process_code_param
and WorkflowTransitionPolicy.from_status_code = from_status_code_param
and WorkflowTransitionPolicy.action_code = action_code_param
and WorkflowTransitionPolicy.active = true
and WorkflowTransitionPolicy.valid_from <= action_date_param
and (
WorkflowTransitionPolicy.valid_to is null
|| WorkflowTransitionPolicy.valid_to >= action_date_param
);
private function require_workflow_transition(
company_code_param: CompanyCode,
process_code_param: WorkflowProcessCode,
from_status_code_param: WorkflowStatusCode,
action_code_param: WorkflowActionCode,
action_date_param: date
): WorkflowTransitionPolicy {
let match_count = count_workflow_transitions(
company_code_param,
process_code_param,
from_status_code_param,
action_code_param,
action_date_param
);
if (match_count < 1) {
raise message workflow_transition_required(
company_code_param,
process_code_param,
from_status_code_param,
action_code_param,
action_date_param
) with {
company_code: company_code_param;
process_code: process_code_param;
from_status_code: from_status_code_param;
action_code: action_code_param;
action_date: action_date_param;
};
}
if (match_count > 1) {
raise message workflow_transition_ambiguous(
company_code_param,
process_code_param,
from_status_code_param,
action_code_param,
action_date_param
) with {
company_code: company_code_param;
process_code: process_code_param;
from_status_code: from_status_code_param;
action_code: action_code_param;
action_date: action_date_param;
};
}
select var transition: WorkflowTransitionPolicy
where WorkflowTransitionPolicy.company_code = company_code_param
and WorkflowTransitionPolicy.process_code = process_code_param
and WorkflowTransitionPolicy.from_status_code = from_status_code_param
and WorkflowTransitionPolicy.action_code = action_code_param
and WorkflowTransitionPolicy.active = true
and WorkflowTransitionPolicy.valid_from <= action_date_param
and (
WorkflowTransitionPolicy.valid_to is null
|| WorkflowTransitionPolicy.valid_to >= action_date_param
);
return transition;
}Do not add a fallback transition such as "if no row exists, move to submitted." Missing transition configuration is an error.
Reason resolver#
Reason validation is separate from transition resolution because only some transitions require a reason.
private function require_workflow_reason(
company_code_param: CompanyCode,
transition: WorkflowTransitionPolicy,
reason_code_param: WorkflowReasonCode?,
action_date_param: date
): WorkflowReasonCode? {
if (transition.reason_required == false) {
return reason_code_param;
}
if (reason_code_param is null) {
raise message workflow_reason_required(
company_code_param,
transition.process_code,
transition.action_code
) with {
company_code: company_code_param;
process_code: transition.process_code;
action_code: transition.action_code;
};
}
select var reason: WorkflowReasonPolicy
where WorkflowReasonPolicy.company_code = company_code_param
and WorkflowReasonPolicy.reason_set_code = transition.reason_set_code
and WorkflowReasonPolicy.reason_code = reason_code_param
and WorkflowReasonPolicy.active = true
and WorkflowReasonPolicy.valid_from <= action_date_param
and (
WorkflowReasonPolicy.valid_to is null
|| WorkflowReasonPolicy.valid_to >= action_date_param
);
if (reason is null) {
raise message workflow_reason_invalid(
company_code_param,
transition.reason_set_code,
reason_code_param
) with {
company_code: company_code_param;
reason_set_code: transition.reason_set_code;
reason_code: reason_code_param;
};
}
return reason_code_param;
}If reason_set_code is required whenever reason_required is true, enforce that with table constraints or resolver checks and a declared configuration message.
Command boundary#
Commands should resolve transition and reason policy before changing persisted state. Failed policy checks must leave the original state untouched.
public function apply_workflow_action(
document_id_param: string,
company_code_param: CompanyCode,
process_code_param: WorkflowProcessCode,
current_status_param: WorkflowStatusCode,
action_code_param: WorkflowActionCode,
reason_code_param: WorkflowReasonCode?,
action_date_param: date
): WorkflowStatusCode {
let transition = require_workflow_transition(
company_code_param,
process_code_param,
current_status_param,
action_code_param,
action_date_param
);
let accepted_reason = require_workflow_reason(
company_code_param,
transition,
reason_code_param,
action_date_param
);
update WorkflowDocument
set status_code = transition.to_status_code,
last_action_code = action_code_param,
last_reason_code = accepted_reason
where document_id == document_id_param
and company_code == company_code_param
returning status_code;
return transition.to_status_code;
}The policy rows own to_status_code, reason requirements, and route metadata. The function owns orchestration and state mutation order.
Service boundary#
Services expose workflow operations to callers, but they should not own tenant policy values.
public service WorkflowActionService {
public function submit(
document_id: string,
company_code: CompanyCode,
process_code: WorkflowProcessCode,
current_status: WorkflowStatusCode,
action_date: date
): WorkflowStatusCode {
return apply_workflow_action(
document_id,
company_code,
process_code,
current_status,
"submit",
null,
action_date
);
}
public function reject(
document_id: string,
company_code: CompanyCode,
process_code: WorkflowProcessCode,
current_status: WorkflowStatusCode,
reason_code: WorkflowReasonCode,
action_date: date
): WorkflowStatusCode {
return apply_workflow_action(
document_id,
company_code,
process_code,
current_status,
"reject",
reason_code,
action_date
);
}
}The string literals in this service are operation inputs. The resolver still decides whether the action is allowed for the current company, process, status, and date.
Tests#
Workflow policy tests should cover both allowed movement and closed failure behavior. Include mutation-preservation checks for commands that update state.
test WorkflowSubmitUsesConfiguredTransition {
context company_code: CompanyCode = "SA01";
context process_code: WorkflowProcessCode = "vendor_invoice";
context current_status: WorkflowStatusCode = "draft";
context action_code: WorkflowActionCode = "submit";
context action_date: date = today();
setup {
seed_workflow_transition(company_code, process_code, current_status, action_code, action_date, "submitted");
}
expect apply_workflow_action("INV-1000", company_code, process_code, current_status, action_code, null, action_date) to be "submitted";
teardown {
clear_workflow_transition(company_code, process_code, current_status, action_code);
}
}
test WorkflowMissingTransitionFailsClosed {
context company_code: CompanyCode = "SA01";
context process_code: WorkflowProcessCode = "vendor_invoice";
context current_status: WorkflowStatusCode = "draft";
context action_code: WorkflowActionCode = "submit";
context action_date: date = today();
expect apply_workflow_action("INV-1001", company_code, process_code, current_status, action_code, null, action_date) to throw WorkflowPolicyError;
}
test WorkflowInvalidReasonPreservesState {
context document_id: string = "INV-1002";
context company_code: CompanyCode = "SA01";
context process_code: WorkflowProcessCode = "vendor_invoice";
context current_status: WorkflowStatusCode = "submitted";
context action_code: WorkflowActionCode = "reject";
context action_date: date = today();
setup {
seed_workflow_document(document_id, company_code, current_status);
seed_reject_transition_with_reason(company_code, process_code, current_status, action_code, action_date, "rejection_reason");
}
expect apply_workflow_action(document_id, company_code, process_code, current_status, action_code, "not_configured", action_date) to throw WorkflowPolicyError;
expect workflow_document_status(document_id, company_code) to be current_status;
teardown {
clear_workflow_document(document_id, company_code);
clear_workflow_transition(company_code, process_code, current_status, action_code);
}
}At minimum, cover configured success, missing transition, ambiguous transition, inactive or expired transition, required reason missing, invalid reason, denied action, and failed-update state preservation.
Review checklist#
- Every variable workflow decision is represented as field and table data.
- Every resolver takes explicit scope inputs.
- Missing, inactive, expired, and ambiguous configuration fails closed.
- Reason requirements and reason-set membership are checked from configuration.
- Routing rank, threshold, or priority values are data, not branch order.
- User-facing failures use declared messages.
- Commands resolve policy before mutation.
- Tests cover success and failure paths.