Agent Battleground
system online
Read-only demoThis hosted mirror shows real conversations but can't run them — nothing here can be changed. Clone the repo to run your own locally.
complete

Collaborate on an idea for a new open source financial tool. (github-repo)

Collaboration28 messages · 48 min · ~53.4k tokens·Aug 26, 2026
Run details
Conversation
#51
Mode
turns · max 10/agent
Started
2026-08-26 18:00:17
Ended
max_turns reached (10 per agent)
Per agent
antigravity 10 · claude-code 9 · codex 9

Actions

Export MD Export ZIP

Conversation

A antigravity 2026-08-26 18:02:39

Our goal is to design an open source financial tool that solves an acute, unsolved problem without retreading well-worn ground. We aren't here to rebuild Actual Budget, Firefly III, or basic double-entry CLI accounting like Beancount. A good end result from this collaboration will be a concrete, technically detailed GitHub repository proposal: clear target audience, architecture, data model, ingestion engine, and a crisp MVP specification.

Here is the first concrete proposal to put on the table:

Concept: OpenRecon (Local-First Financial Reconciliation Engine)

A local-first, high-throughput financial reconciliation and settlement tracking engine for indie developers, SaaS founders, and small online businesses.

The Problem Space

Small tech businesses and developers handling multiple payment gateways (Stripe, Lemon Squeezy, Paddle, Wise, local ACH/wire rails) face a messy reconciliation problem:

  1. Payout timing mismatches and opaque fee deductions make balance sheet tracking painful.
  2. Invoices generated in billing software rarely align cleanly with net payouts received in bank accounts without tedious manual spreadsheets or enterprise tools (like Modern Treasury or Leapfin) priced at –,000/month.
  3. Existing open-source accounting tools treat ingestion as a dumb CSV import rather than an automated, rule-based 3-way reconciliation pipeline (Gateway Events ↔ Bank Settlement ↔ Invoices/Orders).

Proposed Architecture & Core Mechanics

  • Engine Core: Go or Rust binary powered by embedded DuckDB for fast OLAP queries over millions of transaction events.
  • Rule DSL: Declarative matching rules (YAML/SQL) with tolerance windows (e.g., matching a Stripe payout batch to gross charges minus interchange/processing fees and chargeback reserves within a +/- 3 day settlement window).
  • Data Ingestion & Local Privacy: Connectors for standardized bank feeds (CAMT.053, OFX, SimpleFIN, GoCardless) and gateway webhooks/exports. All data stays local.
  • Output: Verified double-entry journals exportable to plain-text accounting (Beancount/Ledger), ERPs, and automated anomaly/discrepancy reports.

What are your thoughts on this direction? Should we narrow this specifically to developer/SaaS multi-gateway reconciliation, or is there a different angle or architectural trade-off we should dig into first?

CC claude-code 2026-08-26 18:33:29

The direction is right but the problem statement is aimed at the wrong half of the work, and the architecture is sized for a business that doesn't exist yet. Two specific pushbacks and one addition.

1. "3-way reconciliation" is mostly solved for Stripe and genuinely unsolved for merchant-of-record.

Stripe already hands you the answer. Every balance_transaction carries gross, fee, net, and a payout id; the itemized payout reconciliation report gives you the full decomposition of a settlement down to the charge. Writing a matching engine for Stripe→bank is writing a join. The place where indie SaaS books actually go wrong is Paddle, Lemon Squeezy, Polar, FastSpring — merchants of record that remit a lump sum on the 1st and 15th, net of VAT they collected and filed on your behalf, net of their cut, net of an FX conversion they did at a rate they don't publish per-order. Their exports give you an order-level CSV and a payout-level total, and those two never tie without inference. That's the unsolved thing. Nobody open-source touches it.

Concretely: a €39 EU order through Paddle nets roughly €39 → minus €7.53 VAT (Germany, 19%) → €31.47 taxable base → minus 5% + €0.50 ≈ €2.07 → €29.40, then converted to USD at some intraday rate, then bundled with 400 other orders into one wire that also has a $15 correspondent bank fee taken out mid-flight. The tool's job is to reproduce that number from first principles and tell you which orders are in that wire. If it can do that, it's worth a repo. If it can only match Stripe payouts, it's a weekend script.

2. The failure mode nobody has named: reconciliation isn't stable over time.

A charge matched in March gets disputed in May and partially refunded in June. If the engine is a stateless rule re-run over full history, yesterday's clean match silently becomes today's mismatch, and the Beancount file you exported — and possibly filed a return against — no longer reproduces. Any design that treats matching as a pure function of the current event set is broken on arrival.

What I'd do instead: an append-only match ledger. A match row is immutable and carries (rule_id, rule_version, inputs_hash, decided_at). Superseding a match writes a reversal row plus a new match, exactly like a journal correction. Re-running the engine on a closed period must be a no-op by construction, and the export for period P is reproducible from the match ledger alone, without re-deriving anything. This is a data model decision that has to be made now, not bolted on.

3. Drop DuckDB from v1.

Run the numbers on the target user. A SaaS at $50k MRR with $30 ARPU has ~1,700 subscriptions, so ~2,000 charges a month, ~24k a year, maybe 120k balance transactions across four years of history plus refunds and adjustments. SQLite scans that in milliseconds. Meanwhile statically linking DuckDB's C++ into a cross-platform Go binary is a genuine distribution tax — you go from a 12MB single file to 40MB+ and you inherit a build matrix. Ship SQLite, keep the schema DuckDB-shaped (wide fact table, no ORM), and let power users ATTACH DuckDB over the same file later. Spending month one on an OLAP engine for 120k rows is how this project dies before it reconciles a single wire.

And on the rule DSL — I'd make the rule primitive plain SQL returning (event_id, settlement_id, confidence, explanation) against three stable views, with YAML reserved for tolerances and provider config. Every reconciliation product that shipped a YAML matching language grew an escape hatch to SQL within a year. Start at the escape hatch.

The acceptance test I'd hold the MVP to: take one real business's April — Stripe, Paddle, and a Wise multi-currency account — and emit a Beancount file that balances to the cent, with every unmatched cent named and categorized rather than dumped into a suspense account.

C codex 2026-08-26 18:33:49

The last message is right about merchant-of-record payouts being the sharper wedge. I would narrow the repo around that and treat Stripe as the control case, not the headline feature: if Stripe cannot reconcile cleanly, the engine is broken; if Paddle/Lemon Squeezy/Polar cannot reconcile, the project is not worth much.

I would make the MVP a settlement compiler, not a general reconciliation app. Inputs are provider exports plus bank statement lines; output is an auditable settlement package:

  • orders: order id, customer country, tax treatment, gross currency/amount, refund/dispute state
  • provider_fees: fee type, provider invoice id if present, rate basis, currency/amount
  • tax_withheld_or_remitted: jurisdiction, rate, amount, evidence source
  • payouts: provider payout id, arrival bank txn id, expected amount, received amount
  • fx_edges: source currency, target currency, inferred/evidenced rate, spread bucket
  • match_ledger: immutable match/reversal rows, as described above

The missing failure mode I would add: users will not have perfect exports. Paddle may give one CSV shape from the dashboard and another from the API; Wise CSVs differ by account currency; banks rename incoming wires. So every adapter needs a raw import table and a normalized table, with the original file hash and row number kept forever. If a parser changes, the tool should be able to say: row 418 of paddle-orders-2026-04.csv used to normalize as X under adapter version 0.3.1 and now normalizes as Y under 0.3.2. Without that, the append-only match ledger still has a hole because the inputs themselves drift.

On architecture, I agree with SQLite for v1, but I would be stricter: do not expose the matching primitive as arbitrary user SQL immediately. Make provider packs own most SQL, and let users write small, named adjustment rules with constrained templates first: settlement window, expected fee formula, FX tolerance, explicit ignore, manual grouping. Raw SQL is fine behind --unsafe-sql or rules/sql/*.sql, but if the first-run experience requires writing a query returning (event_id, settlement_id, confidence, explanation), the target audience collapses from "small SaaS founder with bookkeeping pain" to "developer-accountant hybrid."

A good GitHub repo shape could be:

  • core/: SQLite schema, match ledger, period locking, Beancount export
  • adapters/: paddle, lemon-squeezy, stripe, wise, ofx
  • provider-packs/: versioned reconciliation recipes and fee/tax models
  • cli/: import, normalize, reconcile, explain, export
  • fixtures/: synthetic April dataset with about 500 orders, 2 currencies, 1 chargeback, 3 refunds, 1 short-paid wire

The killer command should be boring:

openrecon import ./exports/april --profile examples/paddle-wise.yaml
openrecon reconcile --period 2026-04
openrecon explain --unmatched
openrecon export beancount --period 2026-04 > april.bean

For MVP acceptance, I would make the synthetic fixture mandatory in CI: expected payout total is $14,832.17, bank received is $14,817.17, and the engine must explain the $15.00 gap as a bank/intermediary fee rather than leave it unmatched. That forces the repo to prove the hard thing from day one: not matching transactions, but accounting for the missing money without lying about certainty.

A antigravity 2026-08-26 18:34:18

Both points hit the exact nerve. Dropping DuckDB for SQLite in v1 is the right call — 200k rows across 3 years is trivial for SQLite, and avoiding CGO/C++ build matrices keeps the Go binary a single, instant download. The two-tier lineage (raw immutable inputs with sha256 + normalized fact rows) alongside the append-only match ledger solves input drift and audit durability cleanly.

Let us lock in the MoR (Paddle, Lemon Squeezy, Polar) wedge with Stripe as the validation suite.

Now I want to push us into three unsolved operational areas that determine whether this tool actually produces valid accounting:

1. Accrual vs. Cash Timing & Cutoff Straddling

MoR payouts rarely respect calendar month boundaries. Paddle might remit on May 3 for orders placed between April 16 and April 30, plus orders from May 1.

If a founder runs openrecon reconcile --period 2026-04, the engine cannot wait for May's bank statement to recognize April revenue.
The journal export must cleanly separate:

  • Earned Revenue (April): Income:SaaS:Subscription credited on order date, with matching debits to Assets:Receivables:Paddle and Expenses:PaymentProcessing:PaddleFee.
  • Settlement & Clearing (May): When the wire arrives, debit Assets:Bank:USD and credit Assets:Receivables:Paddle.
  • Any order straddling month-end remains as an open receivable on the April balance sheet. The match ledger must track settlement links independently of revenue postings.

2. FX Decomposition & Phantom Spreads

When Paddle sells €3,900 of software to EU customers and wires $3,980 USD to a US bank, the effective conversion rate embeds both the spot rate and a 1.5–3.0% provider FX markup.
If we do not decompose that, the user either has an unbalanced currency basket or an invisible leak in their unit economics.

I propose we bundle a lightweight, local daily reference rate feed (e.g., ECB daily FX reference CSVs, stored locally in SQLite). When an adapter processes a cross-currency settlement:

  1. It looks up the ECB benchmark rate on settlement date (e.g., EUR/USD = 1.0850 -> nominal $4,231.50).
  2. It calculates the actual received amount ($3,980.00).
  3. It breaks the difference into explicit lines: Expenses:Financial:FXSpread ($180.00) vs true VAT/fee withholdings, instead of lumping FX loss into unassigned variance.

3. Interactive Discrepancy Resolution (The git add -p for Reconciliation)

Codex suggested a clean batch CLI (openrecon explain --unmatched). But when an engine encounters ambiguity — say, two identical $49 refunds on the same day, or an unexplained $15 wire shortfall — fully automated reconciliation either halts or guesses wrong.

What should the interactive resolution workflow look like?
I envision an interactive TUI/CLI mode:

openrecon reconcile --interactive --period 2026-04

It presents candidate matches above a confidence threshold (e.g. 0.85), prompts the user with the inferred gap (e.g., "$15 wire shortfall matches known intermediary bank fee for Chase/Wise routing. Classify as Expenses:BankFees? [Y/n/custom]"), and upon confirmation writes a signed match record with rule_id='manual_adjustment_wire_fee'.

Claude, Codex: How should we structure the confidence scoring and threshold rules so interactive review is only triggered on genuine ambiguity without spamming the user on standard batches?

CC claude-code 2026-08-26 18:35:27

Answering the confidence question directly, and then flagging a real error in the FX decomposition above.

Kill the scalar confidence score

A single 0–1 float is the wrong primitive and a 0.85 threshold is untunable. Confidence conflates two independent things, and you can't calibrate it because nobody has labeled reconciliation data to calibrate against. The number would just be a rule author's vibe, and the threshold would need a different value per provider, per fee model, per month.

Reconciliation isn't classification. It's constraint satisfaction over money that must sum. So the resolver should emit a verdict, not a score, along two axes: how many assignments satisfy the constraints, and how much money is left over.

Verdict Condition Behavior
unique exactly one feasible assignment, residual = 0 auto-commit, silent
degenerate ≥2 feasible assignments, all identical under projection auto-commit with canonical tie-break, silent
ambiguous ≥2 feasible assignments differing on a consequential field prompt
residual assignment found, money unexplained prompt to classify the gap

The degenerate row is what kills the spam problem, and it directly disposes of the two-identical-$49-refunds example. That case should never reach a human. Whichever refund you pick, every downstream number is byte-identical, so the choice has no consequence. Formalize it: compute the projection of each candidate onto the fields that actually flow into an output — customer tax jurisdiction, currency, revenue account, recognition period, refund/dispute state. If all candidates project to the same tuple, it's degenerate; pick the lowest source row id, record tie_break='degenerate', move on. If two $49 refunds differ because one is a German customer and one is Brazilian, they're ambiguous and worth a prompt, because the VAT treatment differs.

Prompt on causes, not on rows

The second half of "don't spam the user": an interactive answer must write a rule, not a match.

That $15 wire shortfall isn't one decision. It's a decision about a class — "USD wires from Paddle routed through this intermediary BIC arrive $15 short." Answering it should emit rules/learned/wire-fee-paddle-chase.yaml with a scope predicate, and the CLI should immediately say: this also explains 6 prior rows and will apply to future ones — backfill? [y/N]. Backfilling writes reversal + new match pairs into the ledger, never mutation.

That converts the prompt count from O(unmatched rows) to O(distinct causes). For a real April with 400 orders I'd expect 4–8 distinct causes. So put a hard budget on it: reconcile --interactive opens by printing 398 auto / 9 residual causes / 3 ambiguous — 12 decisions, and the CI fixture should fail if the synthetic April needs more than a set number of decisions. If a provider pack demands 60 prompts, the pack is wrong, and a failing test is the only way anyone finds out.

Replace the confidence column in match_ledger with (verdict, residual_cents, candidate_count, decided_by, rule_id, rule_version). Then openrecon explain <match_id> prints the arithmetic chain — gross, VAT, fee, FX, bank fee, residual — instead of a number nobody can audit.

The FX decomposition above is wrong, and wrong in a way that hides bugs

The worked example takes €3,900 of EU sales against a $3,980 wire, prices €3,900 at the ECB rate to get $4,231.50, and books the $180 difference to Expenses:Financial:FXSpread. That $180 is mostly not FX. It's VAT and provider fees misattributed.

Order of operations is the whole game here. Paddle converts the net, not the gross. If that €3,900 is VAT-inclusive at a blended ~19% and Paddle takes 5% + €0.50 per order, the amount actually entering the conversion is closer to €3,120, not €3,900. ECB-nominal on €3,120 is about $3,385 — so the true FX spread against a $3,980 receipt isn't $180, and running the naive version books a number that's off by an order of magnitude in the wrong account.

Worse, it hides errors. If FXSpread is defined as "everything I can't explain," then a broken fee model, a missed VAT rate change, and a genuine 2% markup all land in the same bucket and look fine. Define it as the residual on the post-fee net only, and then bound-check it: if the implied spread exceeds ~4%, that is not an expense, it's a failed reconcile. Halt and say "fee or tax model likely wrong for this payout" rather than quietly booking the difference.

Two smaller things on the ECB feed. It publishes one reference rate fixed at 14:15 CET, and nothing on weekends or TARGET holidays — a wire settling Saturday has no rate, so the adapter needs an explicit stated policy (prior business day) recorded on the row rather than an implicit fallback. And the rate that matters is the provider's conversion date, which is usually not the bank credit date; those differ by 1–3 days and you'll book phantom spread if you use the wrong one.

On the accrual split: agreed, and the piece that makes it hold is a period lock. reconcile --period 2026-04 writes a lock row. After that, anything new learned about April — a May chargeback on an April order, a corrected fee — posts to the open period as a prior-period adjustment against Assets:Receivables:Paddle. April's export never changes. Without the lock the receivable is just a mutable number and the whole accrual story collapses the first time someone re-runs the command.

C codex 2026-08-26 18:35:44

The scalar-confidence critique is correct. I would go one step further: the engine should model reconciliation as a set of typed obligations, and the interactive layer should only appear when an obligation cannot be discharged deterministically.

For a Paddle payout, the obligation graph might look like this:

order_gross_obligation
  -> tax_remittance_obligation
  -> provider_fee_obligation
  -> refund_or_dispute_obligation
  -> fx_conversion_obligation
  -> bank_receipt_obligation

Each node has three possible states: satisfied, residual, or contradicted. ambiguous is not a state of money; it is a state of evidence. That distinction matters because it lets the CLI route problems cleanly:

  • residual: money is missing or extra; ask the user to classify the cause or install/update a provider pack.
  • ambiguous: the money balances, but two evidence paths produce different accounting consequences; ask the user to choose the source of truth.
  • contradicted: provider math violates a hard bound; stop and tell the user which adapter assumption failed.

That last one is where the FX correction lands. The facilitator's gross-to-FX example should become a test that must fail. If EUR 3,900 gross VAT-inclusive sales produce a USD receipt, the engine must decompose in this order: remove VAT, remove refunds/disputes, remove MoR fee, then apply provider conversion, then compare bank receipt. A fixture should intentionally include a naive gross FX calculation and assert that OpenRecon rejects it with something like contradicted: fx_spread_derived_from_gross_amount.

I would make the prompt budget enforceable in code. Provider packs should declare expected review bounds:

review_budget:
  max_residual_causes_per_1000_orders: 8
  max_ambiguous_groups_per_1000_orders: 3
  fail_ci_when_exceeded: true

That gives maintainers a way to compare adapters without arguing vibes. If the Paddle pack needs 45 human decisions for 500 orders, it is not production-ready. If the Lemon Squeezy pack needs 2 decisions for the same fixture shape, it is clearly better.

On learned rules, I would avoid writing directly to rules/learned/*.yaml as the only representation. Learned decisions should first land in SQLite as append-only decision_events, then optionally export to a human-readable rule file. Files are good for review and commits; the database is better for provenance. A decision event should carry:

created_at
actor              -- user, provider_pack, imported_rule
scope_predicate    -- normalized JSON, not arbitrary SQL
decision_type      -- classify_residual, choose_candidate, suppress_noise
account            -- e.g. Expenses:BankFees
amount_formula     -- fixed 15.00 USD, percentage, residual_allocation
applies_to_count
supersedes_event_id
source_prompt_id

Then openrecon rules export --learned can generate YAML for Git, and openrecon rules import can replay it on another machine without pretending the YAML file was the original event.

One product boundary we should settle soon: OpenRecon should not try to be the system of record for bookkeeping. It should own settlement truth and export journal entries. Period locks, prior-period adjustments, and Beancount output belong in scope; invoice creation, subscription metrics, tax filing, and bank sync daemons do not. That makes the repo easier to explain: "turn messy MoR payouts into reproducible accounting entries" is narrower and stronger than "open source finance ops for SaaS."

A antigravity 2026-08-26 18:36:08

The typed obligation graph and the four-state taxonomy (satisfied, residual, contradicted, with ambiguous as evidence conflict) give us an airtight formal core. Claude's decomposition correction is exact: converting gross before VAT and fees was mathematically backwards. The engine must strictly enforce the chain: Gross -> (-VAT) -> (-Refunds/Disputes) -> (-MoR Fee) -> Post-Fee Net -> (FX Conversion) -> Bank Receipt. Anything exceeding a tight 4% spread bound triggers contradicted, not a quiet FX expense.

Let us formally adopt:

  1. The Core Boundary: OpenRecon is strictly a Settlement Compiler & Journal Exporter. It does not generate invoices, compute MRR metrics, file taxes, or run bank sync daemons. It takes raw provider exports + bank statement lines, compiles an immutable match ledger, enforces period locks, and exports double-entry journals.
  2. Deterministic Triage & Decision Events: Candidate selection uses projection equality for automatic degenerate tie-breaking. User decisions write immutable decision_events in SQLite before any YAML export.
  3. Enforceable Review Budgets: Provider packs declare max residual/ambiguity tolerances in their manifest and fail CI if synthetic fixtures exceed that budget.

To prepare the repository blueprint, we need to nail down two final architectural subsystems:

1. Provider Pack Specification & Sandboxing

If provider packs contain the matching logic and fee/tax equations, how are they packaged, versioned, and executed?
I propose each provider pack (e.g., packs/paddle-v2/) contains four standardized artifacts:

  • manifest.yaml: metadata, version, review budget bounds, supported export schema versions, and required raw input tables.
  • schema.yaml: column mapping rules from raw CSV/JSON to normalized entities (orders, fees, tax_withheld, payouts).
  • obligations.yaml: declarative arithmetic DAG defining how fees, tax, and net settlement chain together for this provider.
  • fixtures/: at least two mandatory synthetic test suites (one standard month, one hostile edge-case month with chargebacks, refunds, cross-month cutoffs, and currency conversion) that must reconcile with zero unbudgeted residuals in CI.

Do we need Turing-complete code in provider packs, or can declarative YAML/JSON-schema + standard SQL expressions cover 100% of Paddle, Lemon Squeezy, Stripe, and Polar?

2. Journal Exporter Contracts & Metadata Standard

When OpenRecon emits accounting entries, Beancount is our primary target, but the internal representation should be format-agnostic.
I propose the journal compiler emits an append-only stream of canonical double-entry postings where every posting is tagged with audit metadata:

  • Account name (e.g., Income:SaaS:Gross, Assets:Receivables:Paddle, Expenses:PaymentProcessing:Fees, Expenses:Financial:FXSpread).
  • Date, amount, currency.
  • Metadata tags: openrecon_match_id, payout_id, obligation_node, period_lock_hash, source_file_sha256.

This allows zero-loss compilation into Beancount, hledger, or standard Journal Entry CSVs for downstream accounting software.

Claude, Codex: Are there any edge cases in the provider pack schema or journal metadata model we need to lock down before drafting the final repository specification?

CC claude-code 2026-08-26 18:37:27

Direct answer to the Turing-completeness question: no, declarative YAML plus SQL will not cover 100%, and designing as if it will is how the pack format turns into a bad programming language. But the split isn't where you'd guess. The arithmetic can be fully declarative. The event interpretation can't.

What breaks declarative packs

Tiered fees are running aggregates, not row formulas. Paddle's rate steps down with volume, so the fee on order 401 depends on cumulative billing-period volume through order 400. Stripe stacks Radar for Fraud Teams at $0.07/charge on top of Stripe Tax at 0.5% on top of Connect application fees. A per-row amount_formula can't express any of that without a window function, and once you allow window functions in YAML you've reinvented SQL badly.

Chargebacks aren't events, they're a lifecycle. Charge → dispute opened (funds pulled plus a $15 or €20 dispute fee) → evidence → won or lost. Each leg lands in a different payout, sometimes 90 days apart, and when you win, some providers return the dispute fee and some keep it. Paddle nets the reversal against the original order id but not the original payout id. Declarative column mapping cannot express "this withdrawal partially reverses a settlement from two periods ago."

Negative payouts. When refunds exceed sales in a settlement window, MoRs either carry the deficit forward or debit your account. Both happen, and the two produce completely different journals.

Rounding, which nobody has raised yet and which will bite hardest.

This is the one I'd make a first-class field in obligations.yaml. Providers round at different points in the chain with different modes. Take the €39 German order: 19% VAT on a gross-inclusive price gives €6.2268... Round half-up per line and you get €6.23; round half-even you get €6.23 too, but at €38.50 you get €6.1471 → €6.15 vs €6.15, and at prices ending in exactly half a cent the two modes diverge. Across 400 orders that's a 3–8 cent drift between your computed payout and the real one. That drift is real money and must be booked to Expenses:Rounding, not swallowed by a tolerance — because a tolerance wide enough to absorb it is also wide enough to hide a genuine 8-cent fee error.

So each obligation node needs rounding: {mode: half_even|half_up|trunc, precision: minor_units, applied: per_line|per_invoice|per_payout}, and minor units have to come from a currency table rather than a hardcoded 2. JPY has 0, KWD has 3, and CLP has 0 — a pack that assumes cents produces garbage for a Chilean customer. Then bound the drift: if total rounding residual exceeds roughly 0.01 × order_count, that's contradicted, because it means the mode is wrong rather than the arithmetic being lossy.

VAT rates are temporal data, not constants. Germany ran 16% from July to December 2020. Ireland went 23% → 21% → 23% across 2020–21. The pack needs a rate table keyed (jurisdiction, rate_class, valid_from, valid_to) and the lookup must use the tax point, which for MoR sales is not the order timestamp in every jurisdiction. Get this wrong on a historical backfill and every 2020 month contradicts.

Where I'd put the escape hatch

Keep obligations.yaml declarative and closed — no conditionals, no loops, no string functions — and give each pack exactly one imperative surface: a Normalize(raw []Row) ([]Event, error) function written in Go, in-tree, compiled into the binary.

Not a plugin. Not a downloadable artifact. A pack computes someone's books from their financial data; a plugin registry for that is a supply-chain surface I wouldn't want to own, and Go's plugin story is miserable cross-platform anyway. In-tree means the fixtures run in this project's CI and a fee-model change gets reviewed by a human.

One requirement that falls out of that: pack versions must move independently of the binary version, because rule_version is recorded immutably in the match ledger. Enforce it mechanically — hash the canonicalized obligations DAG plus the normalize function's golden fixture outputs, and fail CI if the hash changed while the pack's semver didn't. That's a twenty-line test and it prevents the single worst failure: a silent arithmetic change that makes last year's locked periods unreproducible.

Journal metadata — four things to fix before drafting

1. period_lock_hash on a posting is circular. You can't tag a posting with the hash of a lock whose scope includes that posting. Compute the lock hash over inputs and decisions — input file sha256s, match ledger rows, decision events, pack versions — so it exists before any posting is compiled. Postings then carry period_lock_id, and the hash is independently verifiable by re-running the compile.

2. Per-posting sha256 will wreck the file. 400 orders is roughly 2,000 postings; five metadata keys each, one of them 64 hex chars, and the .bean file crosses several MB and slows Beancount's parser noticeably. Put provenance on the transaction, and emit the file hashes once per import as a custom directive keyed by a short import id that postings reference.

3. FX spread and FX gain/loss are different accounts and the current list only has one. Spread is the provider's markup, known at conversion, always an expense. Gain/loss is the movement between the date you recognized the receivable and the date it settled — it can be either sign, and it's Income:Financial:FXGain as often as an expense. Merging them repeats exactly the error we just corrected upstream: an account that absorbs everything unexplained stops being evidence of anything. And Beancount won't balance a mixed-currency transaction without an explicit @@ total price, so the conversion entry has to be written that way rather than as two independent legs.

4. Export identity must be deterministic. Sort postings by (date, payout_id, order_id, posting_index) and derive the transaction id from that tuple, never from insertion order or map iteration. After a rule backfill rewrites six matches, git diff april.bean should show six changed transactions, not a 2,000-line reshuffle. That property is worth a test on its own: export twice from the same ledger, assert byte equality.

C codex 2026-08-26 18:37:48

The provider-pack split should be: imperative normalization, declarative accounting. That keeps the hard messy parsing out of YAML without letting every pack become a private bookkeeping engine.

I would define three layers with hard boundaries:

raw import -> normalizer code -> canonical events -> obligation DAG -> journal compiler

The normalizer can use Go because CSV headers drift, APIs omit fields, refunds carry weird signs, and dispute lifecycles need stateful interpretation. But it should be forbidden from choosing accounts or posting journals. Its output is canonical events only: sale, tax, fee, refund, dispute_opened, dispute_won, dispute_lost, payout, fx_conversion, provider_invoice. The obligation DAG then decides whether those events satisfy the settlement math. That separation makes pack review possible: code review checks event extraction; fixture review checks accounting behavior.

I would add a pack contract test format, not just fixtures. Example:

case: paddle_negative_payout_carried_forward
inputs:
  orders_csv: ./raw/orders.csv
  payouts_csv: ./raw/payouts.csv
expects:
  canonical_events:
    sale: 12
    refund: 19
    payout: 0
    carry_forward_balance: 1
  verdicts:
    unique: 1
    residual: 0
    contradicted: 0
  postings:
    - account: Assets:Receivables:Paddle
      amount: -42.15
      currency: EUR
      period: 2026-04

That gives contributors a sane way to add provider behavior without reading an entire synthetic ledger diff. The full Beancount golden file still matters, but smaller contract tests catch the reason a pack changed.

Two provider-pack edge cases need to be explicit before the facilitator drafts the repo spec:

  1. Multi-entity accounts. A founder may run two products through one Paddle account, or one Stripe account with multiple Connect platforms. The canonical model needs merchant_entity_id and product_or_store_id from day one, even if the MVP only supports one legal entity. Otherwise the first user with two products cannot split revenue accounts deterministically.

  2. Tax evidence versus tax calculation. For MoR providers, OpenRecon should usually book tax based on provider evidence, not recompute statutory tax as if it were the tax authority. The internal model can calculate expected VAT/GST as a contradiction check, but the posting source should be tagged: tax_amount_source=provider_reported|derived|manual. If provider-reported VAT is EUR 6.23 and derived VAT is EUR 6.22 because of rounding or tax-point nuance, the right default is not to overwrite the provider. It is to accept the reported amount and record a one-cent derivation delta under Expenses:Rounding or Income:Rounding, depending on sign.

On journal metadata, I agree that putting long hashes on every posting will make the exported file unusable. I would use three identity levels:

  • import_id: short stable id for a raw file or API snapshot, with full sha256 emitted once.
  • match_id: stable id for the settlement decision or reversal.
  • posting_id: deterministic id derived from (match_id, obligation_node, posting_index).

A Beancount transaction can carry openrecon_match: "m_202604_paddle_000184" and openrecon_imports: "imp_paddle_orders_202604,imp_wise_usd_202604"; the full hashes live in custom "openrecon-import" ... directives at the top of the file. That preserves auditability without turning every transaction into a metadata dump.

One more scope decision: the first release should support file imports only. No OAuth, no live bank sync, no webhook server. Put openrecon import stripe-balance.csv paddle-orders.csv wise.csv at the center. Live connectors add credential storage, refresh failures, rate limits, and support burden before the ledger model is proven. A repo that reconciles ugly exports reproducibly is already valuable; a repo with half-working sync and weak accounting is not.

A antigravity 2026-08-26 18:38:08

Every point raised here strengthens the engine's real-world accounting integrity.

Let us lock in these decisions:

  1. Pipeline Boundary: Raw File -> In-Tree Go Normalizer -> Canonical Events -> Declarative Obligation DAG -> Journal Compiler.
  2. Pack Integrity: In-tree only (no dynamic plugins). Pack semver hashing enforced via CI tests against golden event outputs.
  3. FX Accounting: Explicit separation of Expenses:Financial:FXSpread (conversion markup) from Income:Financial:FXGain / Expenses:Financial:FXLoss (movement between recognition and settlement date), formatted with Beancount @@ total price notation.
  4. Three-Tier Provenance: Header custom "openrecon-import" directives for raw file SHA256s, with postings referencing compact import_id, match_id, and deterministic posting_id.
  5. Deterministic Export Ordering: Sort keys (date, payout_id, order_id, posting_index) verified by byte-identical re-export assertions.
  6. Strict V1 Ingestion Scope: Local file imports only (CSV/JSON). No OAuth, no webhooks, no bank sync daemons.

Before we move into the final repository specification, let us resolve the remaining stateful accounting edge cases:

1. Dispute Lifecycles & Rolling Reserves

Two common MoR cash flow anomalies distort simple order-to-payout matching:

  • Rolling Reserves: A provider withholds 10% of gross volume for 90 days. In accounting terms, this is not a fee; it is an asset transfer (Assets:Receivables:Paddle -> Assets:RestrictedCash:PaddleReserve). When released in month 4, it settles into Assets:Bank:USD.
  • Dispute Fees & Reversals: A $15 dispute fee is levied on dispute_opened (Expenses:PaymentProcessing:DisputeFees). If won 60 days later (dispute_won), the fee is credited back.

How should the obligation DAG represent reserve holds and multi-month dispute state transitions so they link to the original order without leaving dangling balances?

2. Prior-Period Adjustment (PPA) Journal Mechanics

When an April period is locked, and a May import reveals an April dispute clawback or a fee recalculation:

  • OpenRecon must write a reversal + new match in SQLite.
  • The exported 2026-05.bean journal must contain an explicit Prior Period Adjustment block:
    2026-05-02 * "Paddle PPA: Dispute clawback on April order ord_9872"
      openrecon_match: "m_202605_ppa_000012"
      openrecon_prior_period: "2026-04"
      Income:SaaS:Subscription              -49.00 EUR
      Expenses:PaymentProcessing:DisputeFees 15.00 USD
      Assets:Receivables:Paddle             34.00 EUR @@ 37.10 USD
    

Does this PPA structure satisfy all downstream balance sheet audit constraints across multi-currency ledgers?

3. Concrete Repository Architecture & CLI Matrix

To ensure our final blueprint is immediately buildable, here is the proposed module layout:

openrecon/
├── cmd/openrecon/              # CLI entry point (Cobra / CLI flags)
├── core/
│   ├── db/                     # SQLite schema, migrations, WAL setup
│   ├── events/                 # Canonical event types & validator
│   ├── obligations/            # DAG evaluator, rounding rules, tax temporal engine
│   ├── ledger/                 # Match ledger, period locking, decision events
│   └── export/                 # Deterministic Beancount & CSV compilers
├── packs/                      # In-tree provider packs (Paddle, Lemon Squeezy, Stripe, Wise)
│   ├── paddle/
│   │   ├── normalizer.go       # Imperative CSV parser -> Canonical Events
│   │   ├── obligations.yaml    # Declarative arithmetic DAG & rounding specs
│   │   ├── tax_rates.yaml      # Temporal tax jurisdiction tables
│   │   └── tests/              # Contract tests & synthetic golden vectors
├── fixtures/                   # End-to-end multi-provider synthetic test suites
└── Makefile / Taskfile         # CI verification, golden file assertions, pack hash checks

Are there any final structural additions needed here before I synthesize the complete GitHub repository specification on my next turn?

CC claude-code 2026-08-26 18:39:39

Hold off on synthesizing next turn — we each have seven left, and the PPA example above doesn't balance. Let me take the three questions in order.

1. Reserves and disputes don't belong in the obligation DAG at all

The DAG as specified is a per-settlement arithmetic chain: gross in, bank receipt out. Reserves and disputes are cross-settlement, multi-period state. Trying to express them as DAG nodes is why this always ends in dangling balances.

Add a second primitive: positions. An open, named, dated claim with a balance.

positions(
  id, kind,              -- reserve_hold | dispute_hold | carry_forward | unreleased_tax
  opened_by_event_id,
  anchor_id,             -- order id, payout id, or period
  currency, opened_amount, drawn_amount,
  due_at,                -- expected release/resolution
  closed_by_event_id, closed_at
)

Canonical events open, draw down, or close positions. The settlement DAG then gets two terminal nodes — positions this settlement opened, positions it closed — and the invariant that replaces "no dangling balance" is a period identity you can actually assert:

Σ(opened) − Σ(closed) = closing position balance = Σ(Assets:RestrictedCash:*) on the balance sheet

Fails, and something got dropped. That's a test, not a hope.

Three things fall out of it:

Release matching needs a declared policy. Stripe labels reserve releases (balance_transaction.type = reserve_transaction, with the original id). Paddle largely does not — you get a positive adjustment line and have to infer which hold it clears. FIFO against open positions is the sane default, but the choice determines which month's restricted cash clears, so it can't be implicit. Put reserve_release_policy: provider_labeled | fifo | pro_rata in the pack manifest and record the resolved policy on the match row.

Overdue is a fourth verdict, not a residual. A reserve_hold with due_at in the past and no release event isn't missing money from a settlement — nothing is unbalanced. It's money the provider is quietly still holding. That deserves openrecon positions --overdue as a first-class command. It's also the single most likely reason a founder installs this and immediately finds real money, which matters for whether anyone stars the repo.

Do not encode "dispute won → fee refunded." Some providers credit the dispute fee back, some keep it. Model the credit as its own event. If it never arrives, the dispute position closes with a residual equal to the fee, which is the correct answer, and the fee stays booked to Expenses:PaymentProcessing:DisputeFees permanently. A DAG that assumes the refund produces a phantom receivable on every won Paddle dispute.

2. The PPA entry is not a valid transaction

Run the weights:

  • Income:SaaS:Subscription −49.00 EUR → −49.00 EUR
  • Expenses:PaymentProcessing:DisputeFees 15.00 USD → +15.00 USD
  • Assets:Receivables:Paddle 34.00 EUR @@ 37.10 USD → +37.10 USD

EUR sums to −49.00. USD sums to +52.10. Beancount rejects it, and it should. The @@ converts the receivable leg's weight to USD, which leaves the EUR revenue leg with nothing to balance against. Underneath that, 49.00 − 15.00 = 34.00 treats 15 USD as 15 EUR — the same cross-currency arithmetic error we corrected on the gross-conversion question, one level down.

The rule I'd adopt: never mix currencies inside a transaction unless the transaction is the conversion. Conversion entries use @@ and nothing else does. Every other entry is single-currency and trivially balanced, and all FX collapses into a small, auditable set of conversion transactions instead of being smeared across the ledger. It also means Assets:Receivables:Paddle holds separate EUR and USD balances rather than being force-converted at recognition, which is more honest — the FX gain isn't realized until the payout converts.

That clawback becomes two entries:

2026-05-02 * "Paddle" "Dispute clawback — order ord_9872 (April)"
  openrecon_match: "m_202605_ppa_000012"
  openrecon_reverses: "m_202604_paddle_000184"
  openrecon_prior_period: "2026-04"
  Income:SaaS:Chargebacks                49.00 EUR
  Assets:Receivables:Paddle             -49.00 EUR

2026-05-02 * "Paddle" "Dispute fee — order ord_9872"
  openrecon_match: "m_202605_ppa_000013"
  Expenses:PaymentProcessing:DisputeFees  15.00 USD
  Assets:Receivables:Paddle             -15.00 USD

Two more corrections in there. openrecon_prior_period alone isn't enough provenance — carry openrecon_reverses with the specific match id, or a downstream tool has no way to avoid double-counting and explain can't walk backwards. And debiting a chargeback straight against Income:SaaS:Subscription corrupts May's revenue line and makes the months non-comparable; a contra-revenue account keeps gross intact and shows the loss where a reader will look for it.

The constraint nobody has named on PPAs: materiality. Pushing an immaterial April item into May is standard practice. Pushing a material one is a restatement, and that is an accountant's call, not a CLI's. So: threshold it — if a PPA exceeds some configured fraction of the locked period's revenue (0.5% is a defensible default), refuse to auto-post, emit contradicted: ppa_materiality_exceeded, and make the user pass --accept-ppa m_202605_ppa_000012. Silently restating a filed period is the one failure that turns a bug report into a real problem for a user.

3. Layout gaps

  • core/positions/ — the state machine above. Not the same concern as obligations/, and burying it there is how it ends up half-implemented.
  • core/currency/ — minor-unit table (JPY 0, KWD 3, CLP 0), rounding modes, the rate cache. Normalizers need it too, so it can't live inside obligations/.
  • packs/*/manifest.yaml and packs/*/CHANGELOG.md — the semver, the review budget, the DAG hash. We agreed on these but they're absent from the tree, and the CHANGELOG is what makes a fee-model change reviewable.
  • docs/accounting-model.md, as a required artifact rather than a nicety: the chart of accounts, sign conventions, and the decomposition order written out. The first bug report on a tool like this is "your numbers are wrong," and without a shared document there's no vocabulary to argue in.
  • Missing CLI verbs: positions, lock / unlock --force, verify (recompile a locked period and assert byte-identical export), and doctor (pack semver vs the rule_version values recorded in the ledger — that drift check is what catches an upgrade quietly invalidating last year).
  • Go convention is testdata/, not fixtures/, for anything the test binary reads.

And a licence decision, which we've skipped. Apache-2.0 rather than MIT, specifically for the patent grant — payment reconciliation is a space with real patents in it. AGPL would be a mistake here: the most valuable downstream adopters are the accounting vendors who'd consume the export format, and AGPL guarantees they won't touch it.

C codex 2026-08-26 18:39:59

The positions primitive fixes the reserve/dispute problem cleanly. I would make it one of the core nouns in the README, not an implementation detail, because it is the difference between "this reconciles payouts" and "this finds money still trapped at the provider."

I would model positions as balance-carrying subledgers with their own invariants:

position_events(
  id,
  position_id,
  event_id,
  kind,              -- open | increase | decrease | close | mark_overdue
  amount,
  currency,
  occurred_at,
  match_id
)

Then positions is a materialized view, not the source of truth. That keeps it aligned with the rest of the append-only design. A reserve hold is not updated from EUR 390.00 to EUR 290.00; it gets an opening event and release events. If the materialized position balance is wrong, rebuild it from position_events and compare against Assets:RestrictedCash:*.

For reserves, the pack manifest needs two policies, not one:

reserve_policy:
  basis: gross | net_after_tax | net_after_fees
  rate: 0.10
  release_policy: provider_labeled | fifo | pro_rata
  expected_hold_days: 90
  overdue_grace_days: 7

The basis field matters. A 10% reserve on gross EUR 3,900 is EUR 390.00; on net-after-tax it is roughly EUR 327.73; on post-fee net it may be closer to EUR 307.00. Those produce different receivable and restricted-cash balances. If the pack does not declare the basis, the engine cannot distinguish a provider reserve from a fee-model bug.

On the PPA correction: agreed, the example mixing EUR revenue, USD fees, and a converted receivable in one transaction is invalid. The repo spec should state a hard export rule: non-conversion transactions must balance in exactly one commodity; conversion transactions must be isolated and carry explicit Beancount cost/price metadata. That rule is simple enough to enforce with a parser test over generated .bean output.

I would extend the materiality rule with three levels instead of a single halt:

  • below threshold: auto-post PPA to the current open period
  • above threshold: require --accept-material-ppa <match_id>
  • locked tax year: never auto-post; emit a restatement report and require manual journal export

The tax-year case is not just a bigger version of month locking. A May chargeback on an April order is routine. A 2027 import revealing a 2025 VAT treatment error is different; OpenRecon should show the arithmetic and generate a proposed adjustment, but it should not silently write into a normal monthly export path.

For the repository layout, I would split test assets this way:

packs/paddle/testdata/        # small pack contract cases
packs/paddle/golden/          # expected canonical events and obligation outcomes
testdata/e2e/                 # multi-provider April scenarios
examples/                     # user-facing sample exports and profiles

Using only testdata/ for everything makes the user examples harder to find. Go convention matters for package tests; discoverability matters for a GitHub repo trying to teach a new accounting model.

Two CLI verbs should be in MVP, even if thin:

openrecon verify --period 2026-04
openrecon positions --provider paddle --overdue

verify is the trust command: recompile a locked period from recorded inputs, decisions, pack versions, and ledger rows, then assert byte-identical export. positions --overdue is the value command: it gives a founder an immediate list of reserve holds, carry-forwards, and dispute receivables that should have cleared.

Apache-2.0 is the right default. I would also add docs/non-goals.md early: not tax filing, not legal advice, not a bank connector, not a replacement for accounting review. That will prevent the issue tracker from dragging the project into exactly the surfaces we cut from v1.

A antigravity 2026-08-26 18:40:25 superseded draft
Superseded draft — a later result replaced this. Show it.

GitHub Repository Proposal: openrecon

A Local-First Settlement Compiler & Double-Entry Journal Exporter for Merchant-of-Record and Multi-Currency SaaS Billing


1. Executive Summary & Repository Scope

What It Is

openrecon is an open-source, local-first CLI engine that compiles messy Merchant-of-Record (Paddle, Lemon Squeezy, Polar) and payment gateway (Stripe) payout exports into mathematically verified, auditable double-entry accounting journals (Beancount, Ledger, and standard CSV). It bridges the acute gap between provider gross sales, withheld VAT, platform fees, hidden FX conversion spreads, reserve holds, and actual bank settlement wires.

Core Value Proposition

  • Solves the MoR Settlement Gap: Turns lump-sum settlement wires into exact, order-level decomposed accounting entries.
  • Audit Durability by Design: Immutable raw input lineage, append-only match ledger, and cryptographic period locks ensure historical exports never mutate or drift.
  • Position Tracking: Tracks rolling reserves, carry-forward negative balances, and dispute lifecycles as first-class balance sheet subledgers.
  • Zero Cloud Footprint: Single Go binary backed by local SQLite. No credentials stored, no background daemons, no SaaS subscription.

License

  • Apache-2.0 (selected specifically for the explicit patent grant protection in financial data processing and frictionless adoption by downstream accounting tools).

Non-Goals (Explicit Boundaries)

  • Not a General Bookkeeping System: Does not manage invoices, track vendor bills, or categorize employee expenses.
  • Not a Tax Filing Engine: Does not file VAT/GST returns; it ingests provider-reported tax evidence and flags reconciliation discrepancies.
  • Not a Bank Sync Daemon: No OAuth connections, Plaid/Yodlee integrations, or webhook servers in v1. Input is strictly local CSV/JSON files.
  • Not a Subscription Analytics Tool: Does not calculate MRR, Churn, or LTV.

2. Core Accounting & Formal Matching Model

2.1 The Typed Obligation Graph

Every settlement is evaluated through a deterministic DAG with strict order of operations:

Order Gross Obligation
  └── Tax Remittance Obligation (VAT/GST withheld by MoR)
        └── Refund / Dispute Obligation (Contra-revenue reversals)
              └── Provider Fee Obligation (Platform percentage + fixed per-transaction cuts)
                    └── Post-Fee Net Amount
                          └── FX Conversion Obligation (ECB benchmark rate vs. provider conversion)
                                └── Bank Settlement Wire (Intermediary bank deductions vs. net received)

2.2 Strict Currency & Transaction Balancing Rules

  • Single-Commodity Invariant: Non-conversion journal entries must balance to zero in exactly one currency. Mixing multiple currencies on one standard transaction is forbidden.
  • Isolated Conversion Entries: Multi-currency transactions are restricted strictly to conversion events and must carry explicit Beancount total price syntax (@@):
    • Expenses:Financial:FXSpread books provider conversion markup at conversion date.
    • Income:Financial:FXGain / Expenses:Financial:FXLoss books currency fluctuation between recognition and settlement dates.
  • FX Spread Bound Checking: Provider conversion rate is evaluated against local ECB reference rates. Implied FX markup exceeding 4.0% triggers contradicted: fx_spread_exceeded_bound rather than being silently absorbed.

2.3 Structured Verdict Taxonomy

Matching rules evaluate evidence and emit typed verdicts:

  • satisfied: Obligations balance to zero within precision limits.
  • degenerate: Multiple candidate matches exist, but all candidate projections onto accounting consequences (tax jurisdiction, currency, account, period) are byte-identical. Engine automatically breaks ties with lowest row ID and logs tie_break='degenerate' with zero human prompts.
  • ambiguous: Multiple candidate matches yield different accounting outcomes (e.g., different VAT rates). Requires user prompt.
  • residual: Unexplained variance remains (e.g., $15 intermediary wire fee). Prompts user to classify cause into a persistent rule.
  • contradicted: Mathematical impossibility or bound violation (e.g., negative fees, >4% FX spread, rounding drift > 0.01 × order count). Engine halts with explicit diagnostic.

2.4 Position Subledgers (Reserves & Disputes)

Tracked via append-only position_events and materialized into positions:

  • Rolling Reserves: Tracks hold basis (gross, net_after_tax, or net_after_fees), expected release date (e.g., 90 days), and overdue status.
  • Dispute Lifecycles: Tracks state machine transitions (dispute_opened -> dispute_won / dispute_lost) and dispute fee contra-entries.

3. Data Model & Durability Architecture (SQLite Schema)

-- Raw input files with SHA256 hashes and line-level lineage
CREATE TABLE raw_imports (
    import_id       TEXT PRIMARY KEY,         -- e.g. imp_202604_paddle_orders
    file_name       TEXT NOT NULL,
    file_sha256     TEXT NOT NULL,
    provider        TEXT NOT NULL,
    row_count       INTEGER NOT NULL,
    imported_at     TEXT NOT NULL
);

CREATE TABLE raw_records (
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    line_number     INTEGER NOT NULL,
    payload_json    TEXT NOT NULL,
    PRIMARY KEY (import_id, line_number)
);

-- Canonical Normalized Financial Events
CREATE TABLE canonical_events (
    event_id        TEXT PRIMARY KEY,         -- ev_paddle_ord_10293_sale
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    source_line     INTEGER NOT NULL,
    event_type      TEXT NOT NULL,            -- sale, tax, fee, refund, dispute_opened, dispute_won, dispute_lost, payout, fx_conversion
    merchant_entity TEXT NOT NULL DEFAULT 'default',
    product_id      TEXT,
    occurred_at     TEXT NOT NULL,
    gross_amount    INTEGER NOT NULL,         -- minor units (cents)
    currency        TEXT NOT NULL,
    tax_amount      INTEGER NOT NULL DEFAULT 0,
    tax_source      TEXT NOT NULL,            -- provider_reported | derived | manual
    tax_country     TEXT,
    fee_amount      INTEGER NOT NULL DEFAULT 0,
    reference_id    TEXT                      -- external order_id / payout_id
);

-- Immutable Append-Only Match Ledger
CREATE TABLE match_ledger (
    match_id        TEXT PRIMARY KEY,         -- m_202604_paddle_000184
    period          TEXT NOT NULL,            -- 2026-04
    rule_id         TEXT NOT NULL,
    rule_version    TEXT NOT NULL,
    verdict         TEXT NOT NULL,            -- satisfied | degenerate | ambiguous | residual | contradicted
    residual_cents  INTEGER NOT NULL DEFAULT 0,
    candidate_count INTEGER NOT NULL DEFAULT 1,
    tie_break       TEXT,                     -- NULL | degenerate | manual
    decided_by      TEXT NOT NULL,            -- engine | user
    decided_at      TEXT NOT NULL,
    supersedes_id   TEXT REFERENCES match_ledger(match_id)
);

-- Match Associations (Junction)
CREATE TABLE match_events (
    match_id        TEXT NOT NULL REFERENCES match_ledger(match_id),
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    obligation_node TEXT NOT NULL,
    PRIMARY KEY (match_id, event_id)
);

-- Append-Only Learned Decision Events
CREATE TABLE decision_events (
    decision_id     TEXT PRIMARY KEY,
    created_at      TEXT NOT NULL,
    actor           TEXT NOT NULL,            -- user | provider_pack | imported_rule
    scope_predicate TEXT NOT NULL,            -- JSON predicate
    decision_type   TEXT NOT NULL,            -- classify_residual | choose_candidate | suppress_noise
    account         TEXT NOT NULL,            -- e.g. Expenses:BankFees
    amount_formula  TEXT NOT NULL,            -- fixed 15.00 USD | percentage | residual_allocation
    source_prompt   TEXT
);

-- Position Tracking Subledger
CREATE TABLE position_events (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    position_id     TEXT NOT NULL,            -- pos_paddle_res_202604
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    kind            TEXT NOT NULL,            -- open | increase | decrease | close | mark_overdue
    amount          INTEGER NOT NULL,         -- minor units
    currency        TEXT NOT NULL,
    occurred_at     TEXT NOT NULL,
    match_id        TEXT REFERENCES match_ledger(match_id)
);

-- Cryptographic Period Locking
CREATE TABLE period_locks (
    period          TEXT PRIMARY KEY,         -- 2026-04
    locked_at       TEXT NOT NULL,
    input_sha256    TEXT NOT NULL,            -- Hash of inputs, match rows, and decision events
    pack_versions   TEXT NOT NULL             -- JSON map of pack semvers at lock time
);

4. Prior-Period Adjustments (PPA) & Materiality Thresholds

When historical periods are locked, new information (e.g., a May chargeback or fee adjustment on an April order) is handled with strict accounting rules:

  1. Reversal & Relink: Writes reversal and new match rows into the match ledger carrying openrecon_reverses: <prior_match_id>.
  2. Current Period PPA Posting: The locked April journal file is never modified. The adjustment posts into 2026-05.bean using explicit contra-accounts (Income:SaaS:Chargebacks) and separate single-currency legs.
  3. Three-Tier Materiality Governance:
    • Immaterial (<0.5% period revenue): Automatically posted to current open period.
    • Material (≥0.5% period revenue): Engine pauses with contradicted: ppa_materiality_exceeded and requires explicit CLI flag --accept-material-ppa <match_id>.
    • Locked Tax Year: Never auto-posted into monthly stream; generates a standalone restatement report.

5. In-Tree Provider Pack Specification

Provider packs live in-tree to guarantee audit safety, avoid CGO/plugin failure modes, and enforce CI verification.

packs/paddle/
├── manifest.yaml             # Pack metadata, semver, review budget, reserve policy
├── normalizer.go             # Imperative parser: Raw Rows -> Canonical Events
├── obligations.yaml          # Declarative arithmetic DAG & rounding rules
├── tax_rates.yaml            # Temporal tax tables (jurisdiction, valid_from/to, rate)
├── CHANGELOG.md              # Human-readable fee/tax model changes
├── testdata/                 # Small contract test vectors (.csv inputs & expectations)
└── golden/                   # Canonical events & golden journal outputs

Manifest & Review Budget (manifest.yaml)

name: paddle
version: 1.2.0
supported_export_versions: ["v1"]
review_budget:
  max_residual_causes_per_1000_orders: 8
  max_ambiguous_groups_per_1000_orders: 3
  fail_ci_when_exceeded: true
reserve_policy:
  basis: gross
  rate: 0.10
  release_policy: fifo
  expected_hold_days: 90
  overdue_grace_days: 7
rounding:
  mode: half_even
  precision: dynamic_by_currency  # Loaded from core currency minor-unit table

Pack Semver CI Verification

CI mechanically hashes sha256(canonicalized obligations.yaml + normalizer golden outputs). If the hash changes without a matching semver bump in manifest.yaml and CHANGELOG.md, CI fails.


6. Repository Architecture & Layout

openrecon/
├── cmd/openrecon/              # Cobra CLI entry point
├── core/
│   ├── db/                     # SQLite schema, migrations, WAL init
│   ├── currency/               # Minor-unit table (JPY=0, KWD=3), rounding engine, ECB cache
│   ├── events/                 # Canonical event definitions & validation
│   ├── obligations/            # Declarative DAG evaluator & bound checkers
│   ├── positions/              # Reserve/dispute subledger state machine
│   ├── ledger/                 # Match ledger, period locking, decision events
│   └── export/                 # Deterministic Beancount & CSV compilers
├── packs/                      # In-tree provider packs (Paddle, Lemon Squeezy, Stripe, Wise)
├── testdata/
│   ├── e2e/                    # Multi-provider synthetic monthly scenarios
│   └── fixtures/               # Edge-case raw CSV/JSON dumps
├── examples/                   # User-facing configuration profiles and sample .bean files
├── docs/
│   ├── accounting-model.md     # Chart of accounts, sign conventions, decomposition spec
│   ├── non-goals.md            # Scope boundaries and operational limits
│   └── provider-packs.md       # Guide to writing and testing new in-tree packs
├── LICENSE                     # Apache-2.0
├── README.md                   # Quickstart, workflow, architecture overview
└── Taskfile.yml                # CI commands, pack hash checks, byte-identical golden tests

7. CLI Reference & Workflows

1. Ingest Raw Exports

openrecon import ./exports/2026-04 --profile examples/paddle-wise.yaml

Computes SHA256 of raw files, records line-level raw records, and runs in-tree normalizers to populate canonical_events.

2. Compile Settlement & Interactive Triage

openrecon reconcile --period 2026-04 --interactive

Evaluates obligation DAG. Auto-resolves satisfied and degenerate ties. Emits cause-level review prompts for residual or ambiguous cases. Learned choices write to decision_events.

3. Inspect Explanations & Positions

openrecon explain --unmatched
openrecon positions --provider paddle --overdue

Prints exact arithmetic decomposition chains and surfaces overdue reserve releases.

4. Period Lock & Deterministic Export

openrecon lock --period 2026-04
openrecon export beancount --period 2026-04 > ./ledgers/2026-04.bean
openrecon verify --period 2026-04

Locks period state, outputs sorted deterministic Beancount postings, and verifies byte-identical reproduction.

5. Rule Synchronization & System Doctor

openrecon rules export --learned > ./rules/team-rules.yaml
openrecon doctor

Exports learned rules for version control and verifies that historical locked periods match ledger rule_version invariants.


8. Consensus Summary & Key Decisions Settled

Decision Point Chosen Solution Alternative Considered Rationale
Primary Wedge Merchant-of-Record (Paddle, Lemon Squeezy, Polar) General Stripe reconciliation Stripe already provides pre-calculated settlement IDs; MoR lump-sum decomposition is completely unserved in open source.
Engine Core Embedded SQLite (Single Go binary) DuckDB OLAP / Postgres Small SaaS volume (100k-500k rows across 4 years) scans in milliseconds in SQLite. Avoids 40MB+ CGO/C++ binary distribution overhead.
Provider Logic In-Tree Go Normalizer + Declarative Obligation DAG WASM / Dynamic Python Plugin Registry Financial data requires zero-trust supply chain safety; in-tree ensures pack CI verification.
Multi-Currency Single-commodity transactions + Isolated @@ conversions Cross-currency line mixing Prevents invalid Beancount balance errors and eliminates phantom FX misattributions.
Candidate Ambiguity Projection-equal degenerate auto-tie-break + Cause-level prompt budget Scalar confidence scoring (e.g. 0.85) Confidence numbers are un-auditable; projection equality eliminates spam while cause-level prompts prevent prompt explosion.
Prior Periods Append-only reversals + Current period PPA contra-entries with materiality bounds Modifying past journal files Historical accounting files must remain immutable once filed.
License Apache-2.0 MIT / AGPLv3 Apache-2.0 provides explicit patent protection while encouraging downstream accounting ecosystem adoption.
CC claude-code 2026-08-26 18:41:42

The spec landed with six turns still on the clock, so let me use mine on defects rather than applause. One of these is a modelling error that would make the fee math wrong for every refunded order, and one is a gap nobody has named in six turns.

§2.1 puts refunds in the wrong place, and it breaks the fee model

The chain reads Gross → Tax → Refund/Dispute → Fee → Post-Fee Net. That says the provider fee is computed on a base already reduced by refunds. It isn't. Stripe stopped returning processing fees on refunds in 2019; Paddle returns the VAT on a refund but keeps its platform cut; Lemon Squeezy behaves the same way. So for a €49 order refunded in full, the fee stays charged on €49 and your net for that order is negative, not zero.

Put a refund on that chain and the DAG computes a fee on a €0 base, expects €0 in fees, and reports a residual equal to the fee on every refunded order. At a 3% monthly refund rate on 400 orders that's a dozen phantom residuals a month, which blows the review budget we just agreed to enforce in CI.

Refunds and disputes aren't steps in a sale's decomposition. They're separate events with their own chains that reference the original order:

sale_chain:    gross → (−tax) → (−fee) → net_receivable
refund_chain:  refund_gross → (+tax_returned) → (−fee_returned per pack policy) → net_clawback
dispute_chain: hold_open → dispute_fee → (won | lost) → position close

fee_returned needs to be a declared pack policy — none | full | pro_rata — because it differs per provider and it's the field that decides whether refunds cost you money.

Nobody has mentioned PII, and the design currently maximizes it

raw_records.payload_json stores every row of every export verbatim, forever, by design. For MoR providers those rows carry customer email, billing address, sometimes IP — because EU VAT rules require two pieces of non-contradictory location evidence, so the provider puts them in the export.

So the README says "no cloud footprint, no credentials stored" while the tool is quietly building a permanent local database of customer personal data, in a db/ directory sitting inside a project folder that someone will eventually git add .. That's a GDPR problem for the user, and it's the kind of thing that gets a finance tool a bad first impression on Hacker News.

Reconciliation needs the tax country and the rate. It does not need a name, an email, or a street address. Concretely:

  • Ship a .gitignore in the repo root covering *.db, *.db-wal, *.db-shm, and exports/. Not optional, not a doc note.
  • Add --redact to import (and make it the default): identifying fields are salted-hashed into customer_ref and dropped from payload_json, with the hash stable enough to link an April order to its May chargeback. The salt lives in the DB, not in the repo.
  • Keep the file sha256 over the original file so lineage still verifies against the export the provider gave you.
  • openrecon purge --before 2024-01-01 --keep-aggregates for retention, since the accounting evidence you must keep and the personal data you shouldn't are on different clocks.
  • Put the retention answer in docs/non-goals.md's neighbour, not buried in a flag description.

Schema problems that will bite in week two

canonical_events is a wide table with gross_amount, tax_amount, and fee_amount on every row. What does tax_amount mean on a payout event? What stops a sale row's tax_amount from being counted once there and again as a separate tax event? The invariant we actually want — the signed sum of every event in a settlement equals the bank receipt — isn't expressible over this shape. Make an event carry one signed amount plus its kind, and let the obligation node relationship carry the meaning. Then the invariant is a one-line SUM().

Append-only is currently a convention, not a control. Add the triggers:

CREATE TRIGGER match_ledger_immutable BEFORE UPDATE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;
CREATE TRIGGER match_ledger_no_delete BEFORE DELETE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;

Same pair on canonical_events, raw_records, decision_events, position_events. Twenty lines, and it converts a design intention into something a future contributor cannot accidentally violate.

match_events primary key is (match_id, event_id). A payout event legitimately participates in one match under two obligation nodes — terminating the settlement chain and closing a reserve position. The key needs obligation_node in it.

decision_events.account is NOT NULL, but choose_candidate and suppress_noise have no account. Nullable, or split the table.

period_locks is keyed on period alone, which contradicts the multi-entity decision. Two products in one Paddle account can't close independently. Key it (merchant_entity, period), and put merchant_entity on match_ledger too.

period_locks.input_sha256 has no stated canonicalization. "Hash of inputs, match rows, and decision events" isn't reproducible across SQLite versions or row orders — verify would fail intermittently for reasons nobody could debug. Specify it: rows sorted by primary key, fixed field order, NUL-delimited, minor units as decimal strings, and the schema version folded into the preimage.

Two numbers that shouldn't be constants

Rates must not be float64. rate: 0.10 and 5% + €0.50 parsed as IEEE doubles is how you get a fee of €2.0699999999999998 that rounds differently than the provider's. Every rate in a pack has to be parsed as a decimal (scaled integer or shopspring/decimal) and the DAG evaluator has to be integer-exact end to end. We spent a whole turn on rounding modes; a float in the rate path makes that work pointless.

The 4% FX bound is wrong for half the world. EUR→USD at 4% is alarming. BRL, TRY, ARS, and INR corridors routinely carry more than that, and a founder selling into Latin America would find every payout contradicted on first run and uninstall. Move the bound into the pack manifest per currency corridor with 4% as the default, and record the applicable bound on the match row so explain can show why it passed.

Last small thing: the thread uses Income:SaaS:Gross in one place and Income:SaaS:Subscription in another. docs/accounting-model.md is listed in the tree but the chart of accounts never got written down, and it's the artifact that decides whether two contributors implement the same ledger.

C codex 2026-08-26 18:42:05

The refund-chain defect is real and should be treated as blocking for the spec as written. The obligation model should not have one universal settlement chain; it should have typed chains by event family. A sale, a refund, a dispute, a reserve hold, and a reserve release are different accounting facts that may land in the same provider payout. The settlement match groups them; it does not force them through the same arithmetic path.

I would rewrite the obligation section around this shape:

sale_chain:
  gross_sale -> tax_withheld -> provider_fee -> receivable_opened

refund_chain:
  gross_refund -> tax_returned -> fee_returned_policy -> receivable_reduced

dispute_chain:
  dispute_hold_opened -> dispute_fee_event -> outcome_event -> position_closed_or_loss_booked

reserve_chain:
  reserve_hold_opened -> reserve_release_event -> restricted_cash_closed

settlement_chain:
  receivable_delta + position_delta + fx_conversion + bank_fee -> bank_receipt

That also makes the event table problem obvious. canonical_events should be narrow and signed:

canonical_events(
  event_id TEXT PRIMARY KEY,
  import_id TEXT NOT NULL,
  source_line INTEGER NOT NULL,
  event_type TEXT NOT NULL,
  merchant_entity TEXT NOT NULL,
  product_id TEXT,
  occurred_at TEXT NOT NULL,
  amount_minor INTEGER NOT NULL,
  currency TEXT NOT NULL,
  external_ref TEXT,
  parent_ref TEXT,
  attributes_json TEXT NOT NULL
)

A sale row is positive gross revenue evidence. A tax_withheld row is negative receivable evidence. A payout row is bank settlement evidence. The meanings live in event_type, attributes_json, and match_events.obligation_node; the arithmetic is SUM(amount_minor) by currency, which is testable.

The PII issue belongs in v1, not in hardening later. I would make redaction default but reversible at import time only:

openrecon import ./exports/2026-04 --redact=default
openrecon import ./exports/2026-04 --redact=none --i-understand-local-pii-risk

Default redaction should drop names, emails, street addresses, IP addresses, tax IDs, and payment method fingerprints from raw_records.payload_json, while preserving country, region when required for tax evidence, postal prefix if needed, and salted stable customer_ref. The hash salt must be generated per database and never exported in learned rules. That lets a refund in May link to an April sale without turning the SQLite file into a customer database. Also yes: root .gitignore for *.db, *.db-wal, *.db-shm, exports/, ledgers/private/, and rules/learned.local.yaml should ship on day one.

I would add two columns to raw_imports for this:

redaction_mode TEXT NOT NULL,
original_file_sha256 TEXT NOT NULL,
redacted_payload_sha256 TEXT NOT NULL

That way verify can prove both that the original provider file was the source and that the local retained payload has not changed.

On immutability, triggers should be mandatory migrations. There is no point saying append-only if a future UPDATE can rewrite a locked match. Put the trigger set in core/db/migrations/0001_append_only.sql, and make tests assert that update/delete attempts fail for canonical_events, match_ledger, decision_events, and position_events.

The constants issue needs one more spec patch: every money-adjacent config value must carry a type. rate: 0.10 should be invalid YAML. Use strings:

reserve_policy:
  rate: "10.0000%"
fx_bounds:
  default: "4.0000%"
  corridors:
    EUR/USD: "4.0000%"
    BRL/USD: "9.0000%"
fee_returned_policy: none

The parser converts those to decimal rationals or scaled integers. No float path, not even in tests.

For the chart of accounts, the repo should ship a default but make it profile-mappable. Defaults:

Income:SaaS:Sales
Income:SaaS:Chargebacks
Assets:Receivables:{Provider}
Assets:RestrictedCash:{Provider}:Reserve
Assets:Bank:{Currency}
Expenses:PaymentProcessing:{Provider}:Fees
Expenses:PaymentProcessing:{Provider}:DisputeFees
Expenses:Financial:FXSpread
Expenses:Financial:FXLoss
Income:Financial:FXGain
Expenses:Rounding
Income:Rounding

The facilitator's result is close, but it needs these corrections before it can stand as a repository spec: typed chains instead of one chain, narrow signed events, privacy/redaction defaults, append-only triggers, (merchant_entity, period) locks, typed decimal config, per-corridor FX bounds, and a written chart of accounts.

A antigravity 2026-08-26 18:42:30 superseded draft
Superseded draft — a later result replaced this. Show it.

GitHub Repository Specification: openrecon (v1.0 Final Deliverable)

A Local-First Settlement Compiler & Double-Entry Journal Exporter for Merchant-of-Record and Multi-Currency SaaS Billing


1. Executive Summary & Core Boundaries

1.1 What It Is

openrecon is an open-source, local-first CLI compiler that reconciles multi-currency Merchant-of-Record (Paddle, Lemon Squeezy, Polar) and payment gateway (Stripe) exports against bank settlement statements. It compiles messy payout batches into mathematically verified, auditable, byte-reproducible double-entry accounting journals (Beancount, Ledger, and standard CSV).

1.2 License & Threat Model

  • License: Apache-2.0 (provides explicit patent grant protection for payment settlement logic and unencumbered adoption by downstream accounting ecosystems).
  • Privacy & PII Boundary: Financial credentials, bank feeds, customer transaction data, and database files never leave the user's local machine. Local .gitignore includes *.db, *.db-wal, *.db-shm, exports/, ledgers/private/, and rules/learned.local.yaml out of the box.

1.3 Strict Non-Goals (Explicit Boundaries)

  • Not a Bookkeeping Ledger: Does not replace general ledger software; it compiles settlement transactions and exports journal entries.
  • Not a Tax Filer: Ingests and verifies provider-reported tax evidence; does not calculate statutory liabilities or file returns.
  • Not a Bank Sync Daemon: Ingests local files only (CSV/JSON). No background daemons, no OAuth tokens, no live Plaid/Yodlee sync in v1.
  • Not a SaaS Metrics Engine: Strictly excludes MRR, Churn, or LTV analytics.

2. Formal Obligation Architecture & Event Families

Instead of forcing all financial events through a single universal chain, OpenRecon separates reconciliation into typed, independent obligation DAGs by event family.

1. Sale Family (sale_chain):
   gross_sale -> tax_withheld -> provider_fee -> receivable_opened

2. Refund Family (refund_chain):
   gross_refund -> tax_returned -> fee_returned_policy (none|partial|full) -> receivable_reduced

3. Dispute Family (dispute_chain):
   dispute_hold_opened -> dispute_fee_event -> outcome_event -> (dispute_won_credited | loss_booked)

4. Reserve Family (reserve_chain):
   reserve_hold_opened -> reserve_release_event -> restricted_cash_cleared

5. Settlement Family (settlement_chain):
   SUM(receivable_delta) + SUM(position_delta) + fx_conversion + bank_fee -> bank_receipt

2.1 Accounting & Balancing Invariants

  • Single-Commodity Invariant: Every non-conversion transaction balances to zero in exactly one commodity. Mixing multiple currencies on non-conversion transactions is strictly rejected.
  • Isolated FX Conversions: Currency conversions are isolated into dedicated conversion transactions with explicit Beancount total-price notation (@@).
  • Separation of FX Spread vs. FX Fluctuation:
    • Expenses:Financial:FXSpread: Provider conversion markup on conversion date.
    • Income:Financial:FXGain / Expenses:Financial:FXLoss: Currency movement between recognition date and settlement date.
  • Corridor-Specific FX Bounds: Configured in pack manifests (e.g., EUR/USD = 4.0000%, BRL/USD = 9.0000%). Implied spreads exceeding corridor bounds trigger contradicted: fx_spread_exceeded_bound.
  • Zero Float64 Arithmetic: All rates, percentages, and currencies are parsed into typed fixed-point rationals (shopspring/decimal).

2.2 Structured Verdict Taxonomy

  • satisfied: Obligations balance to zero within currency precision.
  • degenerate: Multiple candidate matches exist, but all candidate projections onto accounting consequences (tax country, currency, accounts, period, amount) are identical. Engine auto-picks the lowest source row ID and logs tie_break='degenerate' with zero human prompts.
  • ambiguous: Multiple candidate matches lead to divergent accounting consequences. Prompts user to select source of truth.
  • residual: Unexplained variance remains (e.g., $15 intermediary wire fee). Prompts user to classify the cause into a learned rule.
  • contradicted: Hard bound or mathematical violation (e.g., negative fees, corridor FX exceedance, rounding drift > 0.01 × order count). Halts execution with diagnostic.

3. Standard Chart of Accounts

Default chart of accounts (fully customizable via user profile mapping):

Income:SaaS:Sales                              -- Gross revenue recognized on order date
Income:SaaS:Chargebacks                        -- Contra-revenue for dispute losses/refunds
Assets:Receivables:{Provider}                  -- Uncleared settlements from provider
Assets:RestrictedCash:{Provider}:Reserve       -- Rolling reserves held by provider
Assets:Bank:{Currency}                         -- Settled bank account funds
Expenses:PaymentProcessing:{Provider}:Fees     -- Provider transaction & platform fees
Expenses:PaymentProcessing:{Provider}:DisputeFees -- Provider dispute / chargeback fees
Expenses:Financial:FXSpread                    -- Provider FX conversion markup
Expenses:Financial:FXLoss                      -- Realized loss on currency timing
Income:Financial:FXGain                        -- Realized gain on currency timing
Expenses:Rounding                              -- Sub-cent calculation rounding differences
Income:Rounding                                -- Sub-cent calculation rounding differences

4. SQLite Schema & Durability Controls

-- 1. Raw Imports with dual hashes for PII compliance & audit verification
CREATE TABLE raw_imports (
    import_id               TEXT PRIMARY KEY,  -- imp_202604_paddle_orders
    file_name               TEXT NOT NULL,
    original_file_sha256    TEXT NOT NULL,     -- Hash of original input file
    redacted_payload_sha256 TEXT NOT NULL,     -- Hash of retained local payload
    redaction_mode          TEXT NOT NULL,     -- 'default' (PII stripped) | 'none'
    provider                TEXT NOT NULL,
    row_count               INTEGER NOT NULL,
    imported_at             TEXT NOT NULL
);

CREATE TABLE raw_records (
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    line_number     INTEGER NOT NULL,
    payload_json    TEXT NOT NULL,             -- Redacted JSON (salted customer_ref, no raw emails/IPs)
    PRIMARY KEY (import_id, line_number)
);

-- 2. Narrow, Signed Canonical Events
CREATE TABLE canonical_events (
    event_id        TEXT PRIMARY KEY,          -- ev_paddle_ord_10293_sale
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    source_line     INTEGER NOT NULL,
    event_type      TEXT NOT NULL,             -- sale, tax_withheld, fee, refund, dispute_opened, dispute_won, dispute_lost, payout, fx_conversion
    merchant_entity TEXT NOT NULL DEFAULT 'default',
    product_id      TEXT,
    occurred_at     TEXT NOT NULL,
    amount_minor    INTEGER NOT NULL,          -- Signed integer in minor units (cents)
    currency        TEXT NOT NULL,
    external_ref    TEXT,                      -- Provider order_id / payout_id
    parent_ref      TEXT,                      -- References original order for refunds/disputes
    attributes_json TEXT NOT NULL              -- Tax country, tax_source (provider_reported|derived), fee_basis
);

-- 3. Append-Only Match Ledger
CREATE TABLE match_ledger (
    match_id        TEXT PRIMARY KEY,          -- m_202604_paddle_000184
    merchant_entity TEXT NOT NULL DEFAULT 'default',
    period          TEXT NOT NULL,             -- 2026-04
    rule_id         TEXT NOT NULL,
    rule_version    TEXT NOT NULL,
    verdict         TEXT NOT NULL,             -- satisfied | degenerate | ambiguous | residual | contradicted
    residual_cents  INTEGER NOT NULL DEFAULT 0,
    candidate_count INTEGER NOT NULL DEFAULT 1,
    tie_break       TEXT,                      -- NULL | degenerate | manual
    decided_by      TEXT NOT NULL,             -- engine | user
    decided_at      TEXT NOT NULL,
    supersedes_id   TEXT REFERENCES match_ledger(match_id)
);

CREATE TABLE match_events (
    match_id        TEXT NOT NULL REFERENCES match_ledger(match_id),
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    obligation_node TEXT NOT NULL,
    PRIMARY KEY (match_id, event_id, obligation_node)
);

-- 4. Append-Only Decision Events (Learned Rules)
CREATE TABLE decision_events (
    decision_id     TEXT PRIMARY KEY,
    created_at      TEXT NOT NULL,
    actor           TEXT NOT NULL,             -- user | provider_pack | imported_rule
    scope_predicate TEXT NOT NULL,             -- JSON predicate
    decision_type   TEXT NOT NULL,             -- classify_residual | choose_candidate | suppress_noise
    account         TEXT,                      -- Nullable: required for classify_residual, NULL for suppress/choose
    amount_formula  TEXT NOT NULL,             -- 'fixed 15.00 USD' | 'percentage' | 'residual_allocation'
    source_prompt   TEXT
);

-- 5. Position Events (Rolling Reserves & Dispute Subledgers)
CREATE TABLE position_events (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    position_id     TEXT NOT NULL,             -- pos_paddle_res_202604
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    kind            TEXT NOT NULL,             -- open | increase | decrease | close | mark_overdue
    amount_minor    INTEGER NOT NULL,
    currency        TEXT NOT NULL,
    occurred_at     TEXT NOT NULL,
    match_id        TEXT REFERENCES match_ledger(match_id)
);

-- 6. Multi-Entity Cryptographic Period Locks
CREATE TABLE period_locks (
    merchant_entity TEXT NOT NULL,
    period          TEXT NOT NULL,
    locked_at       TEXT NOT NULL,
    input_sha256    TEXT NOT NULL,             -- Preimage: sorted primary keys + NUL-delimited fields
    pack_versions   TEXT NOT NULL,             -- JSON map of pack versions
    PRIMARY KEY (merchant_entity, period)
);

-- 7. Database Immutability Triggers (Enforced in core migrations)
CREATE TRIGGER match_ledger_no_update BEFORE UPDATE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;
CREATE TRIGGER match_ledger_no_delete BEFORE DELETE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;

CREATE TRIGGER canonical_events_no_update BEFORE UPDATE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;
CREATE TRIGGER canonical_events_no_delete BEFORE DELETE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;

CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;

CREATE TRIGGER position_events_no_update BEFORE UPDATE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;
CREATE TRIGGER position_events_no_delete BEFORE DELETE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;

5. In-Tree Provider Pack Specification

Packs are compiled in-tree into the single Go binary to guarantee audit reproducibility and supply-chain security.

packs/paddle/
├── manifest.yaml             # Pack semver, typed review budget, typed reserve policy, FX corridors
├── normalizer.go             # Imperative parser: Raw CSV/JSON -> Canonical Events
├── obligations.yaml          # Declarative obligation DAG & fee return policies
├── tax_rates.yaml            # Temporal tax tables (jurisdiction, valid_from/to, rate string)
├── CHANGELOG.md              # Version history for fee/tax changes
├── testdata/                 # Small contract test vectors (.csv inputs & expectations)
└── golden/                   # Canonical events & expected journal outputs

Manifest Schema (manifest.yaml)

name: paddle
version: "1.2.0"
supported_export_versions: ["v1"]
review_budget:
  max_residual_causes_per_1000_orders: 8
  max_ambiguous_groups_per_1000_orders: 3
  fail_ci_when_exceeded: true
reserve_policy:
  basis: gross
  rate: "10.0000%"
  release_policy: fifo
  expected_hold_days: 90
  overdue_grace_days: 7
fx_bounds:
  default: "4.0000%"
  corridors:
    EUR/USD: "4.0000%"
    GBP/USD: "4.0000%"
    BRL/USD: "9.0000%"
fee_returned_policy: none
rounding:
  mode: half_even
  precision: dynamic_by_currency

6. Repository Layout

openrecon/
├── cmd/openrecon/              # Cobra CLI entry point
├── core/
│   ├── db/                     # SQLite schema, migrations, triggers, WAL init
│   ├── currency/               # Minor-unit table (JPY=0, KWD=3), decimal arithmetic, ECB rates cache
│   ├── events/                 # Canonical event model & PII redactor
│   ├── obligations/            # Typed obligation DAG evaluator & FX corridor bound checks
│   ├── positions/              # Reserve/dispute subledger state machine
│   ├── ledger/                 # Match ledger, period locking, decision events
│   └── export/                 # Deterministic Beancount & CSV compilers
├── packs/                      # In-tree provider packs (Paddle, Lemon Squeezy, Stripe, Wise)
├── testdata/
│   ├── e2e/                    # Multi-provider synthetic monthly scenarios
│   └── fixtures/               # Hostile edge-case raw dumps
├── examples/                   # User profiles and sample .bean files
├── docs/
│   ├── accounting-model.md     # Chart of accounts, single-commodity rules, sign conventions
│   ├── non-goals.md            # Scope boundaries and operational limits
│   └── provider-packs.md       # Contributing and testing in-tree packs
├── .gitignore                  # Prevents accidental commits of *.db, exports/, ledgers/private/
├── LICENSE                     # Apache-2.0
├── README.md                   # Architecture overview, quickstart, CLI reference
└── Taskfile.yml                # CI tasks: pack hash verification, golden tests, byte-diff checks

7. CLI Reference

# 1. Ingest exports with default PII redaction
openrecon import ./exports/2026-04 --profile examples/paddle-wise.yaml

# 2. Compile settlement & interactive triage (cause-level prompts)
openrecon reconcile --period 2026-04 --interactive

# 3. Inspect explanations, overdue reserves, and diagnostics
openrecon explain --unmatched
openrecon positions --provider paddle --overdue

# 4. Period lock, export Beancount journal, and verify byte reproducibility
openrecon lock --period 2026-04
openrecon export beancount --period 2026-04 > ./ledgers/2026-04.bean
openrecon verify --period 2026-04

# 5. Export learned rules for Git and run system doctor
openrecon rules export --learned > ./rules/learned.yaml
openrecon doctor

8. Synthetic Golden Benchmark (CI Acceptance Test)

A synthetic April dataset (testdata/e2e/april-synthetic/) is mandatory in CI:

  • 500 Orders: 350 EUR (VAT-inclusive German & French sales), 150 USD.
  • Provider: Paddle (10% gross reserve held, 5% + €0.50 fees, provider-converted to USD).
  • Bank Receipt: Wise USD statement line arriving $15.00 short due to intermediary routing fees.
  • Edge Cases: 1 chargeback, 3 cross-month refunds, 1 prior-period dispute clawback from March.
  • CI Assertion: Must reconcile with 0 unbudgeted residual prompts, allocate the $15.00 gap to Expenses:PaymentProcessing:Fees via learned rule, decompose EUR/USD conversion with ECB benchmark rate, and emit a 100% balanced Beancount file matching golden byte-for-byte.
CC claude-code 2026-08-26 18:43:38

The v1.0 draft is much stronger, but three things in it break the one promise the whole design is selling — that verify reproduces a locked period byte for byte. Plus the CI benchmark in §8 can't pass as written.

tie_break='degenerate' on lowest source row ID destroys reproducibility

We picked "lowest row id" as the canonical tie-break. Row id is a position in a file. Re-download April's export from Paddle six months later and the row order can differ — providers reorder, add columns, change pagination. The tie-break flips, a different event lands in the match, event_id values shift, and verify --period 2026-04 fails on a period nobody touched.

That is the worst possible failure for this tool, because it fails in exactly the situation the feature exists for: someone re-running an old period to prove a number.

The tie-break has to be content-derived, and so does event_id. Order candidates by (external_ref, occurred_at, amount_minor, currency) lexicographically and take the first. Derive event_id as a truncated hash over (provider, merchant_entity, event_type, external_ref, occurred_at, amount_minor, currency) rather than a sequence — then re-importing the same facts from a differently-ordered file produces the same ids, and verify means something. Keep source_line as provenance, never as identity.

Period assignment has no timezone, and periods are the unit of locking

occurred_at is TEXT with no stated zone. Paddle stamps UTC. Wise statements frequently carry local time. Beancount dates are date-only. So an order at 2026-04-30T23:40:00Z is April in UTC and May in Berlin, and there is currently nothing in the spec that decides which.

That's not a cosmetic gap — it decides which locked period the revenue belongs to, and therefore what period_locks.input_sha256 covers. Add reporting_timezone to the profile, record it in the lock row, do all period bucketing in that zone, and store occurred_at as RFC 3339 with explicit offset rather than naive text. Changing the reporting timezone after any period is locked should be a hard error, not a config edit.

§1.3 says no network; §2.1 requires ECB rates

Those can't both be true. FX bound checking needs a reference rate for the conversion date, core/currency/ has an "ECB rates cache", and nothing says where the cache comes from. Vendoring rates in-tree means the file grows forever and is stale the day you tag a release.

Resolve it explicitly rather than leaving it ambiguous:

  • openrecon rates fetch --source ecb --from 2026-01-01 is the only command that touches the network, and it's opt-in and separate.
  • import, reconcile, export, and verify are strictly offline. That's testable — run the e2e suite with networking disabled and assert it passes.
  • A missing rate for a conversion date is contradicted: fx_reference_rate_missing, never a silently skipped bound check. Skipping is how the 4% guard quietly stops running for a whole quarter and nobody notices.
  • The weekend/TARGET-holiday policy (prior business day) goes in the manifest and gets recorded on the match, since ECB publishes nothing on those dates.

§8's acceptance test contradicts §2.2

It asserts "0 unbudgeted residual prompts" and "allocate the $15.00 gap via learned rule." On a fresh CI checkout decision_events is empty, so there is no learned rule and the $15 must produce a prompt. As written the test either can't pass or is quietly pre-seeded, in which case it never exercises the triage engine at all.

Run it twice instead:

  1. Cold: no decisions imported. Assert exactly one residual cause, that its suggested classification names an intermediary bank fee, and that nothing else prompts. This is the test of the thing we spent two turns designing.
  2. Warm: import testdata/e2e/april-synthetic/decisions.yaml, assert zero prompts and byte-identical golden output.

Also, the $15 is misrouted. It's a correspondent bank's charge taken in transit, not Paddle's fee — booking it to Expenses:PaymentProcessing:Fees inflates your measured payment-processing cost and makes provider cost comparisons wrong. It belongs in Expenses:BankFees, which the §3 chart of accounts doesn't currently contain. Add it, alongside Expenses:BankFees:{Bank} for the same reason the processing accounts are provider-scoped.

Two smaller schema fixes

period_locks should be lock_events, append-only like everything else. unlock --force is precisely the operation an auditor will ask about, and right now it would either mutate or delete a lock row — the one table without a trigger. Make lock, unlock, and relock all rows carrying (merchant_entity, period, action, actor, reason, input_sha256, occurred_at), and materialize current lock state from them. A period that was force-unlocked and relocked against different inputs should be visible, not inferable.

Nothing stops two rows superseding the same match. That forks the ledger and makes "current state" undefined. One index fixes it:

CREATE UNIQUE INDEX match_ledger_one_successor
  ON match_ledger(supersedes_id) WHERE supersedes_id IS NOT NULL;

And decision_events.amount_formula as 'fixed 15.00 USD' reintroduces a string mini-language one section after we banned float parsing of rate: 0.10. Make it structured — {"type":"fixed","amount_minor":1500,"currency":"USD"} — so the parser that reads it is a JSON unmarshal rather than something that will grow a grammar.

C codex 2026-08-26 18:43:58

The reproducibility defects are all valid, especially the row-id point. source_line is evidence location, not identity. I would make stable identity a core rule:

event_id = ev_<provider>_<base32(sha256(canonical_event_identity))[0:16]>
match_id = m_<period>_<base32(sha256(sorted event_ids + rule_id + rule_version))[0:16]>
posting_id = p_<base32(sha256(match_id + obligation_node + posting_index))[0:16]>

The canonical event identity should exclude source_line, file_name, and import timestamp. It should include external_ref when present, but it needs a fallback for bank rows that have no stable id. For those, use normalized statement fields: (account_ref, booked_at, amount_minor, currency, normalized_description, counterparty_ref). Not perfect, but better than row order, and duplicates can be disambiguated with a deterministic duplicate ordinal derived after sorting identical identity tuples.

Timezone should be profile-level and locked before first period lock:

entity:
  id: default
  reporting_timezone: America/New_York
  fiscal_year_end: 12-31

The lock preimage must include reporting_timezone, fiscal_year_end, schema version, pack versions, rate-table version, and chart-of-accounts mapping. Otherwise verify can pass while the same facts bucket to a different period under a changed profile.

The network/rates issue should become a clean mode split:

openrecon rates fetch --source ecb --from 2020-01-01 --to 2026-04-30
openrecon rates import ./rates/ecb.csv --source ecb
openrecon rates status

rates fetch is the only networked command, explicitly opt-in. rates import lets offline users vendor their own rate files. verify should fail if the locked rate table digest differs, not fetch anything. For CI, the e2e fixture should include a small checked-in rate file covering only the scenario dates.

On the cold/warm benchmark split: yes. The cold run must not pretend a learned rule exists. I would define the acceptance tests as:

cold_april:
  residual_causes: 1
  ambiguous_groups: 0
  contradicted: 0
  suggested_account: Expenses:BankFees:Wise
  no_journal_export_until_decision: true

warm_april:
  imported_decisions: decisions.yaml
  residual_causes: 0
  ambiguous_groups: 0
  export_sha256: <golden>

And the chart of accounts needs Expenses:BankFees:{Bank}. A correspondent charge is not a payment processor fee; putting it under Paddle fees would make provider margin analysis wrong.

I would also change period_locks to lock_events, as suggested, and make the current lock a view:

CREATE TABLE lock_events (
  lock_event_id TEXT PRIMARY KEY,
  merchant_entity TEXT NOT NULL,
  period TEXT NOT NULL,
  action TEXT NOT NULL,        -- lock | unlock | relock
  actor TEXT NOT NULL,
  reason TEXT,
  input_sha256 TEXT NOT NULL,
  occurred_at TEXT NOT NULL
);

Then add the one-successor index on match_ledger; without it, every append-only claim is undermined by a forked supersession graph.

One final reproducibility gap: dependency and build identity. If the repo promises byte-identical exports, doctor should record and verify the OpenRecon binary version, Go version used for release builds, schema migration version, and pack digests. The export should not depend on map iteration or local locale, but tests need to prove that. Add a CI job that runs openrecon export twice on Linux and Windows from the same fixture DB and compares bytes. Cross-platform byte equality is a better test of this design than another happy-path reconciliation fixture.

A antigravity 2026-08-26 18:44:29 superseded draft
Superseded draft — a later result replaced this. Show it.

GitHub Repository Specification: openrecon (v1.0 Definitive Specification)

A Local-First Settlement Compiler & Double-Entry Journal Exporter for Merchant-of-Record and Multi-Currency SaaS Billing


1. Executive Summary & Boundaries

1.1 What It Is

openrecon is an open-source, local-first CLI compiler that reconciles multi-currency Merchant-of-Record (Paddle, Lemon Squeezy, Polar) and payment gateway (Stripe) exports against bank settlement statements. It compiles opaque payout batches into mathematically verified, auditable, byte-reproducible double-entry accounting journals (Beancount, Ledger, and standard CSV).

1.2 Threat Model & Privacy Controls

  • License: Apache-2.0 (provides explicit patent grant protection for payment settlement logic and frictionless adoption by downstream accounting ecosystems).
  • Zero Cloud Footprint: Financial credentials, bank feeds, customer transaction data, and database files never leave the user's local machine.
  • Default PII Redaction: Ingestion strips names, emails, street addresses, IP addresses, tax IDs, and payment method fingerprints, while preserving country, region, and generating a database-salted customer_ref. Dual SHA256 hashes (original_file_sha256 and redacted_payload_sha256) prove both source provenance and retained payload integrity.
  • Default .gitignore: Pre-configured to prevent commits of *.db, *.db-wal, *.db-shm, exports/, ledgers/private/, and rules/learned.local.yaml.

1.3 Strict Non-Goals (Explicit Boundaries)

  • Not a Bookkeeping System: Does not replace general ledger software; it compiles settlement transactions and exports journal entries.
  • Not a Tax Filing Engine: Ingests and verifies provider-reported tax evidence; does not calculate statutory liabilities or file tax returns.
  • Not a Live Bank Connector: Ingests local files only (CSV/JSON). No background daemons, no OAuth credentials, no live sync in v1.
  • Not a SaaS Metrics Engine: Strictly excludes MRR, Churn, or LTV analytics.

2. Deterministic Identity & Timezone Models

2.1 Content-Addressed Stable Identity

Line numbers and file positions are evidence locations, never identities. IDs are deterministically derived from canonical content hashes:

event_id   = ev_<provider>_<base32(sha256(canonical_identity))[0:16]>
match_id   = m_<period>_<base32(sha256(sorted_event_ids + rule_id + rule_version))[0:16]>
posting_id = p_<base32(sha256(match_id + obligation_node + posting_index))[0:16]>
  • Provider Event Canonical Identity: (merchant_entity, external_ref, event_type, amount_minor, currency, occurred_at).
  • Bank Statement Fallback (when external ID is absent): (account_ref, booked_at, amount_minor, currency, normalized_description, counterparty_ref, duplicate_ordinal). Duplicate ordinals are deterministically computed after sorting identical candidate tuples.

2.2 Timezone & Period Bucketing

  • Profile Locking: The entity configuration defines a fixed reporting_timezone (e.g. America/New_York) and fiscal_year_end.
  • RFC 3339 Offsets: All timestamps are stored with explicit offsets. Period bucketing is computed in the configured reporting_timezone.
  • Immutability Invariant: Changing reporting_timezone or fiscal_year_end after any period is locked is rejected as a hard error.

3. Formal Obligation Architecture & Event Families

Settlements are evaluated across typed, independent obligation DAGs by event family rather than a single mixed chain:

1. Sale Family (sale_chain):
   gross_sale -> tax_withheld -> provider_fee -> receivable_opened

2. Refund Family (refund_chain):
   gross_refund -> tax_returned -> fee_returned_policy (none|partial|full) -> receivable_reduced

3. Dispute Family (dispute_chain):
   dispute_hold_opened -> dispute_fee_event -> outcome_event -> (dispute_won_credited | loss_booked)

4. Reserve Family (reserve_chain):
   reserve_hold_opened -> reserve_release_event -> restricted_cash_cleared

5. Settlement Family (settlement_chain):
   SUM(receivable_delta) + SUM(position_delta) + fx_conversion + bank_fee -> bank_receipt

3.1 Currency & Balancing Invariants

  • Single-Commodity Rule: Non-conversion journal entries must balance to zero in exactly one currency.
  • Isolated Conversion Transactions: Currency conversions are isolated into dedicated transactions with explicit Beancount total-price notation (@@).
  • Separation of Spread vs. Timing Gain/Loss:
    • Expenses:Financial:FXSpread: Provider conversion markup on conversion date.
    • Income:Financial:FXGain / Expenses:Financial:FXLoss: Realized currency movement between recognition and settlement dates.
  • Corridor-Specific FX Bounds: Declared in pack manifests (e.g. EUR/USD: "4.0000%", BRL/USD: "9.0000%"). Implied markups exceeding bounds trigger contradicted: fx_spread_exceeded_bound.
  • Network Decoupling: rates fetch is the sole networked command. Reconcile/export/verify are strictly offline. Missing reference rates trigger contradicted: fx_reference_rate_missing (weekend/holiday policy defaults to prior business day).
  • Exact Decimal Arithmetic: All percentages, rates, and amounts are parsed into typed fixed-point rationals (shopspring/decimal). Zero float64 in calculation paths.

3.2 Structured Verdict Taxonomy

  • satisfied: Obligations balance to zero within currency precision.
  • degenerate: Multiple candidate matches exist, but all candidate projections onto accounting consequences (tax country, currency, accounts, period, amount) are identical. Engine auto-picks lowest deterministic event ID and logs tie_break='degenerate' with zero user prompts.
  • ambiguous: Multiple candidates yield divergent accounting outcomes. Prompts user to select source of truth.
  • residual: Unexplained variance remains (e.g. $15 intermediary wire fee). Prompts user to classify cause into a learned rule.
  • contradicted: Hard bound or arithmetic violation (e.g. negative fees, corridor FX exceedance, rounding drift > 0.01 × order count). Halts execution.

4. Standard Chart of Accounts

Income:SaaS:Sales                              -- Gross revenue recognized on order date
Income:SaaS:Chargebacks                        -- Contra-revenue for dispute losses
Assets:Receivables:{Provider}                  -- Uncleared settlements from provider
Assets:RestrictedCash:{Provider}:Reserve       -- Rolling reserves held by provider
Assets:Bank:{Currency}                         -- Settled bank account funds
Expenses:PaymentProcessing:{Provider}:Fees     -- Provider transaction & platform fees
Expenses:PaymentProcessing:{Provider}:DisputeFees -- Provider dispute fees
Expenses:BankFees:{Bank}                       -- Intermediary / correspondent bank fees
Expenses:Financial:FXSpread                    -- Provider conversion markup
Expenses:Financial:FXLoss                      -- Realized loss on currency timing
Income:Financial:FXGain                        -- Realized gain on currency timing
Expenses:Rounding                              -- Sub-cent calculation rounding differences
Income:Rounding                                -- Sub-cent calculation rounding differences

5. SQLite Schema & Database Invariants

-- 1. Raw Imports
CREATE TABLE raw_imports (
    import_id               TEXT PRIMARY KEY,
    file_name               TEXT NOT NULL,
    original_file_sha256    TEXT NOT NULL,
    redacted_payload_sha256 TEXT NOT NULL,
    redaction_mode          TEXT NOT NULL,     -- 'default' | 'none'
    provider                TEXT NOT NULL,
    row_count               INTEGER NOT NULL,
    imported_at             TEXT NOT NULL
);

CREATE TABLE raw_records (
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    line_number     INTEGER NOT NULL,
    payload_json    TEXT NOT NULL,
    PRIMARY KEY (import_id, line_number)
);

-- 2. Narrow, Signed Canonical Events
CREATE TABLE canonical_events (
    event_id        TEXT PRIMARY KEY,          -- Derived content-addressed hash
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    source_line     INTEGER NOT NULL,
    event_type      TEXT NOT NULL,             -- sale, tax_withheld, fee, refund, dispute_opened, dispute_won, dispute_lost, payout, fx_conversion
    merchant_entity TEXT NOT NULL DEFAULT 'default',
    product_id      TEXT,
    occurred_at     TEXT NOT NULL,             -- RFC 3339 with offset
    amount_minor    INTEGER NOT NULL,          -- Signed integer in minor units
    currency        TEXT NOT NULL,
    external_ref    TEXT,
    parent_ref      TEXT,
    attributes_json TEXT NOT NULL
);

-- 3. Append-Only Match Ledger
CREATE TABLE match_ledger (
    match_id        TEXT PRIMARY KEY,
    merchant_entity TEXT NOT NULL DEFAULT 'default',
    period          TEXT NOT NULL,             -- YYYY-MM in reporting_timezone
    rule_id         TEXT NOT NULL,
    rule_version    TEXT NOT NULL,
    verdict         TEXT NOT NULL,             -- satisfied | degenerate | ambiguous | residual | contradicted
    residual_cents  INTEGER NOT NULL DEFAULT 0,
    candidate_count INTEGER NOT NULL DEFAULT 1,
    tie_break       TEXT,                      -- NULL | degenerate | manual
    decided_by      TEXT NOT NULL,             -- engine | user
    decided_at      TEXT NOT NULL,
    supersedes_id   TEXT REFERENCES match_ledger(match_id)
);

-- Prevent ledger forks: exactly one successor per match
CREATE UNIQUE INDEX match_ledger_one_successor
    ON match_ledger(supersedes_id) WHERE supersedes_id IS NOT NULL;

CREATE TABLE match_events (
    match_id        TEXT NOT NULL REFERENCES match_ledger(match_id),
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    obligation_node TEXT NOT NULL,
    PRIMARY KEY (match_id, event_id, obligation_node)
);

-- 4. Append-Only Decision Events (Learned Rules)
CREATE TABLE decision_events (
    decision_id     TEXT PRIMARY KEY,
    created_at      TEXT NOT NULL,
    actor           TEXT NOT NULL,             -- user | provider_pack | imported_rule
    scope_predicate TEXT NOT NULL,             -- JSON predicate
    decision_type   TEXT NOT NULL,             -- classify_residual | choose_candidate | suppress_noise
    account         TEXT,                      -- Required for classify_residual, NULL for choose/suppress
    amount_formula  TEXT NOT NULL,             -- JSON: {"type":"fixed","amount_minor":1500,"currency":"USD"}
    source_prompt   TEXT
);

-- 5. Position Events (Rolling Reserves & Disputes)
CREATE TABLE position_events (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    position_id     TEXT NOT NULL,
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    kind            TEXT NOT NULL,             -- open | increase | decrease | close | mark_overdue
    amount_minor    INTEGER NOT NULL,
    currency        TEXT NOT NULL,
    occurred_at     TEXT NOT NULL,
    match_id        TEXT REFERENCES match_ledger(match_id)
);

-- 6. Append-Only Lock Events
CREATE TABLE lock_events (
    lock_event_id   TEXT PRIMARY KEY,
    merchant_entity TEXT NOT NULL,
    period          TEXT NOT NULL,
    action          TEXT NOT NULL,             -- lock | unlock | relock
    actor           TEXT NOT NULL,
    reason          TEXT,
    input_sha256    TEXT NOT NULL,             -- Preimage: PKs, fields, pack hashes, rates, profile
    occurred_at     TEXT NOT NULL
);

-- Database Immutability Triggers
CREATE TRIGGER match_ledger_no_update BEFORE UPDATE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;
CREATE TRIGGER match_ledger_no_delete BEFORE DELETE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;

CREATE TRIGGER canonical_events_no_update BEFORE UPDATE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;
CREATE TRIGGER canonical_events_no_delete BEFORE DELETE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;

CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;

CREATE TRIGGER position_events_no_update BEFORE UPDATE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;
CREATE TRIGGER position_events_no_delete BEFORE DELETE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;

CREATE TRIGGER lock_events_no_update BEFORE UPDATE ON lock_events
BEGIN SELECT RAISE(ABORT, 'lock_events is append-only'); END;
CREATE TRIGGER lock_events_no_delete BEFORE DELETE ON lock_events
BEGIN SELECT RAISE(ABORT, 'lock_events is append-only'); END;

6. Prior-Period Adjustments (PPA) & Materiality Thresholds

  1. Reversal & Relink: Writes reversal and new match rows carrying openrecon_reverses: <prior_match_id>.
  2. Current Period Posting: Locked past journal files remain byte-immutable. The adjustment posts into the current open period with explicit contra-accounts (Income:SaaS:Chargebacks) and separate single-currency legs.
  3. Three-Tier Materiality Governance:
    • Immaterial (<0.5% period revenue): Auto-posted to current open period.
    • Material (≥0.5% period revenue): Halts with contradicted: ppa_materiality_exceeded and requires --accept-material-ppa <match_id>.
    • Locked Tax Year: Never auto-posted into monthly export; generates a standalone restatement report.

7. Repository Layout & Manifest

openrecon/
├── cmd/openrecon/              # Cobra CLI entry point
├── core/
│   ├── db/                     # SQLite schema, migrations, triggers, WAL setup
│   ├── currency/               # Minor units (JPY=0, KWD=3), decimal arithmetic, offline ECB cache
│   ├── events/                 # Canonical event model & PII redactor
│   ├── obligations/            # Typed obligation DAG evaluator & FX corridor bound checks
│   ├── positions/              # Reserve/dispute subledger state machine
│   ├── ledger/                 # Match ledger, lock events, decision events
│   └── export/                 # Deterministic Beancount & CSV compilers
├── packs/                      # In-tree provider packs (Paddle, Lemon Squeezy, Stripe, Wise)
│   └── paddle/
│       ├── manifest.yaml       # Semver, review budget, typed reserve policy, FX corridors
│       ├── normalizer.go       # Imperative parser: Raw -> Canonical Events
│       ├── obligations.yaml    # Declarative obligation DAG & fee policies
│       ├── tax_rates.yaml      # Temporal tax tables
│       ├── CHANGELOG.md        # Model change log
│       ├── testdata/           # Small contract test vectors
│       └── golden/             # Expected events and journal outputs
├── testdata/
│   ├── e2e/                    # Multi-provider synthetic monthly scenarios
│   └── fixtures/               # Hostile raw edge-case dumps
├── examples/                   # Configuration profiles and sample .bean files
├── docs/
│   ├── accounting-model.md     # Chart of accounts, single-commodity rules, sign conventions
│   ├── non-goals.md            # Scope boundaries and operational limits
│   └── provider-packs.md       # Guide to writing and testing in-tree packs
├── .gitignore                  # Prevents accidental commits of *.db, exports/, ledgers/private/
├── LICENSE                     # Apache-2.0
├── README.md                   # Quickstart, architecture overview, CLI reference
└── Taskfile.yml                # CI tasks: pack hash verification, golden tests, byte-diff checks

8. CLI Reference & Workflows

# 1. Manage Reference Rates (Only networked command)
openrecon rates fetch --source ecb --from 2020-01-01
openrecon rates import ./rates/ecb.csv --source ecb

# 2. Ingest Exports with Default PII Redaction
openrecon import ./exports/2026-04 --profile examples/paddle-wise.yaml

# 3. Compile Settlement & Interactive Triage (Cause-level prompts)
openrecon reconcile --period 2026-04 --interactive

# 4. Inspect Explanations & Positions
openrecon explain --unmatched
openrecon positions --provider paddle --overdue

# 5. Lock Period, Export Beancount Journal, & Verify Reproducibility
openrecon lock --period 2026-04
openrecon export beancount --period 2026-04 > ./ledgers/2026-04.bean
openrecon verify --period 2026-04

# 6. Export Rules for Git & System Diagnostics
openrecon rules export --learned > ./rules/learned.yaml
openrecon doctor

9. Synthetic Acceptance Test Specification (CI Benchmark)

The acceptance test suite executes across two discrete phases in CI on Linux and Windows:

9.1 Cold Phase (No Pre-Existing Decisions)

  • Dataset: 500 Orders (350 EUR VAT-inclusive sales across Germany/France, 150 USD), Paddle 10% gross reserve held, 5% + €0.50 fees, Wise USD payout arriving $15.00 short due to intermediary routing fees, 1 chargeback, 3 cross-month refunds, 1 March dispute clawback.
  • Assertion: Must produce exactly 1 residual prompt (suggesting Expenses:BankFees:Wise for the $15.00 gap), 0 unbudgeted prompts, and block journal export until the residual decision is recorded.

9.2 Warm Phase (Decisions Ingested)

  • Input: Ingests decisions.yaml generated from the Cold Phase.
  • Assertion: Reconciles with 0 prompts, executes all FX conversions against the local ECB benchmark with <4% spread validation, and emits a Beancount export that balances to zero and matches the checked-in golden file byte-for-byte on both Linux and Windows CI runners.
CC claude-code 2026-08-26 18:45:47

Two holes left, and the first one is a hole we dug ourselves.

In-tree packs with no escape hatch means a pack lag blocks the monthly close

Look at what we've decided together: no plugins, no downloadable packs, no arbitrary user SQL, --unsafe-sql quietly dropped somewhere around turn three. Every one of those calls was defensible. Stacked, they mean that when Paddle changes its fee schedule on the 1st — and Paddle has changed pricing more than once — a user cannot reconcile, cannot export, and cannot close the month until a maintainer merges a pack update and cuts a release. For a hobby tool that's an annoyance. For the thing standing between a founder and their accountant's deadline, it's why they go back to the spreadsheet and never come back.

We need exactly one escape hatch, scoped tightly enough that it doesn't reopen the supply-chain or reproducibility problems:

openrecon reconcile --period 2026-05 \
  --pack-override paddle=./overrides/paddle-1.2.0-fee-patch.yaml

The override file may only replace declared values in the obligation DAG — fee rates, fixed components, tolerance windows, reserve rate, FX corridor bounds. It cannot add nodes, cannot change the chain shape, and cannot touch normalizer.go, so the imperative parsing surface stays reviewed and compiled. Every match decided under an override records rule_id with an +override suffix and the override file's sha256 lands in the lock preimage, which keeps verify honest — a locked period reproduces because the override is part of what was locked. doctor reports active overrides prominently, and explain shows them on every affected match. Nobody accidentally runs an override for a year without noticing.

That also gives you the contribution funnel for free: a user who patched a fee rate locally has the diff a pack PR needs.

Re-import is undefined, and cumulative exports make it the common case

The spec never says what openrecon import does when it sees the same file twice, or — far more common — an April export re-downloaded in May that contains April's rows plus late refunds. Provider exports are cumulative. Users will absolutely re-download.

Right now import_id has no derivation rule, so a second import creates fresh raw_records, and then the content-addressed event_id values collide against the append-only canonical_events primary key and the import fails with a constraint error. The tool's most predictable user action is an error.

Two rules fix it:

  • Derive import_id from original_file_sha256 plus provider. Re-importing a byte-identical file is a detected no-op that prints "already imported as imp_…" rather than an error.
  • Importing an overlapping file merges on event_id. Events already present are skipped; genuinely new ones append. That is the whole point of content addressing, and it should be stated as a guarantee in the README, because it's the property that makes the tool safe to re-run.

One collision case needs handling. The identity tuple (merchant_entity, external_ref, event_type, amount_minor, currency, occurred_at) is not unique for two partial refunds of €10 issued against the same order in the same second — a scripted refund in two tranches produces exactly that. We gave bank rows a duplicate_ordinal; provider events need the same. Assign it by stable sort over all events sharing an identity tuple, and freeze it once assigned, so a later import that brings a third tranche appends ordinal 3 rather than renumbering the first two. Renumbering would break every match that references them.

§9.1 conflates "can't export" with "can't lock"

"Block journal export until the residual decision is recorded" makes the tool unusable mid-month. Someone reconciling on the 12th, with one unclassified $15 wire fee, still wants to see their numbers.

Split it. lock refuses while any residual or ambiguous verdict is outstanding — that's the guarantee worth having, and it's what makes "locked means clean" true. export on an unlocked period succeeds with --allow-residual, posts the unexplained amount to Expenses:Suspense:Unreconciled, and stamps the file header provisional: true with the residual count. Provisional exports are explicitly excluded from the byte-identical verify contract. That keeps the strong claim attached to locked periods, where it belongs, without making the daily path a wall.

Expenses:Suspense:Unreconciled needs adding to the chart of accounts, and it should be the one account doctor warns about having a non-zero balance.

Three small schema fixes

residual_cents is the wrong name and is missing a currency. Everything else moved to amount_minor precisely so JPY and KWD don't break; this column reintroduces the cents assumption in the one place that matters most. And a settlement chain spans currencies, so "the residual" has no defined denomination right now. Make it residual_minor INTEGER plus residual_currency TEXT.

period TEXT -- YYYY-MM can't express the tax-year tier. §6 defines a locked-tax-year materiality tier and §2.2 introduced fiscal_year_end, but the column shape only admits months. Either allow 2026, 2026-Q1, 2026-04 with a period_granularity column, or the restatement path has nothing to key on.

doctor should verify the triggers. We claim append-only enforcement, and the enforcement is eight triggers that a .dump/restore cycle or a well-meaning sqlite3 session can remove. Have doctor assert the presence of each trigger and compare a schema_sha256 against the migration version, then report the binary version, Go version, schema version, pack digests, active overrides, and rate-table digest in one block. That's the command an accountant would be shown, so it should answer "is this database still trustworthy" in one screen.

C codex 2026-08-26 18:46:07

The pack override is necessary, but it needs one extra guard: the override content should be stored in the DB, not merely hashed and referenced by path. A local path will disappear, or worse, point to different content later. On use, OpenRecon should copy the canonicalized override JSON/YAML into an append-only pack_overrides table and lock against override_sha256 plus override_id.

pack_overrides(
  override_id TEXT PRIMARY KEY,
  provider TEXT NOT NULL,
  base_pack_version TEXT NOT NULL,
  override_sha256 TEXT NOT NULL,
  canonical_payload TEXT NOT NULL,
  created_at TEXT NOT NULL
)

Then verify reads the override payload from the ledger DB. doctor can still warn if the original file path is gone, but reproducibility does not depend on it. The allowed override fields should be a schema whitelist: fee rate, fixed fee amount, reserve basis/rate, review budget, FX bounds, rounding policy. I would not let users override tax rates in the same mechanism. Tax rate overrides deserve a separate tax_rates import path with source attribution, because a fee tweak and a statutory tax table change have different audit weight.

The re-import point also needs an event_sources table. If overlapping imports skip existing event_ids entirely, we lose the fact that a later provider export independently confirmed the same event. If they append duplicate canonical events, matching breaks. Split it:

canonical_events(event_id, ...stable financial fact...)
event_sources(event_id, import_id, source_line, source_payload_hash, first_seen_at)

Then canonical_events stays one row per financial fact, while event_sources records every file row that asserted it. This is useful in practice: if a Paddle dashboard export and an API export disagree on the same order, OpenRecon can show the two sources side by side instead of overwriting one or throwing a vague collision error.

For duplicate provider events, I would avoid assigning ordinals from an import batch alone. The ordinal allocator has to look at already-known events for the same identity group and append only after the current max. It also needs a stable source fingerprint in the duplicate identity, such as provider transaction sub-id when available or a payload hash of non-PII financial fields. Two EUR 10 partial refunds in the same second are rare; two retries of the same export are common. The allocator must distinguish "new duplicate financial event" from "same event seen again in a later file."

On provisional export, the split is right: lock must refuse outstanding residual or ambiguous; export --allow-residual can exist for unlocked periods. But I would make suspense export noisy by default:

openrecon export beancount --period 2026-04 --allow-residual --provisional

Both flags should be required. The header should include openrecon_provisional: "true", residual count, residual amount by currency, and verify_eligible: "false". That prevents someone from piping a provisional file into their real ledger and later mistaking it for locked output.

Schema patches I would fold into the final result:

residual_minor INTEGER NOT NULL DEFAULT 0,
residual_currency TEXT,
period_key TEXT NOT NULL,
period_granularity TEXT NOT NULL CHECK (period_granularity IN ('month','quarter','year'))

For normal monthly close, period_granularity='month'. For restatement reports, use year or quarter without overloading YYYY-MM strings.

One last CLI addition: openrecon import preview. Before writing anything, it should show redaction mode, detected provider, row count, date range, currencies, new events, duplicate events, and PII fields that will be dropped. Finance users will trust a destructive-looking import more if the first screen says exactly what will be retained. It also gives us a testable contract for redaction without making users inspect SQLite.

A antigravity 2026-08-26 18:46:29 superseded draft
Superseded draft — a later result replaced this. Show it.

GitHub Repository Specification: openrecon (Release-Ready Spec)

A Local-First Settlement Compiler & Double-Entry Journal Exporter for Merchant-of-Record and Multi-Currency SaaS Billing


1. Executive Summary & Boundaries

1.1 What It Is

openrecon is an open-source, local-first CLI compiler that reconciles multi-currency Merchant-of-Record (Paddle, Lemon Squeezy, Polar) and payment gateway (Stripe) exports against bank settlement statements. It compiles opaque payout batches into mathematically verified, auditable, byte-reproducible double-entry accounting journals (Beancount, Ledger, and standard CSV).

1.2 License, Privacy & Threat Model

  • License: Apache-2.0 (provides explicit patent grant protection for payment settlement logic and frictionless adoption by downstream accounting tools).
  • Zero Cloud Footprint: Financial credentials, bank feeds, customer transaction data, and database files never leave the user's local machine.
  • Default PII Redaction: Ingestion automatically drops names, emails, street addresses, IP addresses, tax IDs, and payment method fingerprints, while preserving country, region, and generating a database-salted customer_ref. Dual SHA256 hashes (original_file_sha256 and redacted_payload_sha256) prove both original source provenance and local payload integrity.
  • Default .gitignore: Pre-configured to prevent commits of *.db, *.db-wal, *.db-shm, exports/, ledgers/private/, and rules/learned.local.yaml.

1.3 Strict Non-Goals (Explicit Boundaries)

  • Not a Bookkeeping System: Does not replace general ledger software; it compiles settlement transactions and exports journal entries.
  • Not a Tax Filing Engine: Ingests and verifies provider-reported tax evidence; does not calculate statutory liabilities or file tax returns.
  • Not a Live Bank Connector: Ingests local files only (CSV/JSON). No background daemons, no OAuth credentials, no live sync in v1.
  • Not a SaaS Metrics Engine: Strictly excludes MRR, Churn, or LTV analytics.

2. Deterministic Identity, Idempotent Imports & Timezone Models

2.1 Content-Addressed Identity

Line numbers and file positions are evidence locations, never identities. IDs are deterministically derived from canonical content hashes:

event_id   = ev_<provider>_<base32(sha256(canonical_identity))[0:16]>
match_id   = m_<period_key>_<base32(sha256(sorted_event_ids + rule_id + rule_version))[0:16]>
posting_id = p_<base32(sha256(match_id + obligation_node + posting_index))[0:16]>
  • Provider Event Canonical Identity: (merchant_entity, external_ref, event_type, amount_minor, currency, occurred_at, duplicate_ordinal).
  • Bank Statement Fallback: (account_ref, booked_at, amount_minor, currency, normalized_description, counterparty_ref, duplicate_ordinal).
  • Duplicate Ordinal Allocation: Computed against all previously recorded events in the same identity group and frozen once assigned.

2.2 Idempotent Re-Import & Source Provenance

  • import_id is derived from (provider, original_file_sha256). Re-importing a byte-identical file is a detected no-op.
  • Overlapping cumulative exports merge cleanly: existing event_ids are skipped, new events append to canonical_events, and every asserting file line records a row in event_sources(event_id, import_id, source_line, source_payload_hash, first_seen_at).

2.3 Timezone & Period Bucketing

  • Entity profile specifies a locked reporting_timezone (e.g. America/New_York) and fiscal_year_end.
  • Timestamps are stored in RFC 3339 with explicit offsets. Period bucketing (period_key with period_granularity IN ('month','quarter','year')) is evaluated in the configured timezone. Modifying reporting_timezone after any period is locked is rejected as a hard error.

3. Formal Obligation Architecture & Event Families

Settlements are evaluated across typed, independent obligation DAGs by event family:

1. Sale Family (sale_chain):
   gross_sale -> tax_withheld -> provider_fee -> receivable_opened

2. Refund Family (refund_chain):
   gross_refund -> tax_returned -> fee_returned_policy (none|partial|full) -> receivable_reduced

3. Dispute Family (dispute_chain):
   dispute_hold_opened -> dispute_fee_event -> outcome_event -> (dispute_won_credited | loss_booked)

4. Reserve Family (reserve_chain):
   reserve_hold_opened -> reserve_release_event -> restricted_cash_cleared

5. Settlement Family (settlement_chain):
   SUM(receivable_delta) + SUM(position_delta) + fx_conversion + bank_fee -> bank_receipt

3.1 Currency & Balancing Invariants

  • Single-Commodity Rule: Non-conversion journal entries balance to zero in exactly one currency.
  • Isolated Conversion Transactions: Currency conversions are isolated with explicit Beancount total-price notation (@@).
  • Separation of Spread vs. Timing Gain/Loss:
    • Expenses:Financial:FXSpread: Provider conversion markup on conversion date.
    • Income:Financial:FXGain / Expenses:Financial:FXLoss: Realized currency movement between recognition and settlement dates.
  • Corridor-Specific FX Bounds: Declared in pack manifests (e.g. EUR/USD: "4.0000%", BRL/USD: "9.0000%"). Markups exceeding bounds trigger contradicted: fx_spread_exceeded_bound.
  • Network Decoupling: rates fetch is the sole networked command. Reconcile/export/verify are strictly offline. Missing reference rates trigger contradicted: fx_reference_rate_missing (weekend/holiday policy defaults to prior business day).
  • Exact Decimal Arithmetic: All percentages, rates, and amounts are parsed into typed fixed-point rationals (shopspring/decimal). Zero float64 in calculation paths.

3.2 Structured Verdict Taxonomy

  • satisfied: Obligations balance to zero within currency precision.
  • degenerate: Multiple candidate matches exist, but all candidate projections onto accounting consequences (tax country, currency, accounts, period, amount) are identical. Auto-picks lowest event ID and logs tie_break='degenerate' with zero user prompts.
  • ambiguous: Multiple candidates yield divergent accounting outcomes. Prompts user to select source of truth.
  • residual: Unexplained variance remains (e.g. $15 intermediary wire fee). Prompts user to classify cause into a learned rule.
  • contradicted: Hard bound or arithmetic violation (e.g. negative fees, corridor FX exceedance, rounding drift > 0.01 × order count). Halts execution.

4. Standard Chart of Accounts

Income:SaaS:Sales                              -- Gross revenue recognized on order date
Income:SaaS:Chargebacks                        -- Contra-revenue for dispute losses
Assets:Receivables:{Provider}                  -- Uncleared settlements from provider
Assets:RestrictedCash:{Provider}:Reserve       -- Rolling reserves held by provider
Assets:Bank:{Currency}                         -- Settled bank account funds
Expenses:PaymentProcessing:{Provider}:Fees     -- Provider transaction & platform fees
Expenses:PaymentProcessing:{Provider}:DisputeFees -- Provider dispute fees
Expenses:BankFees:{Bank}                       -- Intermediary / correspondent bank fees
Expenses:Financial:FXSpread                    -- Provider conversion markup
Expenses:Financial:FXLoss                      -- Realized loss on currency timing
Income:Financial:FXGain                        -- Realized gain on currency timing
Expenses:Rounding                              -- Sub-cent calculation rounding differences
Income:Rounding                                -- Sub-cent calculation rounding differences
Expenses:Suspense:Unreconciled                 -- Unallocated variance (provisional exports only)

5. SQLite Schema & Database Invariants

-- 1. Raw Imports & Source Provenance
CREATE TABLE raw_imports (
    import_id               TEXT PRIMARY KEY,
    file_name               TEXT NOT NULL,
    original_file_sha256    TEXT NOT NULL,
    redacted_payload_sha256 TEXT NOT NULL,
    redaction_mode          TEXT NOT NULL,     -- 'default' | 'none'
    provider                TEXT NOT NULL,
    row_count               INTEGER NOT NULL,
    imported_at             TEXT NOT NULL
);

CREATE TABLE raw_records (
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    line_number     INTEGER NOT NULL,
    payload_json    TEXT NOT NULL,
    PRIMARY KEY (import_id, line_number)
);

-- 2. Narrow, Signed Canonical Events & Multi-Source Tracking
CREATE TABLE canonical_events (
    event_id        TEXT PRIMARY KEY,          -- Derived content-addressed hash
    merchant_entity TEXT NOT NULL DEFAULT 'default',
    product_id      TEXT,
    event_type      TEXT NOT NULL,             -- sale, tax_withheld, fee, refund, dispute_opened, dispute_won, dispute_lost, payout, fx_conversion
    occurred_at     TEXT NOT NULL,             -- RFC 3339 with offset
    amount_minor    INTEGER NOT NULL,          -- Signed integer in minor units
    currency        TEXT NOT NULL,
    external_ref    TEXT,
    parent_ref      TEXT,
    attributes_json TEXT NOT NULL
);

CREATE TABLE event_sources (
    event_id            TEXT NOT NULL REFERENCES canonical_events(event_id),
    import_id           TEXT NOT NULL REFERENCES raw_imports(import_id),
    source_line         INTEGER NOT NULL,
    source_payload_hash TEXT NOT NULL,
    first_seen_at       TEXT NOT NULL,
    PRIMARY KEY (event_id, import_id, source_line)
);

-- 3. Append-Only Match Ledger
CREATE TABLE match_ledger (
    match_id           TEXT PRIMARY KEY,
    merchant_entity    TEXT NOT NULL DEFAULT 'default',
    period_key         TEXT NOT NULL,          -- e.g. '2026-04', '2026-Q1', '2026'
    period_granularity TEXT NOT NULL CHECK (period_granularity IN ('month','quarter','year')),
    rule_id            TEXT NOT NULL,
    rule_version       TEXT NOT NULL,
    verdict            TEXT NOT NULL,          -- satisfied | degenerate | ambiguous | residual | contradicted
    residual_minor     INTEGER NOT NULL DEFAULT 0,
    residual_currency  TEXT,
    candidate_count    INTEGER NOT NULL DEFAULT 1,
    tie_break          TEXT,                   -- NULL | degenerate | manual
    decided_by         TEXT NOT NULL,          -- engine | user
    decided_at         TEXT NOT NULL,
    supersedes_id      TEXT REFERENCES match_ledger(match_id)
);

CREATE UNIQUE INDEX match_ledger_one_successor
    ON match_ledger(supersedes_id) WHERE supersedes_id IS NOT NULL;

CREATE TABLE match_events (
    match_id        TEXT NOT NULL REFERENCES match_ledger(match_id),
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    obligation_node TEXT NOT NULL,
    PRIMARY KEY (match_id, event_id, obligation_node)
);

-- 4. Append-Only Decision Events (Learned Rules)
CREATE TABLE decision_events (
    decision_id     TEXT PRIMARY KEY,
    created_at      TEXT NOT NULL,
    actor           TEXT NOT NULL,             -- user | provider_pack | imported_rule
    scope_predicate TEXT NOT NULL,             -- JSON predicate
    decision_type   TEXT NOT NULL,             -- classify_residual | choose_candidate | suppress_noise
    account         TEXT,                      -- Required for classify_residual, NULL for choose/suppress
    amount_formula  TEXT NOT NULL,             -- JSON: {"type":"fixed","amount_minor":1500,"currency":"USD"}
    source_prompt   TEXT
);

-- 5. Position Events (Rolling Reserves & Disputes)
CREATE TABLE position_events (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    position_id     TEXT NOT NULL,
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    kind            TEXT NOT NULL,             -- open | increase | decrease | close | mark_overdue
    amount_minor    INTEGER NOT NULL,
    currency        TEXT NOT NULL,
    occurred_at     TEXT NOT NULL,
    match_id        TEXT REFERENCES match_ledger(match_id)
);

-- 6. Append-Only Pack Overrides (DB-persisted local adjustments)
CREATE TABLE pack_overrides (
    override_id       TEXT PRIMARY KEY,
    provider          TEXT NOT NULL,
    base_pack_version TEXT NOT NULL,
    override_sha256   TEXT NOT NULL,
    canonical_payload TEXT NOT NULL,           -- Whitelisted JSON: fee_rate, fixed_fee, reserve_policy, bounds
    created_at        TEXT NOT NULL
);

-- 7. Append-Only Lock Events
CREATE TABLE lock_events (
    lock_event_id   TEXT PRIMARY KEY,
    merchant_entity TEXT NOT NULL,
    period_key      TEXT NOT NULL,
    period_granularity TEXT NOT NULL CHECK (period_granularity IN ('month','quarter','year')),
    action          TEXT NOT NULL,             -- lock | unlock | relock
    actor           TEXT NOT NULL,
    reason          TEXT,
    input_sha256    TEXT NOT NULL,             -- Preimage: PKs, canonical rows, overrides, rates, profile
    occurred_at     TEXT NOT NULL
);

-- Database Immutability Triggers (Enforced in core migrations)
CREATE TRIGGER match_ledger_no_update BEFORE UPDATE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;
CREATE TRIGGER match_ledger_no_delete BEFORE DELETE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;

CREATE TRIGGER canonical_events_no_update BEFORE UPDATE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;
CREATE TRIGGER canonical_events_no_delete BEFORE DELETE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;

CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;

CREATE TRIGGER position_events_no_update BEFORE UPDATE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;
CREATE TRIGGER position_events_no_delete BEFORE DELETE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;

CREATE TRIGGER lock_events_no_update BEFORE UPDATE ON lock_events
BEGIN SELECT RAISE(ABORT, 'lock_events is append-only'); END;
CREATE TRIGGER lock_events_no_delete BEFORE DELETE ON lock_events
BEGIN SELECT RAISE(ABORT, 'lock_events is append-only'); END;

CREATE TRIGGER pack_overrides_no_update BEFORE UPDATE ON pack_overrides
BEGIN SELECT RAISE(ABORT, 'pack_overrides is append-only'); END;
CREATE TRIGGER pack_overrides_no_delete BEFORE DELETE ON pack_overrides
BEGIN SELECT RAISE(ABORT, 'pack_overrides is append-only'); END;

6. Prior-Period Adjustments (PPA) & Materiality Thresholds

  1. Reversal & Relink: Writes reversal and new match rows carrying openrecon_reverses: <prior_match_id>.
  2. Current Period Posting: Locked past journal files remain byte-immutable. Adjustments post into current open period with explicit contra-accounts (Income:SaaS:Chargebacks) and separate single-currency legs.
  3. Three-Tier Materiality Governance:
    • Immaterial (<0.5% period revenue): Auto-posted to current open period.
    • Material (≥0.5% period revenue): Halts with contradicted: ppa_materiality_exceeded and requires --accept-material-ppa <match_id>.
    • Locked Tax Year: Never auto-posted into monthly export; generates a standalone restatement report.

7. Repository Layout

openrecon/
├── cmd/openrecon/              # Cobra CLI entry point
├── core/
│   ├── db/                     # SQLite schema, migrations, 12 immutability triggers, WAL setup
│   ├── currency/               # Minor units (JPY=0, KWD=3), decimal arithmetic, offline ECB cache
│   ├── events/                 # Canonical event model & PII redactor
│   ├── obligations/            # Typed obligation DAG evaluator & FX corridor bound checks
│   ├── positions/              # Reserve/dispute subledger state machine
│   ├── ledger/                 # Match ledger, lock events, decision events, pack overrides
│   └── export/                 # Deterministic Beancount & CSV compilers (provisional & locked)
├── packs/                      # In-tree provider packs (Paddle, Lemon Squeezy, Stripe, Wise)
│   └── paddle/
│       ├── manifest.yaml       # Semver, typed review budget, typed reserve policy, FX corridors
│       ├── normalizer.go       # Imperative parser: Raw -> Canonical Events
│       ├── obligations.yaml    # Declarative obligation DAG & fee policies
│       ├── tax_rates.yaml      # Temporal tax tables
│       ├── CHANGELOG.md        # Model change log
│       ├── testdata/           # Small contract test vectors
│       └── golden/             # Expected events and journal outputs
├── testdata/
│   ├── e2e/                    # Multi-provider synthetic monthly scenarios
│   └── fixtures/               # Hostile raw edge-case dumps
├── examples/                   # Configuration profiles and sample .bean files
├── docs/
│   ├── accounting-model.md     # Chart of accounts, single-commodity rules, sign conventions
│   ├── non-goals.md            # Scope boundaries and operational limits
│   └── provider-packs.md       # Guide to writing and testing in-tree packs
├── .gitignore                  # Prevents accidental commits of *.db, exports/, ledgers/private/
├── LICENSE                     # Apache-2.0
├── README.md                   # Quickstart, architecture overview, CLI reference
└── Taskfile.yml                # CI tasks: pack hash verification, golden tests, byte-diff checks

8. CLI Reference & Workflows

# 1. Manage Reference Rates (Only networked command)
openrecon rates fetch --source ecb --from 2020-01-01
openrecon rates import ./rates/ecb.csv --source ecb

# 2. Preview and Ingest Exports (Default PII Redaction)
openrecon import preview ./exports/2026-04 --profile examples/paddle-wise.yaml
openrecon import ./exports/2026-04 --profile examples/paddle-wise.yaml

# 3. Compile Settlement & Interactive Triage (Cause-level prompts)
openrecon reconcile --period 2026-04 --interactive

# 4. Mid-Month Provisional Inspection vs. Overdue Positions
openrecon export beancount --period 2026-04 --allow-residual --provisional > ./ledgers/provisional.bean
openrecon positions --provider paddle --overdue

# 5. Lock Period, Export Canonical Beancount Journal, & Verify Reproducibility
openrecon lock --period 2026-04
openrecon export beancount --period 2026-04 > ./ledgers/2026-04.bean
openrecon verify --period 2026-04

# 6. Apply Local Pack Override & System Diagnostics
openrecon overrides apply ./overrides/paddle-fee-adjust.yaml
openrecon rules export --learned > ./rules/learned.yaml
openrecon doctor

9. Synthetic Acceptance Test Specification (CI Benchmark)

The acceptance test suite executes across two discrete phases in CI on Linux and Windows:

9.1 Cold Phase (No Pre-Existing Decisions)

  • Dataset: 500 Orders (350 EUR VAT-inclusive sales across Germany/France, 150 USD), Paddle 10% gross reserve held, 5% + €0.50 fees, Wise USD payout arriving $15.00 short due to intermediary routing fees, 1 chargeback, 3 cross-month refunds, 1 March dispute clawback.
  • Assertion: Must produce exactly 1 residual prompt (suggesting Expenses:BankFees:Wise for the $15.00 gap), 0 unbudgeted prompts, refuse openrecon lock, and require explicit --allow-residual --provisional flags if exported before locking.

9.2 Warm Phase (Decisions Ingested)

  • Input: Ingests decisions.yaml generated from Cold Phase resolution.
  • Assertion: Reconciles with 0 prompts, executes all FX conversions against the local ECB benchmark with <4% spread validation, locks cleanly, and emits a Beancount export that balances to zero and matches the checked-in golden file byte-for-byte on both Linux and Windows CI runners.
CC claude-code 2026-08-26 18:47:33

event_sources doesn't do the job it was added for, and the mandatory CI benchmark never exercises the hardest subsystem in the spec.

Content addressing makes source disagreement undetectable

event_sources was introduced so that when a Paddle dashboard export and an API export disagree about the same order, the tool can show both side by side. It can't. event_id is a hash over the financial facts, so if the two sources disagree about the amount, the tax, or the timestamp, they produce two different event_id values — two separate rows in canonical_events, each with one happy source, and nothing anywhere marked as a conflict. source_payload_hash differing tells you only that a non-financial column changed formatting.

The failure that produces is worse than an error. Both events are live, both feed SUM(amount_minor) in the settlement chain, and the payout comes up short or long by the difference. You get a residual verdict pointing at a wire fee that doesn't exist, and the user classifies a phantom cause into a learned rule that then fires forever.

Detect it on the natural key, not the content hash:

CREATE UNIQUE INDEX canonical_events_natural_key
  ON canonical_events(merchant_entity, external_ref, event_type, duplicate_ordinal)
  WHERE external_ref IS NOT NULL;

Two rows colliding there mean two sources assert different financial facts about the same provider record. That's contradicted: source_disagreement, surfaced by explain with both payloads and both import ids, and resolved by an explicit choose_candidate decision naming the authoritative source. Which is exactly the decision type we already have and currently have no producer for.

§9 never tests prior-period adjustment, and that's the riskiest code in the design

The benchmark starts from an empty database, imports April, and includes "1 March dispute clawback." March was never imported and never locked, so there's nothing to adjust against. The PPA path, the materiality tiers, the supersedes_id chain, lock_events relock, and the immutability promise all have zero coverage in the one test we made mandatory.

Make it three phases:

  1. march_close — import March, reconcile, lock. Record the export's sha256.
  2. cold_april — import April carrying the clawback against a March order. Assert exactly one residual (the $15 Wise fee), that the clawback produces a reversal + successor pair whose supersedes_id points at the March match, and that lock refuses.
  3. warm_april — import decisions, assert zero prompts, lock, export.

Then the assertion that actually matters, and that nothing currently makes: after April closes, re-export March and assert its bytes are unchanged from phase 1. That single check is the difference between "immutable by design" and "immutable." It's also the one an accountant would ask for.

Add a materiality case while you're there: make the March clawback large enough to cross 0.5% of March revenue, and assert the run halts with contradicted: ppa_materiality_exceeded until --accept-material-ppa is passed. Right now that tier is prose with no test behind it.

Three regressions and omissions

The export sort order dropped out. We settled on (date, payout_id, order_id, posting_index) several drafts ago and the current spec asserts byte-identical output without ever saying how postings are ordered. That ordering is the implementation contract for core/export/, and sorting by the content-addressed posting_id instead — the obvious thing a contributor would reach for now that it's a hash — would be stable but produce a file in random-looking order that no human can diff. Put the sort key back in the spec text.

raw_records and event_sources have no immutability triggers. There are twelve triggers covering six tables, and raw_records isn't one of them — which means redacted_payload_sha256 in raw_imports is a claim about a table anyone can UPDATE. Make it fourteen.

Beancount open directives are unspecified, and this is what breaks the first user's bean-check. Strict Beancount requires an open for every account before its first posting. If each period's export emits its own opens, concatenating 2026-03.bean and 2026-04.bean — which is precisely how someone assembles a year — fails with duplicate-open errors. Emit them once into a generated accounts.bean with open dated to each account's earliest activity across the whole ledger, have the period files carry only transactions, and ship an include line in the quickstart. Also emit option "operating_currency" per currency in use, or multi-currency balance reports come out empty and people assume the numbers are wrong.

C codex 2026-08-26 18:48:14

The source-disagreement point is right: event_sources only preserves provenance after we already decided two facts are the same. It does not decide whether they conflict. We need a second identity layer: a provider natural key for conflict detection, and a content hash for event identity.

I would store both:

canonical_events(
  event_id TEXT PRIMARY KEY,             -- content-derived fact id
  natural_key TEXT,                      -- provider/order/refund/dispute key when available
  natural_key_scope TEXT NOT NULL,       -- provider + merchant_entity + event_type
  ...
)

CREATE UNIQUE INDEX canonical_events_no_duplicate_fact
  ON canonical_events(event_id);

But I would not make the natural-key index unique, because a conflict must be representable in the DB before the resolver can show it. Instead add a detector query or materialized view:

source_conflicts AS
SELECT natural_key_scope, natural_key, COUNT(DISTINCT event_id) AS variants
FROM canonical_events
WHERE natural_key IS NOT NULL
GROUP BY natural_key_scope, natural_key
HAVING variants > 1;

That produces contradicted: source_disagreement and blocks matching until a choose_candidate decision picks one event_id as authoritative or marks a provider export as superseded. If the unique index fires during import, the user gets a constraint error before they can inspect either version. Conflicts should be first-class rows, not failed inserts.

I would add this table to make the resolution auditable:

source_conflict_decisions(
  conflict_id TEXT PRIMARY KEY,
  natural_key_scope TEXT NOT NULL,
  natural_key TEXT NOT NULL,
  chosen_event_id TEXT NOT NULL,
  rejected_event_ids TEXT NOT NULL,      -- JSON array
  decision_id TEXT NOT NULL REFERENCES decision_events(decision_id)
)

On the CI benchmark, the three-phase version is the right shape. The PPA machinery cannot be tested by smuggling a March clawback into a database that never knew March existed. I would make the acceptance fixture explicitly state the expected invariant:

march_close:
  lock: succeeds
  export_sha256: sha256_march_v1

cold_april:
  ppa_successor_pairs: 1
  ppa_materiality: exceeded
  lock: fails until --accept-material-ppa
  residual_causes: 1

after_april_close:
  reexport_march_sha256: sha256_march_v1

That last line belongs in bold in the spec. If March's bytes change after April reveals new information, OpenRecon has failed its central promise.

The export-account issue is also an MVP requirement. I would define three export targets:

openrecon export accounts > accounts.bean
openrecon export beancount --period 2026-03 > 2026-03.bean
openrecon export root --year 2026 > main.bean

accounts.bean owns open directives and operating currencies. Period files own transactions only. main.bean owns include ordering:

include "accounts.bean"
include "2026-03.bean"
include "2026-04.bean"

export accounts should scan all current canonical postings and choose each account's open date as the earlier of the first posting date or the profile's configured ledger start date. No duplicate opens in monthly files.

The export sort key should be explicit and human-oriented:

(date, provider, payout_external_ref, order_external_ref, obligation_family, posting_index, posting_id)

Use posting_id only as the final tie-breaker. Sorting primarily by hash would be stable but unreadable; a reconciliation ledger has to be diffable by humans.

Add the missing triggers for raw_records and event_sources. Since event_sources is append-only provenance, mutating it would let a bad restore or script detach an event from the file that proved it. doctor should count fourteen triggers now, plus the one-successor index, plus the schema hash. If any are missing, it should print database_integrity: failed and refuse verify.

A antigravity 2026-08-26 18:48:34 superseded draft
Superseded draft — a later result replaced this. Show it.

GitHub Repository Specification: openrecon (v1.0 Production Blueprint)

A Local-First Settlement Compiler & Double-Entry Journal Exporter for Merchant-of-Record and Multi-Currency SaaS Billing


1. Executive Summary & Boundaries

1.1 What It Is

openrecon is an open-source, local-first CLI compiler that reconciles multi-currency Merchant-of-Record (Paddle, Lemon Squeezy, Polar) and payment gateway (Stripe) exports against bank settlement statements. It compiles opaque payout batches into mathematically verified, auditable, byte-reproducible double-entry accounting journals (Beancount, Ledger, and standard CSV).

1.2 License, Privacy & Threat Model

  • License: Apache-2.0 (provides explicit patent grant protection for payment settlement logic and frictionless adoption by downstream accounting ecosystems).
  • Zero Cloud Footprint: Financial credentials, bank feeds, customer transaction data, and database files never leave the user's local machine.
  • Default PII Redaction: Ingestion automatically drops names, emails, street addresses, IP addresses, tax IDs, and payment method fingerprints, while preserving country, region, and generating a database-salted customer_ref. Dual SHA256 hashes (original_file_sha256 and redacted_payload_sha256) prove both original source provenance and local payload integrity.
  • Default .gitignore: Pre-configured to prevent accidental commits of *.db, *.db-wal, *.db-shm, exports/, ledgers/private/, and rules/learned.local.yaml.

1.3 Strict Non-Goals (Explicit Boundaries)

  • Not a Bookkeeping System: Does not replace general ledger software; it compiles settlement transactions and exports journal entries.
  • Not a Tax Filing Engine: Ingests and verifies provider-reported tax evidence; does not calculate statutory liabilities or file tax returns.
  • Not a Live Bank Connector: Ingests local files only (CSV/JSON). No background daemons, no OAuth credentials, no live sync in v1.
  • Not a SaaS Metrics Engine: Strictly excludes MRR, Churn, or LTV analytics.

2. Deterministic Identity, Idempotent Imports & Timezone Models

2.1 Content-Addressed Identity

Line numbers and file positions are evidence locations, never identities. IDs are deterministically derived from canonical content hashes:

event_id   = ev_<provider>_<base32(sha256(canonical_identity))[0:16]>
match_id   = m_<period_key>_<base32(sha256(sorted_event_ids + rule_id + rule_version))[0:16]>
posting_id = p_<base32(sha256(match_id + obligation_node + posting_index))[0:16]>
  • Provider Event Identity: (merchant_entity, external_ref, event_type, amount_minor, currency, occurred_at, duplicate_ordinal).
  • Bank Statement Fallback: (account_ref, booked_at, amount_minor, currency, normalized_description, counterparty_ref, duplicate_ordinal).
  • Duplicate Ordinal Allocation: Computed against all previously recorded events in the same identity group and frozen once assigned.

2.2 Source Conflict Resolution & Natural Keys

  • To handle cases where different files (e.g. Dashboard CSV vs. API JSON) assert conflicting data for the same provider record, events track natural_key and natural_key_scope.
  • Conflicting variants trigger contradicted: source_disagreement and halt matching until the operator selects the authoritative source via openrecon resolve-conflict, recording the audit trail in source_conflict_decisions.

2.3 Idempotent Re-Import & Source Provenance

  • import_id is derived from (provider, original_file_sha256). Re-importing a byte-identical file is a detected no-op.
  • Overlapping cumulative exports merge cleanly: existing event_ids are skipped, new events append to canonical_events, and every asserting file line records a row in event_sources(event_id, import_id, source_line, source_payload_hash, first_seen_at).

2.4 Timezone & Period Bucketing

  • Entity profile specifies a locked reporting_timezone (e.g. America/New_York) and fiscal_year_end.
  • Timestamps are stored in RFC 3339 with explicit offsets. Period bucketing (period_key with period_granularity IN ('month','quarter','year')) is evaluated in the configured timezone. Modifying reporting_timezone after any period is locked is rejected as a hard error.

3. Formal Obligation Architecture & Event Families

Settlements are evaluated across typed, independent obligation DAGs by event family:

1. Sale Family (sale_chain):
   gross_sale -> tax_withheld -> provider_fee -> receivable_opened

2. Refund Family (refund_chain):
   gross_refund -> tax_returned -> fee_returned_policy (none|partial|full) -> receivable_reduced

3. Dispute Family (dispute_chain):
   dispute_hold_opened -> dispute_fee_event -> outcome_event -> (dispute_won_credited | loss_booked)

4. Reserve Family (reserve_chain):
   reserve_hold_opened -> reserve_release_event -> restricted_cash_cleared

5. Settlement Family (settlement_chain):
   SUM(receivable_delta) + SUM(position_delta) + fx_conversion + bank_fee -> bank_receipt

3.1 Currency & Balancing Invariants

  • Single-Commodity Rule: Non-conversion journal entries balance to zero in exactly one currency.
  • Isolated Conversion Transactions: Currency conversions are isolated with explicit Beancount total-price notation (@@).
  • Separation of Spread vs. Timing Gain/Loss:
    • Expenses:Financial:FXSpread: Provider conversion markup on conversion date.
    • Income:Financial:FXGain / Expenses:Financial:FXLoss: Realized currency movement between recognition and settlement dates.
  • Corridor-Specific FX Bounds: Declared in pack manifests (e.g. EUR/USD: "4.0000%", BRL/USD: "9.0000%"). Markups exceeding bounds trigger contradicted: fx_spread_exceeded_bound.
  • Network Decoupling: rates fetch is the sole networked command. Reconcile/export/verify are strictly offline. Missing reference rates trigger contradicted: fx_reference_rate_missing (weekend/holiday policy defaults to prior business day).
  • Exact Decimal Arithmetic: All percentages, rates, and amounts are parsed into typed fixed-point rationals (shopspring/decimal). Zero float64 in calculation paths.

3.2 Structured Verdict Taxonomy

  • satisfied: Obligations balance to zero within currency precision.
  • degenerate: Multiple candidate matches exist, but all candidate projections onto accounting consequences (tax country, currency, accounts, period, amount) are identical. Auto-picks lowest event ID and logs tie_break='degenerate' with zero user prompts.
  • ambiguous: Multiple candidates yield divergent accounting outcomes. Prompts user to select source of truth.
  • residual: Unexplained variance remains (e.g. $15 intermediary wire fee). Prompts user to classify cause into a learned rule.
  • contradicted: Hard bound or arithmetic violation (e.g. negative fees, corridor FX exceedance, source disagreement, rounding drift > 0.01 × order count). Halts execution.

4. Standard Chart of Accounts

Income:SaaS:Sales                              -- Gross revenue recognized on order date
Income:SaaS:Chargebacks                        -- Contra-revenue for dispute losses
Assets:Receivables:{Provider}                  -- Uncleared settlements from provider
Assets:RestrictedCash:{Provider}:Reserve       -- Rolling reserves held by provider
Assets:Bank:{Currency}                         -- Settled bank account funds
Expenses:PaymentProcessing:{Provider}:Fees     -- Provider transaction & platform fees
Expenses:PaymentProcessing:{Provider}:DisputeFees -- Provider dispute fees
Expenses:BankFees:{Bank}                       -- Intermediary / correspondent bank fees
Expenses:Financial:FXSpread                    -- Provider conversion markup
Expenses:Financial:FXLoss                      -- Realized loss on currency timing
Income:Financial:FXGain                        -- Realized gain on currency timing
Expenses:Rounding                              -- Sub-cent calculation rounding differences
Income:Rounding                                -- Sub-cent calculation rounding differences
Expenses:Suspense:Unreconciled                 -- Unallocated variance (provisional exports only)

5. SQLite Schema & Database Invariants

-- 1. Raw Imports & Source Provenance
CREATE TABLE raw_imports (
    import_id               TEXT PRIMARY KEY,
    file_name               TEXT NOT NULL,
    original_file_sha256    TEXT NOT NULL,
    redacted_payload_sha256 TEXT NOT NULL,
    redaction_mode          TEXT NOT NULL,     -- 'default' | 'none'
    provider                TEXT NOT NULL,
    row_count               INTEGER NOT NULL,
    imported_at             TEXT NOT NULL
);

CREATE TABLE raw_records (
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    line_number     INTEGER NOT NULL,
    payload_json    TEXT NOT NULL,
    PRIMARY KEY (import_id, line_number)
);

-- 2. Narrow, Signed Canonical Events & Multi-Source Tracking
CREATE TABLE canonical_events (
    event_id          TEXT PRIMARY KEY,        -- Derived content-addressed hash
    natural_key       TEXT,                    -- External order_id, payout_id, etc.
    natural_key_scope TEXT NOT NULL,           -- provider + merchant_entity + event_type
    merchant_entity   TEXT NOT NULL DEFAULT 'default',
    product_id        TEXT,
    event_type        TEXT NOT NULL,           -- sale, tax_withheld, fee, refund, dispute_opened, dispute_won, dispute_lost, payout, fx_conversion
    occurred_at       TEXT NOT NULL,           -- RFC 3339 with offset
    amount_minor      INTEGER NOT NULL,        -- Signed integer in minor units
    currency          TEXT NOT NULL,
    external_ref      TEXT,
    parent_ref        TEXT,
    duplicate_ordinal INTEGER NOT NULL DEFAULT 0,
    attributes_json   TEXT NOT NULL
);

CREATE TABLE event_sources (
    event_id            TEXT NOT NULL REFERENCES canonical_events(event_id),
    import_id           TEXT NOT NULL REFERENCES raw_imports(import_id),
    source_line         INTEGER NOT NULL,
    source_payload_hash TEXT NOT NULL,
    first_seen_at       TEXT NOT NULL,
    PRIMARY KEY (event_id, import_id, source_line)
);

-- Materialized detection view for conflicting source data on identical natural keys
CREATE VIEW source_conflicts AS
SELECT natural_key_scope, natural_key, COUNT(DISTINCT event_id) AS variant_count
FROM canonical_events
WHERE natural_key IS NOT NULL
GROUP BY natural_key_scope, natural_key
HAVING variant_count > 1;

CREATE TABLE source_conflict_decisions (
    conflict_id        TEXT PRIMARY KEY,
    natural_key_scope  TEXT NOT NULL,
    natural_key        TEXT NOT NULL,
    chosen_event_id    TEXT NOT NULL REFERENCES canonical_events(event_id),
    rejected_event_ids TEXT NOT NULL,          -- JSON array of discarded event_ids
    decision_id        TEXT NOT NULL REFERENCES decision_events(decision_id)
);

-- 3. Append-Only Match Ledger
CREATE TABLE match_ledger (
    match_id           TEXT PRIMARY KEY,
    merchant_entity    TEXT NOT NULL DEFAULT 'default',
    period_key         TEXT NOT NULL,          -- e.g. '2026-04', '2026-Q1', '2026'
    period_granularity TEXT NOT NULL CHECK (period_granularity IN ('month','quarter','year')),
    rule_id            TEXT NOT NULL,
    rule_version       TEXT NOT NULL,
    verdict            TEXT NOT NULL,          -- satisfied | degenerate | ambiguous | residual | contradicted
    residual_minor     INTEGER NOT NULL DEFAULT 0,
    residual_currency  TEXT,
    candidate_count    INTEGER NOT NULL DEFAULT 1,
    tie_break          TEXT,                   -- NULL | degenerate | manual
    decided_by         TEXT NOT NULL,          -- engine | user
    decided_at         TEXT NOT NULL,
    supersedes_id      TEXT REFERENCES match_ledger(match_id)
);

CREATE UNIQUE INDEX match_ledger_one_successor
    ON match_ledger(supersedes_id) WHERE supersedes_id IS NOT NULL;

CREATE TABLE match_events (
    match_id        TEXT NOT NULL REFERENCES match_ledger(match_id),
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    obligation_node TEXT NOT NULL,
    PRIMARY KEY (match_id, event_id, obligation_node)
);

-- 4. Append-Only Decision Events (Learned Rules)
CREATE TABLE decision_events (
    decision_id     TEXT PRIMARY KEY,
    created_at      TEXT NOT NULL,
    actor           TEXT NOT NULL,             -- user | provider_pack | imported_rule
    scope_predicate TEXT NOT NULL,             -- JSON predicate
    decision_type   TEXT NOT NULL,             -- classify_residual | choose_candidate | suppress_noise
    account         TEXT,                      -- Required for classify_residual, NULL for choose/suppress
    amount_formula  TEXT NOT NULL,             -- JSON: {"type":"fixed","amount_minor":1500,"currency":"USD"}
    source_prompt   TEXT
);

-- 5. Position Events (Rolling Reserves & Disputes)
CREATE TABLE position_events (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    position_id     TEXT NOT NULL,
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    kind            TEXT NOT NULL,             -- open | increase | decrease | close | mark_overdue
    amount_minor    INTEGER NOT NULL,
    currency        TEXT NOT NULL,
    occurred_at     TEXT NOT NULL,
    match_id        TEXT REFERENCES match_ledger(match_id)
);

-- 6. Append-Only Pack Overrides (DB-persisted local adjustments)
CREATE TABLE pack_overrides (
    override_id       TEXT PRIMARY KEY,
    provider          TEXT NOT NULL,
    base_pack_version TEXT NOT NULL,
    override_sha256   TEXT NOT NULL,
    canonical_payload TEXT NOT NULL,           -- Whitelisted JSON: fee_rate, fixed_fee, reserve_policy, bounds
    created_at        TEXT NOT NULL
);

-- 7. Append-Only Lock Events
CREATE TABLE lock_events (
    lock_event_id   TEXT PRIMARY KEY,
    merchant_entity TEXT NOT NULL,
    period_key      TEXT NOT NULL,
    period_granularity TEXT NOT NULL CHECK (period_granularity IN ('month','quarter','year')),
    action          TEXT NOT NULL,             -- lock | unlock | relock
    actor           TEXT NOT NULL,
    reason          TEXT,
    input_sha256    TEXT NOT NULL,             -- Preimage: PKs, canonical rows, overrides, rates, profile
    occurred_at     TEXT NOT NULL
);

-- 14 Database Immutability Triggers (Enforced in core migrations)
CREATE TRIGGER match_ledger_no_update BEFORE UPDATE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;
CREATE TRIGGER match_ledger_no_delete BEFORE DELETE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;

CREATE TRIGGER canonical_events_no_update BEFORE UPDATE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;
CREATE TRIGGER canonical_events_no_delete BEFORE DELETE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;

CREATE TRIGGER raw_records_no_update BEFORE UPDATE ON raw_records
BEGIN SELECT RAISE(ABORT, 'raw_records is append-only'); END;
CREATE TRIGGER raw_records_no_delete BEFORE DELETE ON raw_records
BEGIN SELECT RAISE(ABORT, 'raw_records is append-only'); END;

CREATE TRIGGER event_sources_no_update BEFORE UPDATE ON event_sources
BEGIN SELECT RAISE(ABORT, 'event_sources is append-only'); END;
CREATE TRIGGER event_sources_no_delete BEFORE DELETE ON event_sources
BEGIN SELECT RAISE(ABORT, 'event_sources is append-only'); END;

CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;

CREATE TRIGGER position_events_no_update BEFORE UPDATE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;
CREATE TRIGGER position_events_no_delete BEFORE DELETE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;

CREATE TRIGGER lock_events_no_update BEFORE UPDATE ON lock_events
BEGIN SELECT RAISE(ABORT, 'lock_events is append-only'); END;
CREATE TRIGGER lock_events_no_delete BEFORE DELETE ON lock_events
BEGIN SELECT RAISE(ABORT, 'lock_events is append-only'); END;

CREATE TRIGGER pack_overrides_no_update BEFORE UPDATE ON pack_overrides
BEGIN SELECT RAISE(ABORT, 'pack_overrides is append-only'); END;
CREATE TRIGGER pack_overrides_no_delete BEFORE DELETE ON pack_overrides
BEGIN SELECT RAISE(ABORT, 'pack_overrides is append-only'); END;

6. Prior-Period Adjustments (PPA) & Materiality Thresholds

  1. Reversal & Relink: Writes reversal and new match rows carrying openrecon_reverses: <prior_match_id>.
  2. Current Period Posting: Locked past journal files remain byte-immutable. Adjustments post into current open period with explicit contra-accounts (Income:SaaS:Chargebacks) and separate single-currency legs.
  3. Three-Tier Materiality Governance:
    • Immaterial (<0.5% period revenue): Auto-posted to current open period.
    • Material (≥0.5% period revenue): Halts with contradicted: ppa_materiality_exceeded and requires --accept-material-ppa <match_id>.
    • Locked Tax Year: Never auto-posted into monthly export; generates a standalone restatement report.

7. Modular Beancount Export Specification

Exports are modularized to prevent duplicate open directive errors in bean-check:

  1. accounts.bean (openrecon export accounts):
    • Declares option "operating_currency" for all utilized currencies.
    • Emits exactly one dated open directive per account, dated to the earliest activity across the entire ledger.
  2. Period Files (2026-03.bean, 2026-04.bean):
    • Contain strictly transactions and custom metadata directives. No account open statements.
    • Deterministic Human-Diffable Sort Ordering: Postings sorted by:
      (date, provider, payout_external_ref, order_external_ref, obligation_family, posting_index, posting_id)
  3. Master Ledger Index (main.bean via openrecon export root --year 2026):
    include "accounts.bean"
    include "2026-03.bean"
    include "2026-04.bean"
    

8. Repository Layout

openrecon/
├── cmd/openrecon/              # Cobra CLI entry point
├── core/
│   ├── db/                     # SQLite schema, migrations, 14 immutability triggers, WAL setup
│   ├── currency/               # Minor units (JPY=0, KWD=3), decimal arithmetic, offline ECB cache
│   ├── events/                 # Canonical event model & PII redactor
│   ├── obligations/            # Typed obligation DAG evaluator & FX corridor bound checks
│   ├── positions/              # Reserve/dispute subledger state machine
│   ├── ledger/                 # Match ledger, lock events, decision events, pack overrides
│   └── export/                 # Deterministic Beancount (modular) & CSV compilers
├── packs/                      # In-tree provider packs (Paddle, Lemon Squeezy, Stripe, Wise)
│   └── paddle/
│       ├── manifest.yaml       # Semver, typed review budget, typed reserve policy, FX corridors
│       ├── normalizer.go       # Imperative parser: Raw -> Canonical Events
│       ├── obligations.yaml    # Declarative obligation DAG & fee policies
│       ├── tax_rates.yaml      # Temporal tax tables
│       ├── CHANGELOG.md        # Model change log
│       ├── testdata/           # Small contract test vectors
│       └── golden/             # Expected events and journal outputs
├── testdata/
│   ├── e2e/                    # 3-phase March-April synthetic scenario
│   └── fixtures/               # Hostile raw edge-case dumps
├── examples/                   # Configuration profiles and sample .bean files
├── docs/
│   ├── accounting-model.md     # Chart of accounts, single-commodity rules, sign conventions
│   ├── non-goals.md            # Scope boundaries and operational limits
│   └── provider-packs.md       # Guide to writing and testing in-tree packs
├── .gitignore                  # Prevents accidental commits of *.db, exports/, ledgers/private/
├── LICENSE                     # Apache-2.0
├── README.md                   # Quickstart, architecture overview, CLI reference
└── Taskfile.yml                # CI tasks: pack hash verification, golden tests, byte-diff checks

9. CLI Reference & Workflows

# 1. Manage Reference Rates (Only networked command)
openrecon rates fetch --source ecb --from 2020-01-01
openrecon rates import ./rates/ecb.csv --source ecb

# 2. Preview and Ingest Exports (Default PII Redaction)
openrecon import preview ./exports/2026-04 --profile examples/paddle-wise.yaml
openrecon import ./exports/2026-04 --profile examples/paddle-wise.yaml

# 3. Resolve Conflicting Sources (if contradictory exports exist)
openrecon resolve-conflict --conflict-id conf_paddle_ord_1029 --choose ev_paddle_api_1029

# 4. Compile Settlement & Interactive Triage (Cause-level prompts)
openrecon reconcile --period 2026-04 --interactive

# 5. Mid-Month Provisional Inspection vs. Overdue Positions
openrecon export beancount --period 2026-04 --allow-residual --provisional > ./ledgers/provisional.bean
openrecon positions --provider paddle --overdue

# 6. Lock Period, Export Modular Beancount Journals, & Verify Reproducibility
openrecon lock --period 2026-04
openrecon export accounts > ./ledgers/accounts.bean
openrecon export beancount --period 2026-04 > ./ledgers/2026-04.bean
openrecon export root --year 2026 > ./ledgers/main.bean
openrecon verify --period 2026-04

# 7. Apply Local Pack Override & System Integrity Diagnostics
openrecon overrides apply ./overrides/paddle-fee-adjust.yaml
openrecon rules export --learned > ./rules/learned.yaml
openrecon doctor

10. Synthetic Acceptance Test Specification (CI Benchmark)

The acceptance test suite executes across three sequential phases in CI on Linux and Windows:

10.1 Phase 1: March Initial Close (march_close)

  • Dataset: March normal sales volume.
  • Assertion: Reconciles, locks, exports 2026-03.bean. Records sha256(2026-03.bean) = sha256_march_v1.

10.2 Phase 2: Cold April Triage & PPA Materiality (cold_april)

  • Dataset: April orders + March dispute clawback crossing 0.5% materiality threshold + $15.00 Wise intermediary wire shortfall.
  • Assertion:
    1. Halts with contradicted: ppa_materiality_exceeded until operator passes --accept-material-ppa.
    2. Generates exactly 1 residual prompt (suggesting Expenses:BankFees:Wise for the $15.00 gap) and 0 unbudgeted prompts.
    3. Refuses openrecon lock while residual is open.

10.3 Phase 3: Warm April Close & Historical Immutability (warm_april)

  • Input: Ingests decisions.yaml from Phase 2.
  • Assertion:
    1. Reconciles with 0 prompts, locks April, and exports 2026-04.bean.
    2. Core Invariant Assertion: Re-exports March (2026-03.bean) and asserts that sha256(2026-03.bean) == sha256_march_v1 byte-for-byte.
    3. Passes cross-platform byte-equality tests between Linux and Windows runners.
CC claude-code 2026-08-26 18:49:31

The conflict-detection machinery has two defects that cancel out its purpose, §3.2 and §10.2 contradict each other, and the cross-platform byte test will fail on the first Windows run for a reason that has nothing to do with the code.

source_conflicts fires on every partial refund

GROUP BY natural_key_scope, natural_key
HAVING COUNT(DISTINCT event_id) > 1

natural_key_scope is provider + entity + event_type, and natural_key is the order reference. Two legitimate partial refunds against order ord_9872 — the exact case we added duplicate_ordinal to support one draft ago — share both. So the view reports them as a source disagreement and halts reconciliation for a merchant who did nothing but refund in two tranches.

duplicate_ordinal has to be in the grouping key:

GROUP BY natural_key_scope, natural_key, duplicate_ordinal
HAVING COUNT(DISTINCT event_id) > 1

Then a conflict means what it should: two sources asserting different financial facts about the same record instance.

Resolving a conflict doesn't change the arithmetic

resolve-conflict writes a row to source_conflict_decisions naming the chosen event_id. canonical_events is append-only, so the rejected variant stays in the table, and nothing in the spec says the obligation evaluator excludes it. The audit trail says "resolved" while both variants still feed SUM(amount_minor) in the settlement chain.

That's worse than not detecting the conflict, because the user is told it's handled.

Every obligation and export query has to read a view, never the base table:

CREATE VIEW authoritative_events AS
SELECT e.* FROM canonical_events e
LEFT JOIN source_conflict_rejections r ON r.rejected_event_id = e.event_id
WHERE r.rejected_event_id IS NULL;

And rejected_event_ids TEXT -- JSON array should be a row-per-rejection table instead. A JSON blob has no foreign key, can name an event that doesn't exist, and turns "is this event rejected" — a question asked on every read — into a JSON scan:

CREATE TABLE source_conflict_rejections (
    conflict_id       TEXT NOT NULL REFERENCES source_conflict_decisions(conflict_id),
    rejected_event_id TEXT NOT NULL REFERENCES canonical_events(event_id),
    PRIMARY KEY (conflict_id, rejected_event_id)
);

The conflict decision also has to land in the lock preimage. Which variant was authoritative changes the numbers, so a period locked before a re-resolution must not silently verify afterwards.

§3.2 says contradicted halts execution; §10.2 asserts a halt and a prompt from the same run

Phase 2 requires the run to halt with ppa_materiality_exceeded and to produce exactly one residual prompt for the $15 Wise fee. Under the current definition of contradicted — "halts execution" — the second assertion is unreachable. The test as specified cannot pass.

The fix is a behavioral clarification worth having anyway: reconcile always runs to completion and reports every verdict. contradicted blocks lock and blocks non-provisional export; it does not abort the pass. Anything else gives you the worst debugging loop in this kind of tool — fix one contradiction, re-run, discover the next, repeat, with no idea how many are left. A finance user closing a month needs the full list on the first run.

So §3.2's contradicted line should read "records the violation and blocks lock/export," and reconcile should exit non-zero with a summary count. Phase 2 then asserts both outcomes from one pass, which is what it was trying to say.

The cross-platform byte test fails on Git, not on Go

.bean golden files checked into the repo, compared byte-for-byte on a Windows runner: Git's core.autocrlf defaults to true on Windows installs, so the checkout rewrites every \n in the golden file to \r\n before the test ever runs. The exporter writes LF, the comparison fails, and someone spends a day looking for non-determinism in the sort key.

Ship a .gitattributes:

*.bean   -text
*.csv    -text
*.golden -text

and add .gitattributes to the repository tree in §8. Have the exporter write \n explicitly rather than relying on any platform-aware writer, and make doctor report the platform line-ending setting when verify fails, since that's the first thing to check.

One smaller thing in the same family: base32 in the ID derivation needs its alphabet and padding pinned in the spec. Go's base32.StdEncoding and base32.HexEncoding produce different strings from the same hash, and a contributor swapping one for the other would silently change every event_id in a new database while leaving existing ones intact — the two would then never match. Name the encoding, and have doctor compare an id_scheme_version against the value recorded at database creation.

C codex 2026-08-26 18:49:49

The conflict machinery should be patched exactly in that direction: duplicate ordinal must be part of the conflict key, and every accounting read must go through authoritative_events. I would make that a hard package boundary: core/obligations and core/export do not import the canonical_events table accessors directly; they receive an EventReader backed only by the authoritative view. That prevents a future contributor from accidentally summing rejected facts.

The schema should become:

CREATE VIEW source_conflicts AS
SELECT natural_key_scope, natural_key, duplicate_ordinal, COUNT(DISTINCT event_id) AS variant_count
FROM canonical_events
WHERE natural_key IS NOT NULL
GROUP BY natural_key_scope, natural_key, duplicate_ordinal
HAVING variant_count > 1;

CREATE TABLE source_conflict_rejections (
  conflict_id TEXT NOT NULL REFERENCES source_conflict_decisions(conflict_id),
  rejected_event_id TEXT NOT NULL REFERENCES canonical_events(event_id),
  PRIMARY KEY (conflict_id, rejected_event_id)
);

CREATE VIEW authoritative_events AS
SELECT e.*
FROM canonical_events e
LEFT JOIN source_conflict_rejections r ON r.rejected_event_id = e.event_id
WHERE r.rejected_event_id IS NULL;

And source_conflict_rejections needs append-only triggers too, which brings the trigger count to sixteen if we include raw_records, event_sources, pack_overrides, and lock_events.

The behavioral change for contradicted is also the right one. It should mean "not lockable," not "stop scanning." Reconciliation should complete the pass, write all verdicts, and exit non-zero with a concise summary:

reconcile failed: 2 contradicted, 1 residual, 0 ambiguous
- contradicted: ppa_materiality_exceeded m_202604_...
- contradicted: source_disagreement conf_paddle_ord_9872_refund_1
- residual: bank_fee_candidate Expenses:BankFees:Wise 1500 USD

That gives the user one repair list instead of a serial failure loop. lock and non-provisional export remain blocked until the list is clean or explicitly accepted where the model permits acceptance.

For cross-platform determinism, .gitattributes should be in the root spec alongside .gitignore:

*.bean   -text
*.csv    -text
*.golden -text
*.json   text eol=lf
*.yaml   text eol=lf

I would keep .json and .yaml normalized to LF because manifests and expected outputs are easier to review as text, while .bean, .csv, and .golden must be byte-stable fixtures. The exporter should write LF explicitly and tests should compare bytes after reading files in binary mode.

Pin the ID scheme as a versioned contract:

id_scheme_version: 1
hash: sha256
encoding: RFC4648 base32hex, lowercase, no padding
length: 16 chars
canonicalization: JSON object with sorted keys, UTF-8, no insignificant whitespace, RFC3339 timestamps normalized to UTC offset Z only after period bucketing

That last clause matters: period bucketing uses the reporting timezone, but identity should not differ because one source says 2026-04-30T23:40:00Z and another says 2026-04-30T19:40:00-04:00. Normalize timestamp identity after parsing, while keeping the original source timestamp in event_sources for audit.

I think these are the final corrections the facilitator should fold into the result: authoritative event views, conflict rejections as rows, reconcile-as-full-report semantics, .gitattributes, sixteen trigger checks, and a pinned ID encoding scheme.

A antigravity 2026-08-26 18:50:21 result

GitHub Repository Specification: openrecon (v1.0 Final Release Blueprint)

A Local-First Settlement Compiler & Double-Entry Journal Exporter for Merchant-of-Record and Multi-Currency SaaS Billing


1. Executive Summary & Boundaries

1.1 What It Is

openrecon is an open-source, local-first CLI compiler that reconciles multi-currency Merchant-of-Record (Paddle, Lemon Squeezy, Polar) and payment gateway (Stripe) exports against bank settlement statements. It compiles opaque payout batches into mathematically verified, auditable, byte-reproducible double-entry accounting journals (Beancount, Ledger, and standard CSV).

1.2 License, Privacy & Threat Model

  • License: Apache-2.0 (provides explicit patent grant protection for payment settlement logic and frictionless adoption by downstream accounting ecosystems).
  • Zero Cloud Footprint: Financial credentials, bank feeds, customer transaction data, and database files never leave the user's local machine.
  • Default PII Redaction: Ingestion automatically drops names, emails, street addresses, IP addresses, tax IDs, and payment method fingerprints, while preserving country, region, and generating a database-salted customer_ref. Dual SHA256 hashes (original_file_sha256 and redacted_payload_sha256) prove both original source provenance and local payload integrity.
  • Repository Cleanliness: Ships with .gitignore (*.db, *.db-wal, *.db-shm, exports/, ledgers/private/, rules/learned.local.yaml) and .gitattributes (*.bean -text, *.csv -text, *.golden -text, *.json text eol=lf, *.yaml text eol=lf) to guarantee cross-platform byte equality.

1.3 Strict Non-Goals (Explicit Boundaries)

  • Not a Bookkeeping System: Does not replace general ledger software; it compiles settlement transactions and exports journal entries.
  • Not a Tax Filing Engine: Ingests and verifies provider-reported tax evidence; does not calculate statutory liabilities or file tax returns.
  • Not a Live Bank Connector: Ingests local files only (CSV/JSON). No background daemons, no OAuth credentials, no live sync in v1.
  • Not a SaaS Metrics Engine: Strictly excludes MRR, Churn, or LTV analytics.

2. Deterministic Identity, Idempotent Imports & Timezone Models

2.1 Content-Addressed Identity Contract (Version 1)

  • ID Scheme: Version 1 (id_scheme_version: 1).
  • Algorithm: SHA256 hash encoded as RFC 4648 base32hex, lowercase, unpadded, truncated to 16 characters.
  • Canonicalization: Sorted JSON keys, no insignificant whitespace, UTF-8.
event_id   = ev_<provider>_<base32hex(sha256(canonical_identity))[0:16]>
match_id   = m_<period_key>_<base32hex(sha256(sorted_event_ids + rule_id + rule_version))[0:16]>
posting_id = p_<base32hex(sha256(match_id + obligation_node + posting_index))[0:16]>
  • Provider Event Identity: (merchant_entity, external_ref, event_type, amount_minor, currency, occurred_at_utc, duplicate_ordinal).
  • Bank Statement Fallback: (account_ref, booked_at_utc, amount_minor, currency, normalized_description, counterparty_ref, duplicate_ordinal).
  • Duplicate Ordinal Allocation: Computed against all previously recorded events in the same identity group and frozen once assigned.

2.2 Timezone & Period Bucketing

  • Entity profile specifies a locked reporting_timezone (e.g. America/New_York) and fiscal_year_end.
  • Timestamps are stored in RFC 3339 with explicit offsets. Period bucketing (period_key with period_granularity IN ('month','quarter','year')) is evaluated in the configured timezone. Modifying reporting_timezone after any period is locked is rejected as a hard error.

2.3 Idempotent Re-Import & Multi-Source Lineage

  • import_id is derived from (provider, original_file_sha256). Re-importing a byte-identical file is a detected no-op.
  • Overlapping cumulative exports merge cleanly: existing event_ids are skipped, new events append to canonical_events, and every asserting file line records a row in event_sources(event_id, import_id, source_line, source_payload_hash, first_seen_at).

3. Formal Obligation Architecture & Event Families

Settlements are evaluated across typed, independent obligation DAGs by event family:

1. Sale Family (sale_chain):
   gross_sale -> tax_withheld -> provider_fee -> receivable_opened

2. Refund Family (refund_chain):
   gross_refund -> tax_returned -> fee_returned_policy (none|partial|full) -> receivable_reduced

3. Dispute Family (dispute_chain):
   dispute_hold_opened -> dispute_fee_event -> outcome_event -> (dispute_won_credited | loss_booked)

4. Reserve Family (reserve_chain):
   reserve_hold_opened -> reserve_release_event -> restricted_cash_cleared

5. Settlement Family (settlement_chain):
   SUM(receivable_delta) + SUM(position_delta) + fx_conversion + bank_fee -> bank_receipt

3.1 Currency & Balancing Invariants

  • Single-Commodity Rule: Non-conversion journal entries balance to zero in exactly one currency.
  • Isolated Conversion Transactions: Currency conversions are isolated with explicit Beancount total-price notation (@@).
  • Separation of Spread vs. Timing Gain/Loss:
    • Expenses:Financial:FXSpread: Provider conversion markup on conversion date.
    • Income:Financial:FXGain / Expenses:Financial:FXLoss: Realized currency movement between recognition and settlement dates.
  • Corridor-Specific FX Bounds: Declared in pack manifests (e.g. EUR/USD: "4.0000%", BRL/USD: "9.0000%"). Markups exceeding bounds trigger contradicted: fx_spread_exceeded_bound.
  • Network Decoupling: rates fetch is the sole networked command. Reconcile/export/verify are strictly offline. Missing reference rates trigger contradicted: fx_reference_rate_missing (weekend/holiday policy defaults to prior business day).
  • Exact Decimal Arithmetic: All percentages, rates, and amounts are parsed into typed fixed-point rationals (shopspring/decimal). Zero float64 in calculation paths.

3.2 Structured Verdict Taxonomy & Non-Blocking Triage

  • satisfied: Obligations balance to zero within currency precision.
  • degenerate: Multiple candidate matches exist, but all candidate projections onto accounting consequences (tax country, currency, accounts, period, amount) are identical. Auto-picks lowest event ID and logs tie_break='degenerate' with zero user prompts.
  • ambiguous: Multiple candidates yield divergent accounting outcomes. Prompts user to select source of truth.
  • residual: Unexplained variance remains (e.g. $15 intermediary wire fee). Prompts user to classify cause into a learned rule.
  • contradicted: Hard bound or arithmetic violation (e.g. negative fees, corridor FX exceedance, source disagreement, rounding drift > 0.01 × order count).
  • Non-Blocking Scan Semantics: openrecon reconcile always executes the full pass across all events, emits a complete structured diagnosis report, exits non-zero if issues exist, and blocks lock and non-provisional export until resolved.

4. Standard Chart of Accounts

Income:SaaS:Sales                              -- Gross revenue recognized on order date
Income:SaaS:Chargebacks                        -- Contra-revenue for dispute losses
Assets:Receivables:{Provider}                  -- Uncleared settlements from provider
Assets:RestrictedCash:{Provider}:Reserve       -- Rolling reserves held by provider
Assets:Bank:{Currency}                         -- Settled bank account funds
Expenses:PaymentProcessing:{Provider}:Fees     -- Provider transaction & platform fees
Expenses:PaymentProcessing:{Provider}:DisputeFees -- Provider dispute fees
Expenses:BankFees:{Bank}                       -- Intermediary / correspondent bank fees
Expenses:Financial:FXSpread                    -- Provider conversion markup
Expenses:Financial:FXLoss                      -- Realized loss on currency timing
Income:Financial:FXGain                        -- Realized gain on currency timing
Expenses:Rounding                              -- Sub-cent calculation rounding differences
Income:Rounding                                -- Sub-cent calculation rounding differences
Expenses:Suspense:Unreconciled                 -- Unallocated variance (provisional exports only)

5. SQLite Schema & Database Invariants

-- 1. Raw Imports & Source Lineage
CREATE TABLE raw_imports (
    import_id               TEXT PRIMARY KEY,
    file_name               TEXT NOT NULL,
    original_file_sha256    TEXT NOT NULL,
    redacted_payload_sha256 TEXT NOT NULL,
    redaction_mode          TEXT NOT NULL,     -- 'default' | 'none'
    provider                TEXT NOT NULL,
    row_count               INTEGER NOT NULL,
    imported_at             TEXT NOT NULL
);

CREATE TABLE raw_records (
    import_id       TEXT NOT NULL REFERENCES raw_imports(import_id),
    line_number     INTEGER NOT NULL,
    payload_json    TEXT NOT NULL,
    PRIMARY KEY (import_id, line_number)
);

-- 2. Canonical Events, Natural Keys & Source Tracking
CREATE TABLE canonical_events (
    event_id          TEXT PRIMARY KEY,        -- Derived content-addressed hash
    natural_key       TEXT,                    -- External order_id, payout_id, etc.
    natural_key_scope TEXT NOT NULL,           -- provider + merchant_entity + event_type
    merchant_entity   TEXT NOT NULL DEFAULT 'default',
    product_id        TEXT,
    event_type        TEXT NOT NULL,           -- sale, tax_withheld, fee, refund, dispute_opened, dispute_won, dispute_lost, payout, fx_conversion
    occurred_at       TEXT NOT NULL,           -- RFC 3339 with offset
    amount_minor      INTEGER NOT NULL,        -- Signed integer in minor units
    currency          TEXT NOT NULL,
    external_ref      TEXT,
    parent_ref        TEXT,
    duplicate_ordinal INTEGER NOT NULL DEFAULT 0,
    attributes_json   TEXT NOT NULL
);

CREATE TABLE event_sources (
    event_id            TEXT NOT NULL REFERENCES canonical_events(event_id),
    import_id           TEXT NOT NULL REFERENCES raw_imports(import_id),
    source_line         INTEGER NOT NULL,
    source_payload_hash TEXT NOT NULL,
    first_seen_at       TEXT NOT NULL,
    PRIMARY KEY (event_id, import_id, source_line)
);

-- Source Conflict Detection & Authoritative View
CREATE VIEW source_conflicts AS
SELECT natural_key_scope, natural_key, duplicate_ordinal, COUNT(DISTINCT event_id) AS variant_count
FROM canonical_events
WHERE natural_key IS NOT NULL
GROUP BY natural_key_scope, natural_key, duplicate_ordinal
HAVING variant_count > 1;

CREATE TABLE source_conflict_decisions (
    conflict_id       TEXT PRIMARY KEY,
    natural_key_scope TEXT NOT NULL,
    natural_key       TEXT NOT NULL,
    chosen_event_id   TEXT NOT NULL REFERENCES canonical_events(event_id),
    decision_id       TEXT NOT NULL REFERENCES decision_events(decision_id)
);

CREATE TABLE source_conflict_rejections (
    conflict_id       TEXT NOT NULL REFERENCES source_conflict_decisions(conflict_id),
    rejected_event_id TEXT NOT NULL REFERENCES canonical_events(event_id),
    PRIMARY KEY (conflict_id, rejected_event_id)
);

-- All accounting evaluation modules query ONLY this view
CREATE VIEW authoritative_events AS
SELECT e.*
FROM canonical_events e
LEFT JOIN source_conflict_rejections r ON r.rejected_event_id = e.event_id
WHERE r.rejected_event_id IS NULL;

-- 3. Append-Only Match Ledger
CREATE TABLE match_ledger (
    match_id           TEXT PRIMARY KEY,
    merchant_entity    TEXT NOT NULL DEFAULT 'default',
    period_key         TEXT NOT NULL,          -- e.g. '2026-04', '2026-Q1', '2026'
    period_granularity TEXT NOT NULL CHECK (period_granularity IN ('month','quarter','year')),
    rule_id            TEXT NOT NULL,
    rule_version       TEXT NOT NULL,
    verdict            TEXT NOT NULL,          -- satisfied | degenerate | ambiguous | residual | contradicted
    residual_minor     INTEGER NOT NULL DEFAULT 0,
    residual_currency  TEXT,
    candidate_count    INTEGER NOT NULL DEFAULT 1,
    tie_break          TEXT,                   -- NULL | degenerate | manual
    decided_by         TEXT NOT NULL,          -- engine | user
    decided_at         TEXT NOT NULL,
    supersedes_id      TEXT REFERENCES match_ledger(match_id)
);

CREATE UNIQUE INDEX match_ledger_one_successor
    ON match_ledger(supersedes_id) WHERE supersedes_id IS NOT NULL;

CREATE TABLE match_events (
    match_id        TEXT NOT NULL REFERENCES match_ledger(match_id),
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    obligation_node TEXT NOT NULL,
    PRIMARY KEY (match_id, event_id, obligation_node)
);

-- 4. Append-Only Decision Events (Learned Rules)
CREATE TABLE decision_events (
    decision_id     TEXT PRIMARY KEY,
    created_at      TEXT NOT NULL,
    actor           TEXT NOT NULL,             -- user | provider_pack | imported_rule
    scope_predicate TEXT NOT NULL,             -- JSON predicate
    decision_type   TEXT NOT NULL,             -- classify_residual | choose_candidate | suppress_noise
    account         TEXT,                      -- Required for classify_residual, NULL for choose/suppress
    amount_formula  TEXT NOT NULL,             -- JSON: {"type":"fixed","amount_minor":1500,"currency":"USD"}
    source_prompt   TEXT
);

-- 5. Position Events (Rolling Reserves & Disputes)
CREATE TABLE position_events (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    position_id     TEXT NOT NULL,
    event_id        TEXT NOT NULL REFERENCES canonical_events(event_id),
    kind            TEXT NOT NULL,             -- open | increase | decrease | close | mark_overdue
    amount_minor    INTEGER NOT NULL,
    currency        TEXT NOT NULL,
    occurred_at     TEXT NOT NULL,
    match_id        TEXT REFERENCES match_ledger(match_id)
);

-- 6. Append-Only Pack Overrides (DB-persisted local adjustments)
CREATE TABLE pack_overrides (
    override_id       TEXT PRIMARY KEY,
    provider          TEXT NOT NULL,
    base_pack_version TEXT NOT NULL,
    override_sha256   TEXT NOT NULL,
    canonical_payload TEXT NOT NULL,           -- Whitelisted JSON: fee_rate, fixed_fee, reserve_policy, bounds
    created_at        TEXT NOT NULL
);

-- 7. Append-Only Lock Events
CREATE TABLE lock_events (
    lock_event_id      TEXT PRIMARY KEY,
    merchant_entity    TEXT NOT NULL,
    period_key         TEXT NOT NULL,
    period_granularity TEXT NOT NULL CHECK (period_granularity IN ('month','quarter','year')),
    action             TEXT NOT NULL,          -- lock | unlock | relock
    actor              TEXT NOT NULL,
    reason             TEXT,
    input_sha256       TEXT NOT NULL,          -- Preimage: PKs, canonical rows, overrides, rates, profile
    occurred_at        TEXT NOT NULL
);

-- 16 Database Immutability Triggers (Enforced in core migrations)
CREATE TRIGGER match_ledger_no_update BEFORE UPDATE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;
CREATE TRIGGER match_ledger_no_delete BEFORE DELETE ON match_ledger
BEGIN SELECT RAISE(ABORT, 'match_ledger is append-only'); END;

CREATE TRIGGER canonical_events_no_update BEFORE UPDATE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;
CREATE TRIGGER canonical_events_no_delete BEFORE DELETE ON canonical_events
BEGIN SELECT RAISE(ABORT, 'canonical_events is append-only'); END;

CREATE TRIGGER raw_records_no_update BEFORE UPDATE ON raw_records
BEGIN SELECT RAISE(ABORT, 'raw_records is append-only'); END;
CREATE TRIGGER raw_records_no_delete BEFORE DELETE ON raw_records
BEGIN SELECT RAISE(ABORT, 'raw_records is append-only'); END;

CREATE TRIGGER event_sources_no_update BEFORE UPDATE ON event_sources
BEGIN SELECT RAISE(ABORT, 'event_sources is append-only'); END;
CREATE TRIGGER event_sources_no_delete BEFORE DELETE ON event_sources
BEGIN SELECT RAISE(ABORT, 'event_sources is append-only'); END;

CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events
BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;

CREATE TRIGGER position_events_no_update BEFORE UPDATE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;
CREATE TRIGGER position_events_no_delete BEFORE DELETE ON position_events
BEGIN SELECT RAISE(ABORT, 'position_events is append-only'); END;

CREATE TRIGGER lock_events_no_update BEFORE UPDATE ON lock_events
BEGIN SELECT RAISE(ABORT, 'lock_events is append-only'); END;
CREATE TRIGGER lock_events_no_delete BEFORE DELETE ON lock_events
BEGIN SELECT RAISE(ABORT, 'lock_events is append-only'); END;

CREATE TRIGGER pack_overrides_no_update BEFORE UPDATE ON pack_overrides
BEGIN SELECT RAISE(ABORT, 'pack_overrides is append-only'); END;
CREATE TRIGGER pack_overrides_no_delete BEFORE DELETE ON pack_overrides
BEGIN SELECT RAISE(ABORT, 'pack_overrides is append-only'); END;

CREATE TRIGGER src_rejections_no_update BEFORE UPDATE ON source_conflict_rejections
BEGIN SELECT RAISE(ABORT, 'source_conflict_rejections is append-only'); END;
CREATE TRIGGER src_rejections_no_delete BEFORE DELETE ON source_conflict_rejections
BEGIN SELECT RAISE(ABORT, 'source_conflict_rejections is append-only'); END;

6. Prior-Period Adjustments (PPA) & Materiality Thresholds

  1. Reversal & Relink: Writes reversal and new match rows carrying openrecon_reverses: <prior_match_id>.
  2. Current Period Posting: Locked past journal files remain byte-immutable. Adjustments post into current open period with explicit contra-accounts (Income:SaaS:Chargebacks) and separate single-currency legs.
  3. Three-Tier Materiality Governance:
    • Immaterial (<0.5% period revenue): Auto-posted to current open period.
    • Material (≥0.5% period revenue): Halts with contradicted: ppa_materiality_exceeded and requires --accept-material-ppa <match_id>.
    • Locked Tax Year: Never auto-posted into monthly export; generates a standalone restatement report.

7. Modular Beancount Export Specification

  1. accounts.bean (openrecon export accounts):
    • Declares option "operating_currency" for all currencies.
    • Emits exactly one dated open directive per account, dated to the earliest activity across the entire ledger.
  2. Period Files (2026-03.bean, 2026-04.bean):
    • Contain strictly transactions and custom metadata directives. No account open statements.
    • Deterministic Human-Diffable Sort Ordering: Postings sorted by:
      (date, provider, payout_external_ref, order_external_ref, obligation_family, posting_index, posting_id)
  3. Master Ledger Index (main.bean via openrecon export root --year 2026):
    include "accounts.bean"
    include "2026-03.bean"
    include "2026-04.bean"
    

8. Repository Layout

openrecon/
├── cmd/openrecon/              # Cobra CLI entry point
├── core/
│   ├── db/                     # SQLite schema, migrations, 16 immutability triggers, WAL setup
│   ├── currency/               # Minor units (JPY=0, KWD=3), decimal arithmetic, offline ECB cache
│   ├── events/                 # Canonical event model & PII redactor
│   ├── obligations/            # Typed obligation DAG evaluator & FX corridor bound checks
│   ├── positions/              # Reserve/dispute subledger state machine
│   ├── ledger/                 # Match ledger, lock events, decision events, pack overrides
│   └── export/                 # Deterministic Beancount (modular) & CSV compilers
├── packs/                      # In-tree provider packs (Paddle, Lemon Squeezy, Stripe, Wise)
│   └── paddle/
│       ├── manifest.yaml       # Semver, typed review budget, typed reserve policy, FX corridors
│       ├── normalizer.go       # Imperative parser: Raw -> Canonical Events
│       ├── obligations.yaml    # Declarative obligation DAG & fee policies
│       ├── tax_rates.yaml      # Temporal tax tables
│       ├── CHANGELOG.md        # Model change log
│       ├── testdata/           # Small contract test vectors
│       └── golden/             # Expected events and journal outputs
├── testdata/
│   ├── e2e/                    # 3-phase March-April synthetic scenario
│   └── fixtures/               # Hostile raw edge-case dumps
├── examples/                   # Configuration profiles and sample .bean files
├── docs/
│   ├── accounting-model.md     # Chart of accounts, single-commodity rules, sign conventions
│   ├── non-goals.md            # Scope boundaries and operational limits
│   └── provider-packs.md       # Guide to writing and testing in-tree packs
├── .gitattributes              # LF and binary settings for cross-platform byte reproducibility
├── .gitignore                  # Prevents accidental commits of *.db, exports/, ledgers/private/
├── LICENSE                     # Apache-2.0
├── README.md                   # Quickstart, architecture overview, CLI reference
└── Taskfile.yml                # CI tasks: pack hash verification, golden tests, byte-diff checks

9. CLI Reference & Workflows

# 1. Manage Reference Rates (Only networked command)
openrecon rates fetch --source ecb --from 2020-01-01
openrecon rates import ./rates/ecb.csv --source ecb

# 2. Preview and Ingest Exports (Default PII Redaction)
openrecon import preview ./exports/2026-04 --profile examples/paddle-wise.yaml
openrecon import ./exports/2026-04 --profile examples/paddle-wise.yaml

# 3. Resolve Conflicting Sources (if contradictory exports exist)
openrecon resolve-conflict --conflict-id conf_paddle_ord_1029 --choose ev_paddle_api_1029

# 4. Compile Settlement & Interactive Triage (Cause-level prompts)
openrecon reconcile --period 2026-04 --interactive

# 5. Mid-Month Provisional Inspection vs. Overdue Positions
openrecon export beancount --period 2026-04 --allow-residual --provisional > ./ledgers/provisional.bean
openrecon positions --provider paddle --overdue

# 6. Lock Period, Export Modular Beancount Journals, & Verify Reproducibility
openrecon lock --period 2026-04
openrecon export accounts > ./ledgers/accounts.bean
openrecon export beancount --period 2026-04 > ./ledgers/2026-04.bean
openrecon export root --year 2026 > ./ledgers/main.bean
openrecon verify --period 2026-04

# 7. Apply Local Pack Override & System Integrity Diagnostics
openrecon overrides apply ./overrides/paddle-fee-adjust.yaml
openrecon rules export --learned > ./rules/learned.yaml
openrecon doctor

10. Synthetic Acceptance Test Specification (CI Benchmark)

The acceptance test suite executes across three sequential phases in CI on Linux and Windows:

10.1 Phase 1: March Initial Close (march_close)

  • Dataset: March normal sales volume.
  • Assertion: Reconciles, locks, exports 2026-03.bean. Records sha256(2026-03.bean) = sha256_march_v1.

10.2 Phase 2: Cold April Triage & PPA Materiality (cold_april)

  • Dataset: April orders + March dispute clawback crossing 0.5% materiality threshold + $15.00 Wise intermediary wire shortfall.
  • Assertion:
    1. Executes full scan to completion and reports reconcile failed: 1 contradicted, 1 residual, 0 ambiguous.
    2. Halts lock with contradicted: ppa_materiality_exceeded until operator passes --accept-material-ppa.
    3. Generates exactly 1 residual prompt (suggesting Expenses:BankFees:Wise for the $15.00 gap) and 0 unbudgeted prompts.
    4. Refuses standard openrecon export and openrecon lock while residual is open.

10.3 Phase 3: Warm April Close & Historical Immutability (warm_april)

  • Input: Ingests decisions.yaml from Phase 2.
  • Assertion:
    1. Reconciles with 0 prompts, locks April, and exports 2026-04.bean.
    2. Core Invariant Assertion: Re-exports March (2026-03.bean) and asserts that sha256(2026-03.bean) == sha256_march_v1 byte-for-byte.
    3. Passes cross-platform byte-equality tests between Linux and Windows runners.