From cdf16ba219164fa76dd9b4019b268b4a516f55fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20St=C3=BChmer?= Date: Tue, 26 May 2026 10:36:10 +0200 Subject: [PATCH] docs(audit): add round 01 baseline assumptions + audit charter Phase 1 round 01 discovery output (12 quality + 15 usability assumptions) plus the multi-agent audit charter. Subsequent rounds and verifiers read these files to avoid duplicating earlier claims and to cross-check evidence. Co-Authored-By: Claude Opus 4.7 (1M context) --- audit/README.md | 13 ++++ audit/assumptions/round-01-quality.md | 80 ++++++++++++++++++++ audit/assumptions/round-01-usability.md | 98 +++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 audit/README.md create mode 100644 audit/assumptions/round-01-quality.md create mode 100644 audit/assumptions/round-01-usability.md diff --git a/audit/README.md b/audit/README.md new file mode 100644 index 00000000..6674c6b0 --- /dev/null +++ b/audit/README.md @@ -0,0 +1,13 @@ +# Pulse Audit + +Multi-agent quality + practical-usability audit. + +## Loop schema +- **Phase 1 (looped)** — Discovery scouts (1 quality + 1 usability) in worktrees form ASSUMPTIONS only. Output → `assumptions/round-NN-quality.md` and `assumptions/round-NN-usability.md`. Each round must read prior rounds and avoid duplicating earlier IDs; new assumptions in new round get a fresh prefix (`Q01b`, `U01b`, …) or new numbers continuing the series. +- **Phase 2** — Verifiers in worktrees independently confirm/refute each assumption with file:line evidence, then write FAILING tests for every confirmed one. Output → `verification/round-NN-.md` + tests under `audit/tests/` (later promoted into the real test projects per provider). +- **Phase 3** — Builders in worktrees, 1 PR per confirmed assumption, English (US), conventional commits. + +## Conventions +- All agents work in `git worktree`-isolated copies. +- All PRs against `main`, English (US), conventional commits. +- No code change without a confirming verifier and a failing test first. diff --git a/audit/assumptions/round-01-quality.md b/audit/assumptions/round-01-quality.md new file mode 100644 index 00000000..f59144ee --- /dev/null +++ b/audit/assumptions/round-01-quality.md @@ -0,0 +1,80 @@ +# Phase 1 — Round 01 — Quality Discovery + +> Read-only assumptions. Each must be confirmed or refuted in Phase 2 with file:line evidence. + +## Repo Snapshot +19 src projects: core (`NetEvolve.Pulse`), `Extensibility`, source generator, AspNetCore, EF Core, 4 ADO.NET providers (SqlServer, PostgreSql, SQLite, plus EF-backed MySql/CosmosDb/MongoDB), 3 native transports (Kafka, RabbitMQ, AzureServiceBus), 3 cross-cutting (Polly, FluentValidation, HttpCorrelation), Dapr, Redis, MySql. Core abstractions: `IMediator` (`PulseMediator` in `Internals/`), open-generic interceptors per request kind, `IEventDispatcher` strategies (Parallel/Sequential/Prioritized/RateLimited), outbox subsystem split into `IEventOutbox` + `IOutboxRepository` + `IMessageTransport` + `OutboxProcessorHostedService`. Outbox state machine driven by stored procedures/functions (`get_pending_outbox_messages` flips Pending→Processing using FOR UPDATE SKIP LOCKED), batch defaults `EnableBatchSending=false`, `EnableExponentialBackoff=false`, `MaxRetryCount=3`. TimeProvider is wired through most code; outbox processor leaks back to `DateTimeOffset.UtcNow` in two spots. + +## Assumptions + +### Q01 — Singleton hosted service consumes scoped IOutboxRepository (captive dependency) +- Claim: `OutboxProcessorHostedService` is registered Singleton but constructor-injects `IOutboxRepository`, which is registered Scoped by SQL Server/PostgreSql/SQLite/EF extensions. No `IServiceScopeFactory.CreateScope()` is created per polling cycle. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:93-119` (constructor takes `IOutboxRepository`), `src/NetEvolve.Pulse/OutboxExtensions.cs:72-73` (`Singleton`), `src/NetEvolve.Pulse.SqlServer/SqlServerExtensions.cs:199-202` (`AddScoped`), `src/NetEvolve.Pulse.EntityFramework/Outbox/EntityFrameworkOutboxRepository.cs:41-47` (takes `TContext`). +- Why it matters: Captured EF `DbContext` is not thread-safe, accumulates change-tracker state forever, prevents per-poll connection reuse. ADO.NET impls mask this (fresh connections per call); EF-backed users will hit `ObjectDisposedException`/concurrency exceptions or unbounded memory growth. +- Test idea: Register `AddEntityFrameworkOutbox()`, build host with `ValidateScopes=true`, assert startup throws `InvalidOperationException` for the singleton-consuming-scoped registration; or assert same `IOutboxRepository`/`DbContext` instance across two simulated polling cycles. + +### Q02 — Stuck "Processing" rows never recovered (broken at-least-once) +- Claim: Outbox processor flips messages to `Status=1` (Processing) in the fetch query, but has no reaper for rows stuck in Processing if the host dies between fetch and `MarkAsCompleted/Failed/DeadLetter`. Pending-fetch and retry-fetch only look at `Status=0` and `Status=3`. +- Evidence: `src/NetEvolve.Pulse.PostgreSql/Scripts/OutboxMessage.sql:78-105` (UPDATE…SET Status=1 inside `get_pending_outbox_messages`), `:131-162`, `:180-181`, `:202-203`; `src/NetEvolve.Pulse.SqlServer/Outbox/SqlServerOutboxRepository.cs:163-199`; `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:236-292` (no reaper). +- Why it matters: Breaks at-least-once guarantee on crash/OOM/SIGKILL. Rows stay Processing forever; `pulse.outbox.pending` metric will not see them. +- Test idea: Insert one Pending row, call `GetPendingAsync`, simulate crash (skip MarkAsCompleted), restart host, assert second `GetPendingAsync` returns the stuck row (currently does not). + +### Q03 — OutboxProcessorHostedService bypasses TimeProvider +- Claim: Uses `DateTimeOffset.UtcNow` directly instead of injected `TimeProvider` (which the rest of the codebase honors: `IdempotencyStore`, `OutboxEventStore`, `ActivityAndMetricsRequestInterceptor`). +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:384` (`_options.ComputeNextRetryAt(DateTimeOffset.UtcNow, …)`), `:477` (`var now = DateTimeOffset.UtcNow;`). Compare `src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs:52` using `_timeProvider.GetUtcNow()`. +- Why it matters: Backoff schedules computed with a fake `TimeProvider` are not honored; deterministic retry-window assertions impossible; persisted `NextRetryAt` will not match the test clock. +- Test idea: Inject `FakeTimeProvider` fixed at 2030-01-01, force one message to fail with `EnableExponentialBackoff=true`, assert persisted `NextRetryAt = 2030-01-01 + BaseRetryDelay`. + +### Q04 — IdempotencyCommandInterceptor is check-then-act, not atomic +- Claim: `ExistsAsync` → handler → `StoreAsync`. Two concurrent commands with the same key both pass `ExistsAsync` before either calls `StoreAsync` — handler executes twice. +- Evidence: `src/NetEvolve.Pulse/Interceptors/IdempotencyCommandInterceptor.cs:68-79`; `src/NetEvolve.Pulse/Idempotency/IdempotencyStore.cs:47-64` (no atomic put-if-absent in contract). +- Why it matters: Claims at-most-once execution but only catches sequential duplicates. +- Test idea: Two `IIdempotentCommand`s, same key, gated handler, `Task.WhenAll`; assert handler ran once and the second call surfaced `IdempotencyConflictException`. + +### Q05 — ConcurrentCommandGuardInterceptor: dispose race + GetOrAdd factory leak +- Claim: Singleton interceptor with `Dispose()` walking `_semaphores`. Mid-shutdown awaits surface `ObjectDisposedException` instead of `OperationCanceledException`. `GetOrAdd(typeof(TRequest), _ => new SemaphoreSlim(1,1))` can leak losing-race semaphores. +- Evidence: `src/NetEvolve.Pulse/Interceptors/ConcurrentCommandGuardInterceptor.cs:45,49-68,71-84`. +- Why it matters: ODE during shutdown masks real cancellation; rare contention leaks under heavy load. +- Test idea: `Parallel.For` to provoke factory races and reflectively assert all created semaphores are present; await semaphore in HandleAsync, dispose on another thread, assert surfaced exception is `OperationCanceledException`. + +### Q06 — KafkaMessageTransport ignores CancellationToken on Flush + IsHealthy +- Claim: `_producer.Flush(Timeout.InfiniteTimeSpan)` blocks indefinitely and ignores the passed token; `IsHealthyAsync` does sync `_adminClient.GetMetadata(TimeSpan.FromSeconds(5))` wrapped in `Task.FromResult`. +- Evidence: `src/NetEvolve.Pulse.Kafka/Outbox/KafkaMessageTransport.cs:94`, `:63-102`, `:105-116`. +- Why it matters: `IHostApplicationLifetime` cannot interrupt a stuck flush — graceful shutdown becomes ungraceful, increasing stuck-Processing risk (Q02). +- Test idea: Fake `IProducer<…>` whose `Flush` blocks forever, `SendBatchAsync` with token canceling after 100 ms; assert the task completes (it will not). + +### Q07 — Azure Service Bus non-atomic batch when EnableBatching=false +- Claim: `SendBatchAsync` loops `sender.SendMessageAsync` per message when batching disabled — non-atomic. `ProcessBatchSendAsync` then marks entire batch failed on any error → re-send next poll → duplicate delivery. +- Evidence: `src/NetEvolve.Pulse.AzureServiceBus/Outbox/AzureServiceBusMessageTransport.cs:55-89`; `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:456-500`. +- Why it matters: Violates `IMessageTransport.SendBatchAsync` atomicity contract used by the processor. +- Test idea: Fake `ServiceBusSender` succeeds for messages 1+2 then throws on 3, run with `EnableBatching=false`; verify broker received first two, outbox marks all three Failed (next poll → duplicates). + +### Q08 — RabbitMqMessageTransport channel leak on reconnect +- Claim: `EnsureChannelAsync` overwrites stale `_channel` without disposing the previous instance — leaks an `IRabbitMqChannelAdapter` and its AMQP channel per reconnect. +- Evidence: `src/NetEvolve.Pulse.RabbitMQ/Outbox/RabbitMqMessageTransport.cs:124-147`. +- Why it matters: Long-running services exhaust the connection's `channel_max` budget on transient disconnects → `ChannelAllocationException`. +- Test idea: Fake adapter that returns distinct disposable instances and flips `IsOpen=false` after each publish; send 100 messages, assert previous adapters were disposed. + +### Q09 — Batch mark operations are not atomic (per-row + parallel) +- Claim: Default-interface batch methods on `IOutboxRepository` iterate via `Parallel.ForEachAsync` to single-item overloads (fresh connections, no transaction). PostgreSQL override iterates sequentially under one connection but no transaction. A transient mid-loop failure leaves some Completed, others Processing → duplicates next poll. +- Evidence: `src/NetEvolve.Pulse.Extensibility/Outbox/IOutboxRepository.cs:59-69,113-124,142-153`; `src/NetEvolve.Pulse.PostgreSql/Outbox/PostgreSqlOutboxRepository.cs:243-265`; `src/NetEvolve.Pulse.SqlServer/Outbox/SqlServerOutboxRepository.cs:273-284`. +- Why it matters: `ProcessBatchSendAsync` (Outbox/OutboxProcessorHostedService.cs:438-439) needs atomic `MarkAsCompletedAsync(ids[])` for at-least-once. +- Test idea: Flaky connection failing after N successes; call `MarkAsCompletedAsync({id1,id2,id3})` failing after id1; assert all-or-none semantics (current: only id1 Completed). + +### Q10 — Timeout-cancel vs external-cancel confusion in ProcessMessageAsync +- Claim: When `timeoutCts.CancelAfter` fires, `OperationCanceledException` does not satisfy `when (cancellationToken.IsCancellationRequested)` filter — falls into generic catch and gets treated as retryable failure with msg "The operation was canceled." Telemetry status remains `Ok` while DB state is Failed/DeadLetter. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:331-401`; `src/NetEvolve.Pulse/Interceptors/ActivityAndMetricsRequestInterceptor.cs:105-143`. +- Why it matters: "Processed-but-failed" rows with success logs → operator confusion. +- Test idea: `ProcessingTimeout=50ms`, transport sleeps 200ms; assert row Status=Failed, RetryCount=1, `pulse.outbox.failed.total` advanced, and `ExecuteAsync` did not fault. + +### Q11 — Race in batch failure classification reads EventTypeOverrides three times +- Claim: `ProcessBatchSendAsync` invokes `_options.GetEffectiveMaxRetryCount(m.EventType)` independently in three LINQ predicates — `EventTypeOverrides` is a public `ConcurrentDictionary` and can mutate between reads, causing dead-letter log lines to disagree with mark-as-failed DB writes. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:465-518`; `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:118-119`. +- Why it matters: Inconsistent telemetry vs DB state when overrides tuned at runtime. +- Test idea: Custom repo whose `MarkAsFailedAsync(ids[])` delays 50ms; another thread mutates `EventTypeOverrides[typeof(MyEvent)].MaxRetryCount=1`; assert dead-letter log count ≠ DB dead-letter row count. + +### Q12 (lower confidence) — Interceptor pipeline ordering is implicit +- Claim: Pipeline ordering is registration-order driven via `Reverse().ToArray()` + wrap-from-innermost-out. No priority hook; users cannot predict whether timeout wraps idempotency or vice versa without inspecting registration order. +- Evidence: `src/NetEvolve.Pulse/Internals/PulseMediator.cs:232,243-249`; `src/NetEvolve.Pulse/IdempotencyExtensions.cs:61-63`; `src/NetEvolve.Pulse/ConcurrentCommandGuardExtensions.cs:50-52`. +- Why it matters: Determines whether timeout fires inside or outside concurrency guard, and whether idempotency conflicts are caught by Polly retry. Fragile, load-bearing on registration order. +- Test idea: Register `AddTimeout()` then `AddIdempotency()` and verify which exception escapes; swap order and observe change. diff --git a/audit/assumptions/round-01-usability.md b/audit/assumptions/round-01-usability.md new file mode 100644 index 00000000..5b7da724 --- /dev/null +++ b/audit/assumptions/round-01-usability.md @@ -0,0 +1,98 @@ +# Phase 1 — Round 01 — Usability Discovery + +> Read-only assumptions. Each must be confirmed or refuted in Phase 2 with file:line evidence. + +## Repo Snapshot +~19 src projects (core, source-gen, ASP.NET Core, FluentValidation, HttpCorrelation, Polly, persistence + transport providers). Multi-targets `net8.0;net9.0;net10.0`. Heavy XML docs and per-project READMEs. C# 14 `extension(...)` member syntax pervasively. Central versioning via `Directory.Packages.props`. NuGet metadata per-csproj. No `PackageIcon`/`PackageReadmeFile` anywhere. `SystemTextJsonPayloadSerializer` default and unconditional. + +## Assumptions + +### U01 — README "Quick Use" snippet does not compile / does not dispatch +- Claim: Root README snippet declares record/handler types at file scope after top-level statements and never builds the provider or dispatches anything. Compile error guaranteed. +- Evidence: `README.md:118-136`; counter-example `src/NetEvolve.Pulse/README.md:42-72`. +- Why it matters: First-impression sample. Copy-paste = immediate compile failure. +- Test idea: Paste the README:118-136 snippet verbatim into a new console project; build on net8/net9/net10. + +### U02 — README mis-describes QueryCachingOptions API +- Claim: README claims `QueryCachingOptions` exposes `JsonSerializerOptions`; the type actually only has `ExpirationMode` and `DefaultExpiry`. Serialization is controlled via `IPayloadSerializer` and `IOptions` globally. +- Evidence: `README.md:59`; `src/NetEvolve.Pulse/README.md:152-162`; type at `src/NetEvolve.Pulse.Extensibility/Caching/QueryCachingOptions.cs:10-59`. +- Why it matters: Doc'd code does not compile. +- Test idea: Compile `src/NetEvolve.Pulse/README.md:155-162` snippet → CS0117. + +### U03 — XML doc references non-existent NuGet package +- Claim: `AssemblyScanningExtensions` XML doc recommends `NetEvolve.Pulse.Generators`; actual package is `NetEvolve.Pulse.SourceGeneration`. Typo-squat risk on NuGet. +- Evidence: `src/NetEvolve.Pulse/AssemblyScanningExtensions.cs:15`. +- Why it matters: Users `dotnet add package` something that doesn't exist. +- Test idea: Verify package name vs csproj ``. + +### U04 — Default JSON serializer breaks AOT silently +- Claim: `SystemTextJsonPayloadSerializer` uses reflection-mode `JsonSerializer.Serialize` / `Deserialize` with no `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` annotations. Conflicts with the library's AOT-friendly positioning (`HandlerRegistrationExtensions` advertises AOT compatibility). +- Evidence: `src/NetEvolve.Pulse/Serialization/SystemTextJsonPayloadSerializer.cs:32-44`; AOT claim `src/NetEvolve.Pulse/HandlerRegistrationExtensions.cs:9,15-17`. +- Why it matters: AOT users get runtime failures or silent metadata loss without compile-time warnings. +- Test idea: AOT publish a console app using `AddPulse() + AddQueryCaching()`; verify no IL2026/IL3050 warnings even though reflection happens. + +### U05 — Critical options classes have no IValidateOptions +- Claim: `OutboxProcessorOptions` accepts `BatchSize<=0`, `PollingInterval<=TimeSpan.Zero`, `BackoffMultiplier<=0`, `ProcessingTimeout=TimeSpan.Zero`. `AzureServiceBusTransportOptions` validation runs inline at first resolution, not at startup. Only `LoggingInterceptorOptions` has a registered validator. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:10-99`; `src/NetEvolve.Pulse.AzureServiceBus/AzureServiceBusExtensions.cs:75-86`; only validator: `src/NetEvolve.Pulse/Interceptors/LoggingInterceptorOptionsValidator.cs`. +- Why it matters: Misconfig fails deep inside processor at runtime instead of failing fast at startup. No `ValidateOnStart()` wired up. +- Test idea: Configure `BatchSize=0, PollingInterval=Zero`, build host; verify host starts cleanly. + +### U06 — Aggressive retry defaults +- Claim: `MaxRetryCount=3`, `EnableExponentialBackoff=false`, `EnableBatchSending=false`. New user calling `AddOutbox()` gets 3 quick retries with no backoff (gated only by polling interval) → dead-letter on transient failures. +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxProcessorOptions.cs:28,40,47`. +- Why it matters: Headline reliability feature configured for foot-gun. +- Test idea: Processor with no overrides + transport that fails 3× then succeeds → message dead-lettered. + +### U07 — Transport extension methods do not enforce AddOutbox +- Claim: `UseAzureServiceBusTransport`, `UseKafkaTransport`, `UseRabbitMqTransport`, `UseMessageTransport` silently replace any existing `IMessageTransport` but do NOT register `IOutboxRepository` or `OutboxProcessorHostedService`. Users following per-package READMEs may get half-wired systems. +- Evidence: `src/NetEvolve.Pulse.AzureServiceBus/README.md:32-45`; `src/NetEvolve.Pulse.AzureServiceBus/AzureServiceBusExtensions.cs:26-60`; `src/NetEvolve.Pulse.Kafka/KafkaExtensions.cs:44-59`; counter-example `src/NetEvolve.Pulse.SqlServer/SqlServerExtensions.cs:194-204`. +- Why it matters: Missing-service exceptions at publish, or silently no-op outbox. +- Test idea: Take ASB README:32-45 quick-start verbatim, build provider, call `mediator.PublishAsync(...)`; observe DI failure or silent no-op. + +### U08 — SQL Server schema script not delivered via PackageReference +- Claim: `OutboxOptions.Schema` defaults to `"pulse"`. SQL Server README says "execute the schema script from `Scripts/OutboxMessage.sql`" — script is shipped via NuGet `content\Scripts` (legacy content mechanism that does not flow to PackageReference consumers). +- Evidence: `src/NetEvolve.Pulse/Outbox/OutboxOptions.cs:18`; `src/NetEvolve.Pulse.SqlServer/NetEvolve.Pulse.SqlServer.csproj:18-19`; `src/NetEvolve.Pulse.SqlServer/SqlServerExtensions.cs:23-25`. +- Why it matters: First publish throws "Invalid object name 'pulse.OutboxMessage'"; documented remediation does not deliver the file. +- Test idea: Add ``, build, search consumer output for `OutboxMessage.sql` → absent. + +### U09 — MapStreamQuery uses raw JsonSerializer, ignoring IPayloadSerializer/options +- Claim: `EndpointRouteBuilderExtensions.MapStreamQuery` calls `JsonSerializer.Serialize(item)` / `SerializeToUtf8Bytes(item)` with no options — does not honor configured `JsonSerializerOptions` or `IPayloadSerializer`. +- Evidence: `src/NetEvolve.Pulse.AspNetCore/EndpointRouteBuilderExtensions.cs:226,248`. +- Why it matters: `MapQuery` and `MapStreamQuery` serialize the same DTO differently (camelCase vs PascalCase); blocks AOT for stream endpoints. +- Test idea: Configure camelCase policy; add `MapQuery` and `MapStreamQuery` for same DTO; compare response property casing. + +### U10 — KafkaMessageTransport ignores CancellationToken (also Q06) +- Claim: `_producer.Flush(Timeout.InfiniteTimeSpan)` ignores token; `SendBatchAsync` declared with `CancellationToken` but never observes it. +- Evidence: `src/NetEvolve.Pulse.Kafka/Outbox/KafkaMessageTransport.cs:94`; `src/NetEvolve.Pulse/Outbox/OutboxProcessorHostedService.cs:432-435`. +- Why it matters: Stuck broker leaks processor thread; `ProcessingTimeout` does not bound batch sending. +- Test idea: Unreachable broker, send batch, cancel token; assert task completes within `ProcessingTimeout`. + +### U11 — Transport extensions silently overwrite previous registration +- Claim: `Use*Transport` methods linear-scan and `Remove()` any existing `IMessageTransport`. No warning if a transport is overwritten by a later call. +- Evidence: `src/NetEvolve.Pulse.AzureServiceBus/AzureServiceBusExtensions.cs:51-58`; `src/NetEvolve.Pulse.Kafka/KafkaExtensions.cs:50-57`; `src/NetEvolve.Pulse.RabbitMQ/RabbitMqExtensions.cs:53-60`; `src/NetEvolve.Pulse/OutboxExtensions.cs:94-101`. +- Why it matters: Silent last-write-wins on foundational service makes misconfig undebuggable. +- Test idea: Register both ASB and Kafka in any order; resolve `IMessageTransport`; assert exactly one is registered and no diagnostic emitted. + +### U12 — IMediator registered Scoped, BackgroundService example violates DI lifetime +- Claim: `IMediator` is Scoped, but XML doc example shows `IMediatorSendOnly` injected directly into a `BackgroundService`. With `ValidateScopes=true` (dev default), host startup fails. +- Evidence: `src/NetEvolve.Pulse/ServiceCollectionExtensions.cs:110-111`; example at `src/NetEvolve.Pulse.Extensibility/IMediatorSendOnly.cs:20-34`. +- Why it matters: Copy-paste example throws `InvalidOperationException` at startup. +- Test idea: Implement the example verbatim against `Host.CreateApplicationBuilder()`; observe scope validation failure. + +### U13 — NuGet metadata missing icon + embedded README +- Claim: No `PackageIcon` / `PackageReadmeFile` set anywhere. `Directory.Build.props` defines only `Title`, `RepositoryUrl`, `PackageProjectUrl`, `PackageReleaseNotes`, `PackageTags`. +- Evidence: `Directory.Build.props:1-12`; `logo.png` at repo root unused; grep across repo finds no `PackageIcon`/`PackageReadmeFile`. +- Why it matters: NuGet listing shows generic placeholder, no README on package page. +- Test idea: `dotnet pack`; inspect resulting `.nupkg` `.nuspec` for ``/`` elements. + +### U14 — C# 14 extension blocks lock source build to .NET 10 SDK +- Claim: `extension(TReceiver)` member blocks used across 7+ files; multi-target is `net8.0;net9.0;net10.0` but `LangVersion` is not explicitly set. +- Evidence: `src/NetEvolve.Pulse/AssemblyScanningExtensions.cs:51`; `src/NetEvolve.Pulse.SqlServer/Outbox/SqlServerOutboxOptionsExtensions.cs`; `src/NetEvolve.Pulse.PostgreSql/Outbox/PostgreSqlOutboxOptionsExtensions.cs`; et al. +- Why it matters: Source builds with .NET 8 or .NET 9 SDK fail; CI on older SDKs breaks; cryptic CS1003/CS8400 errors for contributors. +- Test idea: `global.json` pinning to .NET 8 SDK, then `dotnet build` of `Pulse.slnx`. + +### U15 — Missing handler gives generic DI exception, no scanning helper +- Claim: `services.AddPulse()` with no further calls and no `[PulseHandler]` source-gen attributes throws a generic `"no service of type ICommandHandler<...>"` at first dispatch. Compared to MediatR's `RegisterServicesFromAssemblyContaining()` one-liner, Pulse requires source-gen, manual `Add*Handler`, or AOT-incompatible `AddHandlersFromCallingAssembly()`. +- Evidence: `src/NetEvolve.Pulse/ServiceCollectionExtensions.cs:88-114`; error message inherited from `GetRequiredService`. +- Why it matters: #1 first-time error with no actionable remediation in the message. +- Test idea: `services.AddPulse(); …GetRequiredService().SendAsync(new SomeCommand())`; verify error message and that it does not mention scanning or `AddCommandHandler<>`.