Insight · Control

An approval trail is only as good as its enforcement point

Every module in the suite had grown its own approve button. Fourteen of them. Two things were wrong with that, and the second one is the kind of thing an auditor finds after the money has moved.

The first problem: a threshold could not be expressed at all

A variation of five thousand and a variation of five million took the same single click from the same person. There was nowhere in the system to say "above a quarter of a million, the board signs". Companies have delegations of authority. Software that cannot represent one leaves it living in a policy document that the software then quietly contradicts.

This one is annoying but honest — everybody can see it is missing.

The second problem: the rule was a UI instruction

The guard on each of those buttons was a groups="…" attribute in a view. That attribute hides a control. It does not stop the method being called.

Write access on bills of quantities and variations belongs to the commercial group, because those are the people who write them. So a quantity surveyor — the person who produces the bill — had everything needed to approve the bill. Not by finding an exploit: by calling the method the button would have called, from anywhere that can call a method.

This was not left as a theory. A commercial-only user was created and the method was called directly:

GROUPS pm? False | commercial? True
RESULT approved by a non-PM: approved          ← a bill of quantities
CO approved by non-PM: approved | value 197400 ← a variation order

A hundred and ninety-seven thousand of variation, approved by somebody the interface had carefully not shown an approve button to. Every screenshot of that system would have looked correct.

What "in the method" actually buys you

Majal's approval check lives inside action_approve itself, in a _check_approved() call. That is a small relocation with a large consequence: the rule now holds wherever the call comes from — a button, an automated action, a data-migration script, the RPC console, a screen nobody has written yet, or the integration a client's IT team builds next year without telling anybody.

The general form of the lesson: a control that lives in the presentation layer protects the presentation layer. If the thing you are protecting is a financial commitment, the control has to sit where the commitment is made.

The parts that are easy to forget

Moving the check is necessary and not sufficient. An approval engine that is only a check produces a trail nobody can rely on. Five more things had to be true.

Segregation of duties, on by default

The person who raised a document cannot sign it. On by default, because the default is what most rules will be left at — but switchable, because it does not always apply. A daily site log approved by the person who wrote it is normal and forcing a second signature on it just teaches people that approvals are noise.

Order

A step cannot be signed while an earlier one is outstanding. Without that, a chain of three signatures is three independent signatures that happen to be drawn in a row, and the sequence — which is the whole point of a delegation of authority — is decoration.

A reason is required to reject

A rejection without a reason guarantees a second cycle. The document comes back unchanged because nobody told the author what was wrong with it, and the second rejection arrives a week later.

Refusals that explain themselves

"Project manager has to approve this first." "You raised this, so somebody else has to approve it." Not "access denied", which teaches nobody what to do next and generates a support ticket instead of an action.

Delegation that records whose authority was used

This is the one that matters most and gets built last. Somebody goes on leave and approvals stop. The usual field fix is to share a login — which destroys the audit trail at precisely the point where it is load-bearing, because now the record says a director signed something while the director was on a plane.

A delegation in Majal names who is covering and for how long. The delegate's inbox fills with the approver's outstanding steps. And the signature stores decided by and delegated from as separate fields, so a year later the record says what actually happened rather than what the login suggested.

One inbox, because a step is a record

The design choice that made the rest tractable: a request creates one step record per signature, rather than advancing a state field on the document.

That is what makes "waiting on me" a single query instead of a tour of every model. An approver opens one list containing variations, payment certificates, permits to work, bills of quantities, daily logs and inspections, worst first — rather than opening six registers and filtering each by hand, which in practice means opening three of them.

It also means the board's Commercial Exposure screen can end with a Waiting for a signature table — pending steps by value with days outstanding — without any new plumbing. The exposure report and the approver's inbox are two readings of the same table.

The counter-intuitive bit: the completion hook runs elevated

When the last step signs, the document's completion hook runs with elevated rights. That looks wrong until you say the reason out loud: the approver's authority is to decide, not necessarily to edit.

A board member signing a large variation has no business holding write access to bills of quantities, and requiring it would defeat the exact separation the rules exist to create. This was found by a test, where a delegate with only site-engineer rights approved a bill and the callback failed on write access — which is a much better place to find it than a Friday afternoon on a live job.

A related detail, learned the same way: the last signature has to complete the document, not merely unlock a button. Otherwise the approval finishes, the permit stays in "submitted", and it reads to everybody as the system having lost it.

What is deliberately not there

  • No approve-by-reply. The WhatsApp alert carries the document and the value and links into an authenticated session. A token in a message body that approves a quarter-million variation is a signature anybody who picks up the phone can forge.
  • No rule means no approval. A document no rule covers keeps its old behaviour. A suite that refused to work until somebody had written rules would only get the rules written badly and in a hurry, at four o'clock, by whoever was nearest.
  • Not every model is wired up. Six are: bills of quantities, variation orders, payment certificates, permits to work, daily logs and inspections. Moving another across is small and explicit — inherit the mixin, answer what it is worth, call the check from the action that commits it — but it is a decision somebody makes, not something that happens quietly.

One trap worth knowing before you configure it

The construction security groups imply one another: manager implies project manager implies site engineer. A step assigned to the project-manager group can therefore be signed by anyone more senior, which is usually exactly what you want — a delegation of authority normally reads "this level or above".

Where it is not what you want — where the rule is "the board signs, and only the board" — name a specific person on the step rather than a group that others inherit. Group implication is a convenience that silently widens a control if you forget it exists.

In the product

The approval engine lives in construction_base. Rules are configured as data in the approval workspace, and the shipped demo data includes a worked delegation of authority meant to be copied and edited. See the feature summary →