Architecture overview¶
Cadrumo is a local-first application that prepares Spanish tax filings through
the aeat command-line interface. It models the tax authority’s regulatory
registry, ingests and classifies your financial records, and computes the
numbered boxes of each tax form. Then it checks the draft and exports a file
you submit yourself. AEAT is the Agencia Estatal de Administración Tributaria,
Spain’s tax agency.
Use this page as your entry point when reading the codebase for the first time. It’s a map, not a directory: it names the layers, the load-bearing concepts, and the canonical types you’ll navigate to, and it stops there. For exact signatures and commands, follow the links in Crossing into the code.
Why the system is shaped this way¶
Preparing a Spanish tax return by hand is error-prone and hard to audit. A figure on a form has to trace back to a bank movement, a regulation, and a published rule. A later annual form has to stay consistent with the quarterly ones that fed it. Three design choices answer that problem, and they shape everything else:
Local-first. Your financial records never leave your machine. The tool runs offline and stores encrypted payloads in a local database on disk.
The registry is the authority. Tax rules - rates, brackets, deadlines, and the legal basis of each box - aren’t hard-coded. They’re authored as data, compiled, validated, and read through a single authority. A figure can always trace back to the rule that produced it.
The human files, not the tool. The pipeline ends at an export file. Submitting it to the agency is a deliberate human step. The tool has no submission path at all, so live filing is absent rather than guarded.
The structure that follows is hexagonal: business rules sit at the center, and the parsers, browsers, and storage that touch the outside world sit at the edge. Most dependencies point inward. Application composition has an explicit, controlled exception that reaches adapters. This keeps tax logic testable on its own and lets an adapter change without touching a rule.
How to read this page¶
Four views cover the system at the highest level. Each pairs a diagram with a short explanation:
The layers - the five hexagonal layers and what each owns.
The registry authority pipeline - how tax-rule data becomes the snapshots runtime code reads.
The modelo lifecycle - the journey from records to an export file, and how each figure is grounded.
Persistence and the safety boundary - where state lives, and why nothing leaves.
The layers¶
Cadrumo separates responsibilities into five layers under src/cadrumo/.
Layers normally depend only on layers inside them; application composition has
the documented adapter exception.
flowchart TD
EP["entrypoints — CLI (config · app)"]
AD["adapters — inbound · outbound · persistence"]
APP["application — use-case services"]
DOM["domain — tax rules and entities"]
CORE["core — enums · JavaScript Object Notation (JSON) contract · config · primitives"]
EP --> APP
EP --> CORE
APP --> DOM
APP --> AD
APP --> CORE
AD --> DOM
AD --> CORE
DOM --> CORE
DOM -.->|"secure-repo seam"| AD
Text alternative. Entrypoints call the application and core layers. The application calls the domain, adapters, and core layers; adapters call domain and core; and domain calls core. The domain reaches adapters only through the annotated secure-repository seam.
coreis the innermost layer. It owns the cross-cutting primitives every other layer shares. Its typed enums define closed value sets such as period codes, tax domains, modelo identifiers, and binding source kinds. Core also owns the JSON envelope contract, configuration, money and time types, and the error taxonomy.domainholds pure tax rules and records: the modelo registry, casilla definitions (a casilla is one numbered box on a form), filing observations, and the calculations over them. It depends only oncore.applicationorchestrates use cases. It joins domain rules with adapters to build filing drafts, aggregate the ledger, run diagnostics, and project state. It performs no input or output of its own.adaptersconnects the application to the outside world in three parts.inboundparses incoming Portable Document Format (PDF), comma-separated values (CSV), Open Financial Exchange (OFX), and Microsoft Excel Open XML (XLSX) statements.outboundreaches external services (AEAT browser sessions, calculation oracles, and authentication providers).persistencestores encrypted payloads in the local database.entrypointsexposes the application to operators. The command line lives here, and its root surface is limited to two command families,configandapp.
Two boundary rules keep the layers honest. Boundary data crosses as validated
pydantic v2 models, never loose dictionaries. Closed value sets are declared as
typed enums in core and flow as enum members, so an invalid value is rejected
at the boundary.
The dependency arrows point inward with one annotated exception. Three domain
repositories - filing, justificante, and submission - sit directly on the
encrypted-storage base class in adapters.persistence. The diagram draws that
seam as a dashed edge rather than hiding it.
Inside domain and application, the subpackages cluster into five conceptual
groups. Some subpackages are omitted for clarity; the API reference lists them
all.
Cluster |
|
|
|---|---|---|
Registry and calculations |
|
|
Ledger and transactions |
|
|
Filing and export |
|
|
Live, portals, and auth |
|
|
Profile and storage |
|
|
The modelo lifecycle¶
A modelo is a numbered AEAT tax form. Its data flows one way, from your records to a file you upload yourself.
flowchart LR
REC["Financial records — statements · invoices · evidence"]
ING["Ingest and classify — transactions · invoices · evidence"]
LED["Ledger / aggregation substrate — encrypted bucket · resolver mesh"]
CRE["work create"]
CAL["calculate — produces CalculationRevision (casillas)"]
VER["verify — completeness gate"]
FIL["file — local final marker"]
EXP["export — fichero-BOE file on disk"]
HUM{{"Human upload at AEAT sede — outside the tool"}}
AEAT["AEAT"]
REC --> ING --> LED
CRE --> CAL
LED --> CAL
CAL --> VER
VER --> FIL
VER --> EXP
FIL --> EXP
EXP --> HUM
HUM -.->|"manual submission"| AEAT
Text alternative. Financial records are ingested and classified into the encrypted ledger. An operator creates work, calculates it, verifies it, marks it locally filed, and exports a file. A human then uploads that file to AEAT outside Cadrumo.
The tool ingests and classifies financial records - bank statements, invoices, and their evidence - into the encrypted ledger. From there the journey follows a fixed sequence of command-line verbs:
aeat app modelo work createpins a filing to a modelo, year, and period.calculateresolves every casilla and saves aCalculationRevision.verifyruns a completeness and consistency gate over the draft.filemarks a verified revision as internally filed.aeat app modelo exportwrites the official upload file (a fixed-layout fichero-BOE artifact) to disk.
The boundary is structural. file is a local marker, not a submission, and
export writes a file to your disk. No command transmits anything to the
agency: the upload is a human action at the AEAT portal, modeled as a step
beyond the tool’s boundary. Operator live reads require their normal
authentication and active-profile guards; they remain read-only. The separate
CADRUMO_LIVE_TESTS_ENABLED opt-in gates developer live-read tests.
How a figure is grounded¶
A casilla value isn’t a bare number. It carries its provenance - the legal references, source references, and formula identifier that produced it - from the registry definition through to the operator-facing output.
flowchart LR
DEF["Registry casilla definition — legal_refs · source_refs · formula_id"]
subgraph mesh["source-resolver mesh — merge_source_resolutions"]
direction TB
R1["Ledger aggregation resolvers"]
R2["Invoice catalogue resolver"]
R3["Previous-filing resolver"]
R4["Relation-prefill resolver"]
end
ENG["Registry formula engine — evaluates formula_id"]
OBS["CasillaObservation — value + legal_refs + source_refs + formula_id"]
REV["CalculationRevision.observations (+ flat casilla_values)"]
PAY["CLI payloads"]
OPS["Operator surface — JSON envelope + text"]
DEF --> ENG
DEF --> mesh
R1 --> ENG
R2 --> ENG
R3 --> ENG
R4 --> ENG
ENG --> OBS --> REV --> PAY --> OPS
Text alternative. A registry casilla definition and resolved ledger, invoice, previous-filing, and relation inputs feed the formula engine. It emits a provenance-carrying observation that becomes a calculation revision, CLI payload, and operator-facing output.
Each binding on a casilla declares a typed source. A mesh of resolvers - ledger
aggregation, invoice catalogue, previous-filing carry, and cross-modelo relation
prefill - turns those sources into binding values, merged through
merge_source_resolutions. The registry formula engine evaluates the casilla’s
formula over the resolved inputs and stamps the result as a CasillaObservation
carrying its legal_refs, source_refs, and formula_id. That observation
rides inside the persisted CalculationRevision, then into the CLI payloads,
then to the operator. The provenance never drops on the way out.
A binding is a projection contract, not an attachment slot. It identifies the owned source family, validates the selector and aggregation, and says how that source becomes a filing input. A scalar contract produces one value. Repeating record families declare one typed binding per row field. They join the values by grouping and one-based row index. The calculation revision freezes the resolved row bindings. Paths that produce validated detail rows store those separately. It never attaches an opaque record blob to a casilla.
Modelo 720 demonstrates the repeating shape: its foreign-asset resolver is
enrolled in the source mesh and projects typed asset fields into registry row
bindings. Stock inventory is different. InventoryLedger is an implemented,
encrypted business aggregate. The binding-source taxonomy currently has no
inventory kind or enrolled inventory resolver. It therefore remains outside modelo
calculation revisions until a legally grounded projection is added.
Persistence and the safety boundary¶
All sensitive financial data lives in one place: an encrypted, per-profile store. The structure, not a convention, keeps it there.
flowchart TD
OP(["Operator"])
subgraph boundary["Encrypted bucket-scoped store — financial data never leaves (no temp / scratch / plaintext)"]
direction TB
PROF["Active profile — resolve_active_bucket_id"]
SESS["BucketSession — KEK/DEK · idle-timed · one taxpayer at a time"]
FAC["Runtime factories — secure_object_repository_for_active_bucket"]
REPO["SecureObjectRepository — AEAD payloads · HMAC-SHA256 lookup digests"]
MK["MasterKeyProvider — Keyring / FileFallback · AES-256"]
DB[("SQLite with encrypted payload columns")]
ATT["AttachmentStore — put_bytes / read_bytes · content-addressed"]
EV["Evidence bytes — invoices · statements (FINANCIAL)"]
PROF --> SESS --> FAC --> REPO
REPO --> MK
REPO --> DB
ATT --> REPO
EV --> ATT
end
SEDE["adapters/outbound/aeat — read-only live checks"]
AEAT["AEAT"]
HUM{{"Human filing — outside the app"}}
OP --> PROF
SEDE -.->|"read-only operator live checks"| AEAT
OP --> HUM
HUM -.->|"manual submission"| AEAT
Text alternative. An operator selects one active profile. The resulting bucket session opens runtime factories and a secure repository, which encrypts payloads in the local database and attachment evidence. Operator AEAT checks stay read-only; only the human filing action can submit to AEAT.
Key-encryption keys (KEKs) protect data-encryption keys (DEKs). The repository uses authenticated encryption with associated data (AEAD) payloads and HMAC-SHA256 lookup digests keyed from master-derived material. HMAC means hash-based message authentication code. The cipher is the Advanced Encryption Standard with 256-bit keys (AES-256).
You work one taxpayer at a time. Selecting a profile opens a bucket session,
which scopes a SecureObjectRepository to that taxpayer’s encrypted store. The
repository encrypts every payload at the column boundary with a key from the
MasterKeyProvider - the operating system keychain, or a passphrase-derived
fallback. It persists the ciphertext to a local SQLite database with encrypted
payload columns.
Evidence bytes - invoices, statements, and decrypted documents - go through the
content-addressed AttachmentStore, which stores the bytes themselves, never a
link.
Parsed business records do not become attachments merely because they may later support a filing. Transactions, invoice catalogues, and stock inventory remain separate typed aggregates in encrypted storage. Modelo 720 instead receives typed foreign-asset observations from the caller. An enrolled source resolver projects only the fields requested by the registry into a calculation revision. In the current Modelo 100 registry, stock-related boxes 0177, 0181, and 0182 remain manual inputs. The inventory domain now splits 2025 opening-to-closing variation between 0177 and 0182, but that domain projection is not evidence of a live inventory-to-Renta route and does not yet claim complete purchases for 0181.
Nothing in this store crosses outward to the agency as a write. The read-only live checks reach the agency only to read. The single path that reaches the agency as a write is the human upload, which sits beyond the tool’s boundary. A structural test enforces the absence of any write verb in the AEAT sede adapter, so the safety posture can’t erode by accident.
How the documentation stays true to the code¶
The discipline that governs the layers governs the documentation too. Cadrumo keeps three English documentation surfaces close to the code that supplies their facts.
Documentation authors write the repository markdown - this guide and the others
under docs/ - for people orienting to the project. Technical reviewers check
each claim against the code before it lands. In-source docstrings supply the API
reference, so authors never copy a signature into prose. Generators scaffold the
source-code and command-line references from the code itself.
Because generators derive the API and command-line references from source, those surfaces cannot silently drift. A strict build treats warnings as errors and fails on a broken cross-reference, a missing stub, or a command reference that no longer matches the commands.
Crossing into the code¶
Use this overview to orient, then drop into the detail:
The layer tree - browse
src/cadrumo/core,src/cadrumo/domain,src/cadrumo/application,src/cadrumo/adapters, andsrc/cadrumo/entrypointsto see the subpackages each cluster names.The command-line reference - CLI reference for exact verbs, options, and output.
The API reference - cadrumo package for module signatures and the full subpackage list.
The registry authoring guide - Authoring and reviewing documentation for how to author the TOML that the pipeline compiles.
The pipeline explanation - How Cadrumo turns your records into a tax file for the same journey told for the taxpayer.
The glossary - Glossary for any term (casilla, modelo, fichero-BOE, or justificante) you want defined.
Getting it running - Quickstart: prepare a modelo filing to install and prepare a first filing.
Contributing - the project source and issue tracker live on GitHub.