@@ -333,6 +333,105 @@ await act.Should().ThrowAsync<InvalidOperationException>()
333333 deletedAt . Should ( ) . NotBeNullOrEmpty ( "the reconciler must not clear DeletedAt; it must fail deployment instead" ) ;
334334 }
335335
336+ [ Fact ]
337+ public async Task Reconciler_WhenRowExistsForRemovedFlag_ShouldEmitOrphanedTelemetryEvent ( )
338+ {
339+ // Arrange
340+ Connection . Insert ( "feature_flags" , [
341+ ( "id" , FeatureFlagId . NewId ( ) . ToString ( ) ) ,
342+ ( "created_at" , TimeProvider . GetUtcNow ( ) ) ,
343+ ( "modified_at" , null ) ,
344+ ( "deleted_at" , null ) ,
345+ ( "orphaned_at" , null ) ,
346+ ( "flag_key" , "removed-feature" ) ,
347+ ( "tenant_id" , null ) ,
348+ ( "user_id" , null ) ,
349+ ( "enabled_at" , TimeProvider . GetUtcNow ( ) ) ,
350+ ( "disabled_at" , null ) ,
351+ ( "bucket_start" , null ) ,
352+ ( "bucket_end" , null ) ,
353+ ( "source" , "Manual" ) ,
354+ ( "scope" , "Tenant" )
355+ ]
356+ ) ;
357+ TelemetryEventsCollectorSpy . Reset ( ) ;
358+
359+ // Act
360+ await RunReconcilerAsync ( ) ;
361+
362+ // Assert
363+ TelemetryEventsCollectorSpy . CollectedEvents . Should ( ) . ContainSingle ( e => e . GetType ( ) . Name == "FeatureFlagOrphanedByReconciler" ) ;
364+ }
365+
366+ [ Fact ]
367+ public async Task Reconciler_WhenOrphanedBaseRowKeyIsBackInDefinitions_ShouldEmitRestoredTelemetryEvent ( )
368+ {
369+ // Arrange — orphan the sso base row and a tenant override sharing the key so the restore
370+ // event reports a non-zero overrides_restored count.
371+ var orphanedAt = TimeProvider . GetUtcNow ( ) ;
372+ var baseRowId = Connection . ExecuteScalar < string > (
373+ "SELECT id FROM feature_flags WHERE flag_key = 'sso' AND tenant_id IS NULL AND user_id IS NULL" , [ ]
374+ ) ;
375+ Connection . Update ( "feature_flags" , "id" , baseRowId , [ ( "orphaned_at" , orphanedAt ) ] ) ;
376+ Connection . Insert ( "feature_flags" , [
377+ ( "id" , FeatureFlagId . NewId ( ) . ToString ( ) ) ,
378+ ( "created_at" , TimeProvider . GetUtcNow ( ) ) ,
379+ ( "modified_at" , null ) ,
380+ ( "deleted_at" , null ) ,
381+ ( "orphaned_at" , orphanedAt ) ,
382+ ( "flag_key" , "sso" ) ,
383+ ( "tenant_id" , DatabaseSeeder . Tenant1 . Id . Value ) ,
384+ ( "user_id" , null ) ,
385+ ( "enabled_at" , TimeProvider . GetUtcNow ( ) ) ,
386+ ( "disabled_at" , null ) ,
387+ ( "bucket_start" , null ) ,
388+ ( "bucket_end" , null ) ,
389+ ( "source" , "Plan" ) ,
390+ ( "scope" , "Tenant" )
391+ ]
392+ ) ;
393+ TelemetryEventsCollectorSpy . Reset ( ) ;
394+
395+ // Act
396+ await RunReconcilerAsync ( ) ;
397+
398+ // Assert
399+ TelemetryEventsCollectorSpy . CollectedEvents . Should ( ) . ContainSingle ( e => e . GetType ( ) . Name == "FeatureFlagRestoredByReconciler" ) ;
400+ var restored = TelemetryEventsCollectorSpy . CollectedEvents . Single ( e => e . GetType ( ) . Name == "FeatureFlagRestoredByReconciler" ) ;
401+ restored . Properties [ "event.overrides_restored" ] . Should ( ) . Be ( "1" ) ;
402+ }
403+
404+ [ Fact ]
405+ public async Task Reconciler_WhenSsoTenantOverrideSourceDiffersFromDefinition_ShouldEmitSourceTransitionedTelemetryEvent ( )
406+ {
407+ // Arrange — sso is a PlanGatedTenantFlag (definition Source=Plan). Seed a Manual override
408+ // row so the reconciler's source-transition sweep removes it and emits the event.
409+ Connection . Insert ( "feature_flags" , [
410+ ( "id" , FeatureFlagId . NewId ( ) . ToString ( ) ) ,
411+ ( "created_at" , TimeProvider . GetUtcNow ( ) ) ,
412+ ( "modified_at" , null ) ,
413+ ( "deleted_at" , null ) ,
414+ ( "orphaned_at" , null ) ,
415+ ( "flag_key" , "sso" ) ,
416+ ( "tenant_id" , DatabaseSeeder . Tenant1 . Id . Value ) ,
417+ ( "user_id" , null ) ,
418+ ( "enabled_at" , TimeProvider . GetUtcNow ( ) ) ,
419+ ( "disabled_at" , null ) ,
420+ ( "bucket_start" , null ) ,
421+ ( "bucket_end" , null ) ,
422+ ( "source" , "Manual" ) ,
423+ ( "scope" , "Tenant" )
424+ ]
425+ ) ;
426+ TelemetryEventsCollectorSpy . Reset ( ) ;
427+
428+ // Act
429+ await RunReconcilerAsync ( ) ;
430+
431+ // Assert
432+ TelemetryEventsCollectorSpy . CollectedEvents . Should ( ) . Contain ( e => e . GetType ( ) . Name == "FeatureFlagSourceTransitionedByReconciler" ) ;
433+ }
434+
336435 private async Task RunReconcilerAsync ( )
337436 {
338437 using var scope = Provider . CreateScope ( ) ;
0 commit comments