> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vinmake.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Two gates, three people

> Strict separation of duties: three distinct authenticated people behind every document-originated posted entry, enforced in three places, and the honest account of why the rule used to be decorative.

# 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".

| Role                | What they did                              |
| ------------------- | ------------------------------------------ |
| **submitter**       | filed the document into the queue          |
| **intake approver** | let it out of review and into ACTIVE       |
| **posting checker** | approved the journal entry into the ledger |

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**:

| Field         | What it actually held                                                                                                    |
| ------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `submitter`   | the literal string `"submitter"` - the Clerk's own fallback, because the browser never sent one, so **every** row had it |
| `drafted_by`  | `"clerk"` - an agent, correctly refused already                                                                          |
| `reviewer`    | a configured display name                                                                                                |
| `approved_by` | a real Supabase identity                                                                                                 |

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.

<Warning>
  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.
</Warning>

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:

| Error                                                                                              | Consequence                                         |
| -------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| merging two different people who share a local part across domains (`admin@a.com` / `admin@b.com`) | **refuses a posting that should have been allowed** |
| treating one person's two spellings as two people                                                  | **admits a self-approval**                          |

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.

<Note>
  `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.**
</Note>

## Enforced in three places

Deliberately three, because each covers what the others cannot.

```mermaid theme={null}
flowchart TD
  S["a document is filed<br/>submitter recorded from the session"] --> G1
  G1{"intake_service.approve<br/>intake_violation"} -- "submitter == approver,<br/>or either unattributed" --> R1["refused at gate 1"]
  G1 -- ok --> A["ACTIVE · the Bookkeeper is launched"]
  A --> G2{"intake_service.post_approve<br/>strict_violation"} -- violation --> R2["409, before the run"]
  G2 -- ok --> G3{"bookkeeper.graph.post<br/>strict_violation on the RE-READ record"}
  G3 -- violation --> R3["MakerIsCheckerError<br/>before the transaction opens"]
  G3 -- ok --> L["LedgerService.post_entry"]
```

| Where                         | Why there                                                                                                                                           |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `intake_service.approve`      | so a document cannot even enter ACTIVE in a state where it could never be posted. Fail at the gate the human is standing at, not two gates later    |
| `intake_service.post_approve` | the API boundary, so the reviewer gets a clean 409 instead of a failed agent run                                                                    |
| `bookkeeper.graph.post`       | the last gate before the writer, checked against the record the graph **re-read itself**, so the rule holds even if the graph is driven another way |

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.

|                                                        | Posting (`strict_violation`) | Refusal (`rejection_violation`) |
| ------------------------------------------------------ | ---------------------------- | ------------------------------- |
| the checker must be a named human                      | yes                          | yes                             |
| the checker may be none of the record's makers         | yes                          | yes (at the call site)          |
| the record must already have three attributable people | **yes**                      | **no**                          |

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

```
separation of duties: the person who submitted this document and the person approving
its posting are the same person ('thai@vinmake.com'). A posted journal entry needs three
distinct people behind it - whoever filed the document, whoever approved it into the
queue, and whoever approves the posting
```

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

| Concern                                                     | Code                                                               |
| ----------------------------------------------------------- | ------------------------------------------------------------------ |
| the whole rule, in one dependency-free module               | `langgraph_chat/agents/common/sod.py`                              |
| the intake gate                                             | `langgraph_chat/devcenter/intake_service.py`, `approve`            |
| the API boundary                                            | `intake_service._guard_checker`, `_guard_rejecting_checker`        |
| the last gate                                               | `langgraph_chat/agents/bookkeeper/graph.py`, `post`                |
| the derived actor every comparison rests on                 | [`devcenter/identity.py`](/greatbook/capabilities/tenancy-and-rls) |
| a database-level maker-is-not-checker for document approval | migration `0031_typewriter_maker_not_checker`                      |

`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:

```
A document filed before the submitter was captured cannot be posted: it has to be
re-submitted by the person who owns it, so the ledger can say who put it there
```

## 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.

## Related

* [The posting mandate](/greatbook/capabilities/posting-mandate) - what stops an attacker skipping the gate entirely
* [Tenancy, RLS and derived identity](/greatbook/capabilities/tenancy-and-rls) - why the actor cannot be chosen by the browser
* [Who does what](/greatbook/roles) - the roles, from a user's point of view
* [Gate 1, the intake approval](/greatbook/guides/intake-gate) - the same rule, seen from a reviewer's chair
