ADR 0004 — Document & artifact association for transactions¶
- Status: Proposed
- Date: 2026-06-10
- Related: Schema Extensions, Transaction Relationships, Philosophy, ADR 0002, ADR 0005
Context¶
A recurring user need: a transaction is a single opaque charge — "TARGET $63.40" — but the user has the receipt, and wants either the scanned document attached to the transaction or its line-item itemization (paper towels $4.99, detergent $12.00, …) recorded against it.
kasas has no concept of this today. The transaction model has no attachment field; there is no blob storage, no upload endpoint, no document type anywhere in the core. The philosophy is explicit about why: "Lean on storage, comprehensive on exposure. Reuse existing tables and a JSON column before adding schema." kasas owns the ledger, not a document store.
Three distinct sub-problems hide inside "attach the receipt," and conflating them produces the wrong design:
- The blob — the receipt's PDF/JPEG bytes.
- The association — the link from those bytes to
txn:target-…. - The itemization — the structured line items, which are ledger-adjacent data, not a scanned image.
Only #1 is a genuinely new concern. The platform already has primitives for #2 and #3.
Decision¶
Keep the ledger lean: kasas stores references and structured data, not bytes. The bytes live in a system built for documents (paperless-ngx is the motivating example, but S3, Google Drive, or any DMS fit the same shape). This is a direct application of lean on storage, comprehensive on exposure.
1. The blob — referenced, not stored¶
A receipt is recorded as a reference under a reserved schema-extension namespace, never as ledger-owned bytes:
// extension: documents.receipt on txn:target-…
{
"system": "paperless-ngx",
"doc_id": 4821,
"url": "https://paperless.lan/documents/4821",
"sha256": "…", // integrity of the referenced bytes
"mime": "application/pdf",
"added": "2026-06-10"
}
Extensions are the right home: they are arbitrary namespaced JSON that survives every re-sync and is queryable across REST/MCP/dashboard. The ledger stays a ledger; the document store stays the source of truth for bytes, OCR, and retention.
2. The association — extension now, relationship later¶
The reference in #1 is the association for the common one-receipt case. If a richer many-to-many model is later wanted (one receipt spanning several charges, several artifacts on one charge), introduce an artifact as a first-class node and reuse transaction relationships — extending the relationship target beyond txn→txn to txn→artifact — rather than overloading extensions. This ADR does not commit to that; it reserves the direction so the cheap path (extensions) does not foreclose it.
3. The itemization — its own extension namespace¶
Line items are structured ledger-adjacent data and have nothing to do with where a scan lives. They go in their own namespace:
// extension: itemization.lines on txn:target-…
{
"lines": [
{ "desc": "Paper towels", "amount": "4.99", "qty": 1 },
{ "desc": "Detergent", "amount": "12.00", "qty": 1 }
],
"source": "paperless-ocr" // or "manual", "merchant-api", …
}
Amounts are decimal strings, like every amount in kasas. A
plugin can validate sum(lines) == txn.amount entirely in-sandbox — no new
capability needed.
4. The mechanism — who moves the data¶
This is the crux, and it is constrained by the plugin trust model. A plugin
runs sealed and is read +
annotate: it can set_extension, but it cannot reach paperless-ngx, and there
is no transactions:create. So the work splits:
- Fetch + link (needs the network) is done by either:
- an external connector today — a small process that watches
paperless-ngx, matches a document to a transaction (date + merchant +
amount, or a correlation token), and writes the reference back via the
REST API (
PATCHthedocuments.receiptextension); or - a
net:fetchplugin once ADR 0002 lands — the same logic in-process, withpaperless.lanon its declared allowlist, no separate service to run.
- an external connector today — a small process that watches
paperless-ngx, matches a document to a transaction (date + merchant +
amount, or a correlation token), and writes the reference back via the
REST API (
- Present (sealed, no network) is a plugin dashboard page: render the
receipt link/preview and the itemization table from the
documents.*/itemization.*extensions, and flag transactions that are missing a receipt viaOnSyncComplete.
The split — connector/net:fetch does I/O and writes; a sealed plugin
presents — is the only shape compatible with the sandbox, and it composes
cleanly with the rest of the arc: ADR 0002
collapses the two halves into one installable plugin.
What this ADR deliberately does not do¶
- No blob storage in core, no upload endpoint, no
artifactstable. That remains an app-layer concern unless and until a future ADR argues receipts are core ledger data. - No
transactions:createcapability. Whether plugins may originate transactions (vs. that staying a core source) is a separate, larger decision and is out of scope here.
Consequences¶
Gains
- Receipts and itemization ship with zero new core storage — extensions and (optionally) relationships already exist and already span every surface.
- The document store owns what it is good at: OCR, full-text search, retention, a real document UI. kasas does not reinvent any of it.
- Reserved, namespaced contracts (
documents.*,itemization.*) give plugins and apps a stable shape to read and write against immediately.
Costs & mitigations
- Soft dependency on an external system to resolve a link. Mitigation: the ledger stores a self-describing reference (system, id, url, hash); if the DMS is down only the preview is unavailable, the ledger is unaffected.
- No bytes means no kasas-side guarantee the document still exists.
Mitigation: the stored
sha256lets any reader verify integrity when it does fetch; a future connector can re-validate and flag dangling references. - The fetch half needs network, which the sandbox forbids. Mitigation: the external-connector path works today; ADR 0002 is what makes the single-plugin path possible.
Alternatives considered¶
- First-class artifacts in core (an
artifactstable, a multipart upload endpoint, a blob backend → local dir, then S3/GCS). Genuinely useful and the right call if receipts become a headline promise of kasas. Rejected for now: it contradicts the lean-ledger thesis and pulls a storage backend, GC, dedup, size/content-type handling, and thumbnailing into the core. Left open for a future ADR to revisit if demand warrants. - Store bytes inside an extension (base64). Trivial, no external system. Rejected: extensions are capped (~8 KB/value) and are metadata, not a blob store — this would abuse the primitive and bloat the ledger.
- Itemization folded into
documents.receipt. Fewer namespaces. Rejected: itemization is useful with no scan at all (manual or merchant-API sourced), so it earns its own namespace independent of where bytes live.