Tax Rules the Agent Can Only Explain
A Spanish freelancer who invoiced a company on 10 July 2015 had to withhold 19% of the fee for income tax. Two days later the same invoice carried 15%. A decree published on the 11th, Real Decreto-ley 9/2015, moved the rate in the middle of the summer, and before 2015 it had been 21%. Olive oil is worse: its VAT was 5% in the first half of 2024, 0% from July, 2% from October.
Ask a language model for "the withholding for a professional in Spain" and it will say 15%, and today it is right. Ask it while it drafts an invoice dated 10 July 2015 and it will probably still say 15%, with the same confidence. A rate that is right most days is the worst kind of wrong for a tax: nobody checks it, and it ends up on a document the law says you cannot edit.
Sumalos is my invoicing app for freelancers, and its assistant, Suma, creates invoices, quotes and expenses from a chat or a WhatsApp message. So the question for this post is narrow: how do you put an agent in front of tax rules when the answer has to be exact, the same every time for the same input, and explainable a year later to an inspector?
My answer is to split the job in two and never let the halves touch. The model reads and writes language. A small deterministic engine decides, and the model only ever repeats what the engine said.
Where the model stops
Here is what happens when someone tells Suma "invoice Hans in Munich for the logo, 800 euros". The model's job is to turn that into a typed request: a customer it can name (by a handle, never a database id; that mechanism has its own post), a line with a concept and a price, and, if the person said so, whether it's goods or a service. It does not choose the VAT. If the person dictates a rate, the model passes it along as theirs; if not, the field stays empty.
The server builds the line, and when nobody said a rate or an exemption (not the model, not the catalogue item, not the contact's saved conditions), it asks the engine what the editor would suggest for this customer:
// Nobody said a VAT or an exemption (the model, the item, the contact): the treatment the editor would suggest for this customer.
if (customer && line.iva === undefined && line.exencion === undefined && !item?.exemptionCode && !(conditions?.exemptionCode) && !conditioned.exemptionCode) {
const vies = customer.vies ? (customer.vies.valid ? ViesStatus.Valid : ViesStatus.Invalid) : null;
const t = suggestTreatment({ ...customer, kind, vies });
if (t.code) {
conditioned.exemptionCode = t.code;
taxBasis = t.basis;
notes.push(`«${description ?? `Línea ${i + 1}`}»: ${t.title}`);
}The vies flag is a real lookup of the customer's EU VAT number, not something the model claims. The note that comes back ("German customer with a valid VAT number and a service: not subject...") is the engine's sentence, and it is what Suma reads out in the confirmation. The editor in the browser calls the same suggestTreatment, so the chat and the form cannot disagree.
The quarterly VAT and income tax returns follow the same line. When someone asks "how much will my 303 be this quarter?", Suma's action reads the estimate the period page already computed and adds a caveat it cannot drop:
// «¿Cuánto me sale el 303 este trimestre?» (docs/modelos-fiscales.md, «Suma»): Suma reads the
// estimate the period view shows, with its caveat, and never does anything with it. The
// gestoría reviews and files; Suma never says it does the taxes.
const CAVEAT = "Es una estimación con las facturas y los gastos registrados hasta ahora; tu gestoría la revisa y la presenta.";What is left for the model is a lot, and all of it is language: understanding what was asked, picking the action, explaining a decision in plain words, and answering "why is there no VAT on this?" from the engine's trace and the legal sources it carries. When it needs a rate to talk about, it calls a tool, saber, that reads the same facts the engine uses (how those facts sit in the knowledge Suma reads is another post). If the knowledge doesn't cover a question, the tool's answer tells the model to say so and not fill it in from memory.
A rule is a row with tests
If the model can't hold the rules, something else has to, and the first temptation is a big switch in TypeScript. That is what Sumalos had: a hand-written suggestTreatment, a table of where the customer is against goods and services. It worked, and every change was a code deploy, with no record of which version of the table produced which invoice.
The engine that replaced it keeps three kinds of knowledge apart, because they change at different speeds and for different reasons:
A rate change is a new fact. A new nuance is a new rule. A new box on a form is a new formula. None of them touches the other two, and none of them touches application code.
Rules are data written in a small JSON expression language. Here are the withholding rules, trimmed but otherwise as they are in the repo:
const { add } = retencion;
const payer = v("payerWithholds");
add({ key: "pagador-particular", when: not(payer), outcome: { rate: 0, rateKey: null }, explanation: "Un particular no practica retenciones.", sources: ["es.lirpf.a101"] }, [{ context: ctx("actividad_profesional", false), expect: { outcome: { rate: "0" } } }]);
add({ key: "profesional", when: and(eq("incomeType", "actividad_profesional"), payer), outcome: { rate: fact("irpf.retencion.profesional"), rateKey: "irpf.retencion.profesional" }, explanation: "Retención general de los profesionales (art. 101.5 LIRPF, art. 95 RIRPF).", sources: ["es.lirpf.a101", "es.rirpf.a95"] }, [
{ context: ctx("actividad_profesional"), expect: { outcome: { rate: "15" } } },
{ key: "profesional-marzo-2015", context: ctx("actividad_profesional"), date: "2015-03-01", expect: { outcome: { rate: "19" } } },
]);Three things in there carry the design. The rule never says "15": it points at a fact, irpf.retencion.profesional, which has its own history. Every rule names its legal sources, so the explanation can cite them. And the second argument to add is a list of tests, which is not optional:
/** A rule set with its rules; each rule brings at least one test (publishing requires them). */
export function domain(ruleSet: RuleSetDef & Versioned) {
const d: Domain = { ruleSet, rules: [], tests: [] };
const add = (spec: RuleSpec, tests: TestSpec[]) => {
if (!tests.length) throw new Error(`${ruleSet.key}/${spec.key}: a rule needs at least one test`);I looked at JsonLogic and CEL for the expression language and kept JsonLogic's shape ({"operator": [arguments]}, easy for a person or a model to write) with my own small typed interpreter underneath. JsonLogic has no types, uses JavaScript truthiness and float arithmetic; CEL has types but a much larger runtime and no exact decimals. Mine checks every var against the rule set's context schema and every enum literal against its values when the rule is saved. A typo is an error at publish time, not a silent false at run time:
expect(() => check({ "==": [{ var: "zona" }, "islands"] }, env)).toThrow(/unknown field "zona"/);
expect(() => check({ "==": [{ var: "zone" }, "isla"] }, env)).toThrow(/"isla" is not one of enum/);The language has no clock, no randomness and no I/O. The operation date comes in as an argument, and each evaluation has a budget of 200,000 steps, so a badly written rule can be wrong but it cannot hang the server.
Two clocks
That July 2015 change is why the engine needs dates on everything. But a date of validity alone isn't enough, and the reason took me a while to see.
There are two questions an inspector, or a confused customer, can ask. "What withholding applied to an invoice dated 10 July 2015?" is about the law. "Why did your app suggest 19% on the day I made that invoice?" is about what the system knew. If I published a fact on 1 February, found it was wrong and corrected it on 1 April, those two questions have different answers for any invoice made in March. So every fact, rule and formula carries two windows, the pattern known as bitemporal modeling:
The engine picks the legal version by the operation date. A test in the engine suite does exactly the correction above with a toy shipping fee, and rebuilds both answers from the same rows:
const knewInMarch = loadEngine(compileBundle(rows, { asOf: "2026-03-15T12:00:00Z" }));
const knowsNow = loadEngine(compileBundle(rows, { asOf: "2026-10-19T12:00:00Z" }));
const islands = ctx({ zone: "islands" });
expect(evaluate(knewInMarch, "tienda.envio", islands, "2026-03-01").decision.outcome!.fee).toEqual({ amount: "15", currency: "EUR" });
expect(evaluate(knowsNow, "tienda.envio", islands, "2026-03-01").decision.outcome!.fee).toEqual({ amount: "12", currency: "EUR" });In the database this is enforced, not hoped for. The nine rule tables are insert-only: triggers reject DELETE, TRUNCATE and any UPDATE except from a few controlled functions, and those can only move a row through its lifecycle (draft, in_review, validated, published, superseded or retired). Publishing a new version supersedes the old one, and two published versions in force at the same time are rejected. An automatic update can add history. It can never rewrite it.
The engine never guesses
A model asked something it doesn't know will answer anyway. The engine is built to do the opposite in every case where it could be tempted.
Money is never a float. Every amount, rate and percentage is an exact decimal over BigInt, a value times a power of ten, with a canonical text form so the same number always serializes the same way:
/**
* Exact decimals over BigInt: money, percentages and rates never go through binary floats.
* A value is `units × 10^-scale`. Canonical text drops trailing zeros ("21", "5.2", "-0.5"),
* so the same number always serializes (and hashes) the same.
*/A tie is a question for a person. When two rules match, the more specific one wins, then the higher priority. If they still tie and their outcomes differ, the decision is needs_review with both candidates listed. There is a test named for it: "a real tie is needs_review, never a silent pick".
A missing fact is not a zero. If a rule needs a rate that has no version in force on the operation date, that rule errors, the decision becomes needs_review, and the trace says which fact was missing on which day.
Nothing applies: an explicit default, marked. Each rule set declares its default (for withholding: no withholding, "a revisar") and the decision says default, so the interface can show it as a guess.
Each rule set has a switch. Turned off, it returns its default with status disabled, which is how a broken rule set gets taken out of service without a deploy.
suggestTreatment turns every one of those non-decisions into the general Spanish VAT plus a warning, so the invoice editor always has something to show and the person always sees that it needs a look.
A hash for every answer
Exact and deterministic is still not reproducible unless you can say which rules produced an answer. So rules are never evaluated straight from the tables. Publishing compiles the chosen rule sets, and only the facts, formulas and sources they use, into a bundle: one canonical JSON document with a SHA-256 of its content. Input order doesn't matter, so the same content always gets the same hash, and loading a bundle checks the hash first, so an edited bundle refuses to load.
The hash has a small practical problem. The browser's Web Crypto digest is async, and node:crypto doesn't exist in the browser, so neither gives one synchronous function that runs in both. The engine carries its own SHA-256, a few dozen lines of plain JavaScript, checked against the standard test vectors, with a comment that says what it's for:
/**
* Canonical JSON and SHA-256, synchronous and in plain JS: the same bytes and the same hash in
* the browser and on Bun, with no crypto API (Web Crypto is async, node:crypto is not in the
* browser). Content hashes identify rules, facts and bundles; they are not a security boundary.
*/"Same bytes in both places" is a claim, so there's a test for it. It builds the engine for the browser with Bun, runs that build in an empty V8 context where the only global is TextEncoder (no Bun, no process, no require), loads the bundle the server serialized, and compares decisions and full traces as canonical JSON, byte for byte.
The payoff shows up in the quarterly returns. When someone marks a form 303 as reviewed, Sumalos stores a snapshot: the boxes in cents, the bundle hash, the version of each formula, and the input records with their own hash. If an invoice changes later, the snapshot shows as out of date, and "recalculate with the rules of then" loads the old bundle by its hash and runs the same records again. It has to produce the same bytes. It can also say what would change under today's rules and data, which is exactly the question an advisor asks.
Proving the rewrite changed nothing
Moving tax logic from code into a new engine is the kind of refactor that looks finished and quietly changes one case in a thousand. Before the engine touched a single suggestion, a test ran the new suggestTreatment against a literal copy of the old hand-written function over every combination of customer location, VAT number, VIES result, goods or services and EU distance sales: 4,608 inputs, compared as canonical JSON. They match exactly. The 13 sample invoices I had collected (with their PDFs, to check what each line should say) are a regression suite on top: treatment, rate, surcharge and withholding for every line.
Today the Spanish domain is five rule sets with 60 rules, 24 facts, 23 legal sources and 94 tests, every rule with at least one source and one test. The timings are from the engine's own test suite on my development laptop with Bun 1.4.3:
That is fast enough to run on every keystroke in the invoice editor, which is the point of having the same code in the browser.
How a change gets in
The law will change again, and the question is who notices and who gets to act on it. This is where the line between model and engine matters most, and it's also the part that is only half built, so I'll be precise.
What runs today: a worker job fetches ten official sources once a day (the AEAT's record-design pages for the forms, the procedure pages for the 303 and the 130, two AEAT news pages, two BOE feeds and the "later references" section of the two ministerial orders that define the forms). It hashes only the part of each page that matters, and on the busy feeds it only alerts on new entries that mention the forms, VAT, income tax or a ministerial order. A change becomes an alert on the staff page and one email per staff member. The code comment is blunt about its status: "A hint, never authoritative: a person reads the change and decides." There's no model in it at all.
Then a person writes the new version of the fact or rule next to its tests, rules:bundle compiles and runs every test, and the worker publishes the bundle only if all of them pass. Rolling back is publishing the previous bundle again, which is one function call, rule_bundle_transition.
What's designed but not built yet is the step where AI enters: a watcher that reads the changed legal text and drafts a change proposal, with the new versions, their tests and the evidence. The table for proposals exists, and its state machine already refuses an approval that doesn't come from a person. Around it the plan adds an impact report (how many invoices and people a change would touch) and a shadow mode that evaluates the new bundle next to the published one before anyone approves. Even then, the model's output is a draft for a reviewer. It is the same job the model has in the chat, turning language into a typed proposal, one step further from the money.
What the model gives up
The model loses the right to be clever about taxes. It can't decide that a borderline service is probably exempt, or remember a rate from training, or round a total the way it thinks looks right. Every one of those is a decision with a hash, a date, a source and a test, made by about 900 lines of expression interpreter that knows nothing about Spain.
In return the agent gets something a model can't produce on its own: answers that are the same on Tuesday as on Monday, that match what the invoice form shows, and that can be replayed byte for byte a year later with the rules that were in force. The model is still the part people talk to. It just isn't the part that answers to the tax office.
Related articles
A temporary inbox takes mail from anyone, for anyone, with no account. How Buzontemporal runs on Mailingest: no attachments, a light path, hard limits.
Buzontemporal's live inbox held one Mailingest stream per visitor, and Mailingest allows 20 per account. Why raising the cap wasn't the fix, and how Centrifugo was.