hsa-app/DESIGN.md
Jean-Michel Tremblay c715c8e0c0
All checks were successful
Build and Test / build-and-test (push) Successful in 37s
AI classifier correction notes + misread review (AI tab)
Add a global, temporal ai_notes list appended to the classifier prompt
(seeded once from no-PII defaults, documented in README), managed inline
on a new AI tab with a read-only view of the assembled prompt. Every
AI-run upload records the browser-round-tripped suggestion blob + model;
misreads are derived (final field != AI guess) and reviewed one by one
(image + per-field guess-vs-entered + notes-since), attributing which
note fixed each or closing unresolved. Update SPEC (new section 10),
DESIGN item 15, README, and changelog (0.0.3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:04:30 -04:00

30 KiB
Raw Permalink Blame History

================================================================================ DESIGN JOURNAL — historical, chronological record of decisions and rationale. For the current expected behavior of the app, see SPEC.md (the source of truth). This file explains WHY things were built the way they were; SPEC.md says WHAT the app does today. When behavior changes, update SPEC.md; append context here only if the reasoning is worth preserving.

HSA Receipt Tracker — Requirements (v1) Purpose Capture and archive HSA-eligible receipts for future reimbursement and tax substantiation. No parsing, no OCR, no reporting. Users

Two users, both with full access (shared visibility). Authentication via Authelia OIDC. Authorization via membership in an Authelia group (e.g. hsa-users). Not in the group → 403. No other roles or gradations.

Core flow

User opens app on phone (mobile-first UI; camera access matters). Takes a photo of a receipt, or selects an existing image / PDF from device. Form prompts for: amount, date, category. Submit → image stored to disk, metadata row inserted into DB. Confirmation page, with options to view list or add another.

Data model receipts table:

id (UUID) uploaded_by (Authelia username or email) uploaded_at (server timestamp) receipt_date (user-supplied date on the receipt) amount_cents (integer — never store money as float) category (enum) file_path (relative path on disk — the filesystem copy) image_data (BLOB — the receipt file bytes, also stored in the DB itself) file_size_bytes (integer — convenience for listings/exports) original_filename (preserved for reference) mime_type deleted_at (nullable — soft delete)

Categories (fixed list, hardcoded for v1):

Medical Dental Vision Pharmacy Other

Storage

Receipt images/PDFs are stored in BOTH places on upload:

  1. Filesystem at a configurable path, filename randomized (UUID) on save (file_path) — used as the primary path for serving.
  2. As a BLOB inside the SQLite database (image_data column) — so the single .db file is a complete, self-contained dataset (metadata + files).

Rationale: the filesystem copy keeps serving simple/efficient; the DB blob makes backup and export trivial ("hand over one file" gets everything, even without the files directory). Written once at upload; no edit in v1, so the two copies never diverge. Acceptable cost because scale is tiny (two users, small files). original_filename and mime_type kept as metadata for download/serving. Backups remain JM's responsibility outside the app.

Auth integration

OIDC with PKCE against https://auth.jmopines.com. Session cookie after successful callback. /login, /callback, /logout, /healthz are public; everything else requires a valid session. Group claim (hsa-users) gates access; otherwise 403.

Deployment

Runs as a systemd service in an LXC. Caddy reverse proxy at https://hsa.jmopines.com (TBD: maisym.com vs jmopines.com). SQLite DB + filesystem storage. No external dependencies (no Redis, no Postgres, no S3).

Operations

Soft delete supported (set deleted_at, hide from default list views). No edit functionality in v1 — fix mistakes by deleting and re-adding.

Database export

Authenticated users (hsa-users group) can download the data for offline use.

Two endpoints:

GET /export/db — downloads a consistent copy of the SQLite database file. Because images are stored as BLOBs in the DB, this single file IS the complete dataset (metadata + all receipt images). This is the primary export.

  • Must NOT serve the live DB file directly (avoids locking/corruption against the running app). Use SQLite's online backup API (or VACUUM INTO a temp file) to produce a point-in-time snapshot, then stream that.
  • Content-Type: application/octet-stream; filename like hsa-export-YYYY-MM-DD.db.

GET /export/archive (optional convenience) — downloads a zip with the image files extracted to normal files (named by original_filename) plus a CSV/JSON of the metadata, for someone who wants the pictures as browseable files rather than inside a DB.

  • Streamed zip to avoid buffering large archives in memory.
  • filename like hsa-export-YYYY-MM-DD.zip.

Notes:

  • Amounts remain integer cents in the export; consumers divide by 100 for dollars.
  • Read-only operation; no app state is mutated.

Out of scope for v1

OCR / image content parsing Reports, totals, dashboards CSV / tax-software export Reimbursement tracking (paid vs pending status) In-place editing of existing receipts Multi-tenancy or per-user data isolation Notification / reminders

================================================================================ v2 — additions

These supersede the v1 "out of scope" entries for OCR-assisted entry (now present via the classifier) and "Reports, totals, dashboards" (see Tally below). Same two users, same auth model, same storage. Mobile-first still applies.

  1. Skip-AI toggle on upload

A control on the upload form lets the user opt out of AI parsing for the current receipt — for receipts they know are too hard to read, or to avoid spending an API call on a bad result.

  • Default: AI parsing ON (auto-fill runs when a file is attached).
  • When "skip AI" is selected, NO /classify call is made; the user fills amount, date, category, and who by hand.
  • The model footnote stays visible but reads as disabled when skip is selected, so the user always knows whether a call will happen and which model it uses.
  • Skipping is per-upload, not a saved preference.
  1. Duplicate-transaction warning before insert

Once a receipt has both a date and a dollar amount (whether typed or AI-filled), check the DB for an already-posted receipt that looks like the same transaction, and make the user confirm before inserting a possible duplicate.

  • Match: same receipt_date AND same amount_cents, among non-soft-deleted rows. When a "who" is set on both, prefer/highlight a same-person match; a match with a different or absent person is still shown as a weaker warning.
  • On a match, show the existing receipt's details: date, amount, category, who, uploaded_by, uploaded_at, original_filename (and a link to view it).
  • User chooses: Cancel (abort — nothing inserted) or Approve (insert anyway, as a deliberate duplicate). No new schema; this is a pre-insert read + confirm step.
  • No match → insert proceeds as today with no extra prompt.
  1. Tally tab

A totals view (read-only). Bucketed by the YEAR of receipt_date.

  • Matrix: one row per person, one column per year that has data; each cell is the summed amount for that person in that year.
  • Include an "Unassigned" row for receipts with no "who".
  • Right margin column: grand total per year (all persons).
  • Bottom margin row: grand total per person across all years.
  • Bottom-right cell: overall grand total tracked.
  • Excludes soft-deleted rows. Amounts shown in dollars (cents / 100).
  1. Recent uploads tab (by upload date)

A list of the most recently ADDED receipts, ordered by uploaded_at descending.

  • Show the 10 most recent, with a "Load next 10" control that pages further back (offset or cursor based).
  • Each row: receipt_date, amount, category, who, original_filename, and a link to view/download. Excludes soft-deleted rows.
  1. Recent receipts tab (by receipt date)

Identical to #4 but ordered by receipt_date descending instead of uploaded_at — "newest receipts" rather than "newest uploads". Same 10 + "Load next 10" paging.

  1. People catalog integrity (bug)

The Manage page currently shows partial-name duplicates (e.g. both "Jude" and "Jude Tremblay"). Only the canonical full names seeded from config.json ("First Last", per Person.Label) should exist as people.

  • Remove stray partial entries; keep only the config-seeded canonical labels.
  • Before deleting a partial entry, reassign any receipts that point at it to the matching canonical person so no receipt loses its "who".
  • Seeding must be idempotent: re-seeding from config.json must not create a second row for a person who already exists under the canonical label.
  1. Per-parse cost shown in cents (¢)

After a receipt is classified, show the cost of that single AI call, in cents, using the cent sign (e.g. "0.3¢"). The cost is computed locally from the API response — no extra API call needed.

  • The Messages API response includes a usage object (input_tokens, output_tokens, and cache_creation/cache_read token counts). The classifier should capture these and return them alongside the suggestion.
  • Cost = input_tokens × input_price + output_tokens × output_price, using the active model's per-token rates. For the default model (Haiku 4.5): $1 per 1M input tokens and $5 per 1M output tokens — i.e. $0.000001/input-token and $0.000005/output-token. Cache-read tokens bill at ~0.1× input; treat them at the input rate unless we add exact cache pricing later.
  • Display in cents with the ¢ sign next to where the model footnote shows the model name, so the user sees both which model ran and what the scan cost.

Where the rates come from (this is the only maintenance cost of the feature):

  • Token counts are exact and free — they come straight from the response's usage object, no estimation.
  • Per-token PRICES are not available from any API (the Models API exposes capabilities, not dollars), so they live as hardcoded constants in a small rate table keyed by exact model id: haiku-4-5 → $1/1M in, $5/1M out opus-4-8 → $5/1M in, $25/1M out sonnet-4-6 → $3/1M in, $15/1M out
  • This table is low-maintenance: Anthropic prices a specific model id once and ships price changes as NEW model ids, so an existing id's rate does not move. A new row is only needed when we adopt a new model — i.e. exactly when we'd be changing CLASSIFY_MODEL anyway.
  • Unknown model id (not in the table) → show the token counts but omit the ¢ figure (or "cost: n/a"), never a guessed number. A stale table degrades gracefully instead of lying.

(Balance/spend indicator: dropped. The Anthropic API has no remaining-balance endpoint, and a cumulative-spend readout was not wanted. Per-query cost above is the only cost surface.)

  1. Human-readable on-disk file layout

Replace the flat UUID filenames with a dated, amount-tagged layout under the storage root, so the files directory is browsable on its own:

<STORAGE_DIR>/<YYYY>/<MM>_<DD>_<dollars>.<cents>.<ext>
  • Year folder and MM/DD come from the RECEIPT date (not upload date); dollars and cents come from amount_cents (cents zero-padded to two digits, dollars not padded). Example: a $42.50 JPEG dated 2026-06-08 → 2026/06_08_42.50.jpeg.
  • The amount's decimal dot and the extension dot coexist fine — the extension is just the final dot-segment ("jpeg"); the stem is "06_08_42.50". (If that ever feels ambiguous, the accepted alternatives are an underscore "06_08_42_50" or bare cents "06_08_4250" — pick one and keep it consistent.)
  • All path components derive only from the date and amount (digits, underscores, one dot), never from the user-supplied original filename, so there is no path- traversal surface. original_filename stays as metadata in the DB.
  • Collisions (same date + amount + ext — legitimately possible since duplicates can be approved) get a numeric suffix on the STEM, starting at _1: the second file becomes "06_08_42.50_1.jpeg", the third "_2", etc. Use exclusive create (O_CREATE|O_EXCL) and increment the suffix on "already exists" so two concurrent uploads can't race onto the same name.
  • The chosen relative path is stored in receipts.file_path as today; the dual write still also stores the bytes as the DB blob, and serving continues to work from the blob regardless of the on-disk name.
  • Applies to NEW uploads only — existing UUID-named files keep their file_path; no backfill/rename of historical files in scope.
  • saveFile must create the year subdirectory (MkdirAll) before writing.
  1. Additional attachments on a receipt

When uploading a receipt, the user can also attach one or more EXTRA files in the SAME submission (a second page, an itemized list, an EOB, a photo from another angle). Attachments are supplementary files for the same HSA expense — they are not standalone receipts and carry no amount/date/category/who of their own; they inherit the parent receipt's identity (including its date, which drives their on-disk name). Adding attachments to an ALREADY-SAVED receipt is not supported yet (no edit/detail page) — attachments are captured only at receipt-creation time.

Data model — new attachments table:

  • id (UUID)
  • receipt_id (FK → receipts.id; the parent expense)
  • uploaded_by (Authelia username/email)
  • uploaded_at (server timestamp)
  • file_path (relative path on disk — see layout below)
  • image_data (BLOB — the bytes, stored in the DB too, same as receipts)
  • file_size_bytes
  • original_filename
  • mime_type
  • deleted_at (nullable — soft delete) Index on receipt_id (and on deleted_at) for listing a receipt's live attachments.

Storage layout change — split receipts and attachments under the root:

  • Receipts move from <STORAGE_DIR>//… to <STORAGE_DIR>/receipts//… (the item-9 dated name is unchanged; only the "receipts/" prefix is added).
  • Attachments go to <STORAGE_DIR>/attachments//…, named from the PARENT receipt's stem plus an attachment marker, e.g. a JPEG attached to the $42.50 receipt dated 2026-06-08 → attachments/2026/06_08_42.50_att.jpeg with the same exclusive-create disambiguation as receipts: the second attachment becomes _att_1, the third _att_2, etc. (Year/MM/DD/amount come from the parent receipt, so an expense's receipt and its attachments sort together.)
  • Same dual-write as receipts: bytes on disk AND as a DB blob, so the single .db export stays a complete dataset (receipts + attachments + images).
  • Existing receipts keep their stored file_path verbatim — file_path is the source of truth for serving location, so old (ROOT//…) and new (ROOT/receipts//…) paths coexist with no backfill. (In practice the DB was reset, so there are no legacy files to move.)

UI and endpoints:

  • The upload form gets an additional, OPTIONAL multi-file input ("Additional files", accept images + PDF, multiple). These ride along with the normal receipt submission to POST /upload — there is no separate attach endpoint.
  • Submission order: the receipt row is inserted first (so its id exists), then each attached file is saved and linked to it. Same MIME allowlist (images + PDF) and per-file size cap (MAX_UPLOAD_MB) as the receipt. No AI runs on attachments. AI auto-fill still reads only the primary receipt image.
  • The confirm page lists the saved receipt plus the count/filenames of any attachments.
  • GET /attachment/{id}/file serves an attachment's bytes from the DB blob (mirror of GET /receipt/{id}/file), so the recent list can link them.

Lifecycle / interactions:

  • Soft-deleting a receipt also hides its attachments (filter attachments by the parent's deleted_at, or soft-delete the children alongside the parent).
  • Attachments never affect Tally, duplicate detection, or the receipt counts — those operate on receipts only.
  • Export: attachments ride along automatically in the .db blob export; an archive /zip export (if/when added) lists them under their receipt.

Out of scope (for now):

  • Adding attachments to an already-saved receipt (would need an edit/detail page).
  • No AI parsing of attachments; no per-attachment metadata beyond the file.
  • No reordering UI beyond upload order.
  1. Scheduled metadata-only DB backups

The app writes a periodic, metadata-only snapshot of the database to a local directory so the receipt metadata always has a recent recoverable copy, without an external cron job.

  • Built on the existing snapshot primitive (VACUUM INTO, then blob-strip): the backup excludes image blobs from BOTH receipts and attachments, so it is tiny. The on-disk files and the full-blob export (/export/db) remain the source for the images themselves.
  • On by default, weekly. Config: BACKUP_DIR (default ./data/dbbackup), BACKUP_INTERVAL (Go duration sets the frequency, default 168h; set 0 to disable), BACKUP_KEEP (most-recent copies to retain, default 8).
  • Files are named hsa_sqlite_backup_<YYYY_MM_DD>.db — one per day, sorting chronologically; older copies beyond BACKUP_KEEP are pruned. A same-day re-run is a no-op (the dated file already exists).
  • Restart-safe: on startup it backs up immediately only if the newest existing backup is older than the interval, so frequent restarts don't spam the dir and a long gap is covered right away. Runs in a background goroutine for the life of the process; failures are logged, never fatal.

Storage-root note: STORAGE_DIR is the storage ROOT (not the receipts subdir). Receipts live under <STORAGE_DIR>/receipts// and attachments under <STORAGE_DIR>/attachments// (default STORAGE_DIR=./data).

  1. Crash recovery and durability at boot

The database must survive an app crash or kill mid-write without corruption or a half-written row, and recover automatically on the next boot — no manual repair.

  • SQLite runs in WAL mode (PRAGMA journal_mode=WAL, set on open). Every write is an atomic transaction, so an interrupted write is either fully applied or not at all. Opening the file is self-healing: SQLite rolls forward committed transactions from the WAL and discards any incomplete tail, bringing the DB up at its last committed state. There is no custom "revert" logic — SQLite's own recovery is the mechanism, and reimplementing it would be less safe.
  • On open the app runs PRAGMA quick_check to confirm the recovered file is sound. A crash-interrupted write passes (WAL recovery already healed it). A failure means genuine corruption (e.g. disk failure); the app refuses to start with a message pointing at BACKUP_DIR, so the operator restores rather than running on a corrupt DB.
  • Dual-write caveat: a receipt's (or attachment's) file is written to disk BEFORE its DB row is inserted, so a crash in between can leave an orphan file with no row. This is harmless — extra bytes on disk, never a row missing its data — and is not auto-cleaned. The DB/blob is the source of truth for serving.
  • Recovery from true corruption (beyond crash-consistency) is restore-from-backup: the metadata-only daily backup (item 11) recovers the records; the full .db export (/export/db, blobs included) recovers records + images.
  1. Upright receipt images (EXIF orientation normalization)

Phone cameras often save a photo in the sensor's native orientation plus an EXIF "Orientation" tag that says "rotate me when displaying". Some viewers honor the tag and some don't, so a receipt that looks fine in one place shows up sideways in another (and the bytes are stored verbatim, so the problem follows the file). On upload, bake the indicated rotation into the pixels so the stored image is upright for EVERY consumer — browser, download, the AI classifier, a future PDF export — not just EXIF-aware ones.

  • Act only when orientation is actually KNOWN. A JPEG carrying an EXIF Orientation tag of 2..8 is decoded, rotated/flipped upright, re-encoded (JPEG, q≈90), and the tag dropped. A tag of 1 (already upright) or NO tag at all — the common case, where there is genuinely no way to know which way is up — leaves the original bytes byte-for-byte untouched. We never infer orientation from image content or guess; an unrotatable image is left as-is, never wrong.
  • Scope: JPEG only (where camera orientation tags live in practice). PDFs and other image types pass through unchanged, as does any file that fails to decode (kept rather than lost).
  • Applies to the primary receipt, every additional attachment (item 10), and the image sent to the AI classifier (an upright image reads more reliably). It runs after the MIME allowlist check and before the dual-write, so file_size_bytes and both stored copies (disk + DB blob) reflect the normalized bytes.
  • Client side cannot help: there is no browser/camera API to disable capture rotation, so this is necessarily a server-side fix.

Out of scope: content-based auto-rotation (detecting "up" without metadata), rotating PDFs, and backfilling already-stored receipts.

  1. Optional tags on a receipt

Let the user attach zero or more free-form TAGS to a receipt at upload time, on top of the fixed category and the optional "who". Tags are a shared, user-extensible vocabulary (e.g. "orthodontics", "tax-2026", "Jude-braces") for grouping receipts however the household likes, without touching the fixed category list. Optional: a receipt with no tags is normal.

Upload UI:

  • An "Add tags" button on the upload form, placed AFTER the "Who" control. A summary next to it shows the current selection (e.g. "3 tags" or the chip labels), so the user sees what's attached without opening the picker.
  • Tapping it opens an IN-PAGE overlay (modal card), NOT a separate page. The upload form already holds in-progress state — the chosen file, the AI-filled amount/date/category, the "who" — and navigating away would lose it (the file input especially). The picker must preserve all of that; closing it returns to the same half-filled form.
  • The card shows every existing tag as a chip in a wrap/mosaic layout, sorted alphabetically (case-insensitive, like the other lookups). Tap a chip to select it; tap again to deselect. Selected chips are visually distinct (filled vs outline, checkmark, etc.). Multiple selections allowed.
  • At the bottom, a "new tag" text field + add control. Creating a tag adds its chip to the mosaic and marks it SELECTED for this receipt by default. The new chip is client-side only until the receipt is submitted (see persistence). Creating a name that already exists (case-insensitive, trimmed) just selects the existing chip rather than making a duplicate.
  • A "Done with tags" button closes the card and returns to the form with the selection retained. The receipt is then submitted normally; tags ride along in the same POST /upload submission (no separate endpoint), like attachments.

Data model — new tags lookup table and a many-to-many join:

  • tags: id (INTEGER PK), label (TEXT, unique case-insensitively). Same shape and ordering convention as categories/people (ORDER BY label COLLATE NOCASE).
  • receipt_tags: (receipt_id FK → receipts.id, tag_id FK → tags.id), composite primary key (receipt_id, tag_id) so a tag can't be linked twice to one receipt. Index on tag_id for "receipts with this tag" lookups later.
  • Tags are global/shared (both users see the same catalog), consistent with the shared-visibility model. No per-user tag namespaces.

Persistence and submission:

  • The form submits the selected tags as a list (existing tag ids and/or new tag LABELS). On insert, the server resolves each: known label/id → reuse; unknown label → create the tag row first (create-if-missing by normalized label), then link. This means a NEW tag is written to the catalog only when its receipt is actually saved — an abandoned upload never litters the tag list.
  • Tag links are written after the receipt row exists (it owns the id), in the same request that saves the receipt and its attachments.
  • Normalization: trim surrounding whitespace; match/dedup case-insensitively; store the label as the user first typed it (display casing preserved).
  • Soft-deleting a receipt hides its tag links along with it (the catalog entries persist). Tags never affect duplicate detection or Tally totals.

Display:

  • A receipt's tags are shown as chips wherever its details appear (the recent lists, item 4/5, and the confirm page after upload). AI auto-fill does NOT suggest tags; tagging is a manual, deliberate act.

Out of scope (for now):

  • Tag-based filtering/search of receipts and a tally-by-tag view (likely the next step once tags exist).
  • Editing/renaming/merging/deleting tags in the Manage page. Free-form tags will accumulate cruft and a cleanup surface (akin to item 6 for people) will be wanted eventually, but not in this item.
  • Adding/removing tags on an already-saved receipt (no edit/detail page yet, same limitation as attachments in item 10).
  1. AI classifier notes + failure review (implemented — see SPEC.md §10)

A closed loop for improving receipt classification over time: the user accumulates corrective instructions ("notes") that are appended to the classifier prompt, and reviews past misreads one by one to author and attribute those fixes. Big change; spans storage, the upload path, the classifier prompt, and a new top-level tab.

Motivation: classification will misread some receipts (a vendor's odd date format, a statement that lists the patient under "Guarantor", etc.). Rather than hardcode ever-more rules, let the two users add their own corrections as they hit failures, and give them a place to study failures and decide what fixed them.

A. Notes — what they are

  • A single, GLOBAL, free-text list of correction lines, appended to the system prompt as a "Corrections/Notes" appendix. Global because at classify time the app does not yet know the category/vendor, so scoped notes couldn't be selected.
  • Authored English, separate from the prompt's DERIVED parts (people + name variants, category names/examples, today's date), which stay computed in code. The static prompt scaffolding (the rules/warnings) stays in code too — notes only AUGMENT it; this item does not externalize the whole prompt.
  • Curated, not append-forever: each active note costs tokens on every scan and too many dilute the instructions, so delete/edit matter as much as add. Realistically a handful.

B. Notes — storage (temporal)

  • Table ai_notes(id, text, created_at, deleted_at). Active set = deleted_at IS NULL, ordered by created_at; that set is what gets appended to the prompt.
  • EDIT = soft-delete the old row + insert a new one (never update in place), so the full history is preserved. The notes active at any time T are created_at <= T AND (deleted_at IS NULL OR deleted_at > T) — which is what lets a failure be matched against the notes that existed when it happened (section E).
  • Read per /classify call (live; no restart needed). Rides along in /export/db and the daily backup like everything else.
  • SEEDED ONCE from a hardcoded default list in code (the same defaults are published in the README for humans), inserted only when the table is completely empty — so a deliberately-deleted default does not resurrect on restart. Never read from a file/config at runtime; no export/mirror file. Defaults carry no PII; user-authored notes live only in the DB (private, not in git).

C. Capturing classifications (the plumbing)

  • The /classify result is produced server-side but only reaches the browser (to pre-fill the form); by POST /upload the server no longer holds it. So the browser PERSISTS the suggestion blob + model and sends it back in a hidden field on submit. (The API key is and remains server-side — only the suggestions round-trip.)
  • Store one row per AI-run upload: classifications(receipt_id PK/FK, model, response_json, created_at, reviewed BOOL, reviewed_at, resolution). Skip-AI or classification-disabled uploads create NO row.
  • response_json is the /classify blob (suggested amount/date/category_id/person_id, the raw_* text the model read, cost, model). Treat it as DIAGNOSTIC data, not ground truth — it is client-supplied and could be tampered with; nothing security-relevant depends on it. Mild PII (raw read name) but DB-only.

D. Failures are DERIVED, not stored

  • A "failure"/miss = at least one of the 4 AI-suggested fields (amount, date, category, who) differs from the receipt's final stored value, where AI returning null/empty and the user filling it COUNTS as an override (the AI missed it).
  • Receipts are immutable (no edit) and the blob is immutable, so the comparison inputs never change — derived overrides can never drift, so we store no redundant per-field booleans. Per-field detail (which of the 4, AI-guess vs. corrected) is computed on demand from the blob vs. the receipt when a failure is displayed. Volume is tiny, so deriving each time is cheap. (If SQL-level filtering/stats ever matter, a single computed had_override flag could be added purely as an index — not the four booleans.)
  • Review state (reviewed, reviewed_at, resolution) and the fix links below are the only non-derivable things, so they ARE stored.

E. Review — the loop

  • New top-level "AI" tab with three sections (one tab unless it grows):
    1. Notes — list active notes; add / edit / delete (each edit = soft-delete + insert per section B).
    2. Prompt — READ-ONLY view of the live assembled system prompt (static scaffolding + injected people/categories/today + current active notes), so the user sees exactly what is sent. View-only for now; not editable.
    3. Review — unreviewed failures, one at a time.
  • Reviewing one failure shows: the receipt IMAGE, the MODEL used, which fields were overridden (AI guess vs. corrected, derived from the blob), and the NOTES ADDED SINCE this failure's created_at (from the ai_notes temporal history). The user marks which of those notes were the fix → recorded in miss_fixes(receipt_id, note_id); if none fixed it, close as resolution='unresolved'. Either way set reviewed=1, reviewed_at. ("Switched to a stronger model" as a resolution reason may be added later, since model is recorded.)

F. Out of scope / future

  • Per-category or vendor-scoped notes (global only, by design above).
  • Automatic re-classification/recompute of past failures. The data supports it (model + active-notes-at-time-T + the stored image), but review stays MANUAL — the user eyeballs the image to understand the miss.
  • Editing the static prompt scaffolding (view-only here) and externalizing the whole prompt.
  • Accuracy-rate stats across all classifications.
  • No prompting the user to write a note at upload time — failures are recorded silently and dealt with later in the Review tab (likely on a real computer).