Layout
Monorepo, multiple Go modules glued bygo.work. Module boundaries
are the contract, not repo count.
plugin_kv(plugin_name, key, value_json) for its scratch space.
Where does new code go? plugins/ vs core/pipeline/
Two homes, one rule:
-
plugins/<name>/— a separate Go module with its owngo.mod, imported intodistro/cmd/suchi/main.govia a blank import. Meant for things that (a) register with the plugin-api (Subscriber,Authenticator,AuditSink), (b) have their own lifecycle (New(), boot-time configuration), or (c) could plausibly be replaced by an external contributor (an alternative auth backend, a different classifier). Examples:plugins/local-auth,plugins/oidc,plugins/llm-classifier. -
core/pipeline/<name>/— a package inside thecoremodule. A pure library that other core code calls synchronously. No lifecycle, no registration, no imports fromplugins/. Meant for file-format extractors and ingest primitives: exposesRecognized(mime)andExtract(ctx, src, log, opts)shaped likecore/pipeline/djvu. Examples:pdf,pdfinspector,qpdf,tessocr,ocrmypdf,epub,djvu,msg,heic,barcode,eml,anydoc.
Subscriber or gets
constructed once at boot and passed a handle, it’s a plugin. If it’s a
pure function called from postingest.go, it’s a core/pipeline/
package.
Direction of imports: core never imports from plugins/. distro
imports from both. Getting this backwards (a core/pipeline/ package
depending on a plugins/ module) creates a circular go.work headache
and blurs the “core is the platform, plugins are the extensions”
architecture.
Data flow
Non-negotiable design principles
Every architectural decision is tested against these.1. Ultra-lightweight on resources
1. Ultra-lightweight on resources
Idle RAM ≤ ~100 MB; ~35 MB slim image; runs on a $5 VPS or a Pi.
2. Easy to get around
2. Easy to get around
One binary, one config, one data dir.
suchi --help fits on a
screen. Every knob has an env-var equivalent.3. Boring underneath
3. Boring underneath
stdlib where possible, SQLite by default, no message broker, no
cache tier. Modern is adopted only when it makes the stack more
boring.
4. Batteries included but every battery is swappable
4. Batteries included but every battery is swappable
Every integration point is an interface in
plugin-api/.5. Every decision is a log line — every unit of work is a row
5. Every decision is a log line — every unit of work is a row
No in-memory-only work queues; the durable outbox table is the
truth.
6. Regenerable data layout
6. Regenerable data layout
Blobs + SQLite are the source of truth. Rendered file tree,
thumbnails, FTS index are all regenerable.
7. Hostile-input posture
7. Hostile-input posture
PDFs are the malware delivery format. Every parser runs as a
subprocess with timeouts, rlimits, output caps, no network. See
core/sandbox/.8. Your documents, your data
8. Your documents, your data
No telemetry, ever. Every egress opt-in, visible in config,
logged. See privacy.
9. Email is a first-class ingest path
9. Email is a first-class ingest path
Not an add-on. Ships in every default image alongside filesystem
watch and upload.
Durable job outbox
Every unit of async work is a row injobs. The dispatcher polls
jobs_ready (partial index on state='pending' AND next_run_at<=now)
and hands rows to registered subscribers.
- Handlers are idempotent — retry semantics assume this.
- Ingest writes the document row AND its
post-ingestjob in the same transaction. There is no window where a doc exists but its work is lost. - Failures back off exponentially, cap at
attempts=5, then move tostate='dead'. Dead rows are visible via/api/tasks/— the same table doubles as the mobile-app tasks endpoint in Phase 4, free.
SQLite discipline
Two connection pools on the same file:- Write pool:
MaxOpenConns=1+BEGIN IMMEDIATE. One writer, serialized at the pool level soSQLITE_BUSYis structurally impossible. - Read pool: ordinary WAL readers, concurrent with the writer.
journal_mode=WAL, synchronous=NORMAL,
busy_timeout=5000, foreign_keys=ON, mmap_size=256M,
temp_store=MEMORY.
Migrations are one-way and gated by PRAGMA user_version. Down
migrations aren’t supported — production rollbacks restore from the
pre-migration snapshot backups take before each schema change.
Post-ingest pipeline
Every uploaded PDF flows through a fixed chain, hosted by thecore/pipeline/postingest Handler and dispatched via the durable
outbox:
Every subprocess in the chain runs via core/sandbox — empty env,
no network, hard timeout, bounded output, fresh workdir removed on
return. Every step degrades gracefully: if a binary isn’t
installed (or the input is unrecognized), the step logs a skip
and the pipeline continues with the previous state. The design
principle: ingest completes even when some tools aren’t installed.
- qpdf strips restrictions + decrypts empty-user-password PDFs before ocrmypdf sees them. Absorbs decompression bombs.
- pdf-inspector / pdftotext decides whether the PDF has enough
embedded text to skip OCR entirely (threshold: 32 non-whitespace
chars). If yes → shortcut writes text into
documents.content. - ocrmypdf runs only when the shortcut fails. Emits a
searchable-PDF archive (stored in the CAS as
archive_blob) and a plain-text sidecar (stored indocuments.content). The FTS5 trigger picks up the content change automatically. - zugferd (Factur-X / XRechnung / ZUGFeRD) inspects the PDF for
an embedded invoice XML attachment and, if present, populates
invoice_number,invoice_date,invoice_currency,invoice_total_gross,invoice_total_net,invoice_seller,invoice_seller_vat,invoice_buyercustom fields. Thecustom_fieldsrows are created lazily on the first extraction. - epub ingest is a pure-Go zip walker: reads
META-INF/container.xml→ OPF → spine, concatenates the visible text from each XHTML in reading order intodocuments.content. No qpdf, no ocrmypdf, no external binary — works in the slim image too. - djvu ingest routes through
djvutxt(djvulibre-bin) which pulls the embedded text layer from OCR’d DjVu scans (archive.org / library archive workflows). Missing binary → skipped, doc lands with empty content and can be re-ingested later.
Workflow engine
Post-ingest pushes a document into a consistent state. What happens next — the manager sign-off, the finance approval, the escalation when nobody answers in 24 h — lives in the workflow engine. Definitions are versioned JSON inapproval_defs; each run keeps one
current_state cursor in approval_runs; every transition appends to
approval_transitions (audit log); human steps land in
approval_tasks. Advancement rides the same durable outbox as
everything else via approval:advance / approval:resume /
approval:timeout-sweep — no second scheduler. See
approvals for the spec shape, the six HTTP endpoints,
and a worked invoice-approval example. For “when doc lands, do X”
rules that don’t need a human click, see automations.
Permissions
The multi-user data model has been there since Phase 0 — every document has anowner_id. Phase 6 adds:
groups— named collections of usersgroup_members— user ↔ group junctionobject_acls— polymorphic grants keyed on (object_kind, object_id, principal_kind, principal_id, perm_bits)
authz.Authorizer interface owns the decision. Two shipped
implementations:
RoleAuthorizer— legacy owner+admin gate. Zero ACL awareness.ACLAuthorizer— folds ACL grants over the owner/admin fallback. Wired as the default atapi.New(); empty ACLs behave identically toRoleAuthorizer, so this is backward-compatible.
view=1, change=2, delete=4; grants OR together.
Admin bypasses ACLs entirely; owner has the full mask on their own
objects. See permissions for the full model, REST
surface, and worked examples.
Enterprise builds swap the interface for a SAML/SCIM-aware
implementation without touching handlers.
Automations vs Approvals
Two distinct engines with well-separated concerns:- Automations (
core/automations/) — trigger→conditions→actions rules that fire on document events. No human involvement; matches Paperless-ngx’s “workflows” shape in the mobile-compat plane but named for what it does. See automations. - Approvals (
core/approvals/) — human-in-the-loop state machines with timeouts. Doc 17 needs finance sign-off before landing in the vault. See approvals.
core/classify/rules/) sits below both — it’s
the one-shot deterministic classifier that runs first in
post-ingest.
Headless mode
SUCHI_UI_DISABLED=1 skips registration of every browser-facing
route at boot: / (SPA redirect), /app/* (embedded Svelte SPA),
/preview/{id}, /download/{id}, /login, /bootstrap, and
/assets/*. What stays: /api/* (the JSON surface), /healthz,
/readyz, /metrics. Blob access for headless callers keeps
working via the /api/documents/{id}/{preview,download} mirrors.
Intended for deployments where a separate frontend — an external
SPA built against /api/, an internal-tools dashboard, a mobile
app — is the primary UX. suchi runs as an API server; the
frontend is whatever the operator hosts.
The three auth chains (Token, Bearer, OIDC) are unaffected. The
first-boot bootstrap flow (POST /setup) is unaffected — it’s an
API endpoint, not a UI page — but you’ll need to POST the token
manually since the /bootstrap browser wizard is gone.
Content-addressed storage
Blobs live at$DATA_DIR/blobs/sha256/{aa}/{bb}/{full-hash}. Puts
stream through SHA-256 into a per-put temp file in the destination
shard, fsync, atomic rename. Same-device by construction so the
rename is POSIX-atomic. Duplicate puts return the existing ref
without rewriting — free dedup.
Documents carry two blob references:
original_blob(NOT NULL) — the bytes as ingested. Dedup key.archive_blob(nullable) — the OCR-rewritten PDF. Regenerable in principle; stored because OCR is expensive.