Kai · CTO · 2 November 2026
Optima technical design
Technical design v0.1 · Kai, CTO · 2 November 2026
Status: proposed technical baseline for review. This document does not declare the product Ready or authorize a real-data pilot.
The design turns PRD v1.1 into a system that captures forwarded alerts, preserves uncertain evidence and explains reward progress across multiple cards. The reviewed visual reference is mockup v0.17 revision 3. The currently published mockup may lag that reference.
Read the design
- Architecture explains deployment and module boundaries.
- Journeys contains sequence diagrams for J1–J4, ingestion and deletion.
- Data defines records, ownership and consistency.
- Reward engine specifies periods, rounding, caps and revisions.
- RPC contract defines reads, writes, errors and compatibility.
- Security and privacy covers access, forwarding trust and deletion.
- Caching and operations covers retries, recovery and deployment.
- Cost and effort states assumptions, ceilings and estimates.
- Decisions compares options and reconciles the old ADRs.
- Readiness and sources maps requirements to evidence and open gates.
Decisions I recommend
- Run React, TypeScript, TanStack Router, Query and Start on Workers. Use one modular backend, not a fleet of services.
- Use D1 for the bounded pilot and R2 for temporary raw MIME. Keep storage access behind owned repositories to permit a later Postgres migration.
- Use Protobuf and Connect RPC. Backend owns the schema; Frontend reviews it before either implementation starts.
- Publish complete calculation revisions atomically. Never assemble a dashboard from independently updated totals.
- Keep merchant inference asynchronous and disabled until its quality, privacy and cost gates pass. Capturing known amounts does not depend on it.
Decisions still needed
Exact pilot products and their terms are unresolved. Pia recommends three candidate products, conditional on participant ownership; this is not an approved list. Raw-email retention and deletion wording need Owner and privacy review. The proposed seven-day raw retention window is not a promise.
The cost model assumes an Estimated 100-account pilot, not permission to limit the agreed product scope. Larger cohorts need fresh capacity and cost review. No paid service beyond the existing US$5 Workers plan is authorized here.
What this document replaces
This design revises the technical proposals in ADR-001 through ADR-004. It withdraws their assumed 30-day raw retention, KV-based immediate tag revocation, simplified eligible-spend accumulator and unapproved push-alert thresholds. Existing ADR files remain historical records. The decision register identifies each amendment; no product requirement changes silently.
All sample amounts are illustrative fixtures. No production service, database, OAuth application, CI workflow or payment has been created by writing these pages.
Architecture and ownership
Deploy a modular system
The browser uses one same-origin application Worker for rendering, sign-in and Connect requests. A separate Email Worker has only intake permissions. A queue consumer parses alerts and computes results. They share versioned domain packages, not HTTP calls between small internal services.
React · TanStack
HTTPS + session cookie ↓
OIDC · Connect · authorization
owned repositories ↓
Facts · corrections · calculation revisions
↑ idempotent writes
Parse · classify evidence · calculate
↑ message ID only
Tag gate · size gate · durable intake
R2 holds raw MIME without a public URL. Queue bodies contain opaque intake IDs and versions, never email text or account details. A D1 outbox repairs the non-atomic boundary between R2, D1 and Queues.
Package boundaries
| Module | Owns | Must not do |
|---|---|---|
web |
Routes, forms, sheets, accessibility, Query keys | Calculate reward rules in components |
contract |
Protobuf descriptors and generated clients | Import database tables |
api |
Session, tenant authorization, request validation | Trust a supplied account ID |
intake |
Size limits, tag validation, receipts, MIME retention | Guess card ownership from sender |
parsers |
Versioned bank-template adapters and evidence extraction | Fetch remote email content or execute HTML |
ledger |
Facts, credits, revisions and deduplication | Infer that a category proves MCC |
rewards |
Pure deterministic evaluation of verified rule data | Call AI or read the clock implicitly |
projection |
Build complete result revisions and publish pointers | Mix results from different revisions |
jobs |
Outbox, retry, cleanup, deletion and reconciliation | Run unbounded account-wide scans |
Runtime contracts
Conceptual interfaces below describe responsibilities, not implementation code.
parseAlert(raw, parserVersion) -> ParsedEvidence | NeedsReview(reason)
resolveCapture(evidence, accountContext) -> PurchaseFact | CreditFact | HeldCapture
calculate(facts, ruleVersions, settingsVersions, asOf) -> CalculationRevision
publish(revision, expectedLedgerVersion) -> Published | Superseded
readSnapshot(account, selection) -> Snapshot(revision, completeness, provenance)
Use integer minor units for money and integer or rational reward units. Parse external strings at boundaries. Unknown amounts, dates, MCCs and payment methods are explicit variants. They never default to zero or an eligible state.
Why D1 for the pilot
D1 fits the existing Workers plan and supports transactional batches. Each database processes queries on one thread, so expensive calculations stay outside SQL write transactions. The pilot starts with one database per environment and indexed tenant queries. A measured contention or storage limit triggers a new ADR before expansion. D1 limits.
Postgres with Hyperdrive remains the alternative for heavier write concurrency, richer transactional workflows or a residency requirement D1 cannot meet. Hyperdrive is a connection layer, not a supplied Postgres server. No external database subscription is assumed.
Use Workers for the application runtime; the documentation pages are a separate static Pages artifact. TanStack Start has an official Workers deployment path. The Connect adapter, MIME parser and auth library still need a real Workers compatibility spike. Start on Workers.
Journey sequence diagrams
Each diagram has a text transcript for phone and assistive reading. The order is part of the design. The diagrams describe intended behaviour, not delivered functionality.
J1: Sign in and set up
Read the sequence as text
- Browser → App Worker. Open private route; start sign-in.
- App Worker → Google. Authorize with state, nonce and PKCE.
- Google → App Worker. Authorization code callback.
- App Worker → Google. Exchange code; validate token.
- App Worker → D1. Link subject; create session.
- App Worker → Browser. Set session cookie; resume setup.
- Browser → App Worker. Save exact card and statement day.
- App Worker → D1. Persist card and dated settings.
- App Worker → Browser. Show destination and test status.
The server checks OIDC state, nonce, signature, audience, issuer and expiry. It links Google's stable sub to an internal account. No Gmail or Drive scope is requested. Unsupported products can be recorded but have unavailable reward estimates. Setup persists after each successful step. A failed test never claims complete bank coverage. F-01, F-02, F-03.
Forwarding-confirmation messages appear only in the authenticated account's setup panel after exact-provider parsing. Never auto-follow an arbitrary link or forward confirmation messages to another mailbox. Test real Gmail and Outlook formats before accepting them.
Intake: Capture once, even after retries
Read the sequence as text
- Email Worker → R2 / D1. Validate tag; reserve quotas.
- Email Worker → R2 / D1. Store raw object; receipt + outbox.
- R2 / D1 → Queues. Dispatcher sends intake ID.
- Queues → Consumer. Deliver ID; may repeat.
- Consumer → R2 / D1. Check account; parse source; dedupe.
- Consumer → R2 / D1. Commit fact + ledger version.
- Consumer → R2 / D1. Calculate; conditionally publish revision.
- Consumer → Queues. Acknowledge after durable commit.
Reserve quotas before storing MIME. Write an R2 object using an intake ID, then atomically record the intake receipt and outbox row in D1. Dispatch after commit. A bounded orphan sweep deletes objects without receipts. A dispatcher crash may enqueue twice; the consumer deduplicates. A queue failure leaves the outbox pending. Unknown or revoked tags are rejected; persistence failures must be tested for SMTP retry behaviour before launch, not silently treated as accepted.
A second copy with identical raw bytes is deduplicated by a tenant-scoped hash. Forwarded copies with changed MIME use issuer reference and stable semantic evidence. When two real purchases might look identical, hold the possible duplicate for review rather than merge automatically. Ambiguous account, card, currency or amount stays excluded from affected totals. Unknown MCC alone does not exclude a known purchase.
J2: Compare cards before spending
Read the sequence as text
- Browser → App Worker. GetHome(month) via Connect GET.
- App Worker → D1. Validate session and owned selection.
- App Worker → D1. Read published snapshot and pending state.
- D1 → App Worker. Return one calculation revision.
- App Worker → Browser. Return allowances, estimates, evidence.
- Browser → App Worker. GetCard(owned card, period).
- App Worker → Browser. Show full conditions; no invented advice.
Read one account snapshot revision. Cards show separate allowances, conditional minimums and earned estimates in native units. Missing rule or payment evidence suppresses definitive recommendations. User priority breaks ties only among comparable verified choices; cashback is not ranked against miles without an approved valuation. F-05, F-06, F-11.
J3: Correct a purchase in its sheet
Read the sequence as text
- Browser → App Worker. CorrectTransaction(version, command ID).
- App Worker → D1. Authorize; compare record version.
- App Worker → D1. Commit correction + pending + outbox.
- App Worker → Browser. Return corrected fact and pending state.
- D1 → Job worker. Dispatch latest affected calculation.
- Job worker → D1. Publish only if ledger version matches.
- Browser → App Worker. Refetch snapshot while pending.
- App Worker → Browser. Replace totals and review count together.
A command contains an idempotency key and expected record version. A successful correction transaction creates a correction event, increments ledger version, marks affected projections pending and writes the outbox. The UI immediately receives the corrected fact and a pending calculation state. It does not combine a fresh fact with stale numbers presented as final. When recalculation publishes, the client replaces all affected totals and review count together. Cancellation writes nothing; errors preserve form entries. Focus returns to the trigger on close. F-08.
J4: Inspect a month or card cycle
Read the sequence as text
- Browser → App Worker. GetHistory(exact selection).
- App Worker → D1. Read chart and rows at same revision.
- App Worker → Browser. Return period keys and revision.
- Browser → App Worker. GetPeriod(selected key, revision).
- App Worker → D1. Read owned period and paginated facts.
- App Worker → Browser UI. Show totals, credits, uncertainty, reasons.
- Browser → App Worker. ListTransactions(selection, cursor).
- App Worker → Browser UI. Append same-revision page or restart.
Selections are explicit period keys, not a reused October URL. Return chart, list, totals, credits and revision reasons from the same published snapshot. Cursor pagination retains its selection and revision; if that revision expires, ask the client to restart rather than skip rows. No captured purchases yields an em dash, not 100%. F-04, F-09, F-10.
Account deletion: stop access before cleanup
Read the sequence as text
- Browser → App Worker. Recent auth + deletion confirmation.
- App Worker → D1. Revoke access and tags; mark deleting.
- App Worker → Browser. Acknowledge stopped access.
- D1 → Cleanup worker. Dispatch deletion job.
- Cleanup worker → D1. Delete owned records in bounded batches.
- Cleanup worker → D1. Record opaque restore-block marker.
- Cleanup worker → D1. Verify cleanup completion.
- App Worker → Browser. No further private access.
This is a proposed technical deletion mechanism. It is not approval of retention policy. Reauthenticate, revoke sessions and forwarding tokens, and set the account to deleting in one transaction. All later consumers check that state before writes. A bounded job deletes R2 objects, facts, corrections, projections and private overrides. Job retries converge on the same state. A recovery tombstone blocks deleted accounts after a backup restore until cleanup runs again. Proposed deadlines and backup limits are on the security page.
Data model and consistency
Relationships
account ──< identity, session, forwarding_token, user_card
user_card ──> card_product ──< rule_version
account ──< intake_receipt ──< capture_candidate
user_card ──< transaction_fact ──< correction_event, credit_link
account ──< calculation_revision ──< transaction_result, period_projection
account ──> published_revision
intake_receipt / correction_event ──< outbox_job
Records and invariants
| Record | Key fields | Integrity rule |
|---|---|---|
account |
id, status, ledger_version, deleted_at | Active status required for every private operation |
identity |
issuer, subject, account_id | Unique issuer + subject; email is a profile field |
session |
token_hash, account_id, expires_at, auth_time | Store only hash; revocation checked on server |
forwarding_token |
digest, encrypted_display_value, account_id, version, revoked_at | 128-bit random token; unique digest; no token in logs |
user_card |
account_id, id, product_id, last_four, statement_config_version | Composite tenant foreign keys prevent cross-account links |
card_settings_version |
card_id, effective dates, category selections, source | Statement-day and selection changes are dated |
intake_receipt |
account_id, id, raw_hash, raw_object_key, parser_version, state, expires_at | Unique account + raw hash; raw key is never a public URL |
capture_candidate |
intake_id, amount/date/card candidates, evidence, hold_reason | Held state is distinct from a posted purchase |
transaction_fact |
account_id, id, card_id, kind, original_currency, amount_minor, purchased_at, posted_at?, billed_sgd_minor? | Append preserved capture facts; credits have a separate kind |
correction_event |
account_id, transaction_id, version, patch, reason, actor, at | Monotonic version; no overwrite of source evidence |
credit_link |
credit_id, purchase_id?, allocated_minor, evidence | Link may be unknown; never double-subtract |
rule_version |
product_id, version, effective dates, source URLs + digest, schema_version, engine_support | Published versions immutable; no ambiguous overlapping standing versions |
merchant_alias |
normalized_key, readable_category, evidence, version | Public-business descriptors only; never authoritative bank MCC |
user_merchant_override |
account_id, key, category, provenance | Personal preference does not alter another account |
calculation_revision |
account_id, id, ledger_version, rule_ids, engine_version, settings_versions, reason, status | Results reproduce from explicit inputs |
transaction_result |
revision_id, transaction_id, portions, rewards, qualification, reasons | Covered + no-bonus + unclear portions reconcile to purchase amount |
period_projection |
revision_id, card/filter/period, purchases, credits, net, covered, unclear, rewards, completeness | Same revision for charts, lists and totals |
published_revision |
account_id, revision_id, ledger_version | Pointer changes only if inputs still match |
outbox_job |
id, account_id, kind, input_version, status, attempts, lease_expiry | Unique semantic job key; reclaim expired leases |
budget_counter |
scope, period, reserved, limit | Atomic reservation; fail closed when exhausted |
deletion_job |
opaque request_id, status, cursor, deadline | No raw finance data in queue or tombstone |
Tenant indexes
Every private query begins with account_id. Index (account_id, card_id, purchased_at, id) for transaction pagination, (account_id, period_key, revision_id) for projections and (status, next_attempt_at, id) for bounded job dispatch. Query-plan checks must prove that filtered reads avoid global scans. Never enforce ownership only by an unguessable UUID.
Publish a consistent revision
Read inputs at ledger version L. Compute outside the write transaction. Stage results under revision R. A final D1 transactional batch publishes R only if account status is active and ledger version is still L. Use conditional writes and check affected-row counts; stale jobs discard staging and enqueue the newest version. No interactive multi-request SQL transaction is assumed.
A mutation does not promise a new numeric answer before R is complete. Pending markers and corrected facts commit together. All reads return ledger_version, calculation_revision and calculation_state. Personal read responses use Cache-Control: private, no-store.
Start without read replicas for financial reads. If replication is later enabled, use D1 Sessions with a primary first read or a returned bookmark to preserve read-your-writes. D1 Sessions.
Growth and migration
A proposed pilot guard pauses intake at 4 GB total D1 storage across testing and production. History already captured remains readable; no automatic history deletion is allowed to fit a budget. Before expanding, measure row/index bytes and revision growth. Retain explanation evidence for active-account history; compact reproducible intermediate results only under an approved retention policy.
A later Postgres move uses expand-and-contract: export tenant facts and versions, shadow-recompute, compare totals and revision digests, switch reads per account, keep rollback input copies for an approved window. Do not dual-write financial facts without an idempotent reconciliation plan.
Reward calculation and historical revisions
Separate the questions
One evaluator produces captured spend, minimum qualification, cap consumption, estimated base rewards, estimated bonus rewards and reporting portions. These are distinct outputs. A bank MCC, inferred MCC and user category have distinct provenance.
The engine accepts immutable rule data with a schema version and a finite set of supported operators. Rules specify eligibility, date basis, aggregation, rounding order, reward components, cap-sharing groups, selection conditions and qualification gates. Rule data cannot execute code. A new operator requires a reviewed engine release; a new card using existing operators can be a reviewed data publication.
Evaluation order
- Select the exact product, effective rule versions and dated account settings. If date basis or rule coverage is unresolved, retain facts and return unavailable results for affected components.
- Resolve evidence into known, excluded or conditional predicates. A guessed MCC never upgrades evidence to bank-confirmed.
- Build separate minimum, count and reward ledgers using each component's date basis and eligibility terms.
- Apply issuer-specific aggregation, then the specified rounding and base/bonus calculations. Never infer transaction blocking from an advertised rate denominator.
- Allocate shared and nested caps in the verified ordering. If ordering is unknown, withhold affected capacity rather than invent first-come allocation. Resolve ties deterministically from the approved rule.
- Evaluate all monthly or statement-period gates required by a quarter. Retrospective tiers recompute affected periods. An unmet future gate remains conditional.
- Process credits according to verified clawback and capacity-reopening rules. Unknown credit linkage or terms can make rewards uncertain without hiding the credit from net spend.
- Build per-purchase disjoint reporting portions, projections and explanation reasons. Publish them as one revision.
Period model
Store UTC timestamps with original offset evidence and Singapore-local date derivation. Reporting months use Asia/Singapore and half-open intervals [start, next_start). A statement cycle ending on day D runs from the day after the prior closing date through D inclusive. Clamp D to the last day in a short month; date selection changes are versioned. A Miles card closing on 22 Oct places both 7 Oct and 18 Oct in 23 Sep–22 Oct.
Purchase date drives captured-spend views. Reward allocation may require posting date or an issuer-specific date. Preserve both. Never substitute purchase date for an unknown posting date and declare a reward period settled. Calendar, statement, quarter and campaign periods have separate keys.
Reporting invariants
For positive captured purchases P, disjoint bonus-covered B, no-bonus N and unclear/conditional U satisfy P = B + N + U. Covered share is round(100 × B / P). A zero denominator yields no percentage. Credits C are separate and net spend is P − C; credits do not shrink the share denominator. Unreliable foreign conversion stays outside a falsely complete SGD total, with original currency and a completeness flag visible.
Overlapping bonus programmes may produce several reward components, but their covered amount contributes only once to B. Points, miles and cashback remain separate units. An exhausted category never implies all card categories are exhausted.
Required fixtures
| Illustrative input | Required output |
|---|---|
| S$14.99, whole-S$5 blocks, 10 points per block | S$10 bonus-bearing, 20 points, S$4.99 known block remainder; captured spend S$14.99 |
| Two S$2.50 purchases under per-purchase blocking | Zero blocks; never combine into S$5 |
| S$50 crossing a cap: S$20 at total 4 miles/S$1, S$30 at base 0.4 | 92 miles total; 40% covered, no duplicate base addition |
| Minimum S$1,393 of S$1,600, S$98 qualification unknown | S$207 provisional; unknown amount remains captured |
| Online capacity S$180; mobile capacity S$400 | Two allowances; no pooled S$580 headline |
| Quarter requires S$600 and 10 purchases each period; current count nine | Conditional quarter, one purchase outstanding; later period cannot repair failure |
| October B=2,380, N=220, U=50 | P=2,650; rounded covered share 90%; U remains visible after month end |
| Category correction Insurance, MCC unknown | User category changes; bank MCC and eligibility remain unknown |
| Late refund with unknown reopening terms | Credit reduces net once; affected reward capacity stays uncertain |
These fixtures do not prove any real card's terms. Each enabled product needs dated official terms and verified fixtures for every applicable operator.
Revision reasons
A revision records affected transaction IDs, previous and new rule versions, correction IDs, engine version and reason codes. For example, withdrawing an inferred code for a S$50 Marketplace purchase moves its portion from no-bonus to unclear while leaving captured spend unchanged. Current rules do not silently overwrite historical rules. Late alerts recalculate their affected periods and dependent quarterly gates through bounded jobs.
RPC contract
Package proposal: optima.v1. These signatures are design sketches. Ravi owns the real proto; Kai and Mira review it before client mocks or implementation.
Shared value shapes
Money { currency: ISO4217, minor_units: int64 }
Evidence { kind: supplied | inferred | user_asserted | unknown,
source_id, captured_at, confidence? }
Period { kind, local_start, local_end_exclusive, timezone, card_id? }
SnapshotMeta { ledger_version, calculation_revision,
state: ready | pending | unavailable, completeness, revised_at }
Allowance { rule_id, period, native_unit, used, limit?,
status: available | exhausted | conditional | unavailable,
spend_equivalent?, reasons[] }
PurchasePortions { covered_minor, no_bonus_minor, unclear_minor }
Native units distinguish SGD, bonus points, cashback minor units and miles. JSON int64 values remain strings on the wire; clients must not lose precision by converting arbitrary int64 to JavaScript Number. Unsupported enum variants render an unavailable state, not a success label.
Reads use Connect GET
All following RPCs are unary and annotated idempotency_level = NO_SIDE_EFFECTS. Configure the generated Connect client to use GET. Authenticate every request and mark personal responses private/no-store. Do not put forwarding secrets or merchant text in GET requests. Session validation does not slide expiry during these reads; renewal is an explicit POST.
| RPC | Request | Response |
|---|---|---|
GetSession |
empty | Signed-in account summary or unauthenticated |
GetSetup |
empty | Cards, masked forwarding destination, test status |
ListCardProducts |
issuer, cursor | Verified/support-status catalogue; max 50 |
GetHome |
reporting_month | One snapshot with cards, split, rewards, review count |
GetCard |
owned_card_id, period | Identity, allowances, gates and summary |
ListTransactions |
selection, cursor, revision | Max 50 rows, next cursor, metadata |
GetTransaction |
transaction_id, revision? | Fact, current correction, portions, provenance and explanations |
ListReviewItems |
cursor | Max 50 held or unclear items and reasons |
GetHistory |
filter, six_period_window | Six chart rows and matching table; earlier-window cursor |
GetPeriod |
exact period key, revision? | Period totals, credits, card summaries and transaction cursor |
GetRevision |
revision_id | Explainable changed inputs and affected periods |
The protocol defines GET for methods marked as having no side effects. Authentication and cache policy remain application responsibilities. Connect protocol.
Commands use POST
| RPC | Input and concurrency | Result |
|---|---|---|
CreateCard |
product, last_four, statement day, command_id | Owned card and version |
UpdateCardSettings |
dated settings, expected_version, command_id | New settings version and pending recalculation |
UpdatePriority |
ordered owned-card IDs, expected_version, command_id | Saved order |
CorrectTransaction |
transaction_id, typed patch, expected_version, command_id | Corrected fact, pending snapshot and job status |
ResolveCapture |
held capture ID, explicit card/amount/currency choice, expected_version | Confirmed fact or kept-excluded state |
RecordCreditCorrection |
amount, currency, optional original purchase, reason, command_id | Credit fact, optional link, pending calculation |
RevealForwardingDestination |
recent-auth proof | Destination, no-store; never included in analytics |
RotateForwardingDestination |
recent auth, expected_version, command_id | New token; old token revoked atomically |
RenewSession / SignOut |
CSRF protection | New expiry / revoked session |
RequestAccountDeletion |
recent auth, explicit confirmation, command_id | Access revoked and opaque deletion request ID |
Store command result and request digest under (account_id, command_id) in the same transaction as the change. Reusing a key with a different body fails. Retain command receipts for a proposed 48-hour retry window; make that window part of the client contract. Identity comes from the server session, never an account ID in the request.
Errors and response limits
unauthenticated: sign in again, preserve local form entries without shared storage.not_found: missing or not owned; avoid revealing another account's record existence.invalid_argument: field errors, including invalid period or ambiguous amount.aborted: record version conflict; show latest value before retry.resource_exhausted: quota reached, retry time and reason; no automatic busy loop.unavailable: safe retry for reads, command retry with the same command ID.failed_precondition: unsupported product/operator, expired revision cursor or deletion state.
Proposed limits: 64 KiB command bodies, 256 KiB responses, 50 transaction rows, six chart periods per request. History remains reachable through pagination; limits do not delete older history. Cursors are opaque, signed and bound to account, selection and revision.
Compatibility and rollout
Add fields and enum variants; do not reuse tags or remove fields. Reserve retired tags and names. New behaviour uses a new RPC or package version. CI runs buf lint and buf breaking against main and the last production schema. Backend deploys first. Additive database expansion precedes compatible backend and frontend deployment; contraction requires all callers migrated and a separately reviewed release.
Security and privacy
Private access
Google OIDC uses authorization code flow with PKCE, state and nonce. The server validates the signed ID token, accepted issuer, exact audience, expiry and nonce through a maintained library. Request only openid email profile. Account identity is (issuer, sub), not email. Choose and pin the Workers-compatible auth library after a spike. Google OIDC.
Use an opaque random session token in an HttpOnly, Secure, SameSite=Lax cookie; keep its hash in D1. Proposed session policy: 30-day absolute expiry, explicit renewal, immediate revocation on sign-out. Showing or rotating the forwarding address and account deletion require authentication within ten minutes. Check Origin and CSRF protection on commands. Deny cross-origin private RPC access. Do not cache private rendered HTML at the edge.
Every API and queue consumer checks account status and tenant ownership. Composite account foreign keys stop a correction from linking another tenant's card. Automated two-account tests cover IDs, cursors, revisions, R2 reads and exports. Redact financial facts, email addresses, tokens and merchant strings from logs. No personal information belongs in error trackers or public documentation.
Forwarding is a trust boundary
The random tag routes the account; it does not authenticate a bank. A copied tag can receive forged mail. Use a 128-bit random value, store a keyed digest for lookup and encrypt the display value with a versioned application secret. Rotation and lookup use primary D1, not eventually consistent KV.
Treat all message headers and MIME as untrusted. Inspect provider authentication evidence with explicit provenance; a forwarded From header or an attacker-supplied Authentication-Results header proves nothing. The engineer must demonstrate the trusted header boundary and ARC/DKIM behaviour using redacted Gmail and Outlook samples. Until that test passes, unsupported forwarding modes stay held for review. A tag is never sufficient evidence for a confirmed reward.
Proposed app limit is 64 KiB raw MIME, below Cloudflare's documented 25 MiB inbound limit. Oversized messages fail visibly with setup guidance; any increase needs CPU and cost review. Stream MIME with a byte ceiling, limit nesting and part count, ignore attachments and remote images, parse text without executing HTML, and never auto-follow links. Known bank templates and ambiguity checks precede posting. Email limits · Email handler.
Merchant inference
Use a conservative business-descriptor allowlist before any shared lookup or model call. Descriptor normalization alone does not anonymize a person's name. Transfers, personal names and uncertain descriptors stay private and bypass AI. A keyed hash is not permission to publish private text.
Personal corrections win for that account. Three distinct agreeing users make a descriptor a promotion candidate, not an automatic global MCC fact. A reviewed promotion requires business-only evidence. No amount, account ID or card details go to a classifier. Model confidence is not bank MCC confidence. The old 0.7 confidence threshold is a proposal until calibration proves useful. Disable AI and queue no indefinite backlog until benchmark, model budget and privacy gates pass.
Proposed retention and deletion policy
These values are Estimated technical targets pending Owner and privacy approval. Do not collect real pilot mail until approved.
| Data | Proposed lifetime | Mechanism and limit |
|---|---|---|
| Raw MIME | Seven days maximum | expires_at check on reads, hourly deletion job and R2 lifecycle backstop; no indefinite review extension |
| Structured facts and rule provenance | Active-account history | Keep only fields needed for capture, correction and explanation |
| Private runtime logs | Seven days maximum | Redacted metadata only; deployment must verify provider log retention configuration |
| Queue and DLQ payloads | One day | IDs only; expire jobs whose raw source is gone; review says source expired |
| Account access | Revoked in deletion command | Sessions and tags disabled before acknowledgement |
| Primary account-linked data | Within 24 hours of accepted deletion | Bounded resumable job; report overdue cleanup operationally |
| D1 recovery copies | Up to 30 days provider recovery window | No promise of per-account erasure from every recovery copy; verify actual expiry and contract before policy approval |
| Restore-block tombstone | Proposed 31 days | Opaque keyed deletion marker, restricted access; surviving marker must precede any restore |
D1 Paid documents a 30-day Time Travel window. That is a recovery feature, not proof of a legal deletion deadline. A restore runs in an isolated environment with intake disabled, replays deletion markers, and verifies no deleted account can regain access before traffic resumes. R2 raw MIME has no additional backup by this design. Any export or future backup requires its own retention entry. D1 recovery limits.
The design does not assume Singapore-only data storage or make a PDPA compliance claim. Owner and suitable privacy review must resolve processor terms, location, notice, deletion exceptions and breach handling before real data.
Caching, operations and deployment
Cache register
Allowed staleness below is a proposed technical maximum, subject to PRD review where it affects visible behaviour.
| Data and scope | TTL | Invalidation | Allowed staleness and requirement |
|---|---|---|---|
| Hashed static files, public CDN | One year immutable | New content hash | Old file valid for that release; F-12 |
| Public card catalogue, browser memory | One hour | Catalogue version change or refetch | Up to one hour for artwork/name only; F-02 |
| Published rule version, public cache by immutable version | One day | New version gets new key; active pointer never trusted from stale cache | None for version selection; F-05.AC5 |
| Verified business merchant aliases, shared KV | One hour | Versioned key on reviewed update | One hour for readable category only; never authoritative MCC; F-07 |
| Private Home/card/history, TanStack Query in memory | 15 seconds stale time | Invalidate on command, published revision change, focus and account switch | Up to 15 seconds for external intake, with as-of state; corrected facts and pending state update immediately; F-08/F-09 |
| Forwarding tags, session status and deletion markers | No shared cache | Primary D1 transaction | Zero intentional revocation staleness; F-01 |
No personal data in CDN, KV shared caches, localStorage or service-worker response caches. Clear Query state on sign-out and account switch. The cache key includes account identity, exact selection and revision. Private response headers remain no-store even when client memory caches the result. Refresh while visible every 15 seconds only when work is pending; back off when hidden or idle.
KV may serve stale values across locations; therefore it does not control authorization, revocation or budgets. If cache invalidation fails, immutable version keys prevent stale rules from being selected for a new calculation.
Retry and admission controls
Proposed consumer configuration: at most two retries, three total attempts, concurrency two, small batches of five, explicit acknowledgement after durable commit and a one-day ID-only DLQ. Failed messages never retry forever. No raw email bodies in queue payloads. Maximum job age is checked by consumers even if queue retention is longer. A DLQ replay requires an engineer's scoped command, remaining budget and a valid input version.
Outbox dispatch uses leases with expiry. A scheduled application job claims at most 100 rows per run and at most the approved hourly budget. Every job has a semantic deduplication key. A ten-minute reconciliation pass finds missed dispatches. Cleanup and account deletion have a reserved job budget so stopping intake does not stop privacy work.
Application timers and schedules described here are future product infrastructure, not local agent routines. Ravi implements and tests them in the backend deployment.
Failure behaviour
| Failure | User outcome | Recovery |
|---|---|---|
| R2 write fails | Capture not confirmed | No receipt; test SMTP retry or explicit rejection path |
| R2 succeeds, D1 receipt fails | Capture not confirmed | Orphan object expires; bounded sweep deletes it |
| Queue dispatch fails | Setup says pending | Outbox retries with the same intake ID |
| Duplicate delivery | One fact | Tenant unique key and command receipt |
| Parser cannot resolve card/currency | Review item, excluded from affected totals | Explicit user confirmation |
| Unknown MCC | Known amount stays captured; rewards unclear | Later evidence triggers revision |
| Calculation conflicts with a newer correction | Pending or last complete result labelled stale | Discard staged revision, run newest version |
| Quota or kill switch | Capture paused, no claim of complete spending | Preserve history; retry only after operator decision |
| Rule withdrawn | Affected estimate unavailable or pending | Reviewed rule replacement and explainable recompute |
Observability and targets
Proposed, unmeasured acceptance targets: p95 private read under 500 ms from Singapore after warm-up; accepted alert visible or explicitly held within 60 seconds; no projection/fact mismatch across corrections. Measure with real deployed bindings and redacted fixtures. Do not report these as achieved.
Metrics include intake accepted/rejected/held, duplicate candidates, oldest outbox age, queue attempts, DLQ count, stale revision age, CPU, D1 rows scanned/written, R2 bytes, daily AI allowance and deletion deadline breaches. Alerts fire on sustained queue age, any deletion deadline miss, quota reservation failure or usage outside the pilot envelope. Logs use opaque correlation IDs and reason codes only.
Environments and release
Testing and production have distinct D1, R2, queues, secrets, forwarding domains/tags and Google OAuth configuration. Testing uses synthetic or explicitly redacted fixtures, never copied production data.
Ravi owns backend CI/CD, protection and environments; Mira owns frontend CI/CD. Main requires backend-ci and frontend-ci, including Buf compatibility checks. The shared GitHub identity means no required GitHub approval count; Kai records CTO review (Kai): APPROVED and alone merges after all must-fix items close. Main deploys testing only. Production requires Tester sign-off, Kai approval and a release tag. Kai provisions separate scoped CI tokens as environment secrets.
Deploy compatible backend before frontend. Expand schema first. Smoke-test the previous client against the new backend. Roll back application releases without dropping new columns. A bad rule version is withdrawn through a new reviewed version, never edited in place. Restore drills verify reconciliation and deletion replay before production enablement.
Cost envelope and delivery effort
All workload, storage and effort figures on this page are Estimated, not Measured. Prices are published USD rates checked on 2 November 2026, before tax, domain registration or account-wide usage from other projects. Nothing here provisions a paid service.
Pilot workload and application ceilings
Baseline assumption: 100 accounts × 150 alerts per month = 15,000 alerts. Stress envelope: 50,000 accepted alerts per billing month across both environments, maximum raw size 64 KiB. This is a proposed admission envelope; Owner approves pilot size separately.
| Resource | Estimated controlled maximum | Guard |
|---|---|---|
| Accepted alerts | 50,000 per billing month; 100 per account/day | Atomic global and account reservations before persistence |
| Private API requests | 100,000 per billing month, 60/account/minute | Authenticated admission counters; pagination; UI backoff |
| Recalculation jobs | 10,000 per billing month | Coalesce account/period versions; at most 500 facts per job; fail unavailable if affected scope exceeds verified operator bounds |
| Worker invocations | 330,000 from admitted work | 50k intake + 150k parse attempts + 100k API + 30k recalc attempts |
| CPU | 15.25 million ms for the above work | Intake max 25 ms, other workers max 50 ms per invocation; parser compatibility/load spike must pass |
| Queue operations | Reserve at most 600,000/month including DLQ and retry overhead | At most three attempts; IDs under 1 KB; all dispatch and housekeeping counted |
| D1 rows | Reserve at most 25 million read and 5 million written/month | Indexed bounded queries, index-write allowance, metadata audit and 20% early stop |
| D1 storage | 4 GB combined testing/production | Pause intake before 5 GB included limit; no silent history pruning |
| R2 live bytes | About 3.28 GB if all 50k maximum-size emails coexist | Byte reservation, raw expiry, no attachment copies |
| R2 operations | At most 200k Class A, 500k Class B/month | Count writes, lists, replay reads and cleanup overhead |
| AI / Containers / Postgres | Zero in the baseline | Disabled, unprovisioned; no fallback paid vendor |
Budget reservations live in primary D1 and use conditional atomic writes. Failed work does not refund a reservation until reconciliation proves the operation did not occur. Per-worker CPU limits bound an invocation; they are not account spending caps. Reserve a separate housekeeping margin of 1 million CPU ms and job capacity inside the monthly envelope. Query bounds and real metadata must validate the D1 estimates before pilot enablement.
When a cap is reached, new intake or recomputation pauses with an honest pending/unavailable state. Existing history remains accessible within the reserved read budget. Deletion remains operational through reserved capacity. Do not repeatedly enqueue overflow work for tomorrow.
Published price comparison
| Item | Included or published price | Estimated pilot incremental charge |
|---|---|---|
| Workers Paid | US$5/month; 10 million requests, 30 million CPU ms included | US$0 within envelope |
| Workers overage | US$0.30/million requests; US$0.02/million CPU ms | Not authorized as normal operation |
| D1 Paid | 25 billion rows read, 50 million written, 5 GB included | US$0 within envelope |
| D1 overage | US$0.001/million reads; US$1/million writes; US$0.75/GB-month | Pause before planned overage |
| Queues | 1 million operations included; US$0.40/million after | US$0 within envelope |
| R2 Standard | 10 GB-month, 1 million Class A and 10 million Class B included | US$0 within envelope |
| R2 overage | US$0.015/GB-month, US$4.50/million A, US$0.36/million B | Pause before planned overage; provider rounds billing units |
Estimated base total: US$5/month, assuming these allowances are available in the account and configured resources stay within the envelope. Included allowances are shared with other workloads. Before enabling the pilot, reconcile the whole account's usage, not only Optima's counters.
Sources: Workers pricing, D1 pricing, Queues pricing, R2 pricing.
Residual billing risk and stop procedure
Cloudflare provides no general account hard spending cap. Rejected API calls and mail still invoke Workers before application admission, and cleanup/monitoring also consume resources. Therefore these application limits do not guarantee a US$5 maximum during a flood or a bug. Uncontrolled ingress has no finite worst-case bill in this design.
Use provider-side rate controls where available, disable public signup during the pilot and reject unapproved tags early. Set a US$5 account budget alert and early usage alerts at 50% and 80% of each included allowance. Proposed monitor runs hourly; alert delivery and operator response can lag. A primary-D1 kill switch stops expensive writes and jobs. If ingress continues, the runbook removes the email route and disables the affected public Worker route. A kill switch inside a Worker alone cannot stop invocation charges.
The hard unresolved decision is whether the Owner accepts this residual Cloudflare exposure. If an absolute US$5 ceiling is mandatory, do not enable paid production intake until a separately approved provider-level containment approach exists. No extra paid service is silently added to solve it.
Effort by requirement
Ranges are Estimated engineer-days of focused implementation and checks, not elapsed dates or a commitment. They exclude waiting for issuer terms and pilot participants. Shared plumbing is charged once in the most relevant row.
| Scope | Estimated effort | Main uncertainty |
|---|---|---|
| F-01 private access | 3–5 | Workers auth adapter, cross-account tests |
| F-02 card identity | 2–3 | Exact pilot list, artwork rights |
| F-03 intake/setup | 5–8 | Provider confirmations, MIME variants, dedupe |
| F-04 periods/credits | 4–7 | Posting, FX and late refunds |
| F-05 rule engine | 8–15 | Product-specific operators and verified fixtures |
| F-06 compact Home | 2–3 | Snapshot and conditional allowance presentation |
| F-07 transactions/MCC | 2–4 | Provenance, pagination and mixed portions |
| F-08 review sheets | 3–5 | Conflicts, failure preservation, focus behaviour |
| F-09 history | 3–5 | Revisions and actual-period navigation |
| F-10 covered share | 1–2 | Credit/unknown/partial-cap reconciliation |
| F-11 priorities/flags | 2–4 | Evidence and comparable reward units |
| F-12 phone UI | 2–4 | Accessibility and empty/error variants |
| Privacy, operations, CI integration and release tests | 5–8 | Deletion recovery, budgets and backend adapter |
| Total before contingency | 42–73 | Refine after spikes; backend/frontend work can overlap |
A proposed 25% contingency yields about 53–92 engineer-days. This is not a schedule; team availability and review/test time determine calendar duration. The largest risk is incomplete reward evidence, not chart implementation. Do not trade away uncertainty or tenant isolation to meet an inferred deadline.
Architecture decision register
These are CTO recommendations for this design version. They do not approve new spending, select pilot participants or replace the PRD. Prior ADR files remain unchanged historical sources.
ADR-005: TypeScript Workers and D1 for the bounded pilot
Proposed. Choose one language across UI and domain packages, D1 per environment, R2 raw intake and Queues for bounded jobs.
| Capability | TS Workers + D1 | TS Workers + Postgres/Hyperdrive | Go Containers + Postgres |
|---|---|---|---|
| No additional database host required | ✓ | — | — |
| Shared TS evaluator with client fixtures | ✓ | ✓ | — |
| Rich Postgres transaction/tooling support | — | ✓ | ✓ |
| No container runtime to bound | ✓ | ✓ | — |
D1 wins on cost and operational scope for the assumed pilot. Its single-threaded query execution and database-size ceiling are critical drawbacks. Postgres wins if measured write contention, operator complexity or location requirements exceed D1. Go is viable when an essential parser or workload demands it; no such evidence is established. Any future Containers design must specify instance maximum, sleep policy, memory/CPU bounds and a new worst-case bill before approval.
Acceptance gate: deployed Workers spike demonstrates auth, Connect, MIME parsing, D1 conditional batch publication and the CPU envelope. Failure returns this ADR to review; engineers must not add a paid backend quietly.
ADR-004 revision 2: Versioned calculation results
Proposed replacement of the old technical model. Rules use a validated finite DSL, immutable sources and explicit operator support. Complete account result revisions replace one mutable eligible-spend accumulator.
This costs more implementation and stored metadata. It is necessary because minimum qualification, bonus caps, partial purchase coverage, quarterly gates and refund adjustments are different ledgers. A daily correction must reconcile every screen and explain historical changes. Unapproved 80% warnings, ten-push daily limits and blanket switch-card copy are withdrawn. Push remains a separate future product decision.
ADR-001 amendment: Central tagged intake with primary revocation
Addressing direction retained; storage/revocation amended. One central routing pattern with random per-user tags remains. Tags identify the account, not a trusted sender. Use primary D1 to revoke immediately, encrypted reveal values and an R2/D1 outbox for durable intake. The old KV revocation shortcut and assumed 30-day raw retention are not used. Seven days is a pending policy proposal.
A primary lookup increases per-message SQL use. This is preferable to accepting revoked tags from a stale cache. Provider subaddressing and SMTP failure behaviour still need live forwarding tests.
ADR-002 amendment: Safe shared categories, optional AI
Lookup direction retained; model activation gated. Use personal overrides before verified business aliases. Three agreeing users produce a reviewed promotion candidate. No personal-name text is promoted automatically. Merchant categories and inferred codes do not establish bank MCC eligibility.
DeepSeek versus GLM remains unresolved. Do not reuse old model-name, neuron, privacy or price assumptions. An engineer benchmarks roughly 200 labelled redacted descriptors, tests calibration and abstention, and measures current provider usage. Proposed activation bar is at least 90% accuracy on high-confidence answers, with unknown rate disclosed. The Owner approves any nonzero incremental AI budget. No indefinite queue builds while disabled.
ADR-003 amendment: Google-only responsive web app
Google-only decision retained. Use React/TanStack on Workers, internal identity linked to Google's subject, and a server session. A PWA shell may be considered later, but installation prompts, offline financial caching and push are not prerequisites of this design. This removes the prior implication that phone-first automatically approves push or install onboarding.
The session policy changes from implicit sliding expiry to explicit renewal so read RPCs remain side-effect free. Renewal remains a protected POST. Library selection stays with the compatibility spike.
ADR-006: Private snapshots and conservative caching
Proposed. Financial reads use no shared cache. Public immutable rule versions may be cached; selection of the applicable version uses authoritative data. Client memory caches are account-scoped and invalidated together after a revision publishes. No offline persistence of private results by default.
This trades some repeated reads for simpler privacy and revision consistency. Revisit only with measured cost or latency evidence and a stated PRD staleness requirement.
Readiness, verification and sources
Build order
- Close pilot-card, terms, retention and residual-billing decisions. Run bounded compatibility and forwarding spikes with synthetic or explicitly redacted inputs.
- Ravi drafts the proto and versioned engine input/output shapes. Kai and Mira review the contract. Do not start with screen-specific response calculators.
- Generate contract mocks and fixtures. Mira builds UI against those mocks while Ravi builds intake, ledger and evaluation modules.
- Integrate real backend in testing. Reconcile chart/list/sheet totals, verify authorization, run failure and duplicate cases, and test all applicable product operators.
- Tester signs off acceptance evidence. Kai reviews contract, migrations, infra, caching, budgets and security, then approves a tagged production release.
No engineer work is assigned by this document alone. The Owner's request authorizes preparing the technical design, not provisioning new paid services.
Requirement traceability
| PRD | Design responsibility | Required behaviour check |
|---|---|---|
| F-01 | Session and tenant boundary | Signed-out deep link; two-account ID/cursor attacks; immediate sign-out |
| F-02 | Product catalogue and dated settings | Ambiguous products; missing artwork; exact product and last four |
| F-03 | Intake, parser and outbox | Six-bank/provider matrix; changed-MIME duplicates; setup failures |
| F-04 | Ledger and period resolver | Singapore boundaries; short months; refunds; unknown FX |
| F-05 | Versioned evaluator | Every applicable Appendix A operator, including ordering and conditional gates |
| F-06 | Snapshot and allowance contract | Minimum versus cap; independent allowances; unknown capacity |
| F-07 | Transaction facts/results | Reach all rows; code provenance; mixed portions and explanations |
| F-08 | Correction command and sheet | Cancel; error preserves entries; optimistic conflict; focus trap/restore |
| F-09 | Period projection and revisions | Actual previous/next periods; filters; late alert; persistent uncertainty |
| F-10 | Disjoint portions and purchase denominator | Zero purchases; credits; cap crossing; no double-covered amount |
| F-11 | Priority and evidence comparator | Tie-breaking; no incomparable-unit best-card claim |
| F-12 | Frontend design and accessibility | 360px fit; 44px actions; DM Sans; text states beyond colour |
The current mockup PASS proves reviewed static visual states only. It does not prove keyboard focus, persistence, banking rules, reliable alert delivery or this architecture.
Open gates and decision owners
| Gate | Owner | Evidence needed |
|---|---|---|
| Q1 exact pilot cards | Owner + Pia | Participant products and required settings |
| Q2/Q3 alert evidence | Backend, reviewed by Kai | Redacted real alerts via Gmail and Outlook for planned issuers; MCC and authentication provenance |
| Q4/Q11 exact rewards | Product terms research + Kai | Complete dated terms, posting/rounding/refund/cap-order fixtures |
| Q5 effort/cost | Kai + Owner | This estimate reviewed; deployed resource measurements; account budget headroom |
| Q6 retention/deletion | Owner + Kai + privacy reviewer | Policy approval, location/processor review, deletion and restore drill |
| Q7 artwork | Pia | Usage rights or plain-card fallback |
| Q8 proactive alerts | Pia + Owner | Separate product decision; no push implementation now |
| Q9 understanding | Pia + Tester | Pilot tasks distinguish minimum, cap and unknown portions |
| Q10/Q12 UI completeness | Pia + Tester | Reconcile v0.17 coverage with error, empty, refund, correction and account-condition states |
| Runtime portability | Backend + Frontend | Connect/auth/MIME/D1 spike and pinned dependency versions |
| Residual Cloudflare exposure | Owner | Explicit risk decision before paid live intake |
| Documentation hosting | Existing publication task | Verified upload/deployment route; publishing a document does not close app readiness |
Spike acceptance plans
- Runtime spike: make a same-origin Connect GET, protected command and OIDC callback run in deployed Workers; record versions, CPU, failures and D1 batch conflict handling.
- Forwarding spike: cover expected and malformed provider confirmations, original/forwarded authentication headers, duplicate formats, recurring/instalment/refund/FX examples and ambiguous last-four matches. No supported-bank claim without fixtures.
- Reward spike: compare hand-calculated official-term fixtures to evaluator results, including period-boundary cases and S$5/S$1 rounding order. Keep unsupported operators unavailable.
- Capacity spike: run the proposed cohort's reads and write bursts; report p95, errors, row scans, bytes and CPU. Demonstrate quotas fail closed, retries terminate and no partial revision publishes.
- Privacy drill: delete an account during queued intake, prove no resurrection, restore isolated recovery data and replay deletion markers before traffic.
Source register
Product baseline: PRD v1.1, dated 2 November 2026. The local source is preserved in the bundle. No issuer facts have been independently re-certified by this technical design; enabling a card requires the PRD's exact-product gates.
Channel decisions: Google-first (D4), tagged forwarding (D2), honest reward estimates (D5/D6), month/cycle history (D1/D8/D10), corrections in sheets (D11) and covered-share refinements (D12/D14). Mockup v0.17 revision 3 PASS is channel sequence 281. Open pilot/retention recommendations are sequence 258. CI ownership and merge rules come from Kai's standing AGENTS.md dated 2 November 2026.
Platform sources checked 2 November 2026:
- TanStack Start on Workers
- Workers pricing and limits
- D1 pricing, limits and recovery window, batch and Sessions API
- Queues pricing and delivery guarantee
- R2 pricing
- KV consistency
- Email limits and email handler
- Google OpenID Connect
- Connect protocol and Buf breaking checks
No private customer data, access tokens or real email samples belong in these public documentation pages.