high priority medium complexity backend pending backend specialist Tier 3

Acceptance Criteria

Every emitted ExpenseSelectionState includes a disabledTypeIds field of type Set<String> populated by computeDisabledTypes
On initial BLoC load with an empty selection, disabledTypeIds is an empty set (no rules are triggered)
When type A is selected and the rule engine reports that type B is now forbidden, type B appears in disabledTypeIds in the next emitted state
When type A is deselected and the exclusion of type B is lifted, type B is removed from disabledTypeIds in the next emitted state
disabledTypeIds never contains a type ID that is in selectedIds (a selected type cannot simultaneously be disabled)
computeDisabledTypes is called after every successful toggle (task-007) before emitting the updated state
computeDisabledTypes is called during BLoC initialisation so the initial state already contains the correct disabled set
If the rule engine returns an empty forbidden set, disabledTypeIds is an empty Set<String> (not null)
The disabled set computation is synchronous and does not delay state emission
Unit tests verify: empty selection → empty disabled, single selection → correct disabled set, multi-selection → union of all exclusion rules applied, deselection removes previously disabled types

Technical Requirements

frameworks
Flutter
BLoC (flutter_bloc)
data models
activity_type
performance requirements
Disabled set computation must be O(n) or better relative to the number of active rules — no nested loops over the full type catalogue
Computation must complete synchronously to avoid emitting an intermediate state without the disabled set
security requirements
The disabled set is derived entirely from server-fetched rule definitions — never derived from client-only logic that could be bypassed

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Add a computeDisabledTypes(Set selectedIds) → Set private method to the BLoC. This method calls ruleEngine.getForbiddenIds(selectedIds) and returns the result. Call this method at the end of every event handler that modifies selectedIds, and include the result in the state being emitted. Ensure the initial LoadExpenseTypes event also calls computeDisabledTypes with the empty set (or with a restored draft set if task-011 is already integrated).

Avoid duplicating rule logic inside the BLoC — all constraint knowledge lives in the rule engine. The HLF requirement from the workshop (km + bussbillett cannot coexist) is the canonical example of an exclusion rule; ensure the rule engine interface supports this pattern.

Testing Requirements

Unit tests with flutter_test and bloc_test. Use a fake MutualExclusionRuleEngine that maps specific selected sets to known forbidden sets. Test cases: (1) empty selection produces empty disabled set, (2) selecting km-reimbursement disables public-transport, (3) deselecting km-reimbursement re-enables public-transport, (4) multiple selected types produce the union of all their exclusion constraints. Assert disabledTypeIds on every emitted state, not just the final one.

Verify computeDisabledTypes is invoked on initialisation by checking the initial state's disabledTypeIds field.

Component
Expense Selection BLoC
service medium
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.