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

# Intake and the posting gate

> The fourteen routes that carry the money path: master reads, the review queue, both gates, and launch recovery.

# Intake and the posting gate

This is the money path's HTTP surface, and it contains **two gates approved by two different people**.

| Gate                 | Routes                                    | The question it answers                                                                                                    |
| -------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| **the intake gate**  | `/approve` `/reject` `/resubmit`          | *is this document real and correctly extracted?* Advances the row to ACTIVE and launches the Bookkeeper                    |
| **the posting gate** | `/posting` `/post-approve` `/post-reject` | *is this journal entry right?* Releases the Bookkeeper's durable pause so exactly one balanced, immutable entry is written |

The posting checker may not be **any** maker, including the person who approved the intake.

<Warning>
  These two routes are the only way to release the Bookkeeper's pause from outside the agent runtime.
  Without them an approved document never reached the ledger at all - which is exactly what an adversarial review found.
</Warning>

All routes are prefixed `/intake`, require a verified identity, and are org-scoped.
Mutating routes take their acting identity from the derived actor; see [Auth, actors and org scoping](/greatbook/api/auth-and-tenancy).

## Reads

### `GET /intake/queue`

The review inbox.

| Query    | Default | Notes                                                          |
| -------- | ------- | -------------------------------------------------------------- |
| `status` | `all`   | a single status name filters to it; an unknown name is a `400` |
| `limit`  | `100`   |                                                                |

```json theme={null}
{
  "rows": [ { "id": "…", "status": "pending", "doc_type": "supplier_vat_invoice", "…": "…" } ],
  "count": 12,
  "counts": { "pending": 9, "flagged": 3 },
  "status": "all"
}
```

`counts` is what the review page's tab badges render, so the tabs cannot disagree with the rows.

### `GET /intake/{intake_id}`

One row in full, or `404`.

<Note>
  A review deep link resolves through this route.
  The UI's `/books/review?item=<id>` searches the whole queue rather than the selected tab, and confirms an id the loaded list does not hold with a direct call here.
  An id the backend answers `403` or `404` for selects **nothing** and shows a by-id banner - it never falls back to a neighbouring document that has live Approve and Reject controls.
</Note>

### `GET /intake/{intake_id}/audit`

The maker-checker trail for one document.

```json theme={null}
{ "intake_id": "…", "events": [ { "kind": "approved", "actor": "…", "at": "…", "detail": {} } ] }
```

The `posted` event's detail carries the whole accounting fact: the journal entry id, the obligation id the posting opened, the VAT register line id, and `party_resolution` - whether the obligation's party key is a resolved master id or the document's own free-text name.

### `GET /intake/{intake_id}/posting`

The posting gate as a review pane should render it: the row's own truth, plus the paused proposal when the agent runtime is reachable.

```json theme={null}
{
  "intake_id": "…",
  "intake_status": "active",
  "result_entry_id": null,
  "makers": ["huyen@example.com", "thai@example.com"],
  "approved_by": "huyen@example.com",
  "posted_by": null,
  "entry_number": null,
  "gate": { "available": true, "proposed_lines": [ { "account_code": "1200", "account_name": "Inventory - raw materials", "debit": "1000000", "credit": "0", "currency": "VND" } ] }
}
```

Two things are worth reading carefully.

`makers` is who may **not** release this gate, returned so the UI can explain a `409` *before* it happens rather than as a failed request.

`posted_by` is read from the **journal entry**, never from `approved_by`.
Those are two different people under strict separation of duties, and reporting the intake approver as the posting checker would misstate who signed for the money.

This route is read-only and side-effect free: opening a document must never launch or post anything.
When the agent runtime is unreachable, `gate.available` is `false` and the row's own status still tells the truth.

## The intake gate

### `POST /intake/{intake_id}/approve`

No body.
Advances the row to ACTIVE and launches the Bookkeeper.

```json theme={null}
{ "id": "…", "status": "active", "approved_by": "huyen@example.com" }
```

| Status | Cause                                                                                 |
| ------ | ------------------------------------------------------------------------------------- |
| `404`  | not found in this org                                                                 |
| `409`  | the approver is the submitter or the drafter, or the row is not in a reviewable state |

Separation of duties is checked **here** as well as at the posting gate, so a document cannot enter ACTIVE in a state from which it could never be posted.
Failing at the gate the human is standing at beats failing two gates later.

### `POST /intake/{intake_id}/reject`

```json theme={null}
{ "reason": "the supplier tax code does not match the master" }
```

### `POST /intake/{intake_id}/resubmit`

Sends a document back for correction with a patch of fields.

```json theme={null}
{ "patch": { "total_amount": "1100000" } }
```

A body that is not a JSON object is a `400`.

<Warning>
  Resubmit accepts a **`flagged` row only**.
  The UI mirrors that rule rather than rendering the control on every non-terminal row - it used to, which made "Send back" a guaranteed `409` on every Typewriter-composed document, with an error naming an internal verb.
  The control now renders **disabled with a reason**, which is this product's pattern: a rule, not a button that fails.
</Warning>

## The posting gate

### `POST /intake/{intake_id}/post-approve`

No body.
The checker is the derived actor.

Releases the pause, and the Bookkeeper posts exactly **one** balanced immutable entry through the single writer, flipping the row to `posted` in the same transaction.

```json theme={null}
{ "id": "…", "status": "posted", "result_entry_id": "…", "entry_number": "JE-000412" }
```

| Status | Cause                                                                                                                           | Written? |
| ------ | ------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `409`  | the checker is a maker                                                                                                          | nothing  |
| `409`  | the row is not ACTIVE                                                                                                           | nothing  |
| `409`  | a guardrail in the run refused - an unmapped account, an inconsistent VAT triple, an unrecognised currency, a tampered proposal | nothing  |
| `503`  | the posting runtime is not reachable                                                                                            | nothing  |

**Idempotent.**
A row already `posted` returns its entry with no second run.

<Warning>
  A row that cannot name its journal entry is **not** reported as posted.
  This route re-reads the row and refuses to answer at all rather than inventing an entry id - because a posting thread claiming otherwise means its state was written by something other than the Bookkeeper, and that is worth reporting rather than rendering.
</Warning>

### `POST /intake/{intake_id}/post-reject`

```json theme={null}
{ "reason": "coded to inventory; this is a service invoice" }
```

Nothing is written to the ledger.
The document becomes REJECTED with the checker's reason recorded.

The rejecting checker must be a named human, but the *distinctness* half of separation of duties is relaxed here: a refusal writes nothing to the ledger, so it is gated on being attributable rather than on three distinct people.
Applying the full rule to rejections once stranded documents that could be neither posted nor rejected nor resubmitted.

## The launch outbox

An approval and the agent launch it triggers are two things, and the second can be interrupted.
Rather than losing it, the launch is recorded as a job.

### `POST /intake/launches/sweep`

The reaper.
Retries every launch whose backoff is due - the cron and operations route.

| Query   | Default |
| ------- | ------- |
| `limit` | `50`    |

```json theme={null}
{ "attempted": 3, "counts": { "launched": 2, "failed": 1 }, "results": [ … ] }
```

`abandoned` jobs are excluded by design: a dead letter needs a deliberate retry, not a sweep.

### `POST /intake/{intake_id}/retry-launch`

An operator retry for one row: an interrupted, deferred, failed or dead-lettered launch.

```json theme={null}
{ "id": "…", "launch": { "state": "launched", "attempts": 2 } }
```

A job already `launched` is an idempotent no-op, reported as `{"idempotent": true}`.

Both routes are attributable like every other mutating route, because rearming a dead-lettered document is a decision someone owns.

## Where it is enforced

| Concern                                           | Code                                            |
| ------------------------------------------------- | ----------------------------------------------- |
| the HTTP mapping and the error contract           | `langgraph_chat/devcenter/intake_api.py`        |
| every state change, and the posting orchestration | `langgraph_chat/devcenter/intake_service.py`    |
| separation of duties                              | `langgraph_chat/agents/common/sod.py`           |
| the mandate the posting gate mints                | `langgraph_chat/agents/bookkeeper/post_auth.py` |

<Note>
  The schema-resilience wrapper that returns `200`-empty on a missing legacy table is attached to the **read** routers only, deliberately.
  Mounted app-wide it also covered `/post-approve`, `/post-reject` and `/posting`, turning schema drift on a money route into an HTTP `200` with an empty body.
  The posting routes fail closed with a real error.
</Note>

## The test that would fail if it broke

`langgraph_chat/devcenter/tests/test_intake_api.py` covers the route contract and every error mapping.
`test_duplicate_double_post.py` covers the double-post guard, and `test_posting_attribution.py` covers `posted_by` being read from the entry rather than from `approved_by`.

## Related

* [The posting gate guide](/greatbook/guides/posting-gate) - the same flow from a reviewer's seat
* [The intake gate guide](/greatbook/guides/intake-gate) - the first gate, walked through
* [Maker-checker](/greatbook/capabilities/maker-checker) - the rule these routes enforce
* [The posting mandate](/greatbook/capabilities/posting-mandate) - what makes `post-approve` unforgeable
