Repository navigation
An expected import conflict is signalled by throwing, once per conflicted row per render #370
Description
Background
FieldMergeResolver.ResolveWithDecisions reports an ordinary, expected outcome — these fields disagree and nobody has decided yet — by throwing UnresolvedFieldConflictException. Seven call sites catch it and carry on as normal flow: six in ImportActionPlanner, where a conflict rule covers only some of the ambiguous fields, and SqliteImportActionService.ComputeAmbiguousFields, which calls the resolver only so it can catch the throw and read ex.FieldNames.
ComputeAmbiguousFields runs for every pending row in GET /api/v1/import/actions. (A batch export was named here too; it never calls the resolver, and throws nothing — measured while writing this issue's tests.) /import-review reads through the same listing, and Blazor renders the page twice (static, then interactive), so opening the page throws twice per conflicted row. Measured in T1 on 2026-09-01: 4 pending rows produced 8 throws per visit, 8 rows produced 16. Seeding adds one per conflicting file on top.
The decide path throws the same exception for a caller's incomplete decisions — nine calls in SqliteImportActionService.DecideAsync — alongside three other exceptions for conditions it has already checked: ImportActionNotFoundException and ImportActionStateException (from IImportActionCoordinator.DecideAsync), and ImportActionNotDecidableException. The DecideImportAction endpoint and BulkDecideAsync catch all four together to build their responses.
Every one of these conditions is detected before it is thrown. Under the project rule — throw only when a condition cannot be detected any other way, and catch at the first point a response can be formed (#398) — none of them should be a throw.
Impact
- It stops the debugger on every occurrence. With break-on-thrown-exceptions enabled — the normal setting when investigating anything else — a T1 session is interrupted by an exception that means "working as designed". The T1 log for the measured run shows the seeding of a single file spanning 54 seconds (19:59:03 → 19:59:57) for that reason.
- Every one of these throws is an
Errorline in the container log since Exceptions the application catches leave no trace outside Visual Studio #397 made caught exceptions visible, indicating no fault — the noise that trains a reader to skip exception lines. Measured 2026-09-18: threeUnresolvedFieldConflictExceptionlines in one run ofimport-and-staged-actions/01.
An earlier triage called this harmless because the application still functions. That was asserted rather than established.
What needs to be done
FieldMergeResolver.ResolveWithDecisionsreturns the unresolved field names as part of its result instead of throwing, andUnresolvedFieldConflictExceptionis removed.- The six
ImportActionPlannerfall-through sites andSqliteImportActionService.ComputeAmbiguousFieldsread the unresolved fields from that result. IImportActionCoordinator.DecideAsyncandSqliteImportActionService.DecideAsyncreturn an outcome — decided, not found, already resolved, not decidable, or unresolved fields — and throw for none of them.- The
DecideImportActionendpoint returns exactly the responses it returns today for each outcome (204,404, and the three422s with their current messages), andBulkDecideAsyncreports each outcome other than decided as a row error with its current message. ImportActionPlanner's early-rule resolution, which supplies every decision itself, checks the result; an unresolved field there is an invariant violation and is the one case that still throws.- Listing import actions, exporting a batch, planning an import, and deciding an action throw no exception on any of these paths.
Expected tests
| Test class / document | Test method / what it verifies | Starts |
|---|---|---|
| ThrownExceptionRecorderTests | Scope_ExceptionThrownAndCaughtInside_IsRecorded | ❌ |
| ThrownExceptionRecorderTests | Scope_NothingThrown_RecordsNothing — control | ✅ |
| ThrownExceptionRecorderTests | Scope_ExceptionThrownOutside_IsNotRecorded — control | ✅ |
| ThrownExceptionRecorderTests | Scope_ExceptionThrownAfterAnAwait_IsRecorded | ❌ |
| ThrownExceptionRecorderTests | Scopes_OnConcurrentFlows_RecordOnlyTheirOwn | ❌ |
| FieldMergeResolverTests | ResolveWithDecisions_AmbiguousFieldNoDecision_ReportsFieldName | ❌ |
| FieldMergeResolverTests | ResolveWithDecisions_AmbiguousFieldsNoDecision_ReportsEveryAmbiguousFieldName | ❌ |
| FieldMergeResolverTests | ResolveWithDecisions_FieldInCaseSensitiveSet_DiffersOnlyByCase_ReportsFieldName | ❌ |
| FieldMergeResolverTests | ResolveWithDecisions_NothingAmbiguous_ReportsNoUnresolvedFields — control | ✅ |
| FieldMergeResolverTests | ResolveWithDecisions_AmbiguousFieldNoDecision_ThrowsNothing | ❌ |
| ImportActionPlannerTests | PlanAsync_ReviewPolicy_RuleCoversOnlySomeChangedFields_StagesPendingWithoutThrowing | ❌ |
| ImportActionPlannerTests | PlanSourcesAsync_RuleCoversOnlySomeChangedFields_StagesPendingWithoutThrowing | ❌ |
| ImportActionPlannerTests | PlanSourcesAsync_ByNaturalKey_RuleCoversOnlySomeChangedFields_StagesPendingWithoutThrowing | ❌ |
| ImportActionPlannerTests | PlanSeriesAsync_RuleCoversOnlySomeChangedFields_StagesPendingWithoutThrowing | ❌ |
| ImportActionPlannerTests | PlanSeasonsAsync_RuleCoversOnlySomeChangedFields_StagesPendingWithoutThrowing | ❌ |
| ImportActionPlannerTests | ResolveEarlyRule_FieldLeftUnresolved_ThrowsInvalidOperationException | ❌ |
| ImportActionPlannerTests | ResolveEarlyRule_EveryFieldResolved_ReturnsMergedFields — control | ✅ |
| ImportActionResolutionCoordinatorTests | DecideAsync_UnknownId_ReturnsNotFoundWithoutThrowing | ❌ |
| ImportActionResolutionCoordinatorTests | DecideAsync_AlreadyAppliedAction_ReturnsAlreadyResolvedWithoutThrowing | ❌ |
| SqliteImportActionServiceTests | DecideAsync_UnknownId_ReturnsNotFoundWithoutThrowing | ❌ |
| SqliteImportActionServiceTests | DecideAsync_AlreadyAppliedAction_ReturnsAlreadyResolvedWithoutThrowing | ❌ |
| SqliteImportActionServiceTests | DecideAsync_NonQuoteAction_ReturnsNotDecidableWithoutThrowing | ❌ |
| SqliteImportActionServiceTests | DecideAsync_AmbiguousFieldLeftUndecided_ReturnsUnresolvedFieldNamesWithoutThrowing | ❌ |
| SqliteImportActionServiceTests | DecideAsync_AllFieldsDecided_ReturnsDecided — control | ✅ |
| SqliteImportActionServiceTests | BulkDecideAsync_AmbiguousFieldLeftUndecided_ReportedAsRowErrorWithoutThrowing | ❌ |
| SqliteImportActionServiceTests | GetPagedAsync_PendingModifyConflicts_ReportsAmbiguousFieldsWithoutThrowing | ❌ |
| SqliteImportActionServiceTests | ExportBatchAsync_PendingModifyConflicts_ReportsAmbiguousFieldsWithoutThrowing — control: the export never threw | ✅ |
| ImportActionDecideResultTests | Describe_NotDecidable_DoesNotNameASpecificEntityType | ❌ |
| ImportActionDecideResultTests | Describe_EachOutcome_MatchesTheMessageItReplaces | ❌ |
| ImportActionDecideResultTests | Describe_Decided_IsEmpty — control | ✅ |
| automated-testing/import-and-staged-actions/28-review-throws-nothing.md | With three conflicted pending rows of the test's own, listing, exporting, rendering the review page and an undecided decide log no exception while every row still reports its ambiguous fields; a build from before this issue, with #397 in place, logs one exception per conflicted row per listing and per render, and one for the refused decide | ❌ |
Notes
- The existing
DecideAction_UnknownId_Returns404,DecideAction_AmbiguousFieldUnresolved_Returns422WithFieldNames,DecideAction_AlreadyResolved_Returns422andDecideAction_NotDecidable_Returns422endpoint tests, and the planner's rule-resolution tests, are the regression net for requirements 2 and 4. They are rewired to outcomes and must keep asserting exactly what they assert today. - Every "throws nothing" test reads
ThrownExceptionRecorder(Quotinator.Data.Testing), subscribed once in[AssemblyInitialize]: the exceptions this issue removes are caught inside the code, so no assertion on a call can see them. - The automated document depends on Exceptions the application catches leave no trace outside Visual Studio #397: it can only go red against a build that logs caught exceptions. It owns its input — bundled sources off, its own base and conflicting files — and never stops its container.
PlanUniverseAsync's fall-through site cannot reach the throw — a Universe has one field, so a rule deciding anything decides everything — and has no red test of its own.- The decide outcome is an
ImportActionDecideResult(ADR 016'sResultsuffix). - Undo's
ImportActionNotFoundException/ImportActionStateExceptionshare the coordinator's checks but belong to Reversing, discarding and undoing an import report their refusals instead of throwing #404. - Requirement 5's early-rule call builds its own inputs so that no field can be left unresolved. It moves into
ImportActionPlanner.ResolveEarlyRuleso the check can be proven directly, since normal planning cannot reach it. - A quote held for review over a case-only text change shows nothing to decide, and is decided without asking #409 changes the same two calls to pass the case-sensitive field set, and is ordered after this issue.
Definition of done
- All expected tests listed above start red before implementation
- All requirements implemented
- All expected tests pass (green)
- No regression in related tests
- Findings summarised in a closing comment
Activity
docs [#370]: file the conflict-by-exception defect and correct #303's…
docs [#370][#397]: plan #397, re-plan #370 on it, and sequence both i…
docs [#370][#409]: re-plan #370 against the current code, add ADR 016…
test [#370]: let a test see an exception the code threw and caught it…
refactor [#370]: add the shapes for reporting unresolved fields and r…
test [#370]: write every test and automated document, and run each red
fix [#370]: report unresolved fields and decide outcomes instead of t…
docs [#370]: record the T2 pass
docs [#370]: record the connect-timeout form of a failed source downl…
docs [#370]: record the T1 pass, and add #413 to the overview
docs [#370]: move to Waiting for release, with its changelog entries