-
Notifications
You must be signed in to change notification settings - Fork 0
Workflow Gates and Approvals
Four different mechanisms in Forge answer to the word "workflow", and they are not variations on one thing — they are separate subsystems with separate storage, separate capability gates and separate reasons to exist. Reading a screen or a doc without knowing which one you are looking at is the usual source of confusion, and picking the wrong one is the usual source of a lifecycle rule that half-works. This page tells them apart and says which to reach for.
| Mechanism | The question it answers | Storage | Capability |
|---|---|---|---|
| Gated sequence engine | May this step of the process proceed? |
sequence_* tables — definitions, instances, gates, clocks, events |
CAP-CROSS-SEQUENCES, off by default
|
| Workflow definitions and runs | Where is this user in a multi-step form? |
workflow_definitions, workflow_runs, entity_readiness_validators
|
ungated engine; the features built on it carry their own gates |
| Approval workflows | Who has to say yes? |
approval_workflows, approval_steps, approval_requests, approval_decisions
|
CAP-P2P-APPROVALS, off by default |
| Status entries | What state was this record in, and when? | one polymorphic status_entries table |
CAP-CROSS-ACTIVITY-LOG, on by default |
They compose rather than compete. An approval can be the thing a sequence gate waits on; a sequence step can wait on a job reaching a kanban stage; a status entry records what happened afterwards. What none of them do is substitute for each other.
The engine is a general-purpose gated-process primitive: a graph of steps, the edges between them, and named go/no-go gates that must all read Go before a step becomes startable. Its natural framing is a Petri net with guarded transitions and timers, and it is deliberately domain-neutral — routing gates, inspection sign-offs, lot expiry and permit chains are all the same shape.
It is the newest of the four and the one with no reference doc yet. The code is the specification: forge.core/Sequences/ for the pure engine, forge.api/Features/Sequences/ for the commands and gate sources, in forge-api. The catalog row is CAP-CROSS-SEQUENCES; with it off, the API refuses and the /sequences routes are unreachable by URL rather than merely hidden.
Definitions are versioned and immutable once published. A definition's natural key is (code, version). Only a Draft can be edited; publishing runs a structural validator, marks the row Published, and retires any older published version of the same code. To change a published process you take a new version, which copies the graph into a fresh Draft. A run pins the version it started on, so publishing a new version never disturbs work already in flight — and equally, in-flight runs are never migrated forward. Cancel and restart them, or let them finish.
The validator is what publishing has to satisfy: unique step keys, edges between known steps, no self-edges, gate keys unique within their step, at least one start step, every step reachable from a start, and no cycles except through edges explicitly flagged as rework. A back-edge you did not declare is a publish error, not a runtime surprise.
Gates are first-class objects, not step properties, so several can hang off one step and the same source type can be reused anywhere. The built-in sources are a manual clearance (the recorded clearance is the sign-off), a time window, a resource clock, and a terminal approved approval request — that last one is the seam to the approval workflows below. A fifth source type is Custom, resolved by a key in the gate's config against a gate source a module registers in DI. An unresolvable gate fails closed: if no source is registered for the type, or a custom key matches nothing, the gate evaluates to No-Go with a reason. A misconfigured gate can never silently open a step.
Readiness is derived, never stored. A step becomes Ready when its join policy over predecessors holds — all of them complete or skipped, or any one of them — and every one of its gates reads Go. "Blocked" is deliberately not a stored status: it is computed as predecessors-satisfied-but-a-gate-says-no, so it cannot go stale. A Ready step whose gate later flips back to No-Go returns to Pending; nothing is lost, because it had not started.
Two kinds of clock, and they are not the same clock. A dwell clock belongs to a step and caps how long that step may sit in progress. A resource clock is attached to a resource — a lot, a permit, a sample — by polymorphic type and id, so it travels with the resource between steps and between runs: a lot two days from expiry is two days from expiry wherever it goes. Either can block, merely flag for review, or escalate to a named role, and each fires exactly once so an escalation cannot double-send. One recurring background job is the engine's only timer; everything else is push-driven, re-evaluating on step completion, gate clearance, override, approval decision or an explicit re-evaluate call.
Escape hatches are audited, not hidden. A gate can be forced to Go with a mandatory reason, and the override sticks until the step is reset. Rework is a controlled back-edge: it resets the target step and everything downstream of it, clearing completions, clocks, clearances and overrides on those steps, and requires a reason. Both append to a per-run event log that exists precisely so the audit trail is a by-product rather than a feature someone has to remember to write.
Be realistic about how far it reaches today. No sequence definitions ship seeded — authoring the graph is your job. The engine watches jobs (a definition can auto-start a run for every newly created subject of its type, and job stage changes trigger re-evaluation) and surfaces a job's running gates on the job detail. But nothing outside the engine currently refuses an action because a sequence step is not Ready. The engine enforces its own step transitions; enforcing it against the rest of the domain is wiring you add, not behaviour you inherit.
This is the substrate behind guided multi-step entity creation — the wizard-versus-single-page-form pattern. Canonical reference: docs/functional-reference/workflow-engine.md, with the design history in docs/workflow-pattern.md.
A definition is an ordered list of steps stored as data, with an id that carries its own version suffix; a run pins that id, so re-authoring a definition never rewrites what someone is halfway through. Each step names zero or more entity readiness validators as its completion gates.
The decision that shapes everything else: step completion is not stored on the run. It is recomputed from the entity's live data every time the run is loaded, by evaluating each step's validators. That is what makes admin re-authoring of validators safe — the next load simply gets a different answer — and it is why a run row is small: current step, mode (guided or express, switchable at any time), timestamps, and a draft payload held only until the first step materializes the entity row, after which it is cleared. A unique index on entity type and id enforces at most one in-flight run per entity; cross-entity flows add junction rows instead of a second run.
Readiness validators are rows, not code. Each carries a JSON predicate in a small fixed DSL — field presence, equality, comparison, relation counts, boolean composition — plus an optional applicability predicate. When the applicability predicate is present and false for a given record, the validator is skipped entirely: neither evaluated nor reported as missing. That is what lets one validator set serve records that need different things, such as an HTS-code check that only matters for international shipments. The DSL's custom operator resolves against a registry and, like the sequence engine's custom gates, returns false rather than true when nothing matches.
The same validators back promotion. Asking to promote an entity out of Draft runs them server-side and refuses with a 409 carrying the machine-readable code workflow-readiness-missing and the list of what is absent — see API Access for the error contract, and First Week for what that looks like to a user. Parts, vendors and customers have adapters today; extending to another entity type is a small creator/applier/promoter trio plus seed rows, not an engine change.
Configurable multi-step sign-off for any entity type. Canonical reference: docs/functional-reference/approvals.md.
A workflow binds to an entity type, optionally narrowed by activation conditions such as an amount threshold, and owns ordered steps. Each step names an approver by type — a specific user or a role — and can carry an auto-approve-below amount, an escalation deadline in hours, a require-comments flag and a delegation flag. Submitting an entity finds the matching active workflow, auto-approves outright if the amount falls below the first step's threshold, and otherwise opens a request that walks the steps, appending a decision row at each. Purchase orders and expenses submit into it today, alongside a generic submit endpoint. An approved request is exactly what the sequence engine's approval gate source reads.
Two limits are worth knowing before you design around it:
-
The
Managerapprover type does not resolve to anyone. There is no org hierarchy in the data model, so a step routed to the submitter's manager appears in nobody's pending inbox; the per-step "use direct manager" flag is stored and never consulted. Route by role instead until a reporting line exists to resolve against. - Escalation advances rather than notifies. When a step's deadline passes, the request is moved to the next step and marked escalated. If there is no next step, nothing happens and the request sits. That is a reasonable design for "the second approver is more senior", and the wrong one if you expected a nudge to a supervisor.
The plainest of the four, and the one that is a record rather than a rule. A single polymorphic table holds status spans against any entity type and id, each with a code, a label resolved from reference data, a start and an optional end. Canonical reference: docs/functional-reference/status-lifecycle.md.
Entries fall in two categories with different rules. A workflow status is the entity's current state, and only one can be open at a time — setting a new one closes the previous span, which is what makes the history a clean timeline. A hold is a blocking condition, several can be open at once, and each must be released explicitly.
The detail that earns its own sentence: for jobs, the entry freezes the work center and operation at write time. Those columns are a snapshot, not a lookup, so editing a job's routing later cannot retroactively rewrite where a status change happened. On other entity types they are simply null.
Status entries record; they do not enforce. There is no transition table and nothing refuses an illegal status change. If you need a state machine, the enforcement belongs in one of the mechanisms above and the status entry is what you write afterwards.
Start from the question you are actually answering.
"This work must not proceed until conditions X, Y and Z hold." The gated sequence engine. It is the only one of the four that models a graph, evaluates conditions from pluggable sources, and expires things on a clock. Cost of entry: enabling a capability that ships off, authoring a definition, and wiring the enforcement point yourself.
"This record is not finished until these fields and relations exist." A readiness validator, and a workflow definition if the user needs guiding through filling them. Adding a validator row is the cheapest change of the four and it immediately affects both the guided run and the promote-out-of-Draft check, because they share a handler.
"A person with authority must sign this off." An approval workflow — routed by role. If the sign-off is one condition among several on a larger process, keep the approval workflow and let a sequence gate read it, rather than rebuilding approvals inside the sequence.
"We need to know what state this was in last Tuesday." A status entry. If you find yourself wanting to prevent the transition rather than log it, you have the wrong mechanism.
A rule of thumb for the ambiguous cases: if the constraint is about data completeness, it is a validator; if it is about sequence and permission in time, it is a gate or an approval; if it is about what happened, it is a status entry. And when a rule spans two of those, express each half in its own mechanism and let them meet at a gate — that seam is designed for, and the alternatives all end in one subsystem re-implementing another badly.
Related: Capability Gating for turning the sequence and approval capabilities on, Access and Roles for who may clear, override or approve, and Feature Reference for the per-feature docs behind each mechanism.
Forge · Apache 2.0 · built by Armory Works — this wiki maps the docs; the authoritative detail lives in docs/.
Evaluating
Running it
- Installation
- First Week
- Configuration and Integrations
- Hardening a Production Install
- Backup and Restore
- Upgrades and Rollback
- Accounting Modes
Using it
- App Surfaces
- Shop Floor Kiosk
- Mobile and Offline
- Access and Roles
- Customizing an Install
- Feature Reference
- UI Flows
Building on it
- API Access
- Architecture
- Capability Gating
- Workflow, Gates and Approvals
- Data Ownership and Export
- Glossary
Contributing
Repo wikis