Skip to main content

Two gates, three people

What it guarantees

Three distinct authenticated people stand behind every document-originated posted journal entry. Not two. Not “two plus a constant”. Each must be a real authenticated identity, and no two of them may be the same person. Native subledger records, approved workpapers, reversals and system-origin events do not pass through intake or inherit its three identities. Their canonical services apply the controls for those sources before the same single ledger writer posts any resulting entry.

Why the old rule was decorative

This is the part worth reading, because it is the failure mode every maker-checker implementation is vulnerable to and almost none document. The rule existed. The code compared the checker against a maker set. And in the deployed path, that set contained no real human identity except the intake approver: So checker != submitter compared a real email address against the word "submitter", and passed every time. The person who uploaded a document could approve their own document. The product’s advertised “maker is not checker” meant “two humans touch the money”, not “the uploader cannot approve”. Those are very different controls, and only one of them was being enforced.
The lesson generalises past this codebase. A rule comparing identities is only worth writing once the identities are authenticated. A control that compares a verified value against a default constant is not a weak control; it is no control, and it reports success.
The other half of the fix was making the submitter a real identity. The UI’s LangGraph passthrough now injects it from the validated session and strips any the browser sent, exactly as the API proxy already did for the acting identity.

Identity normalisation, and which way it leans

The three identities do not all arrive in the same format: a Supabase email here, a bare username there. So they are compared on a canonical key: casefolded, trimmed, and reduced to the local part of an email. Thai, thai and thai@vinmake.com are therefore one person. That choice is deliberate and it leans one way: A refused posting is a phone call. An admitted self-approval is a fraudulent entry in an immutable ledger. On a single-organisation deployment the merge is not reachable at all.

What counts as a person

Two rejection lists, and neither is cosmetic. Agent identities. clerk, bookkeeper, accountant, auditor, typewriter, data_entry, agent, system, and the legacy internal pseudo-actor worthstate. The document gate records who approved its journal entry, and “an agent approved it” is not an answer that provenance may carry. Placeholder identities. submitter, unknown, anonymous, none, null, n/a, na, -, user, someone, test. These are the shapes an empty identity takes in the wild, and submitter is the one that mattered.
is_person cannot verify that a string was authenticated - that is a property of where it came from, and the guarantee is upstream, in the proxy that derives it from a validated session. What it can check is that the field was not left to a default, which is exactly how the control came to be vacuous.

Enforced in three places

Deliberately three, because each covers what the others cannot. The third is the load-bearing one. The first two are for the human’s experience; the third is the control.

Two rules, not one

A posting and a refusal are held to different standards, and that asymmetry is deliberate. A refusal writes nothing to the ledger, so it is not gated on the record being postable. Applying the full rule to refusals stranded documents: a row whose submitter still held the old "submitter" placeholder failed the unattributed arm, so it could not be posted, could not be post-rejected, and could not be resubmitted. It sat ACTIVE forever, and then showed up in the Auditor’s reconciliation as a document with no journal entry. Fixing that is not a weakening. It is recognising that the question “may this person refuse?” is a different question from “is this record ready to post?”.

The refusal message names the act, not the column

And unattributed is checked first, so “nobody is recorded as having submitted this” is never reported as “you cannot approve your own document”. Those are two very different things to tell someone standing at a money gate.

Where it is enforced

sod.py is pure and dependency-free: it decides, it does not raise. Each caller raises its own typed error, so the HTTP and graph error contracts stay where they are.

The test that would fail if it broke

langgraph_chat/agents/tests/test_sod.py covers the rule itself. langgraph_chat/agents/bookkeeper/tests/ covers the enforcement at the posting gate, including that a refusal raises before the transaction opens, so the gate is not consumed and a real checker can still post.

Rows filed before the fix

Documents filed before the UI passthrough began injecting the session identity are unpostable by design. They have to be re-submitted by the person who owns them, so the ledger can say who put them there. That is stated in the refusal itself rather than being left as a mystery:

What goes wrong without it

The uploader approves their own invoice. That is the entire fraud, and it needs no sophistication: it is the default behaviour of a system whose maker-checker rule compares against a constant.