## Outcome Read-only architecture review completed against exact clean base `a5e8dd0e5b1330b588e5c6e501cd2af03f23818f`. No files were created or modified; `git diff --check` passed. ## Current architecture findings - `HouseholdReviewPage.vue` is row-oriented and already uses safe preview → confirm with: - exact `item_token` + `category_id` selections, - preview and baseline fingerprints, - stale UI-preview invalidation, - exclusion of selected rows hidden by tabs/filters. - `frontend/src/api/household.ts` still exposes legacy classification groups, but the page does not render them. - `get_household_review()` groups legacy actions only by raw candidate `classification`; this is unsuitable for merchant-family category decisions. - The reusable grouping primitives already exist: - `normalize_merchant()` in `household_classification.py`; - `transaction_semantics` from `classify_household_row()`; - opaque row tokens and fingerprint-bound exact batch confirmation. - Current batch confirmation is exact-row scoped and does **not** create a global merchant rule. Preserve that boundary. - `_baseline()` hashes all relevant classification and write relations, so it provides strong—but broad and potentially expensive—staleness protection. - Current `review_count` is only the number of returned/capped items, not necessarily the complete open total. - Existing batch validation accepts arbitrary dictionary payloads and lacks an explicit request-size/count cap or Pydantic schema. - Local-only write protection is IP-based. There is no separate CSRF/Origin check; allowing arbitrary headers and broad Tailnet origins makes deployment/proxy configuration security-sensitive. ## Minimal compatible grouped contract Make the existing GET response additive and retain existing row batch endpoints for compatibility. ```ts type HouseholdMerchantCluster = { cluster_token: string // opaque; never expose normalized key scope_fingerprint: string // binds current cluster membership merchant_family_label: string // human-safe display label transaction_semantics: 'expense' | 'income' total_count: number // all currently matching open rows shown_count: number // 0..3 remaining_count: number examples: HouseholdReviewItem[] // exact actionable rows, max 3 exclusion_count: number exclusions: Array<{ reason: | 'special_semantics' | 'duplicate' | 'transfer' | 'receipt_detail' | 'currency' | 'account_mapping' | 'mixed_semantics' count: number }> proposed_category_id?: string | null technically_confirmable: boolean business_ready: boolean } ``` Semantics: - Cluster identity: `normalize_merchant(merchant || description) + transaction_semantics`. - `cluster_token`: opaque hash/token; do not return normalized merchant text. - `scope_fingerprint`: bind classification version, cluster identity, and sorted identity of **all current cluster members**, including candidate ID, `updated_at`, semantics and relevant mapping/category state. - `examples`: deterministic order and at most three rows. These are the only rows affected by the cluster category action. - `total_count` is informational; do not interpret it as the write scope. - `technically_confirmable`: every shown row passes hard safety requirements—ordinary expense/income semantics, CHF, valid compatible account mapping, open/current state, no special classification. - `business_ready`: shown rows have one valid category decision. It may be false in GET and become true in preview after the user explicitly selects a category. - Excluded rows remain open and are never silently folded into the action. Add narrowly scoped endpoints: ```http POST /api/budget/household/review/clusters/preview { "cluster_token": "...", "scope_fingerprint": "...", "items": ["item_1", "item_2", "item_3"], "category_id": "cat_food" } ``` Preview returns: ```json { "cluster_token": "...", "scope_fingerprint": "...", "preview_fingerprint": "...", "baseline_fingerprint": "...", "cluster_total_count": 42, "confirm_count": 3, "remaining_count": 39, "technically_confirmable": true, "business_ready": true, "category_id": "cat_food", "category_name": "Lebensmittel", "items": [/* exact three human-visible rows */], "expected_writes": { "transactions": 3, "candidate_updates": 3, "audit_events": 1, "merchant_rules": 0 } } ``` Confirm must resend exact scope and include an explicit total: ```http POST /api/budget/household/review/clusters/confirm { "cluster_token": "...", "scope_fingerprint": "...", "items": ["item_1", "item_2", "item_3"], "category_id": "cat_food", "confirm_count": 3, "confirm": true, "preview_fingerprint": "...", "baseline_fingerprint": "..." } ``` The server must reject: - `confirm_count !== len(items)`; - more than three items; - changed cluster membership or scope fingerprint; - items not belonging to the cluster; - excluded/special rows; - stale category/mapping/baseline; - duplicate item tokens; - attempts to submit normalized merchant text or rule-creation flags. No “apply to all 42,” “all future bookings,” or implicit merchant-rule endpoint should be added. After confirming three, reload and present the next bounded set. ## Minimal UI design In `HouseholdReviewPage.vue`: - Render cluster cards in **Zu prüfen**; retain row/special-case treatment for exclusions and **Alle Buchungen**. - Card heading: merchant family label and semantics. - Copy: **“42 offene Buchungen · 3 werden angezeigt.”** - Show the exact three examples as a semantic list/table with date, merchant, amount and masked source. - Use one cluster-level category select with an explicit label such as: - **“Kategorie für die 3 angezeigten Buchungen”** - Preview button: - **“3 angezeigte Buchungen prüfen”** - Final confirmation must repeat both totals: - **“3 von 42 Buchungen werden bestätigt; 39 bleiben offen.”** - Final button: - **“3 Buchungen verbindlich bestätigen”** - Keep exclusions separate: - **“5 Sonderfälle nicht enthalten”**, with aggregated reasons and a link/button to the individual review surface. - Do not use the current client-side source filter to alter cluster membership. Either pass filters to GET and bind them into the server scope, or leave cluster scope unfiltered. ## Concrete implementation plan ### Backend 1. `src/jarvis_finance/services/household_import.py` - Add `_clusterable_review_item(...)`. - Add `_merchant_cluster_identity(...)`. - Add `_household_review_clusters(...)`. - Add `_cluster_scope_fingerprint(...)`. - Extend `get_household_review()` with `clusters` and uncapped aggregate totals. - Add `_validate_cluster_selection(...)`. - Add `preview_household_review_cluster(...)`. - Add `confirm_household_review_cluster(...)`, reusing exact batch materialization internally rather than duplicating writes. - Enforce maximum three selected items and audit `merchant_rules: 0`. 2. `src/jarvis_finance/api/routers/budget.py` - Add cluster preview/confirm routes. - Prefer explicit Pydantic request models over raw `dict`. - Validate list bounds and strict scalar lengths before service execution. 3. Optionally add focused request/response models under: - `src/jarvis_finance/api/schemas/household.py` ### Frontend API 4. `frontend/src/api/household.ts` - Add `HouseholdMerchantCluster`, exclusion, cluster preview and confirm types. - Add `previewHouseholdReviewCluster()` and `confirmHouseholdReviewCluster()`. - Keep existing batch APIs unchanged. 5. `frontend/src/api/household.test.ts` - Assert exact route names and payloads. - Assert `scope_fingerprint`, exact item tokens, `confirm_count`, `confirm: true`, preview and baseline fingerprints survive unchanged. - Assert no rule/global-scope field is sent. ### Frontend page 6. `frontend/src/pages/HouseholdReviewPage.vue` - Add cluster selection/category state keyed by opaque `cluster_token`. - Render up to three exact examples. - Invalidate preview on category, filter, tab, cluster or item changes. - Refuse confirmation if current cluster selection/count differs from the preview. - Announce preview/stale/success states. - Keep special cases outside cluster confirmation. 7. `frontend/src/pages/HouseholdUx.test.ts` - Add tests for: - count versus maximum-three examples; - category label naming the exact shown count; - preview payload containing only shown rows; - explicit “3 of 42” confirmation; - excluded special/duplicate/transfer rows never submitted; - changed scope/category invalidating preview; - stale 409 clearing preview; - `technically_confirmable=false` disabling preview; - `business_ready=false` until category selection; - no “apply to all/future/global rule” control or payload; - keyboard and focus behavior after confirm/reload. ### Backend tests 8. Add focused tests, preferably in a new: - `tests/unit/test_household_grouped_review_v1.py` Cover: - punctuation/case variants cluster under normalized merchant + same semantics; - income and expense never share a cluster; - deterministic maximum-three examples with truthful total count; - special cases and incompatible mappings are counted as exclusions; - preview and GET are database read-only; - category changes alter preview fingerprint; - changed cluster membership invalidates scope; - confirm writes only exact shown tokens; - `confirm_count` mismatch fails closed; - fourth item is rejected; - retry is idempotent only for the exact same payload; - no merchant/alias/review-rule row is created; - response omits normalized keys, candidate IDs, raw references and filenames; - full cluster counts are not truncated by the examples/read limit. ## Accessibility risks - Do not make the whole cluster card clickable; use native buttons and labelled selects. - Associate each category select with the cluster heading and exact shown count. - Use a real list/table for examples; avoid nested conflicting ARIA table roles. - Announce preview creation, stale scope and successful confirmation via a persistent `aria-live` region. - After confirmation/reload, move focus to the next cluster heading or success message. - Exclusion reasons must not rely on color alone. - Ensure 44px targets and no horizontal overflow at 390px, ~820px and desktop. - The confirmation region should receive focus or be a properly managed dialog; `aria-live` alone may not announce all details reliably. ## Security/privacy risks - Never return normalized merchant keys, candidate IDs, account references, filenames or raw descriptions beyond the existing human-safe merchant/example fields. - Bound cluster actions to three exact opaque item tokens; do not let `total_count` become write scope. - Recompute cluster identity, membership, exclusions and readiness server-side; never trust frontend flags. - Add strict payload models and length/count limits to prevent oversized hashing/DB work. - Keep special semantics fail-closed and route them to dedicated actions. - Existing local-IP-only write authorization is not CSRF protection. Before exposing writes through a browser-accessible Tailnet/reverse proxy, add strict Origin/Host enforcement or a CSRF/session boundary. - Do not log request bodies or fingerprints alongside merchant data. - Preserve atomic transaction, stale-baseline rejection, exact-payload idempotency and audit behavior.