Every service that needs sign-off on something (a payout, a purchase order, a stock adjustment, a
credit note, a quote) runs it through one approval engine inside that service. Workflows never keep
their own "approved by" columns or approve endpoints. The engine has the same shape in every service,
so rules, inboxes and audit read the same way across the platform.
A module (the workflow), an amount band (min_amount, optional max_amount) and ordered steps, each naming the role that approves it. The most specific band wins (highest minimum).
Request
One per object sent for approval: module, object id and reference, amount snapshot, the rule used, status (pending, approved, rejected, cancelled) and the current step. Submitting again cancels the object's earlier pending request.
Action
One per step: the step's role, its decision, who decided and when, and a comment.
Decision
Approvers act on the current step in order. The request is approved after the last step and rejected the moment any step is rejected. A step is claimed with a compare-and-set on its pending state, so two approvers deciding at once cannot both win.
internal/modules/approvals (rule steps and request actions as JSON on the row; inbox /approvals, rules /approvals/rules)
credit notes, waivers, manual payment verification, work order quotes; restructure, vendor bill, refund, deposit deduction, remittance and write-off as those flows land
Register defaults and a decision hook at startup (maskani: SetDefault(module, steps...) and
OnDecision(module, hook)). The default applies when no rule matches an amount; a module
without a default needs no approval below its rules.
When the object is created, call Submit with the module, object id, reference, amount and
(where the service scopes by site) the property or outlet.
Approve and reject through the engine (Act, or maskani's Decide, which also runs the hook).
The hook applies the outcome: book the payment, raise the credit note, release the work.
Lists of the workflow's own records show each row's latest request beside it, fetched in one
query for the page (never one query per row).
Separation of duties: the person who submitted an object never decides it. Maskani also lets one
person decide only one step of a request.
Roles, not people: steps name role codes; administrators may act on any step.
Alerts: whoever can act on the waiting step is told (maskani publishes approval.pending to the
holders of the step's role; permission steps are announced by the workflow's own event).
Inbox "waiting for me" is a SQL filter, not a scan: maskani keeps the waiting step's approver on
the request (current_approver) and indexes it.
Sign-offs that carry no amount and no money (publishing a document template, running a billing
period without missing readings) may stay outside the engine, but record who acted.