A correctness-first Go core for multi-source financial transaction reconciliation — the foundation for a counterparty graph and operator-facing product described in docs/spec.md.
Tally ingests canonical transaction events from independent sources (ledger, processor, bank), indexes pending candidates in Redis, scores cross-source pairs on amount, time, account, and reference-string similarity, and confirms matches in SERIALIZABLE Postgres transactions. Confirmation runs on a background worker rather than inline on ingestion, so a decision is made once candidates have had a chance to arrive.
The engine is measured two ways: a synthetic harness with seeded ground truth, and a replay of BenchRec, a real bank-to-ledger reconciliation corpus of 149,854 events. Both sets of numbers are published below and always labelled.
Status: Phase 1, validated on real data. The matching-and-measurement spine works under concurrent ingestion and has been measured against a real corpus. Discrepancy handling, full crash recovery (Redis rebuild on startup), entity resolution, graph materialization, gRPC, product app, and cloud deployment are not built. See
docs/progress.mdfor the living checklist.
| Area | Status |
|---|---|
POST /events ingestion with validation and idempotent insert |
Done |
| Redis sorted-set candidate window (tenant × asset × amount bucket) | Done |
| Scorer: amount, time, account, character n-gram reference similarity | Done |
| Account mismatch as a hard veto | Done |
SERIALIZABLE match confirmation → matches / match_events |
Done |
Reconciliation orchestration (internal/reconcile) |
Done |
| Postgres-backed pending candidate lookup | Done |
| Background pending worker with keyset-cursor sweep | Done |
| Synthetic benchmark harness with ground-truth correctness checks | Done |
| BenchRec real-data adapter and replay runner | Done |
| Sentry observability (error logs, panics, reconcile spans, worker cron monitor) | Done |
| CI: tests + live-server correctness benchmark gate on every push | Done |
| Multi-stage Docker build (distroless runtime) | Done |
| Kubernetes manifests, verified end-to-end on a local kind cluster | Done |
| Margin rule (refuse to confirm near-tied candidates) | Not started |
| Pending expiry → discrepancies | Not started |
| Parallel worker sweep | Not started |
| Per-source connectors (ledger / processor / bank parsers) | Not started |
| Full crash recovery (Redis rebuild from Postgres on startup) | Not started |
| Entity resolution and counterparty graph | Not started |
| gRPC graph API, product app, cloud deployment | Not started |
The same real-world transaction appears across multiple independent systems — an internal ledger, a payment processor webhook, a bank settlement batch. Each source uses different identifiers, timestamps, and delivery semantics.
Reconciliation matches these observations back together. Tally's design constraint: false positives are worse than unmatched events. An unmatched event should eventually be explicit state for review; a false match silently buries a problem.
That constraint is not yet fully met on real data — the BenchRec replay has a 4.6% false-positive rate. See Real-data validation.
┌───────────────────────────────────────────────────────────┐
│ Event sources │
│ • Synthetic: internal/loadgen + cmd/bench │
│ • Real data: internal/benchrec + cmd/benchrec │
└────────────────────────┬──────────────────────────────────┘
│ POST /events (HTTP)
▼
┌───────────────────────────────────────────────────────────┐
│ CORE HTTP API (chi) — main.go │
│ │
│ 1. Validate → CanonicalEvent (server-side idempotency) │
│ 2. InsertEvent (ON CONFLICT DO NOTHING) │
│ 3. AddCandidate → Redis ZSET │
│ (no matching here — see Why confirmation is deferred) │
└────────────────────────┬──────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ Background worker (250ms tick, 500-event batch) │
│ FindPendingEventsAfter(cursor) → ReconcilePendingEvent │
│ Keyset cursor on (ingested_at, event_id); resets on a │
│ short page, so every pending event is tried each sweep │
└────────────────────────┬──────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────┐
│ Reconciliation engine (internal/reconcile) │
│ │
│ • Load event from Postgres │
│ • Find pending candidates from Postgres │
│ • Score cross-source candidates, rank by score │
│ • ConfirmMatch (SERIALIZABLE tx) │
│ • Remove matched events from Redis │
│ • Re-add unmatched pending event to Redis │
└───────────────┬───────────────────────┬───────────────────┘
│ │
Postgres (durable) Redis (candidate index)
canonical_events candidates:{tenant}:{asset}:{bucket}
matches / match_events member=event_id, score=timestamp_ms
Postgres is durable truth. Redis is a rebuildable candidate cache. Reconciliation queries Postgres pending state so concurrent arrival does not depend on Redis visibility order.
Ingestion originally reconciled each event inline, the moment it arrived. That means deciding against whatever happened to be pending at that instant — so when a decoy was already pending and the true partner had not yet been POSTed, the decoy won uncontested. No amount of scoring quality helps, because the true partner is not in the candidate set to be ranked against.
Moving confirmation onto the worker sweep raised the real-data match rate from 0.9189 to 0.9565 and roughly halved false positives on a 666-pair control, at the cost of match latency. Both numbers are below.
The worker originally scanned ORDER BY ingested_at DESC LIMIT 500. That was adequate while ingestion also matched inline and the worker only caught stragglers. As the sole matching path it livelocked: during a burst, newer arrivals continuously displaced the window, and once ingestion stopped, the newest 500 pending events were a fixed set that could not match — their partners were buried behind them — so they were rescanned forever. Measured at full scale: 138,652 events pending, unchanged after 20 seconds on an idle server, and the replay's match rate collapsed to 7.3%.
Flipping to ASC only mirrors the failure, with permanently unmatchable old events blocking the head instead. Any fixed slice under a static ordering rescans itself when its rows do not change state; progress requires the selection to advance.
FindPendingEventsAfter pages on (ingested_at, event_id) > cursor. The tuple is required because ingested_at is not unique under concurrent ingestion, so a cursor on it alone skips or repeats rows at page boundaries. Keyset rather than OFFSET because rows leaving the set as they become MATCHED shift every subsequent offset, silently skipping events.
-
Idempotent insert.
InsertEventusesON CONFLICT DO NOTHINGonidempotency_key(tenant_id:source_type:source_event_id). Duplicate replays return200and skip Redis indexing. -
Candidate indexing. The event is added to Redis sorted sets at
candidates:{tenant_id}:{asset}:{amount_bucket}for the exact amount and adjacent buckets (amount ± 1minor unit). Score = event timestamp in Unix milliseconds. -
Candidate retrieval.
internal/reconcilequeries Postgres for pending candidates: same tenant, opposite source, same asset/currency, exact or adjacent amount bucket, timestamp within ±120 seconds. Anything outside that window is never scored — a real recall ceiling, not a scoring outcome. -
Scoring (
internal/match/score.go). Same-source candidates are skipped. Remaining pairs score:score = 0.40 × amount_score + 0.25 × time_score + 0.15 × account_score + 0.20 × text_scoreamount_score: 1.0 exact, linear decay to 0.0 at ±2 minor unitstime_score: 1.0 within 5s, linear decay to 0.0 at 120saccount_score: 1.0 exact (case-insensitive), 0.5 substring, 0.0 otherwisetext_score: character 6-gram overlap betweencounterparty_refvalues
Match confirms only if
score ≥ 0.79andaccount_score > 0. -
Confirmation. The top-ranked candidate above threshold is confirmed in a
SERIALIZABLEtransaction (internal/store/postgres.go): both events must still bePENDINGand share a tenant; insertmatches(with score and evidence JSON) andmatch_events; update both events toMATCHED. Serialization conflicts retry once. -
Cleanup. Matched events are removed from Redis. Unmatched pending events are re-added so future arrivals can find them.
Reference strings are the only discriminating signal on real data once amount, date, and account agree. The primitive is deliberately character-level, not token-level:
- Strings are lowercased and split into alphanumeric runs.
- Every 6-character window within a run becomes a gram; grams never span a run boundary, or fixed-width padding fuses unrelated fields into false matches.
- Similarity is the overlap coefficient
|A∩B| / min(|A|,|B|).
Two measured reasons for those choices. Whole-token equality finds a shared reference in only 25.0% of true BenchRec pairs, against 82.4% for 6-grams, because references sit embedded inside longer runs — V41806706 inside V418067061617140. And Jaccard collapses on this data because the two sides differ wildly in length: a ~150-character attributes field against a ~20-character reference scores 0.069 under Jaccard for what is a perfect match.
If neither side yields any grams, the component is dropped and the remaining weights are renormalized — no evidence is not evidence of disagreement. If only one side yields grams, the score is 0.0 and still counts. That asymmetry is deliberate: ranking is comparative, so treating one-sided-missing as neutral would let a candidate with no reference data outrank one with weak-but-real overlap, making missing data a competitive advantage.
0.79 looks permissive, and it is, deliberately. A true pair with perfect amount, date, and account but no text overlap scores exactly 0.80. Any threshold high enough to reject a decoy on text alone also rejects those pairs — 18% of real true pairs share no 6-gram at all. Measured: at a 0.85 threshold only 16.6% of true pairs clear the sum.
So discrimination is meant to be comparative — best candidate versus runner-up — not absolute within a single pair. The threshold is a sanity floor; ranking does the work. The remaining piece of that design, refusing to confirm when the top two candidates score within a margin of each other, is not yet implemented, and it is the main reason the false-positive rate is not lower.
cmd/bench, internal/loadgen, internal/bench. Generates deterministic datasets — true ledger/processor pairs plus decoys (same-source, amount skew, account mismatch, time skew) — posts them to the running server, polls Postgres for confirmed matches, and compares against a ground-truth map.
| Metric | How |
|---|---|
| Throughput | Events posted / wall-clock duration |
| Match rate | Confirmed true matches / expected true pairs |
| False positive rate | Confirmed matches not in ground truth |
| Latency p50/p95/p99 | Ms from when both events' POST completes to first DB observation of the match |
A run is clean when match rate = 100%, false positives = 0, missed = 0, HTTP errors = 0.
Measured clean runs (synthetic data, local Docker Postgres/Redis):
| Scenario | Result |
|---|---|
| Correctness gate: shuffled, 16 workers, 100 pairs | 100% match rate, 0 false positives, 0 missed |
| Correctness gate: paired, 16 workers, 100 pairs | 100% match rate, 0 false positives, 0 missed |
| Highest clean stepped load: shuffled, 16 workers (2026-06-05) | 160 true pairs / 832 total events, 1615 events/sec, 958 ms p99 |
First non-clean stepped load step on the same seed: 165 true pairs (1 false positive). These load figures predate the deferred-confirmation change and have not been re-measured.
docker compose up -d
make migrate
go run . # separate terminal
make bench PAIRS=100 WORKERS=16 ARRIVAL=shuffled OUTPUT=bench-results/gate-shuffled.json
make bench PAIRS=100 WORKERS=16 ARRIVAL=paired OUTPUT=bench-results/gate-paired.json
make bench-load WORKERS=16 ARRIVAL=shuffled OUTPUT=bench-results/load-shuffled-w16.jsonReports are written to bench-results/ (gitignored).
The synthetic harness measures the engine against data it generated itself. BenchRec measures it against real bank statement lines reconciled to real internal ledger entries.
Dataset. BenchRec cash reconciliation dataset v1.0, released for an ICAIF 2023 competition, licensed CC BY 4.0. 149,854 rows, 56,074 distinct matches, anonymized with structure preserved. Files live in data/benchrec/, gitignored for size.
Scope of every number below, stated up front:
- 1:1 both-sided subset only — 47,024 of 56,074 matches (83.9%). N:M and one-sided groups are not scored as ground truth.
- Those excluded legs are still replayed, as ambient traffic. The engine has to decline them, not merely find partners known to exist.
- Single account, single currency. The account component contributes no discrimination on this corpus.
- Anonymized tokens. Reference strings are scrambled consistently, so overlap is real but the strings are not human-meaningful.
- One institution's custody flows. Not representative of card, ACH, or crypto reconciliation.
go run ./cmd/benchrec -limit 0 -reset -output bench-results/benchrec-after-full.json-limit N replays a contiguous window of value dates rather than the whole corpus, keeping same-day collision density realistic while running in seconds. -dump-misses N writes missed pairs and false positives with their raw reference strings. -distractors=false restricts the replay to the 1:1 subset alone.
Both runs: 47,024 ground-truth pairs, 55,806 distractor legs, 149,854 events, shuffled arrival, 16 workers, 0 HTTP errors.
| before (2026-08-17) | after (2026-08-18) | |
|---|---|---|
| Match rate | 0.8873 | 0.9334 |
| Confirmed true | 41,723 | 43,891 |
| False positives | 4,615 | 2,111 |
| False-positive rate | 0.0996 | 0.0459 |
| Missed | 5,301 | 3,133 |
| Ingest throughput | 1,105 events/sec | 3,081 events/sec |
Per stratum, by the dataset's own matchRule provenance:
| Stratum | pairs | before | after |
|---|---|---|---|
| RULE 1 | 31,576 | 0.9381 | 0.9768 |
| RULE 3 | 9,274 | 0.8614 | 0.9437 |
| RULE 4 | 4,266 | 0.8994 | 0.9376 |
| RULE 6 | 57 | 0.9649 | 0.9825 |
| MANUAL | 1,787 | 0.1231 | 0.1337 |
| RULE 5 / 7 / 8 / 9 | 64 | 0.0000 | 0.0000 |
A third category is reported separately and excluded from the false-positive count: 17,899 "related, out of scope" matches, where both confirmed events share a matchId but that group is N:M rather than 1:1. Those are defensible partial matches, and folding them into false positives would misstate precision.
Time to reconcile the corpus end to end (separate replay of the same configuration, sampling PENDING every 5s):
| Peak backlog | 144,824 pending at t+68s |
| Fully drained | t+418s |
| Drain window | 350s, 122,772 events reconciled |
| Sustained reconcile rate | ~351 events/sec |
The 22,052 events remaining at the floor have no possible partner — one-sided ledger legs, N:M leftovers, and pairs outside the ±120s candidate window. That is the correct resting state, not a stall.
- Confirmation moved off the ingestion path onto the worker, so decisions are made once candidates have had a chance to arrive. Largest single contributor.
- Keyset cursor for the pending sweep, without which the previous change collapses the full replay to 7.3%.
- Character n-gram reference similarity in the scorer, replacing the assumption that shared references match at token level.
- MANUAL is effectively unsolved at 13.4%, and the cause is retrieval rather than scoring: only 61.9% of those pairs agree within one cent and only 77.2% share a value date, so the candidate query never surfaces most of them.
- RULE 5, 7, 8 and 9 are structurally unreachable for the same reason — all 52 RULE 5 pairs agree to the cent but none share a value date, against a ±120 second window.
- A 4.6% false-positive rate is not zero, and this project defines a clean run as zero. The margin rule is the obvious next lever and is not implemented.
- Match latency regressed sharply under burst load. See below.
Two figures published in an earlier draft of docs/spec.md §9.2 were wrong and are corrected here by measurement on the full subset: collisions are 12.6% of ledger legs facing more than one candidate, not 29% of statement lines (median candidates per leg is 1); and the shared signal is character-level, not token-level (25.0% token overlap versus 82.4% at 6-grams). The MANUAL stratum is 1,787 pairs (3.8%) of the 1:1 subset and lives in matchRule, not matchedBy.
- No discrepancy path. Events that never match stay
PENDING; there is no window-expiry sweep ordiscrepanciestable. - No pending expiry. Events that can never match are re-attempted on every sweep forever — roughly 22,000 events of wasted work per pass at full BenchRec scale. Correctness is unaffected.
- Partial crash recovery. The background worker is the first retry primitive, but startup Redis rebuild from Postgres pending events is not implemented.
- Redis removal is post-commit. Not part of the Postgres transaction; a crash between commit and Redis cleanup leaves stale index entries until recovery exists.
- Match latency under burst load. Because confirmation happens on the worker sweep, a large backfill queues behind it. Steady state is unaffected (p50 627ms on the 520-event synthetic gate), but per-match latency during the BenchRec replay was p50 161.8s / p99 320.6s, against p50 247ms / p99 491ms when confirmation ran inline. In that regime per-match latency measures queue depth, not engine speed; time-to-drain is the meaningful figure. The sweep is work-bound, not tick-bound — it sustains ~351 events/sec, so 500 attempts take roughly 1.4s against a 250ms tick and ticks already run back-to-back. Raising the batch limit would not help; parallelizing the sweep would.
- Load ceiling (synthetic). On seed 42 with 40% decoys, correctness broke at 165 true pairs under shuffled 16-worker ingestion. Measured before deferred confirmation; not re-measured.
- N:M matching is not implemented. Only 1:1 pairs are matched or scored.
| Endpoint | Description |
|---|---|
POST /events |
Ingest a canonical event; inserts and indexes it. Matching happens on the worker sweep, so a 201 does not mean a match was attempted. |
GET /events/{eventID} |
Fetch a canonical event by ID |
GET /health |
Postgres + Redis connectivity check |
Planned but not implemented: GET /metrics/current, GET /metrics/history, gRPC graph queries.
canonical_events — every ingested event. Tenant-scoped with match_status (PENDING or MATCHED). Unique index on idempotency_key. Partial index on (ingested_at, event_id) WHERE match_status = 'PENDING' backs the worker's cursor sweep.
matches — confirmed match rows with match_score and evidence (JSONB scoring breakdown, including text_score, text_match, and account_veto).
match_events — junction linking each match to its two (eventually N) canonical events.
Not yet migrated: discrepancies, metric_snapshots, counterparty graph tables.
tally/
cmd/
bench/ # Synthetic benchmark harness binary
benchrec/ # BenchRec real-data replay runner
internal/
api/ # HTTP handlers and routes
bench/ # Correctness, latency, report computation
benchrec/ # BenchRec CSV adapter → CanonicalEvent
event/ # CanonicalEvent contract and validation
loadgen/ # Deterministic synthetic dataset generation
match/ # Scoring function
reconcile/ # Reconciliation orchestration and pending worker
store/ # Postgres and Redis access
migrations/ # Postgres schema
scratch/ # Throwaway data-profiling scripts
docs/
spec.md # Full product + architecture spec
progress.md # Implementation tracker
private/ # Local working docs, gitignored
Prerequisites: Go 1.26+, Docker, psql (for migrations)
docker compose up -d
make migrate
go run .Run tests (requires Postgres and Redis):
go test ./...Postgres and Redis addresses are configurable via DATABASE_URL and REDIS_ADDR; both default to the local Docker Compose ports.
Every push runs two GitHub Actions jobs against Postgres/Redis service containers: go vet plus the full test suite, and a benchmark gate that boots the server, runs the 100-pair 16-worker correctness benchmark for both shuffled and paired arrival, and fails unless both runs are clean. Reports are uploaded as build artifacts.
The synthetic gate is the only thing that can catch multi-account regressions — BenchRec has a single account, so it is structurally blind to that class of bug.
deploy/k8s/ holds manifests for the full stack: Postgres StatefulSet with a PVC and pg_isready readiness probe, Redis (deliberately on emptyDir — the candidate index is rebuildable from Postgres), a migration Job fed from ConfigMaps, and the tally Deployment with liveness/readiness probes on /health.
make k8s-up # create kind cluster, build + load image, deploy, wait for rollout
make k8s-down # delete the clusterVerified end-to-end on kind: a correlated ledger/processor pair posted through the in-cluster service reconciles to MATCHED with a persisted match row. Cloud deployment (EKS per spec) is not set up.
Optional and disabled unless SENTRY_DSN is set, so local dev and benchmarks are unaffected by default.
SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project> go run .| Variable | Effect |
|---|---|
SENTRY_DSN |
Enables Sentry when set; no-op otherwise |
SENTRY_ENVIRONMENT |
Environment tag (default development) |
SENTRY_TRACES_SAMPLE_RATE |
Enables performance tracing on HTTP requests when > 0 (e.g. 0.2) |
What gets reported:
- Every zerolog
error/fatal/paniclog via the officialsentryzerologwriter, with lower-level logs attached as breadcrumbs. - HTTP handler panics with request context, via
sentryhttpmiddleware on the chi router. - Background worker panics, captured and re-raised.
- With tracing enabled: transactions on
POST /eventsand worker runs, with child spans for event fetch, candidate query, scoring, and match confirmation. - A Sentry Crons heartbeat (
pending-reconcile-worker) so a stalled worker raises a missed-check-in alert. - Ingestion errors carry
tenant_id/source_typetags and event-ID context. - Release tagged with the git commit via Go build info; events flush on shutdown.
No floats on money. Amounts are int64 minor units throughout. The BenchRec adapter parses decimal strings to exact cents and refuses anything that is not 2-decimal.
Serializable isolation for match confirmation. Two concurrent requests cannot both match the same event; integration tests cover replay and race behavior.
Redis as a narrow index, Postgres as truth. Durable reconciliation queries Postgres pending state so concurrent arrival does not depend on Redis visibility order.
Confirmation is deferred, not inline. Matching at arrival decides against a partially-arrived candidate set. Measured worth: +3.8 points of match rate and roughly half the false positives, traded against burst-load latency.
The pending sweep must advance. A fixed slice under a static ordering rescans itself forever once its rows stop changing state. Keyset pagination on (ingested_at, event_id).
Amount bucketing with adjacency. Exact and ±1 minor-unit buckets catch small fee-rounding differences without unbounded scans. This is also a hard recall ceiling: on BenchRec, 97.4% of true pairs fall inside it, and the remaining 2.6% are never scored.
Idempotency at the database layer. ON CONFLICT DO NOTHING makes ingestion replays safe without application-level dedup.
Account mismatch is a veto, not a weight. Different accounts are different money, so no amount of matching reference text can override it. Without the veto, two events with identical amount, timestamp, and description but different accounts scored 0.85 and confirmed.
Precision comes from ranking, not from a high threshold. The 0.79 threshold is a sanity floor. Because 18% of real true pairs share no reference text, a threshold strict enough to reject a text-less decoy also rejects them. Discrimination is comparative between candidates instead — which is why the unimplemented margin rule, not a higher threshold, is the remaining lever on false positives.
| Component | Choice |
|---|---|
| Language | Go |
| HTTP router | chi |
| Postgres driver | pgx/v5 (SERIALIZABLE transactions) |
| Postgres | 16 |
| Redis | 7 (sorted sets for candidate windowing) |
| Logging | zerolog |
| Error monitoring | Sentry (sentry-go + sentryzerolog + sentryhttp) |
| Containers | Multi-stage Docker build on distroless static |
| Orchestration | Kubernetes (local kind cluster) |
| CI | GitHub Actions (tests + correctness benchmark gate) |
Planned (per spec, not wired): OpenTelemetry tracing exported to Sentry via sentryotel, gRPC, Next.js product surface, AWS CDK / EKS Fargate.
docs/spec.md— full architecture, graph model, and roadmapdocs/progress.md— what's done, blockers, and latest verificationdocs/private/— local working docs; kept out of git
The BenchRec cash reconciliation dataset v1.0 (ICAIF 2023) is used under CC BY 4.0. Dataset files are not redistributed in this repository.