KONTA: a ledger a language model cannot corrupt
An AI-assisted business ledger for small merchants, used from a web dashboard and from WhatsApp, hardened so that money only moves through confirmed, idempotent, deterministic code.
- Role
- Security, reliability and deployment hardening
- Period
- Sep 2026
- Status
- Private · Cloud Run, single instance
- Source
- private · architecture and practices described
- Problem
- Small merchants keep their books in notebooks and chat threads. Putting a language model and WhatsApp in front of a financial ledger makes correctness and security the actual product.
- Key decision
- Nothing a model produces is ever written. It becomes a proposal that a person confirms, and deterministic, idempotent code applies it.
- Result
- One service is the only path to the data, every external call is bounded, and redelivered webhooks, double taps and timeouts are handled by design and covered by tests.
Architecture
Components and flows as text
| Component | Kind | Technology | Flows out |
|---|---|---|---|
| Web dashboard | client | React SPA · Firebase Auth client SDK | → Firebase Auth: sign-in, ID token; → KONTA API: REST with ID token + business id |
| Merchant WhatsApp | client | text, buttons, voice notes | → WhatsApp Cloud API: messages and voice notes |
| Firebase Auth | external service | ID tokens, verified server-side | — |
| WhatsApp Cloud API | external service | Meta Graph API · webhooks | → KONTA API: webhook, HMAC-SHA256 verified, fail-closed |
| KONTA API | service | Express · Cloud Run · 1 instance | → WhatsApp Cloud API: send: 10 s timeout, never retried after a timeout; → Gemini API: proposal drafting, bounded and fallible; → Firestore: claim transaction + atomic ledger batch |
| Cloud Scheduler | scheduled job | daily summary job | ⇢ KONTA API: trigger daily summary (job token) |
| Firestore | datastore | Admin SDK only · rules deny-all | — |
| Secret Manager | secret store | per-secret accessor bindings | ⇢ KONTA API: secrets injected at deploy |
| Gemini API | external service | JSON mode · 12 s timeout | — |
Trust boundaries: Google Cloud project (KONTA API, Cloud Scheduler, Firestore, Secret Manager).
Context and constraints
Merchants record sales, orders, stock, debtors and expenses by sending a WhatsApp text or voice note, or from a web dashboard. Three things were non-negotiable:
- Money is never double-posted, however many times a provider redelivers a webhook or a user taps a button.
- A model never writes to the ledger. It is useful for understanding a message, not for being trusted with a balance.
- Tenants are isolated. Profit, cost and expense figures are owner-only by default and redacted server-side for staff.
Every external call is bounded so the synchronous webhook path always answers: Gemini at 12 seconds, sends at 10 seconds per attempt, voice notes capped at 5 MB, inbound text at 4,096 characters, request bodies at 1 MB.
Security posture
- The webhook verifies an HMAC-SHA256 signature over the raw request bytes with a constant-time comparison, and fails closed.
- Webhook-supplied identifiers are validated before they are used in database paths (PR #71).
- Rate limits on the API, a stricter limit on model-backed routes, and a per-phone limit on WhatsApp.
- Strict CSP, HSTS and frame-blocking headers in production; the static server exposes only the client build (PR #49).
- Phone numbers are masked in logs; linking tokens come from a cryptographic random source (PR #51, PR #57).
- A two-stage, non-root container image with a health check; secrets in Secret Manager behind a dedicated service account.
Verification
- 72 merged pull requests, each carrying its own rationale.
- 98 test files run by a custom runner that executes each file in its own process, strips cloud credentials from the environment and refuses to use Firestore unless it points at the emulator; the same suite runs against the emulator.
- Suites target concurrency and isolation directly: claim fencing, proposal concurrency, cross-tenant isolation, end-to-end webhooks, HMAC.
- The tests themselves were audited: nine files that always reported a pass were found and fixed (PR #43).
Decisions
The model drafts; it never writes
Gemini output, or a deterministic parser when Gemini fails, becomes a proposal in PENDING_CONFIRMATION. Only an explicit confirmation runs the Action Engine, which re-validates role, expiry, stock and debt limits before writing.
Options considered
- Let the model call write tools directly: simplest, and one hallucination away from a wrong balance.
- Proposal, confirmation, deterministic execution: one extra tap per entry.
Trade-off accepted
Every entry needs a confirmation, and parsing logic exists twice (model and deterministic paths). In exchange, the books cannot be changed by a model error, and AI-written debt reminders are discarded unless they state the real amount.
Deny-all database rules, server-only access
Firestore rules deny every client read and write. All access goes through the API with the Admin SDK, after verifying the Firebase ID token and an active membership for the business named in the request; owner, manager and cashier roles are enforced per route.
Options considered
- Rules-based client access using membership documents: realtime listeners, but membership documents a client can write would let any signed-in user grant themselves access to any business.
- Server-only access: one place to enforce tenancy and roles.
Trade-off accepted
No client-side realtime listeners; every read is an API round trip.
Never retry a send after a timeout
Outbound WhatsApp sends have a 10-second timeout and one retry, but only for pre-send network errors, 429 and 5xx. A client-side timeout is terminal.
Options considered
- Retry on any failure: maximises delivery, and can message a customer twice, because the send API has no idempotency key.
- Treat timeouts as ambiguous and do not retry.
Trade-off accepted
Some timed-out sends are lost rather than duplicated. Delivery is then diagnosed from a durable send log that the asynchronous status webhook is merged into.
Idempotency at both ends
Inbound messages are claimed by message id in a Firestore transaction with a 45-second lease, released on a crash so the redelivery can succeed. Confirmed actions take a fenced execution claim, and an execution record is written in the same batch as the ledger change, so a replay returns the stored result.
Options considered
- Best-effort de-duplication in memory: lost on restart.
- Durable claims at the message and action level.
Trade-off accepted
More transactions per message and a state machine with more states to test.
One instance, deliberately
Run exactly one Cloud Run instance. Each business's ledger is cached in memory behind a per-business lock; Firestore is committed first and memory updated after, and the process exits on an uncaught exception rather than serve a half-applied cache.
Options considered
- Scale horizontally now: needs every read inside a transaction first.
- Single instance with a documented exit path.
Trade-off accepted
No horizontal scaling or instance-level redundancy; a deploy briefly interrupts service, softened by a 10-second SIGTERM drain and conversation state that resumes from Firestore.
Exit path
Move ledger reads into Firestore transactions, then lift the instance cap.
Failure modes
| Failure | Detection | Handling | Evidence |
|---|---|---|---|
| Meta redelivers a webhook | Same message id already claimed | Acknowledged without reprocessing | idempotencyStore.ts |
| User taps Confirm twice | Second claim on the same action | Fenced claim; replay returns the stored result | executionClaimFencingIsolation.test.ts |
| A send times out | Client-side timeout | Not retried; status webhook reconciles delivery | PR #65, PR #68 |
| Gemini is slow or down | 12-second timeout | Deterministic parsing and synthesis | PR #59 |
| Crash during a request | uncaughtException | Exit; the database is always ahead of memory | server.ts |
| Redeploy mid-conversation | SIGTERM | Drain, then resume state from Firestore | PR #61 |
| A test that never runs | Exit codes ignored | Audited; nine silent passes fixed | PR #43 |
What I would change next
- Run the test suite as a required check on every pull request and deploy from CI; today deploys are a manual Cloud Run source deploy.
- Move ledger reads inside Firestore transactions and lift the single-instance cap.
- Key the general API rate limiter by user after authentication, not by IP.
- Apply the message-log consent gate and a retention TTL to the raw inbound webhook log.
- Make the once-a-day summary guard a transactional create instead of read-then-send.
Evidence index
The source is private; references are to files and pull requests in it.