No Undo

An approval gate for the one step an agent can't take back.

Foxit eSign Nutrient DWS Overall DevNetwork Hackathon 2026

1Three modes, three different ways to be wrong

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.

Kaniefsky Transport LLC — Invoice INV-2026-0418
ACME Freight Services
Parcel 1 ...... 2 × $14.50
Parcel 2 ...... 1 × $39.99
Handling ...... 3 × $4.00
Total due: $86.86
Tax (7.25%): $5.87

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.

Provenance

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

structure
7.5 credits · 1.1s · request GM2bjnrG…
Said "I don't know"

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.

understand
15 credits · 3.9s · request GM2bjtW7…
Confidently wrong, twice

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.

agentic
24 credits · 8.5s · request GM2bj9uG…
Right, and unauditable

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.

What understand mode returned

FieldReturnedDocumentmatchconfidencegroundingrecognition
total_amount26.86$86.86id_match0.9700.950.678
tax_amount5.27$5.87id_match0.9700.950.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.

The honest version of that claim

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.

What agentic mode did instead

Document

Parcel 1   qty 2   $14.50
Parcel 2   qty 1   $39.99

Returned

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

2The routing decisionshipped

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.

The last two steps, on the wrong total

SignalValueinvoice floorVerdict
confidence0.970≥ 0.85pass
groundingScore0.95≥ 0.70pass
recognitionScore0.678≥ 0.80FAIL

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.

What that costs — stated up front

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.

3The signal the documented walk can't seeshipped

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.

4The document that looks redacted and isn'tshipped

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:

DocumentBytesRaw searchAfter decompressionPII recoverable
original3806hityes
staged3653no hithityes
applied5689no hitno hitno

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.

5A preset that returns 200 and redacts nothingshipped

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:

TargetHTTPOutputVIN after apply
preset: "vin"200valid PDF, 67,142 bytesstill present
regex: "[A-HJ-NPR-Z0-9]{17}"200valid PDF, 69,467 bytesremoved

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.

6The gateshipped

What a human actually sees

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.

✍️ Sign: Freight Invoice
PromptTake this freight invoice, redact the PII, and send it to Alice and Bob for signature.
Recipients1. Alice Smith <alice@example.com> 2. Bob Jones <bob@example.com>
Document SHA-256f3589f35e3de7c36… (via enriched-source)
Nutrient extraction0/16 fields auto-approved — 16 need human review — 13 caught by OCR recognition floor — 2 ungrounded (not_found) — thresholds calibrated: false
Nutrient redaction3 target set(s) applied — 5 value(s) verified absent from the outgoing document, 1 signature field(s) verified intact
⚠️ IrreversibleApproving sends this document to the listed recipients for signature. This action cannot be undone.

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 crash-safe lifecycle

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.

previewed awaiting_approval approved executing executed/ failed

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:

folderStatus: DRAFT

The send did not happen. The plan is released for retry, and no executed entry is ever written.

folderStatus: SHARED

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.

no answer

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.

Verbatim from the recorded demo

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.

7We audited our own gate and it failedshipped

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.

Architecture

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.

Nutrient DWS
Extraction routing · staged redaction
shipped
Foxit PDF
Assembly · OCR · merge
shipped
Approval gate
crash-safe two-step
shipped
Foxit eSign
Send for signature
shipped
What "shipped" means here

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

Reproduce it

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.

No key required

# the three live responses this page is built from ls docs/fixtures/nutrient-extract-{structure,understand,agentic}-*.json # routing, redaction and the eSign lifecycle, asserted against those fixtures npm test

Live, billed, needs a Nutrient key

# one understand-mode run against a freshly generated invoice node mcp/nutrient/extract-probe.mjs # all three modes, plus the per-field table thresholds get set from # costs ~28.5 credits per page, per run node mcp/nutrient/extract-probe.mjs --calibrate

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.

Progress

Known gaps

Things we would want a reviewer to know without having to find them.