Skip to main content

The posting mandate

This is the headline security page. An adversarial reviewer defeated this control four times, each time finding the same defect one field to the left, and each round is written down with the attack that broke it. A control that survived five rounds of executed attack, with the attacks published, is a far stronger claim than any adjective.

What a mandate is

A short signed statement, minted by the gated server-side approval path, that says:
org O has authorised CHECKER to DECIDE on RECORD R, which is of kind K.
The post node reads its security-relevant fields out of this token and out of the database row the token names. It reads none of them off the checkpoint. And verify is literally the first statement the node executes, so there is no code above the trust boundary to read anything at all. That sentence is the whole control. Everything below is why it had to be written four times before it was true.

The five rounds

The Bookkeeper’s only gate was interrupt_before=["post"], and everything the post node checked was state the resumer wrote.Any authenticated browser could write a checker, a decision and a set of lines onto the thread through the UI passthrough, then create a run to resume it, and land an arbitrary, attacker-attributed, immutable entry in the ledger.Fix: an HMAC over the decision. A browser cannot mint one, because the key never leaves the server.The lesson: a pause is not an authorisation. “Resumed” and “a human approved this” are different facts, and only the code past the pause can tell them apart.
The signature proved the decision came from the gated path. It could not prove what posted, because it was minted over whatever was on the checkpoint at resume time.So: seed committed_lines onto the thread id - which is deterministic and therefore computable - before the document is approved, and a real reviewer’s approval mints a perfectly valid token over the attacker’s entry.Fix: stop believing the checkpoint’s lines at all. graph.trusted_record re-derives them from the ACTIVE intake row at post time.
The lines were re-derived - from state["intake_id"], for state["entry_kind"], in state["org_id"]. Every one of those is still the checkpoint.Two attacks were executed immediately:
  • flip entry_kind to "adjustment" and the run takes a branch whose “trusted record” is the checkpoint: Dr 6410 / Cr 2000 for 888,000,000 posted under the reviewer’s name, with the real intake row left ACTIVE so it could still be posted again;
  • swap intake_id for another ACTIVE document and the gate posts a document the reviewer never saw, while the one they approved stays ACTIVE.
Both are round 2’s defect, one field to the left.Fix, and the structural insight: signing a decision computed from untrusted state can never fix this, because the token is always consistent with the poison. So the token stopped being an opaque digest of state and became a statement the server writes and the graph reads. It carries the org, the entry kind and the record id, and post takes them from the mandate.
Every input was in the mandate, and that design held under attack: the token could not be forged, replayed onto another record, or spent twice.What it missed was an output. post opened with:
above the mandate check. So one write of {"status": "posted"} onto a paused gate skipped the mandate entirely, and made a real reviewer’s approval return “posted” with an empty ledger and the document still ACTIVE - while the review pane told the human it was finished.Fix, and the generalisation that covers all four rounds:
A field the graph writes as an outcome is never a field it reads as an input.
verify now runs before anything else in the node. status, result_entry_id and entry_number are fields this graph writes.
Round 4 recorded that requiring a result_entry_id alongside a terminal posted closed the class. It did not.Requiring a result_entry_id only asks whether the checkpoint holds a non-empty string, and an attacker supplies one: a planted random UUID satisfied the check, and the resume returned {status: "posted", result_entry_id: <forged>} with an empty ledger.Worse, two of the layers were two separate reads of one mutable checkpoint, so a write landing between them made the approval endpoint return HTTP 200 for a document that never posted.Fix, stated precisely:
Only the database may establish an outcome, and the layer that answers a human is the layer that must ask it.
That layer is intake_service.post_approve, which re-reads the row after the run and refuses unless the row itself says posted with an entry id.The graph-side and runner-side checks are deliberately kept, but they are reclassified: they are tamper signals, not authorisations. They make a poisoned thread loud instead of merely inert, and nothing may read their silence as evidence that an entry exists.

What is in the signature, and what is not

In: org_id, entry_kind, intake_id for intake runs or source_type + source_id for adjustments, checker, decision. Those are exactly the fields the caller of POST /intake/{id}/post-approve supplies from the URL path and the authenticated session. Every field that decides whose money moves and who said so. Out: the lines. Deliberately, and it is not a weakening. A lines digest could only ever be computed from the resumer’s own claim - nobody outside the graph process knows what the record derives - which is precisely how round 2 was defeated. What posts is decided by graph.trusted_record re-reading the row the mandate names. The checkpoint’s lines are only ever compared against that, so a substitution is refused visibly rather than silently overwritten with the honest entry. Replay: a mandate is bound to one org, one record, one checker and one verdict, so the only thing it can be replayed onto is the same decision it was minted for - which the ledger’s idempotency key and the row’s posted status already make a no-op. There is deliberately no nonce, because a nonce with no server-side store is decoration.

The adjustment branch has its own trusted record

An accountant adjustment has no staging row. The proposal is the record, and it travels in the launch state, which is exactly the thing this module refuses to trust. So the proposal is signed where it is staged, inside the server process that built it, over the whole payload: the lines, the amounts, the entry date, the GL source lineage and the proposing agent are all fixed before the run ever pauses. An attacker who rewrites the lines cannot re-sign them. One who keeps the signature and changes the payload fails the comparison.

Fail-closed by construction

WS_POST_SIGNING_KEY unset does not disable the check. It falls back to a key generated randomly per process. A missing secret therefore breaks posting loudly at deploy time, instead of silently degrading to no control at all - which is the exact failure mode this review kept finding elsewhere. The key must be set to the same value on the LangGraph and DevCenter services.

Not load-bearing alone, and no longer claiming to be

The UI passthrough is separately locked down with a route allowlist, so the attack cannot even be attempted from a browser. This layer is what holds if that one is bypassed, misconfigured, or the LangGraph port is reachable directly - which, inside the network, it is.

Where it is enforced

The tests that would fail if it broke

The last three tests in test_resume_posting_live.py are round 5’s: the forged result_entry_id, the two-reads race, and the provenance of the entry_number the reviewer is shown. Two of them fail on round-4 source. That 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.

Consequences for anyone extending this

  • resume_posting and aresume require the org and the record they are approving. Never pass values read off the paused thread.
  • An adjustment has no row to re-read, so its record is the payload signed at stage time.
  • graph.entry_kind_of is a pre-gate helper that post must never call.
  • runner.resume_posting’s return value is not proof of a posting. Its own docstring says so.