## Outcome Read-only audit completed against the exact Git object `572ef44fc330911673ce190b7abfbbf210bff77c` / schema 51. I did **not** inspect production rows, databases, reports, imports, or secrets, and made no repository changes. ## Key findings ### Canonical account model - There is no separate `canonical_accounts` table. `accounts` is the canonical portfolio/account table: - `src/jarvis_finance/storage/schema.py:1` — `CREATE TABLE accounts` - Identity is currently `account_id`, with only `UNIQUE(platform_id, account_name)`. - Household accounts are a second projection: - `src/jarvis_finance/storage/migrations.py:526-542` — `budget_accounts` - `budget_accounts.linked_account_id` points to canonical `accounts.account_id`. - Cash bootstrap collapses Raiffeisen into one generic account: - `src/jarvis_finance/services/cash_service.py:27-34` — `CANONICAL_CASH_ACCOUNTS` - Entry is only `{"platform": "Raiffeisen", "account_name": "Raiffeisen", "balance_mode": "csv_calculated"}`. - `ensure_canonical_cash_accounts()` at `cash_service.py:91-129` identifies accounts by display name, updates existing rows in place, and has no stable source-account identity. - Therefore suffix 5632 and suffix 5031 cannot currently be represented as independently identified Raiffeisen accounts without creating explicit canonical and linked budget accounts. ### Household source mappings - Schema: - `migrations.py:2359-2373` — `household_account_source_mappings` - One row per `(source_type, source_reference_hash)`. - Stores both `budget_account_id` and `canonical_account_id`. - Resolution is fail-closed for inactive/unlinked/wrong-role accounts: - `household_import.py:255-270` — `_mapping_account_is_compatible` - `household_import.py:274-305` — `_mapping` - The mapping must resolve to active linked CHF cash semantics with `performance_included=0`. - Multi-account Raiffeisen files are already supported: - `household_import.py:308-339` — `_mapping_for_file` - Regression: `tests/unit/test_household_classification_v2_golden.py:509-542`. - The correction path is currently unsuitable: - `household_import.py:341-418` — `configure_source_mapping` - It performs `INSERT ... ON CONFLICT ... DO UPDATE` at lines 386-396, overwriting the original target. - API has only direct confirm, no mapping preview: - `api/routers/budget.py:222-231` - Existing audit records only the new target, not old→new evidence: - `household_import.py:398-404`. - No immutable/no-delete triggers exist for mapping rows. - Import/review baselines correctly include mappings and canonical accounts: - `household_import.py:432-446` — `_baseline` - `household_import.py:2372-2398` — `_household_review_data_version`. ### Cash snapshots and current-value precedence - Schema: - `migrations.py:1569-1592` — `cash_account_snapshots` - Schema 51 adds `semantic_identity` and a partial unique index: - `migrations.py:2667-2689`, `2735-2738`. - Snapshot rows are assigned directly to one canonical `account_id`; there is no append-only account-reassignment mechanism. - Selection semantics are inconsistent: - `cash_service._latest_snapshot`, lines `132-141`: latest date/creation only within one requested type. - `reconciliation_snapshot._latest_cash_snapshot`, lines `86-97`: latest date/creation across requested types, but no type priority. - `portfolio_data.py:1613-1618`: latest snapshot of any type, no priority. - `portfolio_analytics._latest_cash_entries`, lines `857-880`: latest of any type, no priority. - `wealth_cockpit.py:349-360` is the only explicit priority: - `reconciliation > manual_balance > csv_anchor_balance > other`. - `cash_service.list_cash_positions()` has separate mode-based behavior: - `cash_service.py:354-480` - `manual` mode uses manual snapshot; `csv_calculated` mode uses calculated value and treats manual as a control. - A stored `reconciliation` snapshot is not considered in `used_value_chf`. - Existing cash Preview→Confirm is not safe enough to reuse for production correction: - Request schema requires `preview_id`: `api/schemas/positions.py:306-316`. - `confirm_cash_snapshot()` at `cash_service.py:513-577` never validates or binds that preview ID. - Existing tests even confirm with arbitrary `"p"` IDs: - `tests/unit/test_cash_truewealth_management.py:102-121`. ### Household import metadata aggregation - Schema 51 adds file-level coverage: - `migrations.py:2667-2675` - `household_import.py:1207-1218` computes file period and physical/logical counts. - `household_import.py:2200-2218` persists them. - Wealth read model: - `wealth_cockpit._household_import_meta`, lines `197-230` - It chooses only the latest file for the entire source profile, then counts all items for the source within that batch. - This produces two account-separation defects: 1. Both Raiffeisen accounts receive the same profile-wide metadata through `wealth_cockpit.py:1130-1144`. 2. With multiple same-source files in one batch, `row_count` comes from one latest file while `imported_rows` counts all source items in the batch; `duplicate_rows = row_count - imported_rows` is therefore not a valid aggregation. - `household_import_files` and `household_import_items` do not retain enough account-scoped lineage to report exact coverage for 5632 versus 5031, especially for duplicate/review dispositions. ## Proposed schema 52 migration Add `_create_account_separation_corrections_v1()` in `src/jarvis_finance/storage/migrations.py`, bumping: - `MIGRATION_VERSION = 52` - `MIGRATION_NAME = "052_account_separation_corrections_v1"` Use insert-only tables; do not rewrite mappings, snapshots, imports, or generic accounts. 1. **`canonical_cash_account_identities`** - `account_id` FK/PK - `institution_key` (`raiffeisen`) - `account_role` (`private_checking`, `savings`, `generic`) - `source_reference_hash` - masked `reference_hint` such as `•••• 5632` - `created_by_batch_id` - Unique `(institution_key, source_reference_hash)`. - Immutable/no-delete triggers. 2. **`account_separation_batches`** - `batch_id`, `contract_version` - `intent_fingerprint`, `preview_fingerprint`, `baseline_fingerprint` - expected/actual item counts - required note, confirmation text/version - one `audit_id` - confirmed timestamp/user - unique intent fingerprint and immutable/no-delete triggers. 3. **`household_mapping_corrections`** - Original `mapping_id` - corrected canonical and budget account IDs - `reason_code` - batch/audit references - Original mapping remains untouched. - One effective correction per mapping; conflicting second correction must fail closed rather than branch silently. 4. **`cash_snapshot_account_corrections`** - Original `snapshot_id` - original and corrected `account_id` - reason/batch/audit references - Snapshot amount, type, date, source, and original account stay unchanged. - One effective correction per snapshot. 5. **`household_import_file_account_coverage`** - `household_file_id`, mapping/effective account IDs - account-specific period start/end - physical/logical/imported/duplicate/review counts - immutable and unique per file/account. - New imports populate this during confirm. Historical rows are inserted only where derivation is unambiguous; otherwise metadata remains `unknown`, never copied profile-wide. 6. Add effective read views or shared query builders: - `effective_household_account_source_mappings` - `effective_cash_account_snapshots` - Both expose original IDs plus corrected effective account IDs and correction audit/batch IDs. Do **not** deactivate or delete generic Raiffeisen automatically. It remains available only for genuinely generic/unknown references. No heuristic name-based redistribution is allowed. ## Batch Preview→Confirm contract Suggested routes: - `POST /api/budget/household/account-separation/preview` - `POST /api/budget/household/account-separation/confirm` ### Preview request The caller must provide explicit opaque mapping/snapshot identities and target definitions: - Private account: masked suffix 5632, role `private_checking`. - Savings account: masked suffix 5031, role `savings`. - Explicit list of mapping IDs assigned to each target. - Explicit list of snapshot IDs assigned to each target. - Expected current account IDs for every correction. - Generic mappings/snapshots explicitly retained as generic. - Required reason and operator note. Preview must: - Be strictly read-only. - Reject suffix/name-only auto-matching. - Verify exact source-reference hashes, platform, CHF currency, active state, role compatibility, `performance_included=0`, and budget↔canonical link. - Reject any unclassified Raiffeisen mapping/snapshot, duplicate assignment, conflicting correction, missing scoped metadata, or unrelated institution item. - Return only masked labels, opaque tokens, counts, and before/after account roles—no raw references or balances. - Produce deterministic: - `intent_fingerprint` - `baseline_fingerprint` - `preview_fingerprint = hash(contract_version, intent, baseline, ordered actions)` - Include an exact action manifest: - canonical accounts to insert - linked budget accounts to insert - mapping corrections - snapshot reassignments - file-account coverage records - generic rows retained - blockers/warnings. ### Confirm request Require: - Full original intent, not merely a random preview ID. - All three fingerprints. - `confirm: true` - Exact confirmation text, e.g. `RAIFFEISEN-KONTEN TRENNEN` - Non-empty operator note. Under one `BEGIN IMMEDIATE` transaction: 1. Recompute the baseline and action manifest after acquiring the write lock. 2. Return `409` if any fingerprint, target state, count, account role, mapping, snapshot, or correction state changed. 3. Insert both canonical/linked accounts if absent and exactly compatible. 4. Insert mapping and snapshot correction rows. 5. Insert account-scoped import metadata. 6. Write exactly one batch audit containing masked old→new account roles, reason, counts, and fingerprints. 7. Insert the immutable batch row. 8. Commit atomically. No `UPDATE` or `DELETE` against existing mapping, snapshot, import, account, or budget-account rows. Identical replay returns the original batch/audit with `idempotent=true`; a changed replay is rejected. The existing PostFinance append-only correction is the closest precedent: - `postfinance_service.py:326-357` — exact correction signature/blocker. - `postfinance_service.py:360-428` — explicit confirmation, original rows retained, correction relation plus audit. - `migrations.py:2700-2714` — immutable correction table and triggers. The new design should strengthen that precedent by adding a real preview fingerprint, baseline binding, and all-or-nothing multi-item transaction. ## Read-model changes - `household_import._mapping`, `_mapping_for_file`, `_review_mapping_is_compatible`, and `list_source_mappings` must use effective corrected mappings. - Add mapping/snapshot correction tables to: - `household_import._baseline` - `_household_review_data_version` - performance/import lineage fingerprints. - Centralize latest effective cash selection and replace direct table reads in: - `cash_service._latest_snapshot` / `list_cash_positions` - `reconciliation_snapshot._latest_cash_snapshot` - `portfolio_data._cash_snapshot_records` and the query at `portfolio_data.py:1613-1618` - `portfolio_analytics._latest_cash_entries` - `wealth_cockpit._household_history`. - Deterministic ordering should be: 1. `balance_date DESC` 2. `reconciliation` 3. `manual_balance` 4. `csv_anchor_balance` 5. `calculated_balance` 6. `created_at DESC, snapshot_id DESC` - Thus a newer business date beats an older high-priority type; priority resolves snapshots on the same effective date. - `wealth_cockpit._household_import_meta` must accept an effective account identity and aggregate all scoped file/account coverage rows, rather than selecting one profile-wide file. - Cash detail/history should expose correction status and masked origin, while retaining access to the immutable original snapshot audit lineage. ## Required counterexample tests Add a focused `tests/unit/test_raiffeisen_account_separation_v1.py` covering: - 5632, 5031, and generic mappings resolve to three distinct effective accounts. - One multi-account CSV maps each row only to its exact source reference. - Snapshot correction moves a row in effective reads exactly once while the raw snapshot remains byte-for-byte unchanged. - Mapping correction changes effective resolution while the original mapping row remains unchanged. - Same-day randomized insertion order always yields `reconciliation > manual > csv_anchor > calculated`. - A newer calculated snapshot beats an older reconciliation snapshot. - Two Raiffeisen files in one batch and one multi-account file produce correct per-account coverage/counts without cross-account duplication. - Missing historical account scope returns `unknown`, not profile-wide Raiffeisen metadata. - Preview causes no DB changes. - Baseline drift after preview returns `409` and inserts nothing. - Wrong/foreign preview fingerprint, changed intent, `confirm=false`, wrong phrase, or empty note inserts nothing. - Unclassified generic rows, ambiguous mapping, inactive target, wrong currency/role, duplicate assignment, or correction conflict block the whole batch. - Injected audit or final-item failure rolls back accounts and every correction row. - Identical replay creates no second batch or audit. - Read APIs never expose raw source references or unmasked account identifiers. Existing tests needing extension: - `tests/unit/test_cash_truewealth_management.py` - `tests/unit/test_reconciliation_snapshot_foundation.py` - `tests/unit/test_household_classification_v2_golden.py` - `tests/unit/test_household_import_v1_golden.py` - `tests/unit/test_wealth_cockpit_v1.py` - `tests/unit/test_portfolio_data_ingestion_reconciliation.py` - `tests/unit/test_portfolio_market_analytics_v1.py` - `tests/unit/test_schema.py` ## Workspace status - Files created or modified: **none** - Worktree remained clean. - Issue noted: repository HEAD is not the requested baseline, so all citations above were taken explicitly from the baseline Git object rather than the checked-out HEAD.