Skip to content

An expected import conflict is signalled by throwing, once per conflicted row per render #370

Open

Description

@DutchJaFO

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

  1. 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.
  2. Every one of these throws is an Error line 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: three UnresolvedFieldConflictException lines in one run of import-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

  1. FieldMergeResolver.ResolveWithDecisions returns the unresolved field names as part of its result instead of throwing, and UnresolvedFieldConflictException is removed.
  2. The six ImportActionPlanner fall-through sites and SqliteImportActionService.ComputeAmbiguousFields read the unresolved fields from that result.
  3. IImportActionCoordinator.DecideAsync and SqliteImportActionService.DecideAsync return an outcome — decided, not found, already resolved, not decidable, or unresolved fields — and throw for none of them.
  4. The DecideImportAction endpoint returns exactly the responses it returns today for each outcome (204, 404, and the three 422s with their current messages), and BulkDecideAsync reports each outcome other than decided as a row error with its current message.
  5. 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.
  6. 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_Returns422 and DecideAction_NotDecidable_Returns422 endpoint 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's Result suffix).
  • Undo's ImportActionNotFoundException/ImportActionStateException share 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.ResolveEarlyRule so 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

  1. added this to the Notification system milestone on Sep 1, 2026
  2. added a commit that references this issue on Sep 1, 2026
  3. added
    enhancementNew feature or request
    and removed
    bugSomething isn't working
    on Sep 16, 2026
  4. added 10 commits that reference this issue on Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions