critical priority medium complexity backend pending backend specialist Tier 4

Acceptance Criteria

ExpenseTypeRepository.persistFinalSelection validates the submitted selection set against all mutual exclusion rules before any Supabase write is attempted
When an invalid combination is detected (e.g., kilometre + transit submitted together), a typed MutualExclusionViolationException is thrown with a descriptive message identifying the conflicting types
No Supabase upsert is executed when a mutual exclusion violation is detected — the method returns without writing
Violations are logged to the audit log with timestamp, user ID, organisation ID, and the rejected selection set
Valid selection sets pass through the guard and the Supabase upsert executes normally
The exception type is exported and catchable by the BLoC layer for user-facing error handling
The mutual exclusion rule set used by the repository is the same canonical source as used by the BLoC rule engine (no duplication of rule logic)
Edge case: empty selection set is treated as valid (no types selected) and persists normally
Edge case: single-type selection always passes validation
The guard logic is synchronous and does not incur additional network calls

Technical Requirements

frameworks
Flutter
BLoC
Dart
apis
Supabase REST API
Supabase upsert
data models
ExpenseType
ExpenseTypeConfig
FormulaParameters
MutualExclusionRule
performance requirements
Validation must complete in under 1ms for any selection set size (pure in-memory logic)
No additional network round-trips introduced by the guard
security requirements
Audit log entry must be written even if the exception propagates — use a fire-and-forget audit write that does not block the exception path
Audit log must not contain sensitive personal data beyond user ID and organisation ID
The guard must run in the repository layer regardless of which caller invokes persistFinalSelection — it must not be bypassable via direct repository injection

Execution Context

Execution Tier
Tier 4

Tier 4 - 323 tasks

Can start after Tier 3 completes

Implementation Notes

Define MutualExclusionViolationException as a typed exception class (not a generic Exception) so callers can distinguish it. The mutual exclusion rule set should be a shared constant or injected dependency — do not duplicate the rule list from the BLoC rule engine. A clean approach: extract a MutualExclusionRuleEngine class or static method that both the BLoC and the repository import. In persistFinalSelection, call the rule engine first; if it returns a violation, throw before opening the Supabase transaction.

For the audit log, use a dedicated Supabase table (e.g., expense_validation_audit) or append to an existing audit stream — do not log to console only. Use a try/finally pattern to ensure the audit write fires even when the exception propagates. Dart pattern: throw MutualExclusionViolationException(conflictingTypes: [ExpenseType.kilometre, ExpenseType.transit]) with a toString() that produces a human-readable message for downstream error handling.

Testing Requirements

Unit tests (task-015) cover this guard directly. This task itself must include a focused manual verification checklist: (1) trigger from BLoC with invalid selection and confirm exception type, (2) confirm audit log row written to Supabase, (3) confirm no partial write in the expense_type_selections table. Integration tests in task-016 further exercise the full flow. All tests use mock Supabase client — no real network calls in automated tests.

Component
Expense Type Repository
data low
Epic Risks (2)
high impact medium prob dependency

The per-km reimbursement rate and transit zone amounts must be read from org-specific configuration stored in Supabase. If the rate configuration table or RLS policies are not yet deployed when this epic runs, the calculation service cannot be completed and integration tests will fail.

Mitigation & Contingency

Mitigation: Define a RateConfigRepository interface and inject a stub implementation with default HLF rates from day one; write the real Supabase adapter in parallel and swap via dependency injection before merge.

Contingency: If org rate config is delayed beyond this epic's window, ship with the default-rate stub and log a prominent warning; calculate with defaults and surface a 'rates not confirmed' notice in the UI preview.

medium impact low prob technical

If the peer mentor opens an expense claim on two devices simultaneously, the local draft and the Supabase record may diverge. The repository's last-write-wins strategy could silently overwrite a valid selection with a stale one.

Mitigation & Contingency

Mitigation: Add an updated_at timestamp to the draft record and reject saves where the server timestamp is newer than the local copy; surface a conflict resolution prompt rather than silently overwriting.

Contingency: If conflict resolution UI is out of scope, fall back to server-authoritative reads on app foreground resume and discard local draft, notifying the user that their draft was refreshed from the server.