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

# The security boundary

> Nothing a browser can drive reaches the ledger. Two disjoint sets enforced at import time, a chat agent with no write path, eleven executed attacks on the proxy, and a second layer that holds when the first is bypassed.

# The security boundary

The Proof tab argues that the judgment layer is measured.
This page argues the other half: that **the measured layer cannot reach the book of record on its own**, and that the claim has been attacked rather than merely written down.

It is here rather than in Capabilities because it is evidence, and it is the same kind of evidence as a scoreboard: a list of things that were tried, and what happened.

***

## The rule, in one line

> **A graph that can reach the ledger is never drivable from a browser.**

Two named sets, in the UI's proxy guard:

| Set              | What it means                                         | Members                                                                                                                                        |
| ---------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `CHAT_GRAPHS`    | a browser may start a run on it                       | the conversational, read-only graphs. `clerk` and `auditor` are listed today, and the Typewriter qualifies under the same admission rule below |
| `POSTING_GRAPHS` | can write to, or stage a write to, the general ledger | `bookkeeper`, `accountant`, `data_entry`                                                                                                       |

Their intersection must be **empty**, and membership of the first set is the single source of truth for "is this agent drivable from a browser".

<Note>
  **And that invariant is executed, not commented.**

  ```
  for (const graph of CHAT_GRAPHS) {
    if (POSTING_GRAPHS.has(graph)) {
      throw new Error(
        `'${graph}' is in CHAT_GRAPHS and in POSTING_GRAPHS. ` +
        "A graph that can reach the ledger must never be drivable from a browser.",
      );
    }
  }
  ```

  It throws **at import time**, in every bundle that loads the guard, so the mistake cannot reach a running deployment.

  A comment saying "keep these in sync" is what let a previous mismatch ship: the Auditor was offered in the agent picker and absent from the allowlist, so every message it received returned 403.
  The agent picker now derives its visible set **from** `CHAT_GRAPHS`, and the disjointness throws.
</Note>

Posting is reachable through exactly one path: the gated server-side approval endpoint.

***

## Why a chat agent is allowed to exist at all

Admission to `CHAT_GRAPHS` has one rule: the graph must accept a conversational turn **and have no write path**.

Two graphs pass it, for structural reasons rather than policy ones.

<CardGroup cols={2}>
  <Card title="The Auditor" icon="search">
    No submit or post tool.
    No interrupt.
    A deterministic scope denylist that refuses any post, fix or reverse request.

    And its database session is put read-only by Postgres and then **read back** to prove it before the connection is used, so the server rejects a write even if the graph attempted one.
  </Card>

  <Card title="The Typewriter" icon="keyboard">
    No database session at all: nothing in the package imports a driver, and no dependency holds a connection.

    No submit tool - the model returns data, and there is no tool it can call that has an effect.
    No interrupt: it runs to a terminal answer in one pass.
    No HTTP client.
  </Card>
</CardGroup>

<Note>
  **The Typewriter's four properties are asserted by a test, not by a docstring.**

  `test_typewriter_graph.py` inspects the package's **imports**, its **source** and its **state keys**, and fails if any of the four stops being true.

  A change that gives that graph an effect goes red in the test suite **before** it reaches the guard, with a message that names the remedy: remove it from `CHAT_GRAPHS`, because a browser-drivable agent with a write path is exactly what this boundary exists to prevent.

  That is why a propose-only agent can safely be the one the browser drives.
  It proposes; the human's Save is the write, always.
</Note>

***

## Eleven executed attacks on the proxy

The UI proxies the browser to the graph runtime, authenticated with the server's own key.
The only thing originally in front of it was a session check - which is a **login** check, not an **authorisation** check.

Six rounds of adversarial review followed. Each one executed the attack before writing the fix.

<AccordionGroup>
  <Accordion title="Round 1 · a login check is not an authorisation check">
    Every authenticated user had the **full** SDK surface against the deployment, including the posting graph.

    Since the Bookkeeper's only gate was `interrupt_before`, and everything the `post` node inspected was state the caller wrote, three SDK calls put an arbitrary, immutable, attacker-attributed journal entry in the general ledger.

    **Fix:** a positive route allowlist - the chat surface and nothing else - with `update_state` denied by name, and any run on a graph outside `CHAT_GRAPHS` denied outright.

    The allowlist is positive on purpose: a new runtime endpoint arrives **denied** and has to be allowed deliberately, rather than arriving exposed and having to be noticed.
  </Accordion>

  <Accordion title="Round 2 · an allowlisted route is not an allowlisted call">
    Two attacks went straight through the route allowlist.

    **3.** Thread creation was allowed with **no body inspection** - and the creation call takes `supersteps`, which is a state write, plus a `graph_id` that assigns the thread's graph. The Bookkeeper's thread id is derived deterministically from an intake id every user is handed, so one call seeded lines onto a posting thread **before** the document was approved. A real reviewer's approval then posted the attacker's entry under the reviewer's name.

    **4.** An **allowed** Clerk run pointed at the Bookkeeper's thread id overwrote that thread's channels. The proposal vanished, the paused gate could never be resumed, and the approved document became permanently unpostable - one call per document, from any authenticated browser.

    **Fix:** body checks on creation, and a run must target a thread its own graph owns.

    **The lesson:** every route that carries a body has to be judged on the body.
  </Accordion>

  <Accordion title="Round 3 · so the body check became an allowlist too">
    Round 2 fixed the two fields that had been abused. Two more arrived wearing a third field.

    **5.** `if_not_exists: "create"`. The run routes take the thread id in the **path**, so denying a client-chosen thread id at creation denied nothing: one Clerk run on the Bookkeeper's computable thread id **claimed that id first**. The ownership lookup answered "missing", and missing was an automatic allow - which is only true if the body cannot ask the server to create it.

    **6.** `command: {goto, update}` - which is a state write wearing a third route. The proxy rewrote one field and never looked at `command`, so a real intake row was filed under a name the browser chose, **defeating the three-person separation of duties by forging one of the three**.

    **Fix:** the body check became **positive**, like the route check. A run body may carry only listed keys; everything else - including whatever the next SDK release adds - arrives denied. "Missing" became a denial.
  </Accordion>

  <Accordion title="Round 4 · the read surface, which nothing was checking">
    Round 3's allowlist held: a third re-verification could not get a single **write** to a posting thread through it.

    What it found instead was that the ownership check only ever ran on writes.

    **7.** A posting thread's state, history, stream and checkpoints were readable by any logged-in browser. That state is a document's proposed journal lines, amounts and counterparties, plus the signed mandate behind its approval. Thread ids are computable, and the search endpoint enumerates them anyway.

    This is **confidentiality, not a path to the ledger** - a leaked mandate is inert from a browser, because every write is denied and the mandate is bound to one org, one record, one checker and one verdict. But a layer that is claimed to exist has to exist.

    **8.** Two body-supplied fields that on paper aim a run at a thread the URL did not name. Executed against a real server, upstream ignores both - which means the control was owned by someone else's undocumented implementation detail. They are now pinned on the run routes, and the **remaining gap on the non-run routes is recorded rather than claimed closed**, because the honest statement of what a control covers is the thing these rounds kept finding wrong.
  </Accordion>

  <Accordion title="Round 5 · what defeated the read check">
    **9.** A URL-encoded path separator stayed inside one segment, so the guard saw an allowlisted read of a thread while the ownership lookup landed on a different upstream endpoint - one whose array response has no graph field, so a posting thread was classified "unassigned" and allowed. The full state, mandate included, came back 200.

    Two locks now: the request is refused **before** a URL is built from the segment, and any non-object lookup payload is refused as unknown.

    **10.** The thread **search** endpoint performed no org scoping and returned every thread on the server, including other graphs' posting threads. It is now filtered by response.
  </Accordion>

  <Accordion title="Round 6 · what defeated the response filter">
    **11.** The filter added in round 5 reads two fields of each row - and the search endpoint lets the **caller choose which fields a row has**. Omitting one parameter and moving the data under a key the filter never looked at brought every posting thread's values and signed mandate back with a 200.

    The same capability round 5 removed, reached through the control that removed it. Round 5's own documentation meanwhile asserted the leak was closed.

    Two locks now, and the documentation says only what they buy: a search body carrying either parameter is denied, and any row that cannot be classified is dropped.
  </Accordion>
</AccordionGroup>

<Warning>
  **The lesson these six rounds kept producing, in the reviewer's own words:**

  > A control written the same day it is claimed is the one claimed too broadly, because nothing has attacked it yet.

  Six rounds found the same failure six times, and every time it was **a sentence about a control, not the control**.

  That is why this page describes gaps that are open (attack 8) alongside the ones that are closed.
</Warning>

***

## The identity the control is enforced against

One thing is **added** to a request rather than filtered from it: the verified caller is stamped onto a Clerk run as the document's **submitter**, and any submitter the browser sent is dropped.

That identity is one of the three people separation of duties is enforced between, and it was the missing one.
The browser sent nothing, the Clerk defaulted to the literal string `"submitter"`, and the backend's *checker is not submitter* comparison was **comparing an email address against a constant** on every document ever filed.

> **An identity a control is enforced against may never come from the party the control constrains.**

The same rule applies to the acting identity on the API proxy.
See [two gates, three people](/greatbook/capabilities/maker-checker) for what the rule became once it had three real people in it.

***

## Not load-bearing alone

<Note>
  **Everything above stops the attack being reachable. A separate layer stops it working.**

  The browser-side guard could be bypassed, misconfigured, or the graph runtime port reached directly - which, inside the network, it can be.

  So the backend enforces the same boundary independently, over **every** security-relevant field: the `post` node reads the org, the entry kind, the record id, the checker and the verdict out of a **server-signed mandate no browser can mint**, re-reads the record those ids name, and refuses a checkpoint that contradicts them or that claims an outcome the graph never produced.

  That control took [five rounds of its own](/greatbook/capabilities/posting-mandate), and two of its round-5 repros still **fail on round-4 source** - which is the only real evidence that a control of this kind exists.
  A security test that passes on the vulnerable version is testing something else.
</Note>

***

## The whole boundary, in order

```mermaid theme={null}
flowchart TD
  B["Browser<br/>authenticated session"] --> G

  G["Proxy guard<br/>positive route allowlist · positive body allowlist<br/>thread ownership on reads AND writes<br/>verified submitter stamped in"]

  G -->|"CHAT_GRAPHS only"| C["conversational graphs<br/>no write path"]
  G -.->|"denied"| P["bookkeeper · accountant<br/>POSTING_GRAPHS"]

  C --> Q["staged proposal<br/>nothing posted"]
  Q --> H1["Gate 1 · intake approval<br/>a second person"]
  H1 --> H2["Gate 2 · posting approval<br/>a third person"]
  H2 --> M["Signed posting mandate<br/>minted server-side"]
  M --> W["Ledger service<br/>THE SINGLE WRITER<br/>18 GL invariants → post → hash chain"]
```

Four independent things have to hold, and none of them is the model:

1. the graph the browser can drive **has no write path**;
2. the proxy refuses any call outside a positive allowlist, judged on the body;
3. two distinct humans approve, and neither may be the person who filed the document;
4. the writer accepts a posting only against a mandate the server minted, and re-reads the record that mandate names.

***

## Where it is enforced

| Concern                                              | Code                                                               |
| ---------------------------------------------------- | ------------------------------------------------------------------ |
| the two disjoint sets, and the throw                 | GreatBook web repository, `src/lib/langgraph-passthrough-guard.ts` |
| the response-level filtering and thread-owner lookup | GreatBook web repository, `src/app/api/[..._path]/route.ts`        |
| the Typewriter has no write path, asserted           | `langgraph_chat/agents/typewriter/tests/test_typewriter_graph.py`  |
| the Auditor's read-only sessions                     | `langgraph_chat/agents/auditor/{graph,tools,gl_read}.py`           |
| the mandate: mint, verify, refusals                  | `langgraph_chat/agents/bookkeeper/post_auth.py`                    |
| the layer that answers the human                     | `langgraph_chat/devcenter/intake_service.py`, `post_approve`       |
| strict separation of duties                          | `langgraph_chat/agents/common/sod.py`                              |

## Related

* [The posting mandate](/greatbook/capabilities/posting-mandate) - the five rounds behind the server-side half
* [Two gates, three people](/greatbook/capabilities/maker-checker) - the human control this boundary protects
* [Tenancy and read-only sessions](/greatbook/capabilities/tenancy-and-rls) - the Postgres-level layer underneath
* [The Typewriter](/greatbook/agents/typewriter) - the propose-only agent the browser drives
* [Worked cases: the Auditor](/greatbook/proof/cases-auditor) - the read-only guarantee, fingerprinted on every run
