← all work

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
  • Cloud Run
  • Firestore
  • Secret Manager
  • Cloud Scheduler
  • Firebase Auth
  • Node.js
  • Express
  • TypeScript
  • Docker
  • WhatsApp Cloud API
  • Gemini
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

KONTA architectureGOOGLE CLOUD PROJECTCLIWeb dashboardReact SPA · Firebase Auth…CLIMerchant WhatsApptext, buttons, voice notesEXTFirebase AuthID tokens, verified serve…EXTWhatsApp Cloud APIMeta Graph API · webhooksSVCKONTA APIExpress · Cloud Run · 1 i…JOBCloud Schedulerdaily summary jobDBFirestoreAdmin SDK only · rules de…SECSecret Managerper-secret accessor bindi…EXTGemini APIJSON mode · 12 s timeoutKONTA architectureCLIWeb dashboardReact SPA · Firebase Auth client SDKCLIMerchant WhatsApptext, buttons, voice notesEXTFirebase AuthID tokens, verified server-sideEXTWhatsApp Cloud APIMeta Graph API · webhooksSVCKONTA APIExpress · Cloud Run · 1 instanceJOBCloud Schedulerdaily summary jobEXTGemini APIJSON mode · 12 s timeoutDBFirestoreAdmin SDK only · rules deny-allSECSecret Managerper-secret accessor bindings
One Express service on Cloud Run is the only path to the ledger. The browser never touches Firestore, WhatsApp traffic arrives as HMAC-verified webhooks, and Gemini only ever produces proposals. Hover, tap or tab through the components; control flows are dashed.
Components and flows as text
ComponentKindTechnologyFlows out
Web dashboardclientReact SPA · Firebase Auth client SDK→ Firebase Auth: sign-in, ID token; → KONTA API: REST with ID token + business id
Merchant WhatsAppclienttext, buttons, voice notes→ WhatsApp Cloud API: messages and voice notes
Firebase Authexternal serviceID tokens, verified server-side—
WhatsApp Cloud APIexternal serviceMeta Graph API · webhooks→ KONTA API: webhook, HMAC-SHA256 verified, fail-closed
KONTA APIserviceExpress · 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 Schedulerscheduled jobdaily summary job⇢ KONTA API: trigger daily summary (job token)
FirestoredatastoreAdmin SDK only · rules deny-all—
Secret Managersecret storeper-secret accessor bindings⇢ KONTA API: secrets injected at deploy
Gemini APIexternal serviceJSON 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

  1. D-01accepted

    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.

    server/nlpOrchestrator.ts · server/actionEngine.ts · PR #1

  2. D-02accepted

    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.

    firestore.rules · server/authMiddleware.ts · PR #1

  3. D-03accepted

    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.

    server/whatsapp/provider.ts · PR #60 · PR #65 · PR #68

  4. D-04accepted

    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.

    server/whatsapp/idempotencyStore.ts · server/actionEngine.ts · server/saleTransactionEngine.ts

  5. D-05accepted

    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.

    server/store.ts · docs/DEPLOY.md · PR #3 · PR #61

Failure modes

FailureDetectionHandlingEvidence
Meta redelivers a webhookSame message id already claimedAcknowledged without reprocessingidempotencyStore.ts
User taps Confirm twiceSecond claim on the same actionFenced claim; replay returns the stored resultexecutionClaimFencingIsolation.test.ts
A send times outClient-side timeoutNot retried; status webhook reconciles deliveryPR #65, PR #68
Gemini is slow or down12-second timeoutDeterministic parsing and synthesisPR #59
Crash during a requestuncaughtExceptionExit; the database is always ahead of memoryserver.ts
Redeploy mid-conversationSIGTERMDrain, then resume state from FirestorePR #61
A test that never runsExit codes ignoredAudited; nine silent passes fixedPR #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.

  • server/nlpOrchestrator.ts
  • server/actionEngine.ts
  • PR #1
  • firestore.rules
  • server/authMiddleware.ts
  • server/whatsapp/provider.ts
  • PR #60
  • PR #65
  • PR #68
  • server/whatsapp/idempotencyStore.ts
  • server/saleTransactionEngine.ts
  • server/store.ts
  • docs/DEPLOY.md
  • PR #3
  • PR #61