Skip to main content

Intake and the posting gate

This is the money path’s HTTP surface, and it contains two gates approved by two different people. The posting checker may not be any maker, including the person who approved the intake.
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.
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.

Reads

GET /intake/queue

The review inbox.
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.
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.

GET /intake/{intake_id}/audit

The maker-checker trail for one document.
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.
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.
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

POST /intake/{intake_id}/resubmit

Sends a document back for correction with a patch of fields.
A body that is not a JSON object is a 400.
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.

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.
Idempotent. A row already posted returns its entry with no second run.
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.

POST /intake/{intake_id}/post-reject

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

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.

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.