An approval gate for the one step an agent can't take back.
We sent one freight invoice to Nutrient's extraction API three times — once in each mode, seconds apart. All three responses are committed in this repo. The cheapest one was the only one that behaved safely.
Rendered from mcp/nutrient/messy-pdf.mjs — a document we generate ourselves, with scan skew and OCR-hostile glyph substitutions baked in on purpose. Hard input is the point: the question is not whether the API can be fooled, it's whether it tells you when it has been.
The document is synthetic — we generated it. The API calls were live and billed: three sequential requests on 2026-08-20, each with a server-issued request ID, and a credit balance that draws down across them — 4889.0 → 4881.5 → 4872.5. Both facts are in docs/fixtures/. We're drawing a line under this because the distinction matters and it would be easy to blur.
Returned 3 of 7 fields. Flagged total_amount as not_found at confidence 0.577, grounding 0.40. The cheapest mode produced the most honest answer.
Returned 26.86 and 5.27 — both wrong — at id_match, confidence 0.970, grounding 0.95. Every signal an integrator would threshold on said auto-approve.
Fixed the totals, broke the line items instead. And emits no recognitionScore on any field — triple the cost bought better answers and no way to check them.
understand mode returned| Field | Returned | Document | match | confidence | grounding | recognition |
|---|---|---|---|---|---|---|
| total_amount | 26.86 | $86.86 | id_match | 0.970 | 0.95 | 0.678 |
| tax_amount | 5.27 | $5.87 | id_match | 0.970 | 0.95 | 0.569 |
The failure is structural, not bad luck. Grounding answers is this value where the model says it is — which was true; it read the right box on the page. It cannot answer were the glyphs read correctly, which is what failed. recognitionScore is the only signal that measures that, and it was the only one that dissented.
recognitionScore is a floor, not a detector. In this same response payer_name — a correct field — scores 0.611, lower than the wrong total at 0.678. It does not tell you which field is wrong. It refuses to vouch for a page it could not read cleanly. That's a weaker claim than "it caught the error," and it's the one the data supports.
agentic mode did insteadParcel 1 qty 2 $14.50 Parcel 2 qty 1 $39.99
"Parcel" qty 1 "Parcel" qty 2
The descriptions collapsed and the row numbers shifted into the quantity column. Two wrong fields either way — the expensive mode just moved them somewhere less visible, and removed the signal that would have flagged them.
Routing is a nine-step ordered walk, not a confidence cutoff (mcp/nutrient/extraction-adapter.mjs). The match label decides first — fuzzy_match, not_found and id_match_partial route to a human regardless of any score. Only then does the composite confidence act as a tie-break, and only then do grounding and recognition act as vetoes.
| Signal | Value | invoice floor | Verdict |
|---|---|---|---|
| confidence | 0.970 | ≥ 0.85 | pass |
| groundingScore | 0.95 | ≥ 0.70 | pass |
| recognitionScore | 0.678 | ≥ 0.80 | FAIL |
Router verdict: human review of this extraction. The extraction result — including the wrong total — is not allowed to flow onward unattended; a person has to look at it. Applying redactions is separately gated behind its own approval (alwaysRequireApproval: true), and the single pipeline (prompt → optional Nutrient enrichment → Foxit assembly → gate → eSign) now shares one PlanStore and one approval queue (P6). When Nutrient keys are absent, the pipeline reproduces with a single Foxit credential pair — the Foxit track's judged path — with Nutrient as optional enrichment.
At this floor, understand mode refers 13 of 16 fields to a human, including correct ones. agentic mode refers all 16, because there is no recognition score to check and requireRecognition is on for this document type. That is a lot of human review.
Every threshold in the repo is marked calibrated: false, and a test asserts that none of them claims otherwise. Calibrating them needs a representative sample per document type, not one invoice — it's open work. Until then the defaults are deliberately strict, because an uncalibrated gate should over-refer rather than under-refer.
When the API cannot ground a field, it omits that field from output.data while leaving its citation — carrying match: "not_found" — in output.metadata.
Nutrient's own published iter_citations example walks data. So it structurally cannot see the single most important routing signal there is: the API telling you it failed. A field that silently vanishes reads, to any code walking data, exactly like a field that was never requested.
We walk the union of both. In all three committed fixtures, due_date and po_number are exactly this case.
Same gate, a second irreversible action. Staging redactions with createRedactions is reversible and runs unattended. applyRedactions destroys content permanently, so it sits behind alwaysRequireApproval: true — approval-gated in its own right, by the same mechanism the eSign stage uses for the send. The single pipeline shares one PlanStore and one approval queue across prompt → optional Nutrient enrichment → Foxit assembly → gate → eSign (P6).
Searching the raw bytes is not a sufficient check. Nutrient re-compresses the content stream, so the PII string is absent from all three files — the naive verification passes on a document that is still dangerous. Decompressing every FlateDecode stream shows what is actually there:
| Document | Bytes | Raw search | After decompression | PII recoverable |
|---|---|---|---|---|
| original | 3806 | hit | — | yes |
| staged | 3653 | no hit | hit | yes |
| applied | 5689 | no hit | no hit | no |
The staged document looks redacted and is not. The gap between a check that passes and a document that is still dangerous is precisely what the approval gate guards — and it is why applying redactions is treated as irreversible rather than as cleanup.
Verified live on 2026-08-20; the byte-level walk is recorded in docs/nutrient-stage-aug20.md. The staged artifact is committed at docs/fixtures/probe-staged.pdf — deliberately unredacted, because it is the evidence, not an accident.
Same API, the opposite failure. Extraction lies about a number with high confidence; redaction lies about having removed one at all. preset: "vin" is in the adapter's CONFIRMED_PRESETS list because probing it against a live document returned HTTP 200 — a valid PDF came back. Testing it in isolation against a document containing the well-formed VIN 1FUJGLDR8CLBP8834 tells a different story:
| Target | HTTP | Output | VIN after apply |
|---|---|---|---|
preset: "vin" | 200 | valid PDF, 67,142 bytes | still present |
regex: "[A-HJ-NPR-Z0-9]{17}" | 200 | valid PDF, 69,467 bytes | removed |
Both calls succeed. Both return a document that opens. One did nothing to the VIN, and the only difference visible to a caller is a byte count — not something anyone thresholds on. A preset name recorded which identifiers the API accepts; that's a different question from which ones it matches, and the name had been carrying more weight than the probe behind it.
So the pipeline stopped trusting the apply call for any target. It re-reads the redacted document through /extraction/parse and confirms each value is gone before anything reaches the gate — the same verification step this page's previous finding rests on. `mcp/nutrient/pipeline-redaction.mjs` fails the run if a target can't be confirmed absent; the VIN preset itself now ships flagged non-functional and surfaced on the approval card, rather than quietly doing nothing.
Rendered by a custom renderPlan hook — the prompt, the recipients, how the extraction routed each field, what was redacted and the proof it's gone, the document hash, and an explicit irrevocability warning. Not JSON.stringify on the raw payload.
Verbatim field labels and values from a real run against --doc messy, reformatted for layout — the full card is in the README. The regex redaction target is deliberately described by shape, not printed: a pattern embeds the values it hides, so putting it on the review screen would leak them.
The industry pattern is consume() — mark the plan used, then make the API call. Both orderings lose on a crash: mark-then-call leaves an audit log claiming a send that never happened; call-then-mark re-sends the document on retry. The dangerous window straddles the call, and most designs don't model it.
beginExecute() journals executing and fsyncs before the send. Only host confirmation writes executed. A process that dies mid-send leaves a plan visibly stuck in executing — a queryable state, not a forgotten one. On restart the journal replays and the core asks the gateway the only question that matters:
The send did not happen. The plan is released for retry, and no executed entry is ever written.
The send did happen. Record executed — and do not send again. This is the half that makes it exactly-once rather than merely fail-safe.
Return unknown and leave the plan stuck for a human. Guessing one way double-sends; guessing the other puts a lie in the audit log.
The journal is append-only, fsync'd per record, mode 0o600, with a parent-directory fsync so a fresh journal survives power loss. Replaying it reconstructs exactly the state the system was in when it died. dataDigest is re-checked on beginExecute and fails closed, so an approval cannot be replayed against changed content.
Real folder IDs, against the live eSign API. NO_UNDO_CRASH_AFTER_FSYNC=1 forces the same SIGKILL the journal is designed to survive, at the exact point after the fsync and before the gateway is called:
$ NO_UNDO_CRASH_AFTER_FSYNC=1 node agent/esign-agent-loop.mjs --auto-approve --doc messy \
--prompt "Take this freight invoice, redact the PII, and send it to Alice and Bob for signature."
[agent] Draft: folderId=35704250 planToken=7c61234a… documentSha256=491d5ef8070b98b3…
[esign-audit] executing token=7c61234a... tool=esign_send
[crash-injection] SIGKILL after beginExecute fsync (token=7c61234a...) before the gateway send
# same command, no crash flag
$ node agent/esign-agent-loop.mjs --auto-approve --doc messy --prompt "Take this freight invoice…"
[agent] Recovered 1 stuck-executing plan(s) (reconciled on load):
- planToken=7c61234a... folderId=35704250 → folderStatus=DRAFT → confirmed not executed → released for retry
[agent] Plan is executing — calling gateway sendDraftFolder…
[agent] Send succeeded — plan executed (verified SHARED)
[agent] Result: { "status": "executed", "verifiedStatus": "SHARED", "folderId": 35704276 }
The crash landed on the DRAFT side of the window: the gateway had never been called, so folder 35704250 was released rather than left stuck. This CLI has no flag to resume a specific plan, so the second command — a fresh invocation, not a retry of the first — drafts and sends a different folder (35704276) instead; the released draft just sits there for a human to act on later, never auto-resent. Had the crash landed on the other side of the window, the same query returns SHARED and recovery records the send instead of repeating it — the branch is chosen by the system of record, not by a guess, and no plan is ever sent twice.
The reconciliation callback and the three-way branch are implemented in mcp/foxit/esign-adapter.mjs and unit-tested against the Gate 0 fixtures, in addition to this live run. What the audit sink does not yet record: the post-send poll-and-download step that retrieves the signed document logs to the console but never writes to the hash-chained journal — the send itself is fully audited, that later step isn't.
The approval card above was written carefully to show only the folder name and recipients — no payload dump, because this is a PII demo. Then we checked the endpoint behind it.
GET /api/plans returned the raw payload anyway — a social security number and the full recipient list, straight past the redaction the card was performing. A host's careful renderPlan hook was being silently bypassed by the API serving it.
Filed upstream as safe-write-mcp-core issue #18, fixed in PR #19, published as v0.3.0 — the raw payload field is now dropped by default and available only via an explicit exposeRawPayload opt-in. This project consumes the fix by version bump. Two other servers built on the same core inherit it.
The second gap in the same server was authentication, not redaction: the approval server bound loopback and checked Host/Origin/Sec-Fetch-Site, which stops a malicious web page but not a hostile local process. Filed as issue #20, fixed upstream in v0.4.0 — every route now requires a per-session bearer token, carried in the approval URL's ?token=. This project runs on 0.4.0 today; both prior gaps are closed at the core, not patched around downstream.
Found by live reproduction, not by reading the code — a renderPlan hook surfacing only the folder name still returned the SSN over the API.
Reversible steps run unattended. The irreversible ones — applying redactions, and sending for signature — stop at a human approval checkpoint that survives a crash without double-sending.
All four boxes above are now running code, and the send has been performed live end to end — see the recorded crash-and-recovery run above, with a real folderStatus: SHARED. The eSign path assembles its document via pdf_from_html → get_task_result and falls back to a fixture only when NO_FOXIT_MCP is set. The cold-open prompt — Take this freight invoice, redact the PII, and send it to Alice and Bob for signature. — is parsed by mcp/foxit/prompt-parser.mjs into a typed payload and echoed back in the approval card for correction before the gate. When NUTRIENT_API_KEY + NUTRIENT_DWS_EXTRACTION_API_KEY are present, the same PlanStore and approval queue run Nutrient extraction, redaction, and a read-back verification on the assembled document; without them, the pipeline reproduces with a single Foxit credential pair (NO_NUTRIENT=1), the Foxit track's judged path. Signed-document retrieval polls EXECUTED and downloads via document/download.
The gate itself is not Foxit-specific. safe-write-mcp-core owns only the plan lifecycle; preview rendering and reconciliation are host-supplied. Foxit and Nutrient are adapters, not dependencies — which is why the same kernel already sits behind two other servers.
Every extraction claim on this page is backed by a committed API response. The fixtures and the test suite need no credentials; the probes make live billed calls and do.
By default probe output lands in an exclusive mode-0o600 temp directory, so nothing document-derived reaches the tracked repo unless --fixture is passed as an explicit decision to commit it. The probe schema deliberately asks for two fields the document does not contain — due_date and po_number — so not_found routing is exercised rather than assumed.
Gate 0's eSign entitlement transcript is committed at docs/fixtures/esign-probe-aug18.txt, and the Nutrient 403 we hit before the extraction product key landed is kept at nutrient-extraction-403.json as the entitlement record rather than deleted.
safe-write-mcp-core@0.4.0 is published on npm and consumed here by version — not vendored, not a fork.consume() split into beginExecute() / confirmExecuted() / confirmFailed(), with a durable streamed-replay journal, and a real crash-and-recover run against the live eSign API produced a real SHARED folder.prevHash + sha256, fsync'd per line, and re-verified by streaming — tamper a record and verification names the line. Crash debris (a torn final write) is truncated on recovery, never chained over./build applies the targets, then /extraction/parse reads the result back and confirms each value is actually gone before the plan reaches the gate.Things we would want a reviewer to know without having to find them.
calibrated: false and a test enforces that. They over-refer on purpose until there's a representative sample per document type — the recognition floor was raised once already, on Sep 3, after a live re-run auto-approved two wrong dollar amounts the previous floor had caught two weeks earlier.safe-write-mcp-core (approvals as a recorded set, per-approver identity instead of a shared token), not a local patch; a shallow local version would look like multi-party approval while being satisfiable by one person clicking Approve twice, which is worse than leaving it cut./build endpoint has no job ID and no server-side state to ask about, so reconcile returns unknown honestly, and a plan interrupted mid-apply stays visibly stuck for a human./build returns — a licensing artifact, not a pipeline behavior, but it's on the redacted PDF that reaches the gate and the recipient./extraction/parse output. A value rendered as an image, or split across text runs the parser rejoins differently, could evade both the redactor and the check.