@@ -45,13 +45,13 @@ public async Task<Result<IReadOnlyList<ConflictRowDto>>> DetectConflictsAsync(
4545 // Entity caches to avoid redundant DB lookups across sub-methods
4646 var cardCache = new Dictionary < Guid , Card ? > ( ) ;
4747 var columnCache = new Dictionary < Guid , Column ? > ( ) ;
48- var projectedColumnAdditions = await GetProjectedColumnAdditionCountsAsync (
48+ var projectedColumnChanges = await GetProjectedColumnChangesAsync (
4949 proposal , cardCache , cancellationToken ) ;
5050
5151 // Check each condition and collect rows
5252 await CheckStaleDataAsync ( proposal , rows , flaggedCardIds , cardCache , cancellationToken ) ;
5353 await CheckWipLimitAsync ( proposal , rows , flaggedColumnIds , columnCache ,
54- projectedColumnAdditions , cancellationToken ) ;
54+ projectedColumnChanges , cancellationToken ) ;
5555 await CheckDuplicatePendingProposalsAsync ( proposal , rows , cancellationToken ) ;
5656 CheckHighRiskOperations ( proposal , rows ) ;
5757 await CheckOutboundWebhooksAsync ( proposal , rows , cancellationToken ) ;
@@ -67,7 +67,7 @@ await CheckWipLimitAsync(proposal, rows, flaggedColumnIds, columnCache,
6767 {
6868 // Add positive signals when applicable
6969 await AddPositiveSignalsAsync ( proposal , rows , flaggedCardIds , flaggedColumnIds ,
70- cardCache , columnCache , projectedColumnAdditions , cancellationToken ) ;
70+ cardCache , columnCache , projectedColumnChanges , cancellationToken ) ;
7171 }
7272
7373 // Sort: Warn first, then Info, then Ok
@@ -146,17 +146,32 @@ private async Task CheckWipLimitAsync(
146146 List < ConflictRow > rows ,
147147 HashSet < Guid > flaggedColumnIds ,
148148 Dictionary < Guid , Column ? > columnCache ,
149- IReadOnlyDictionary < Guid , int > projectedColumnAdditions ,
149+ IReadOnlyDictionary < Guid , ColumnProjection > projectedColumnChanges ,
150150 CancellationToken cancellationToken )
151151 {
152- if ( projectedColumnAdditions . Count == 0 ) return ;
152+ if ( projectedColumnChanges . Count == 0 ) return ;
153153
154- foreach ( var ( columnId , additionCount ) in projectedColumnAdditions )
154+ foreach ( var ( columnId , projection ) in projectedColumnChanges )
155155 {
156156 var column = await GetOrFetchColumnAsync ( columnId , columnCache , cancellationToken ) ;
157- if ( column is null ) continue ;
157+ if ( column is null )
158+ {
159+ if ( projection . ReceivesCards )
160+ {
161+ flaggedColumnIds . Add ( columnId ) ;
162+ rows . Add ( new ConflictRow (
163+ ConflictTone . Warn ,
164+ "missing-target-column" ,
165+ $ "Target column { columnId : N} no longer exists") ) ;
166+ }
167+
168+ continue ;
169+ }
170+
171+ if ( ! projection . ReceivesCards )
172+ continue ;
158173
159- var projectedCount = column . Cards . Count + additionCount ;
174+ var projectedCount = column . Cards . Count + projection . Delta ;
160175 if ( column . WipLimit . HasValue && projectedCount > column . WipLimit . Value )
161176 {
162177 flaggedColumnIds . Add ( columnId ) ;
@@ -299,18 +314,19 @@ private async Task AddPositiveSignalsAsync(
299314 HashSet < Guid > flaggedColumnIds ,
300315 Dictionary < Guid , Card ? > cardCache ,
301316 Dictionary < Guid , Column ? > columnCache ,
302- IReadOnlyDictionary < Guid , int > projectedColumnAdditions ,
317+ IReadOnlyDictionary < Guid , ColumnProjection > projectedColumnChanges ,
303318 CancellationToken cancellationToken )
304319 {
305320 // Ok: target column has capacity (only if we didn't already warn about WIP for this column)
306- foreach ( var ( columnId , additionCount ) in projectedColumnAdditions )
321+ foreach ( var ( columnId , projection ) in projectedColumnChanges )
307322 {
323+ if ( ! projection . ReceivesCards ) continue ;
308324 if ( flaggedColumnIds . Contains ( columnId ) ) continue ;
309325
310326 var column = await GetOrFetchColumnAsync ( columnId , columnCache , cancellationToken ) ;
311327 if ( column is null ) continue ;
312328
313- var projectedCount = column . Cards . Count + additionCount ;
329+ var projectedCount = column . Cards . Count + projection . Delta ;
314330 if ( column . WipLimit . HasValue && projectedCount <= column . WipLimit . Value )
315331 {
316332 rows . Add ( new ConflictRow (
@@ -355,15 +371,15 @@ private static List<Guid> GetDistinctCardTargetIds(AutomationProposal proposal,
355371 }
356372
357373 /// <summary>
358- /// Counts cards each proposal operation would add to a target column .
374+ /// Projects card count deltas per column for create/move operations .
359375 /// Existing cards moved within their current column do not increase projected WIP.
360376 /// </summary>
361- private async Task < IReadOnlyDictionary < Guid , int > > GetProjectedColumnAdditionCountsAsync (
377+ private async Task < IReadOnlyDictionary < Guid , ColumnProjection > > GetProjectedColumnChangesAsync (
362378 AutomationProposal proposal ,
363379 Dictionary < Guid , Card ? > cardCache ,
364380 CancellationToken cancellationToken )
365381 {
366- var additions = new Dictionary < Guid , int > ( ) ;
382+ var changes = new Dictionary < Guid , ColumnProjection > ( ) ;
367383
368384 foreach ( var op in proposal . Operations )
369385 {
@@ -374,19 +390,37 @@ private async Task<IReadOnlyDictionary<Guid, int>> GetProjectedColumnAdditionCou
374390 if ( ! targetColumnId . HasValue )
375391 continue ;
376392
393+ var sourceColumnId = ( Guid ? ) null ;
377394 if ( op . ActionType . Equals ( "move" , StringComparison . OrdinalIgnoreCase )
378395 && op . TargetType . Equals ( "card" , StringComparison . OrdinalIgnoreCase )
379396 && Guid . TryParse ( op . TargetId , out var movedCardId ) )
380397 {
381398 var card = await GetOrFetchCardAsync ( movedCardId , cardCache , cancellationToken ) ;
382399 if ( card ? . ColumnId == targetColumnId . Value )
383400 continue ;
401+
402+ sourceColumnId = card ? . ColumnId ;
384403 }
385404
386- additions [ targetColumnId . Value ] = additions . GetValueOrDefault ( targetColumnId . Value ) + 1 ;
405+ if ( sourceColumnId . HasValue )
406+ AddColumnProjectionDelta ( changes , sourceColumnId . Value , delta : - 1 , receivesCards : false ) ;
407+
408+ AddColumnProjectionDelta ( changes , targetColumnId . Value , delta : 1 , receivesCards : true ) ;
387409 }
388410
389- return additions ;
411+ return changes ;
412+ }
413+
414+ private static void AddColumnProjectionDelta (
415+ Dictionary < Guid , ColumnProjection > changes ,
416+ Guid columnId ,
417+ int delta ,
418+ bool receivesCards )
419+ {
420+ var existing = changes . GetValueOrDefault ( columnId ) ;
421+ changes [ columnId ] = new ColumnProjection (
422+ existing . Delta + delta ,
423+ existing . ReceivesCards || receivesCards ) ;
390424 }
391425
392426 /// <summary>
@@ -442,6 +476,8 @@ private static bool AddsCardToColumn(AutomationProposalOperation operation)
442476 || operation . ActionType . Equals ( "move" , StringComparison . OrdinalIgnoreCase ) ;
443477 }
444478
479+ private readonly record struct ColumnProjection ( int Delta , bool ReceivesCards ) ;
480+
445481 private static IReadOnlyList < string > GetWebhookEventTypes ( AutomationProposal proposal )
446482 {
447483 return proposal . Operations
0 commit comments