Trust Audit — 2026-08-15
The thesis being audited
Most confidential-document tools ask a client to trust the platform: German hosting, a GDPR compliance statement, a track record, maybe a professional-association endorsement (Hegmann's analysis of this market notes the DStV — the German tax advisors' association — as the kind of institutional trust signal that takes years to earn). A conservative professional (a tax advisor, a lawyer, a physician) pastes an official document into a tool only once that trust exists.
Fiduciarium's actual claim is different and stronger: you shouldn't need to trust us at all, because the architecture makes the server structurally unable to read the document, regardless of who operates it, where it's hosted, or how long it's been running. Trust-through-reputation is replaced by trust-through-verifiability — the client can check the claim themselves (canary token, DevTools network inspection, public-key comparison — all three already built into upload.html's "Trust & Verify" panel).
This audit asks one question, systematically, across the original architecture and everything built since 2026-08-14 (Services/Billing, translation, cost tracking, admin tooling, email notifications): does the code actually deliver on "you don't need to trust us," or does it just claim to?
Method: read every place plaintext, PII, or client-identifying data could cross a trust boundary — client→server, server→operator, server→third party (Stripe, Resend, LLM provider) — and check whether that boundary is actually enforced in code, not just documented. Findings are graded HOLDS (verified, architecture delivers the claim), PARTIAL (claim mostly holds, with a caveat worth knowing), or GAP (the claim doesn't fully hold — real work needed).
Part 1 — What the architecture actually eliminates the need to trust
These are the claims that HOLD, verified against the current code (not the docs, which can drift):
1.1 The server never has a decryption opportunity
Client-side AES-GCM encryption happens before the first network request (web/static/crypto.js:encryptDocument) — as of the 2026-08-15 fix, the key is a genuinely random crypto.subtle.generateKey call, not derived from anything guessable (email, mouse movement). The server (web/routes/upload.py:upload) receives and stores only ciphertext. HOLDS, and stronger than it was a week ago (see F2 in compliance_audit_2026-08-06.md — the old email-derived key was a real weakness, now closed).
1.2 The LLM never sees anything but tokens
Verified directly this session: every call site in agent/pickup.py that reaches llm_adapter.correct()/translate() passes anon_text — the token-substituted output of anonymise() — never doc_text. This holds identically for the new translation feature (pickup.py:404) and the original correction feature (pickup.py:426). HOLDS, confirmed for both old and new code paths.
1.3 Page-count pricing doesn't require the server to see the document
A genuinely interesting property of the new Services/Billing feature: pricing needs to know how long the document is, but the server can't read it (1.1). The fix was to compute the estimate client-side, before encryption (web/static/pagecount.js) and never re-derive it server-side — services.py:save_services explicitly refuses a client-submitted override and only trusts the value captured at upload time. This is the right shape: a feature that could have required breaking the zero-trust boundary ("let us peek at the page count") was built without doing so. HOLDS.
1.4 The proofreader (HITL) is architecturally blind to PII
admin/review.html only ever reads corrected_anon.txt/anon.txt (token-substituted) and token_summary.json (counts, not values) — confirmed via admin.py:review_job. As of 2026-08-15, this is now enforced, not just structural: review_job/approve_job/reject_job reject any job that didn't actually purchase human_review, closing a real bug found this session where a job could be "approved" without ever having gone through the gate, silently corrupting its pipeline state. HOLDS, and this is a case where a GAP was found and closed in the same audit cycle — see the incident log at the end of this document.
1.5 The operator can't casually pull a client's finished document
admin/jobs.html used to link straight to /download/<job_id> — the client-facing page — from every row. Removed 2026-08-15. It never actually worked (the AES key lives only in the client's localStorage), but its presence was the wrong signal for a page whose entire design principle is "the operator doesn't get to look." HOLDS, now enforced by omission rather than by a lucky accident of where keys happen to live.
1.6 Client-facing verification tooling exists and is genuine
upload.html's "Trust & Verify" panel offers four independent checks a skeptical client (or their IT department) can run themselves: a canary-token method (prove no LLM ever saw plaintext), a DevTools network-tab inspection (prove only ciphertext left the browser, and that CSP's connect-src 'self' blocks exfiltration to anywhere else), a public-key comparison (prove the RSA key used to wrap the AES key matches a value published on an independent channel), and an OCR-transparency panel (2026-08-15, see 1.7). This is the actual substitute for "trust us" — a client doesn't have to take the architecture's word for it. HOLDS as a genuine capability, though see 3.3 for its limit.
1.7 OCR (scanned PDFs) never leaves the operator's machine, and never touches a network at all
Read agent/extract.py in full: the PDF text-extraction path (pymupdf4llm, falling back to Tesseract via pytesseract+pdf2image, falling back to pdfminer) and the DOCX/ODT paths (python-docx, odfpy) import only local libraries — no httpx, requests, or any HTTP client anywhere in the file, no cloud OCR SDK (no Google Vision, AWS Textract, Azure Read). OCR runs entirely on the operator's own machine, before anonymisation even starts. HOLDS, confirmed by direct code review, not just documentation.
Worth being precise about what kind of claim this is, though: unlike 1.1 (client-side encryption, which happens live in the browser and is directly observable via DevTools), OCR happens on the operator's machine — a client fundamentally cannot watch it happen the way they can watch their own browser. The verifiability here is code transparency (the absence of any network import in the source), not live network observation. upload.html's new OCR tab says this explicitly rather than implying the same kind of proof as the canary-token/DevTools methods — overclaiming verifiability parity would undermine the actual trust-through-verification thesis this whole audit is about.
Part 2 — Residual trust dependencies (can't be architected away, worth naming explicitly)
Not gaps — properties of the model that don't disappear no matter how good the code is. A trust audit should name these rather than imply zero-trust is total.
2.1 The operator's own machine
agent/pickup.py decrypts the document, runs NER, and briefly holds original.txt (fully decrypted plaintext) and token_map.json (the re-identification key) on local disk, unencrypted, for as long as retention policy allows (currently: indefinitely — no purge mechanism exists yet, per compliance_audit_2026-08-06.md finding F4, still open). Someone has to see plaintext to run NER; that someone is the operator's machine, not the server. This is documented and accepted (ConfidentialityArchitecture.md §12), but it means: the "you don't need to trust the platform" claim does not extend to "you don't need to trust the operator's physical/OS security." Worth being explicit about this distinction in any client-facing trust messaging — the server is provably untrusted-by-design; the operator's laptop is trusted-by-necessity.
2.2 The configured LLM provider's infrastructure
Even receiving only tokens, the anonymized text still leaves the operator's control if a cloud provider (OpenAI, currently configured for both correction and translation per data/settings.db) is selected. This is a real, if minimized, cross-border data flow — compliance_audit_2026-08-06.md F8 already covers this; nothing changed the underlying tradeoff this session, but the addition of a second profile (translator, also OpenAI) doubles the surface. PARTIAL — the token boundary holds, but "provider infrastructure sees encoded structure/entity-count/document-length even without PII" is a residual signal a fully paranoid model would still flag.
2.3 Stripe and Resend as payment/email processors
Necessarily, a payment needs an amount and a way to charge someone, and an email needs a recipient address. These are irreducible — you cannot pay by credit card without the card processor knowing you're paying. What's not irreducible is covered in Part 3.
2.4 The client has to trust the JS served right now
The canary-token/DevTools verification (1.6) proves the code behaved correctly for that session. It does not prove the server won't serve different, malicious JS tomorrow, to a different client, or to this client on a bad day. This is a known, unsolved limit of any browser-based crypto model (not specific to this codebase) — worth naming rather than letting "verifiable" imply "provable forever." No code fix closes this; only things like Subresource Integrity pinned to a client-controlled value, or a browser extension the client installs once and trusts thereafter, meaningfully narrow it. Flagging as a known limitation, not a bug.
Part 3 — Findings: places where MORE crosses the boundary than architecturally necessary
These are real, fixable gaps — not inherent tradeoffs. Unlike Part 2, nothing here is a hard requirement of the payment/email use case.
3.1 FIXED (2026-08-15) — Stripe received the real filename
Was: web/routes/webhook.py:create_checkout's priced path put job.get('filename', '') directly into the Stripe line-item description — real patient/client names embedded in filenames (per compliance_audit_2026-08-06.md's sample data) going straight to a US third party on every paid job. Fixed: the description now reads "N Seite(n) — Auftrag <job_id prefix>…" — job_id only, no filename anywhere in the Stripe payload.
3.2 GAP — Stripe receives the real client email
webhook.py:create_checkout, customer_email=_get_job_email(job_id) — pre-fills the client's real email into Stripe's hosted checkout form. This one is more defensible (Stripe needs an email to send its own receipt, and pre-filling is a UX convenience, not a security requirement) — but it's still real PII transmitted to a third party for a feature (autofill convenience) that isn't essential. Lower priority than 3.1, worth a product decision rather than an automatic fix: is receipt-autofill worth the PII transmission, or should the client type their own email into Stripe's form?
3.3 FIXED (2026-08-15) — Resend received the real filename (admin notification)
Was: web/notify.py:send_admin_new_job put the real filename in both the subject line and body of the new-job email to the operator. Same root problem as 3.1. Fixed: subject/body now carry only job_id; the operator opens /admin/jobs to see the real filename server-side. The now-unused filename parameter was removed from the function signature entirely rather than left as dead code.
3.4 PARTIAL — Real email delivery is still non-functional for clients
RESEND_FROM_EMAIL pointed at medizinkorrektorat.de, a domain never verified in Resend, so every email this app ever tried to send was failing — silently, because _send() only logger.warning()'d the failure. Fixed: failures are now logger.error() with the full Resend error message, and RESEND_FROM_EMAIL now points at Resend's sandbox sender (onboarding@resend.dev), which works but only delivers to the account owner's own email, not real clients — a hard Resend-side restriction, not a code bug. Still open: no verified domain exists yet, so the "you'll get an email when it's ready" promise is not true for any real client today. Needs a verified sending domain before this is a shipped feature.
3.5 MOSTLY FIXED (2026-08-15) — Translation quality gap and the incomplete review gate
Originally: real test output showed whole source-language sentences left untranslated, and token corruption ([ PATIENT_NAME_002 ] with inserted spacing) that would have broken rehydration silently, with no human ever looking at the 23 LLM-only languages' output before delivery. Since then: paragraph-chunked translation (more reliable on longer documents), automated token-spacing repair, a post-translation token-presence check, and — the structural fix — every translation job now pauses for a mandatory human check before delivery, not just the DE/EN quality-reviewed pair. For LLM-only languages this is a lightweight "are the tokens still intact" check (no language skill required, tokens visually highlighted); for DE/EN it's the full source/target review with inline editing, find/replace, and a final automated grammar pass on approval. The /admin/review/<job_id> → /admin/review_translation/<job_id> flow was also unified into one page/one code path instead of two separate templates, and a job-to-job termbase (source→target term pairs, admin-curated, looked up automatically on future translations for repeat clients) was added on the same review page.
One deliberate exception, not yet fully reasoned through as a trust question: as of the 2026-08-15 pricing change (first page free for every language), a 1-page LLM-only-language job that works out completely free skips the mandatory check entirely — not worth an operator's manual time on unpaid work. DE/EN always gets checked regardless of price. This means the "a human always looks at the tokens before delivery" claim now has a carved-out exception for free-tier LLM-only jobs specifically — worth being explicit about if this claim is ever stated to a client, since it's no longer universally true.
Still open: the automated token-presence check itself still only logs a warning, it doesn't block delivery — for the (now smaller) set of paid LLM-only jobs, a human eyeballing highlighted tokens is the real safety net, not an automated hard-stop. The original "fail closed, not just loud" recommendation stands for that layer specifically, even though the human-check layer around it is much stronger than when this was first written.
Part 4 — New-feature-specific audit (everything since 2026-08-14)
| Feature | Trust-relevant surface | Verdict |
|---|---|---|
| Services/Billing page | Page count computed client-side, price computed from it, no plaintext to server | HOLDS (2.3 above) |
| Casual-tier size cap | Enforced server-side against the client-reported word count; ciphertext byte-size floor not yet implemented (a client could still under-report on a doc that isn't obviously oversized) | PARTIAL — the byte-size sanity floor discussed earlier in the project (ciphertext length ≈ plaintext length) was never actually built; only the word-count-from-client check exists |
| Translation (multi-language) | LLM only sees tokens (3.5 covers quality, not confidentiality); per-language output files correctly gated behind the same token/AES-key model as correction | HOLDS for confidentiality, PARTIAL for delivery-quality assurance |
LLM cost tracking (llm_cost_cents) |
Real token counts from OpenAI/Anthropic API responses, computed and stored server-side; no new data leaves the system to compute this — it's derived from usage metadata already returned by calls that were happening anyway | HOLDS |
| Admin bilingual UI / Aufträge amount+cost columns | Internal-only, no new client data exposure; correctly still gated behind _authed() |
HOLDS |
| Browser/device reminder email | Adds a browser_device string (e.g. "Chrome auf Mac") to Resend emails — derived from User-Agent, not independently identifying, sent only to the job's own client, not a third party beyond Resend itself |
HOLDS |
hitl field / approve-reject gating |
Fixed this session — previously any job could be force-approved regardless of whether review was purchased, silently breaking the pipeline (see 1.4) | Was a GAP, now HOLDS |
/api/agent/ready status handling |
Fixed this session — previously a job that paid before processing (the new normal) had its "paid" status silently overwritten by every later pipeline stage, meaning get_result() would refuse to serve a legitimately-paid job |
Was a GAP (functional, not confidentiality), now HOLDS |
| Unified translation review + mandatory anon-check | Every translation now gets at least a lightweight human token-integrity check before delivery, not just DE/EN (see 3.5) | Was a GAP, now mostly HOLDS — free-tier exception noted in 3.5 |
| Termbase (source→target term pairs) | Populated only from token-substituted text on the review page (never real PII — rehydration only ever happens client-side, after this step); stores job_id not filename, matching the same reasoning as 3.1/3.3; storage path configurable (TERMBASE_CSV_PATH) so it can point at a Railway persistent volume once deployed |
HOLDS |
| Termbase cross-machine access | The termbase is written by the web process (Railway, eventually) but read by the agent (operator's own machine) — a local file path alone would silently stop working once those are different filesystems. Fixed by adding GET /api/agent/termbase (AGENT_SECRET-protected, same pattern as existing blob/manifest endpoints); the agent fetches over HTTP in REMOTE mode, reads the local file only in LOCAL mode |
Was a latent GAP (would have broken silently on deploy), now HOLDS |
| Pricing: first page free per target language (beta) | Base translation cost only; the human_review labor surcharge is unaffected, still charges full page count | HOLDS — pricing math and the associated review-gate exception both verified against test cases |
Graceful 401 on /download/<job_id> |
Previously a wrong-browser visit showed a bare "Server error 401" with no explanation; now explains the actual cause (key only exists in the browser that uploaded) in both languages | Was a UX gap, now HOLDS |
| Client auto-refresh after agent processing | hitl_pending (the status every translation job sits in while awaiting the mandatory check) was missing from the poll-status allowlist — a client waiting there never saw the page update without a manual refresh |
Was a GAP, now HOLDS |
| Internal TMX export (translation memory logs) | Built from the same token-substituted source/target text already used for review — no rehydration involved, same rule as the termbase (3.1/3.3-style: job_id not filename). Generated automatically at Phase B (covers free-tier jobs that skip the review gate entirely) and refreshed on admin approval with the human-edited/polished text. Not offered to clients — a client-facing TMX would need real text, which only ever exists client-side, and is explicitly out of scope for now |
HOLDS |
| Download passcode (second factor) | 6-digit passcode assigned at upload, required alongside the browser-held client_token/AES key on /api/result (the endpoint that actually returns the rehydratable blobs). Stored only as an HMAC-SHA256 hash (FLASK_SECRET_KEY as key) — never plaintext at rest. Rate-limited: locks the job for 15 minutes after 5 wrong attempts, since 6 digits is only 1M combinations. Admin can regenerate it (/api/admin/reset_passcode) for a client who lost it, but can never recover/view the original — reset only, no reveal |
HOLDS — meaningfully raises the bar against link-only leakage (forwarded email, browser history, a screenshot) since the passcode is delivered out-of-band from the link and never embedded in it |
Recommended priority order
- 3.5 fail-closed (remaining piece) — the automated token-presence check for LLM-only languages still only logs a warning; it should hard-block delivery on a detected missing token, on top of (not instead of) the human check that now exists.
- 3.4 — verify a real sending domain in Resend before treating "you'll get an email" as a shipped feature rather than a demo.
- 3.2 — product decision, not urgent: keep Stripe email-prefill convenience, or drop it.
- Part 4 "casual-tier byte-size floor" — close the gap between the word-count check (client-reported, softly trusted) and a ciphertext-length sanity floor (server-verifiable without decryption) flagged earlier in the project and never implemented.
- Free-tier anon-check exception — decide whether "a human always checks every translation" should ever be stated to clients as an unqualified claim, given the 1-page-free carve-out now exists (see 3.5).
3.1 and 3.3 (Stripe/Resend filename leaks) are fixed. None of the remaining items are confidentiality breaches in the sense Part 1 audits (the core "server never sees plaintext" claim holds throughout, including every new feature). They are, respectively: an incomplete automated safety net (3.5), an operational readiness gap (3.4), a product decision (3.2), a defense-in-depth gap (byte-size floor), and a messaging-precision question (free-tier exception).