Repository navigation
Architecture
Anonify is an AI-assisted redaction tool. The design follows from one claim it has to be able to make honestly:
The value you accepted is not in the file you downloaded.
Everything below exists to make that claim true and to make it checkable.
Canonical source: docs/architecture.md. Deeper pages: Processing Workflow, Storage and Encryption, Streaming IO, Redaction Engine, Review Workspace.
The system keeps four things separate, and most of its correctness comes from refusing to blur them.
flowchart TD
A["A · SOURCE<br/>The uploaded bytes.<br/>Sealed, checksummed, never mutated."]
B["B · NORMALIZED<br/>Pages, spans with geometry, runs, sheets, regions.<br/>One vocabulary for every format, with a page index."]
C["C · REDACTIONS<br/>suggested → accepted / rejected.<br/>The record of intent. The only source of truth."]
D["D · OUTPUT<br/>A new document.<br/>Verified, checksummed, sealed, then delivered."]
A -- extract --> B
B -- detect --> C
C -- "apply: A + accepted C" --> D
A -. "read on every export, never edited" .-> D
| Layer | Lives in | Notes |
|---|---|---|
| A. Source | Object storage (documents/:id/source.bin), AES-256-GCM in 1 MiB chunks |
Never edited in place. Export always reads A, never D. |
| B. Normalized |
documents/:id/normalized.json.bin + Document.normalizedIndex
|
Also encrypted, because it contains the document's text. The index lets one page be read with one ranged read. |
| C. Redactions |
Redaction rows in Postgres; rules in GlobalRule / BatchRule / OwnerRule
|
Detections become suggestions. Rules write accepted redactions directly, because a rule is itself a human decision. |
| D. Output |
documents/:id/redacted.<artifactId>.<ext>.bin + report (+ vault for batches) |
Regenerated on demand, deterministic from A + C. Verified before it is stored. |
The source is never modified, so a bad redaction is a bad export, not a lost document. Re-export with different decisions and you get a different D from the same A. It also means document-level undo is free, because nothing was destroyed.
A detector's output is a proposal. A redaction is a decision. Keeping them as different words in different columns is what makes the human step real rather than decorative. The exporter filters on status === "accepted" and cannot be talked into anything else. A suggestion nobody accepted appears in the export untouched, and a test asserts exactly that.
sequenceDiagram
autonumber
participant B as Browser
participant S as Server routes
participant ST as Object storage
participant W as Durable run
B->>S: POST /api/documents (or /api/batches)
Note right of S: rate limit, quota, ownership<br/>row created with status "uploading"<br/>upload path fixed; uploadMode chosen
S-->>B: id, pathname, uploadMode
alt uploadMode = vercel-blob
B->>S: POST /api/upload/token
Note right of S: onBeforeGenerateToken:<br/>owner, status, exact path, size, 30-min expiry
S-->>B: scoped client token
B->>ST: upload directly (multipart above 5 MiB)
ST--)S: onUploadCompleted webhook (production)
else uploadMode = local / s3
B->>S: POST /api/upload/local (XHR, real progress)
S->>ST: stream into the configured driver
end
B->>S: POST /api/documents/:id/process
Note right of S: idempotent: an existing run is resumed<br/>otherwise queued → admission
S->>W: start processDocument (when a slot is free)
B->>S: GET /api/documents/:id/stream
W-->>B: SSE progress, indexed and resumable
Note over B: review: accept · reject · manual<br/>search · rules · Hush
B->>S: POST /api/documents/:id/export
Note right of S: plan → apply → VERIFY → report check<br/>→ checksum → seal
S-->>B: signed 5-minute links (+ vault inline)
B->>S: GET /api/documents/:id/download?token=…
S-->>B: streamed, hashed as it goes, last piece held until the checksum matches
With Vercel Blob, the browser uploads straight to storage using a token scoped to one reserved path. The file never passes through a serverless function, which has a body limit and a duration budget, and the progress bar reflects the real transfer. With S3/RustFS or local disk there is no safe browser equivalent, so the bytes go through /api/upload/local, streamed into the same storage abstraction. The server decides which path to use because it knows what is configured. Everything downstream is identical.
The cost of a direct upload is that the bytes land in storage before the server has seen them. The pipeline's first step closes that window. It fetches them, sniffs what they really are, checksums them, seals them under a fresh per-document key, and deletes the plaintext upload. That short interval is the only time the file exists unencrypted at rest, and it is stated plainly rather than glossed over.
Extraction, OCR and model calls are slow and fail in uninteresting ways. Running them inside a request means a timeout loses the work. Running them in a background promise means a cold start loses it silently.
Each stage is a "use step" function: retried on its own with paced backoff, its result persisted, and the run resumes from the last completed step. A provider timing out costs a retry, never the upload. Batch export and the expiry sweep are durable runs of the same shape. See Processing Workflow.
There are no accounts. Identity comes from a server-issued random session id in the httpOnly anonify_sid cookie (30 days, rolling on uploads and rule edits). It is split into three derived keys, because the three questions have different right answers:
| Key | Derived from | Answers |
|---|---|---|
ownerKey |
session id | "Is this document yours?" |
quotaKey |
session id + coarsened IP | "Have you used your daily allowance?" |
networkKey |
coarsened IP | "Are you hammering this endpoint?" |
Ownership uses the session alone, so a document stays reachable when the user's IP changes. Quota and rate limiting bring in the network, so clearing a cookie does not hand out a fresh allowance. Addresses are coarsened to /24 and /48 before hashing, and only salted hashes are stored.
No browser fingerprinting and no MAC addresses. A browser cannot expose a MAC address, and a design that assumes otherwise is both insecure and wrong.
A document id alone never authorizes anything. Every read re-derives the caller's identity and compares. A document that exists but belongs to someone else is reported as 404, not 403, so ownership cannot be probed by id. An expired document is 410.
Envelope encryption everywhere bytes are stored:
flowchart LR
MK["Master key<br/>ENCRYPTION_KEY, server-held<br/>checked at boot"]
DK["Random 256-bit data key<br/>one per document"]
ROW["Wrapped key on the Document row<br/>+ encryptionFormat v0 / v1"]
SRC["source.bin"]
NRM["normalized.json.bin"]
EXP["exports · reports · vaults"]
OR["OwnerRule patterns<br/>ChatGPT sign-in token"]
DK -- "AES-256-GCM, chunked, AAD = header ‖ path ‖ final" --> SRC
DK --> NRM
DK --> EXP
MK -- wraps --> DK
DK --> ROW
MK -- "seals directly" --> OR
- Every document gets its own random data key: never derived from an IP, a MAC, browser behaviour or network activity.
- New objects use the chunked
v1envelope: a 16-byte header (ANFY, version, chunk size, nonce prefix), then 1 MiB chunks each with its own GCM tag. The nonce is a prefix plus a counter, so it never repeats. The AAD binds the header, the object's logical path and a final-chunk flag, which blocks truncation, reordering and swapping objects. No plaintext leaves a chunk before its tag verifies. - The envelope is chosen by the document's recorded
encryptionFormat, never guessed from the bytes. Documents stored before 1.6.0 stayv0until they expire. - SHA-256 appears in exactly three roles, all of them hashing: integrity checksums, identity derivation, and HMAC (download tokens, Hush approvals). It is not encryption and is never used as though it were.
- Storage URLs are opaque server-side handles and are never handed to a client.
Detail: Storage and Encryption.
The exporters are tested, but a redaction tool should not take its own word for it on the file it is about to hand back.
Every export is re-opened and read the way an adversary would: extracted PDF text plus a raw byte scan, every OOXML part, every sheet including hidden ones, a re-parsed CSV or MIME tree, sampled pixels. If an accepted value is still present, the export fails. The file is not saved, no download link is issued, and the user is told the export was refused. The report is then checked the same way, and must not quote any accepted value.
tests/security.test.ts is the same idea as a test suite: twenty-one attempts to recover a redacted value from a produced artifact, all of which must fail. tests/adversarial.test.ts runs every format through the real exporter and checks that a rejected value is still there.
Detail: Export and Anonymisation · Security Model.
lib/documents/batch-config.ts, lib/documents/admission.ts, lib/services/throttle.ts, lib/storage/streaming.ts
| Mechanism | Bounds | Answer when full |
|---|---|---|
| Rate limits (token buckets) | how often a caller may start work | refuse (429) |
| Daily quotas | volume per identity per UTC day | refuse, name the reset |
Admission (ANONIFY_BATCH_PROCESSING) |
one owner's documents processing at once |
wait: status queued
|
Export waves (ANONIFY_BATCH_EXPORTING) |
documents exported at once in one batch export | wait for the wave |
Outbound throttle (ANONIFY_AI_*, ANONIFY_OCR_*) |
calls to a provider across every document | wait, then retry per request |
Streaming budget (ANONIFY_STREAM_MEMORY_BUDGET) |
chunk × chunks in flight × processing concurrency |
refused at startup if it cannot fit two chunks per document |
| Setting | Env | Demo | Self-hosted |
|---|---|---|---|
maxFiles |
ANONIFY_BATCH_MAX_FILES |
20 | 50 |
processing |
ANONIFY_BATCH_PROCESSING |
3 | 6 |
exporting |
ANONIFY_BATCH_EXPORTING |
2 | 4 |
Rate is not concurrency. A bucket that permits thirty processing requests a minute refills while all thirty runs are still going. Only concurrency bounds memory, database connections and spend at a model provider. The codebase has both.
The processing queue is not a table. A document with status: "queued" and no workflowRunId is waiting. Admission happens at the process route, the retry route, the end of every run (success or failure, via admitNext), and a backstop in the cleanup endpoint (/api/cron/cleanup) for runs killed mid-flight. It is bounded per owner rather than globally, so one reviewer's twenty-file batch never stalls someone else's single document. Two admissions arriving together can start one document more than the limit. That is deliberate: the alternative is a database lock held across a network call.
Batch export runs in waves, not a rolling window. Nothing after a wave starts until every document in it has finished, so a stop or an exhausted allowance takes effect within one wave. Progress is written with SELECT … FOR UPDATE, so two documents finishing together cannot erase each other's result.
app/
page.tsx · about/ · documents/ · restore/ landing, session documents, restore
workspace/[documentId]/ the editor
batches/[id]/ the batch view
api/
documents/ reserve, list
documents/[id]/ read, extend, delete
process/ retry/ stream/ start, retry, follow the run
content/ source/ normalized model (outline / page), decrypted bytes
redactions/ search/ the review record, server-side find
rules/ rules/preview/ rules at every scope, preview before write
assistant/ Hush, one streamed turn
export/ download/ usage/ generate + verify, serve, cost
batches/ batches/[id]/ reserve many, overview
search/ rules/ batch counts, batch decisions
export/ export/stream/ download/ durable batch export, archive
rules/ rules/export/ rules/import/ the owner's global rules
assistant/ is Hush available, and through what
restore/ put values back from a vault
upload/token/ upload/local/ the two upload paths
limits/ usage/ cron/cleanup/ limits, spend, expiry sweep
lib/
ai/ provider layer (25 providers), catalog, gateway, prompts, schemas,
analyze orchestration, spend, rates, usage reports
assistant/ Hush: agent, tools, learned shapes, signed approvals
documents/ formats.ts (the register), admission, batches, reserve, retention,
purge, expand, normalized-json/-store (page index)
pdf | docx | pptx | xlsx | delimited | text | rtf | eml | mbox | image
ooxml/ (ranged zip, runs) · shared/ (text, atom map)
ocr/ tesseract | mistral, provider selection
redaction/ detectors, entities, patterns (RE2), search, rules, owner-rules,
methods, surrogates, vault, restore, variants, apply, export,
deliver, validation, report, batch-download, batch-layout
security/ fingerprint, access-control, rate-limit, token-bucket, usage, signed-url
services/ limits, throttle (outbound pacing + retry)
storage/ drivers, blob (key layout), encryption, chunked, sealed,
streaming (budget), range-source, integrity, streams
workflows/ process-document, export-batch, cleanup, events, failure
store/ Redux Toolkit: document, redactions, editor, processing, ui, rules, search
hooks/ use-redactions, use-search, use-rules, use-processing-stream,
use-normalized-document, use-batch-export, use-learned-shapes, …
components/ editor, document-viewer, redaction, rules, search, assistant,
batch, documents, upload, processing, ui (shadcn / Base UI)
types/ the shared vocabulary
tests/ unit, adversarial, integration (Postgres), OCR, Ollama contract
benchmarks/ labelled corpus, scorer, model benchmarks, charts
scripts/ setup, ai, rate-limit, cleanup, smoke, bench, warmers, release helpers
Processing logic is independent of React. Nothing in lib/ imports a component, which is why the same extraction and export code runs in a workflow step, in a route handler, in a benchmark and in a test against a real file.
instrumentation.ts runs register() once, before the server accepts traffic. The order is load-bearing:
-
assertMasterKey(): parse and length-checkENCRYPTION_KEYand refuse to start if it is wrong. This exists because a YAML config once turned a 64-zero key into the integer0(#27). - The streaming budget is validated. A budget that cannot give every concurrent document two chunks is refused.
-
The workflow world is started when
WORKFLOW_TARGET_WORLDis set (self-hosted). The literalimport("@workflow/world-postgres")is what makes the standalone build trace the package. Do not move it into a helper.WORKFLOW_POSTGRES_URLdefaults fromDATABASE_URL. - One structured log line announces it:
{"level":"info","context":"workflow.world","world":"@workflow/world-postgres","message":"workflow worker started"}. A deployment that never emits it has not started the worker.
The editor's state is a Redux Toolkit store with seven slices: document (summary, outline, ≤ 24 cached pages), redactions (entities, rules, selection, filters, undo/redo of up to 50 snapshots), editor (page, zoom, tool, fit, grid selection), processing (run status and events), ui (dialogs and panels), rules (every rule in scope) and search (query, options, hits keyed by the question they answer). Occurrence grouping (selectOccurrenceGroups) drives the whole review UX. See Review Workspace, docs/client-state.md and docs/editor.md.
The visual language (dark editorial chrome, a white document surface, red as the colour of "sensitive", black for a decision) is in DESIGN.md.
Worth knowing before trusting this with something that matters:
- Rasterized PDF pages lose their text layer. A page carrying a redaction becomes an image, so it is no longer selectable or screen-reader accessible. Pages without redactions keep their text (#40).
- Blur and pixelate are weaker than solid fill. Solid black is the default.
- Detection is an assist, not a guarantee. The reviewer is the control, which is why the UI never implies completeness.
- Detection is English-shaped (#43), and the failure is silent.
- Pseudonymisation is not anonymisation. A consistent surrogate preserves equality, which is what re-identification joins on.
- The demo is anonymous. Anyone with the session cookie is the owner. That suits a temporary demo, not a multi-tenant product.
- Export still reads each source whole, because the writers rebuild a whole artifact and the gate re-opens it. Everything else streams (Streaming IO §9).
- A batch is processed a few documents at a time. A document in a large batch shows as queued and starts on its own.
Anonify: AI proposes, the application applies, and only what a person accepted is removed.
Repository · Report a bug · Request a feature · Security policy · Apache-2.0
This wiki describes v1.10.0 and summarises and links the in-repo documentation. Where the two disagree, the files under docs/, CHANGELOG.md and SECURITY.md are canonical.
Getting going
Using it
- Supported Formats
- Review Workspace
- Search and Rules
- Hush Assistant
- Redaction Presets
- Export and Anonymisation
- Batches and Mailboxes
How it works
- Architecture
- Processing Workflow
- Redaction Engine
- Storage and Encryption
- Streaming IO
- Data Model
- API Reference
- Security Model
When it breaks
Contributing
Repository · Issues · Releases · Changelog