From 134505cb7535bbae096f66d6fc51b0782cdde5e4 Mon Sep 17 00:00:00 2001 From: Avtandil Ushikishvili Date: Fri, 2 Oct 2026 21:54:52 +0400 Subject: [PATCH 1/2] docs: reject unsupported dynamic message types explicitly --- contents/AgreementDispatcher.md | 5 +- contents/CloudEventsSupport.md | 11 ++- contents/DynamicMessageDeserialization.md | 44 +++++----- contents/FAQ.md | 9 +- contents/RoutingMultipleMessageTypes.md | 100 ++++++++++++++++------ contents/V10MigrationGuide.md | 10 ++- 6 files changed, 125 insertions(+), 54 deletions(-) diff --git a/contents/AgreementDispatcher.md b/contents/AgreementDispatcher.md index c4a894e..f56ef5b 100644 --- a/contents/AgreementDispatcher.md +++ b/contents/AgreementDispatcher.md @@ -114,6 +114,9 @@ registry.RegisterAsync((request, context) => Agreement Dispatcher can be combined with [Dynamic Message Deserialization](DynamicMessageDeserialization.md) for two-level routing: ```csharp +using System; +using Paramore.Brighter.Actions; + using Paramore.Brighter; using Paramore.Brighter.Extensions.DependencyInjection; using Paramore.Brighter.MessagingGateway.Kafka; @@ -128,7 +131,7 @@ var subscription = new KafkaSubscription( { var t when t == new CloudEventsType("com.example.order.created") => typeof(OrderCreated), - _ => throw new ArgumentException($"Unknown type: {message.Header.Type}") + _ => throw new InvalidMessageAction($"Unknown type: {message.Header.Type}") }, groupId: "order-processor", timeOut: TimeSpan.FromMilliseconds(100) diff --git a/contents/CloudEventsSupport.md b/contents/CloudEventsSupport.md index 309b1f9..5209c27 100644 --- a/contents/CloudEventsSupport.md +++ b/contents/CloudEventsSupport.md @@ -188,6 +188,11 @@ See [Dynamic Message Deserialization](DynamicMessageDeserialization.md) for deta **Example:** ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.Kafka; + new KafkaSubscription( new SubscriptionName("paramore.example.orders"), channelName: new ChannelName("orders"), @@ -198,10 +203,10 @@ new KafkaSubscription( => typeof(OrderCreated), var t when t == new CloudEventsType("com.example.order.updated") => typeof(OrderUpdated), - _ => throw new ArgumentException($"Unknown CloudEvents type: {message.Header.Type}") + _ => throw new InvalidMessageAction($"Unknown CloudEvents type: {message.Header.Type}") }, - // ... other config -) + groupId: "order-processor" +); ``` ## OpenTelemetry Integration diff --git a/contents/DynamicMessageDeserialization.md b/contents/DynamicMessageDeserialization.md index d65e25f..a326390 100644 --- a/contents/DynamicMessageDeserialization.md +++ b/contents/DynamicMessageDeserialization.md @@ -122,32 +122,29 @@ getRequestType: message => ### 2. Provide Comprehensive Type Mappings -Handle all expected message types and provide a clear error for unmapped types: +Handle all expected message types. Throw `InvalidMessageAction` from `Paramore.Brighter.Actions` when a message has no supported type. This explicitly requests rejection as an unacceptable message: ```csharp -// Good - Clear error message -getRequestType: message => message.Header.Type switch +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; + +// Pass this callback as the subscription's getRequestType argument. +Func getRequestType = message => message.Header.Type switch { var t when t == new CloudEventsType("com.example.task.created") => typeof(TaskCreated), var t when t == new CloudEventsType("com.example.task.updated") => typeof(TaskUpdated), - _ => throw new ArgumentException( + _ => throw new InvalidMessageAction( $"No type mapping found for CloudEvents type '{message.Header.Type}'. " + - $"Supported types: com.example.task.created, com.example.task.updated", - nameof(message) + $"Supported types: com.example.task.created, com.example.task.updated" ) -} - -// Bad - Generic error -getRequestType: message => message.Header.Type switch -{ - var t when t == new CloudEventsType("com.example.task.created") - => typeof(TaskCreated), - _ => throw new Exception("Unknown message type") -} +}; ``` +An ordinary exception such as `ArgumentException` or `Exception` does not request rejection. If it escapes `getRequestType`, the dispatcher logs it, increments the unacceptable-message count, and acknowledges the message. + ### 3. Use Meaningful CloudEvents Types Follow reverse-DNS naming for CloudEvents types: @@ -222,6 +219,12 @@ var subscription = new KafkaSubscription( Handle unmapped message types gracefully: ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.Kafka; +using Microsoft.Extensions.Logging; + var subscription = new KafkaSubscription( new SubscriptionName("paramore.example.tasks"), channelName: new ChannelName("task.events"), @@ -236,10 +239,9 @@ var subscription = new KafkaSubscription( => typeof(TaskCreated), var t when t == new CloudEventsType("io.goparamore.task.updated") => typeof(TaskUpdated), - _ => throw new ArgumentException( + _ => throw new InvalidMessageAction( $"Unmapped CloudEvents type: {message.Header.Type}. " + - $"Message ID: {message.Id}", - nameof(message) + $"Message ID: {message.Id}" ) }; } @@ -253,11 +255,13 @@ var subscription = new KafkaSubscription( throw; } }, - // ... other config + groupId: "task-processor" ); ``` -Failed messages will go to the dead letter queue based on your failure handling configuration. +`InvalidMessageAction` rejects the message as `Unacceptable`, using its message as the rejection description. The consumer and subscription configuration determine the destination: an invalid-message channel or dead-letter queue where supported and configured. A rejected message may be discarded if neither is available; rejection alone does not guarantee storage in a DLQ. See [Invalid Message Handling](/contents/HandlerFailure.md#invalid-message-handling-invalidmessageaction). + +Rejection increments the unacceptable-message count. Consumption continues unless the configured `UnacceptableMessageLimit` is reached. Ordinary callback exceptions still follow the log-and-acknowledge behavior described above. ## Further Reading diff --git a/contents/FAQ.md b/contents/FAQ.md index 5ff8cd6..665e4c9 100644 --- a/contents/FAQ.md +++ b/contents/FAQ.md @@ -252,6 +252,11 @@ See: [Outbox Support](/contents/BrighterOutboxSupport.md) Yes! Use **Dynamic Deserialization** with a `getRequestType` callback: ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.Kafka; + new KafkaSubscription( new SubscriptionName("task.updates"), channelName: new ChannelName("task.state"), @@ -262,9 +267,9 @@ new KafkaSubscription( => typeof(TaskCreated), var m when m.Header.Type == new CloudEventsType("io.goparamore.task.updated") => typeof(TaskUpdated), - _ => throw new ArgumentException($"Unknown message type: {message.Header.Type}") + _ => throw new InvalidMessageAction($"Unknown message type: {message.Header.Type}") } -) +); ``` **However**, the **DataType Channel** pattern (one type per channel) is simpler and recommended for most scenarios. diff --git a/contents/RoutingMultipleMessageTypes.md b/contents/RoutingMultipleMessageTypes.md index 17373e6..4f150c1 100644 --- a/contents/RoutingMultipleMessageTypes.md +++ b/contents/RoutingMultipleMessageTypes.md @@ -16,6 +16,12 @@ The most common approach for dynamic deserialization is using the **CloudEvents ### CloudEvents Type Routing Example ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.Kafka; +using Confluent.Kafka; + // ... var subscription = new KafkaSubscription( new SubscriptionName("paramore.example.taskstate"), @@ -29,9 +35,8 @@ var subscription = new KafkaSubscription( => typeof(TaskUpdated), var t when t == new CloudEventsType("io.goparamore.task.completed") => typeof(TaskCompleted), - _ => throw new ArgumentException( - $"No type mapping found for message with CloudEvents type {message.Header.Type}", - nameof(message) + _ => throw new InvalidMessageAction( + $"No type mapping found for message with CloudEvents type {message.Header.Type}" ) }, groupId: "kafka-TaskProcessor-Sample", @@ -51,6 +56,8 @@ var subscription = new KafkaSubscription( 4. Brighter deserializes message to correct Request type 5. Routes to appropriate handler based on type +For an unsupported type, throw `InvalidMessageAction` to request rejection as `Unacceptable`. The rejection destination depends on the transport and configuration; see [Dynamic Deserialization Error Handling](/contents/DynamicMessageDeserialization.md#dynamic-deserialization-error-handling). An ordinary `ArgumentException` is logged and acknowledged instead. + ### Setting CloudEvents Type on Publication On the producer side, set the CloudEvents `type` in your Publication: @@ -101,6 +108,11 @@ While CloudEvents type is recommended, you can implement any routing strategy by ### Routing by Custom Header ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.RMQ.Async; + // ... var subscription = new RmqSubscription( new SubscriptionName("paramore.example.orders"), @@ -116,11 +128,11 @@ var subscription = new RmqSubscription( "Create" => typeof(CreateOrder), "Update" => typeof(UpdateOrder), "Cancel" => typeof(CancelOrder), - _ => throw new ArgumentException($"Unknown order type: {orderType}") + _ => throw new InvalidMessageAction($"Unknown order type: {orderType}") }; } - throw new ArgumentException("OrderType header not found"); + throw new InvalidMessageAction("OrderType header not found"); }, timeOut: TimeSpan.FromMilliseconds(100) ); @@ -129,6 +141,12 @@ var subscription = new RmqSubscription( ### Routing by Message Body Content ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.AzureServiceBus; +using System.Text.Json; + // ... var subscription = new AzureServiceBusSubscription( new SubscriptionName("paramore.example.events"), @@ -136,22 +154,31 @@ var subscription = new AzureServiceBusSubscription( routingKey: new RoutingKey("events"), getRequestType: message => { - // Parse JSON to determine type - using var doc = JsonDocument.Parse(message.Body.Value); - var root = doc.RootElement; - - if (root.TryGetProperty("eventType", out var eventType)) + try { + using var doc = JsonDocument.Parse(message.Body.Value); + var root = doc.RootElement; + + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("eventType", out var eventType) || + eventType.ValueKind != JsonValueKind.String) + { + throw new InvalidMessageAction( + "Message body must be an object with a string eventType property"); + } + return eventType.GetString() switch { "UserCreated" => typeof(UserCreated), "UserUpdated" => typeof(UserUpdated), "UserDeleted" => typeof(UserDeleted), - _ => throw new ArgumentException($"Unknown event type: {eventType}") + _ => throw new InvalidMessageAction($"Unknown event type: {eventType}") }; } - - throw new ArgumentException("eventType property not found in message body"); + catch (JsonException ex) + { + throw new InvalidMessageAction("Message body is not valid JSON", ex); + } }, timeOut: TimeSpan.FromMilliseconds(100) ); @@ -192,6 +219,13 @@ With dynamic deserialization: Dynamic message deserialization can be combined with [Agreement Dispatcher](AgreementDispatcher.md) for even more flexible routing: ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.Kafka; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; + // First: Resolve message type dynamically var subscription = new KafkaSubscription( new SubscriptionName("paramore.example.orders"), @@ -201,9 +235,9 @@ var subscription = new KafkaSubscription( { var t when t == new CloudEventsType("com.example.order.created") => typeof(OrderCreated), - _ => throw new ArgumentException($"Unknown type: {message.Header.Type}") + _ => throw new InvalidMessageAction($"Unknown type: {message.Header.Type}") }, - // ... other config + groupId: "order-processor" ); // Second: Dynamically choose handler based on content @@ -240,6 +274,14 @@ This provides two levels of routing: ### Kafka with CloudEvents Routing ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.Kafka; +using Confluent.Kafka; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; +using Paramore.Brighter.Extensions.DependencyInjection; + // ... var subscription = new KafkaSubscription( new SubscriptionName("paramore.example.inventory"), @@ -253,9 +295,8 @@ var subscription = new KafkaSubscription( => typeof(ItemRemoved), var t when t == new CloudEventsType("com.example.inventory.stockadjusted") => typeof(StockAdjusted), - _ => throw new ArgumentException( - $"Unmapped CloudEvents type: {message.Header.Type}", - nameof(message) + _ => throw new InvalidMessageAction( + $"Unmapped CloudEvents type: {message.Header.Type}" ) }, groupId: "inventory-processor", @@ -280,6 +321,11 @@ services.AddConsumers(options => ### RabbitMQ with CloudEvents Routing ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.RMQ.Async; + // ... var subscription = new RmqSubscription( new SubscriptionName("paramore.example.notifications"), @@ -293,9 +339,8 @@ var subscription = new RmqSubscription( => typeof(SmsSent), var t when t == new CloudEventsType("com.example.push.sent") => typeof(PushNotificationSent), - _ => throw new ArgumentException( - $"Unknown notification type: {message.Header.Type}", - nameof(message) + _ => throw new InvalidMessageAction( + $"Unknown notification type: {message.Header.Type}" ) }, timeOut: TimeSpan.FromMilliseconds(100), @@ -306,10 +351,16 @@ var subscription = new RmqSubscription( ### AWS SQS with CloudEvents Routing ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.AWSSQS; + // ... var subscription = new SqsSubscription( new SubscriptionName("paramore.example.orders"), channelName: new ChannelName("orders"), + channelType: ChannelType.PubSub, routingKey: new RoutingKey("orders"), getRequestType: message => message.Header.Type switch { @@ -319,14 +370,13 @@ var subscription = new SqsSubscription( => typeof(OrderShipped), var t when t == new CloudEventsType("com.example.order.delivered") => typeof(OrderDelivered), - _ => throw new ArgumentException( - $"Unrecognized order event: {message.Header.Type}", - nameof(message) + _ => throw new InvalidMessageAction( + $"Unrecognized order event: {message.Header.Type}" ) }, bufferSize: 10, timeOut: TimeSpan.FromMilliseconds(100), - lockTimeout: TimeSpan.FromSeconds(30) + queueAttributes: new SqsAttributes(lockTimeout: TimeSpan.FromSeconds(30)) ); ``` diff --git a/contents/V10MigrationGuide.md b/contents/V10MigrationGuide.md index 839cb2d..ce3017b 100644 --- a/contents/V10MigrationGuide.md +++ b/contents/V10MigrationGuide.md @@ -647,6 +647,11 @@ V10 supports multiple message types on the same channel. **Example**: ```csharp +using System; +using Paramore.Brighter; +using Paramore.Brighter.Actions; +using Paramore.Brighter.MessagingGateway.Kafka; + new KafkaSubscription( new SubscriptionName("task-state-subscription"), channelName: new ChannelName("task.state"), @@ -659,9 +664,8 @@ new KafkaSubscription( => typeof(TaskUpdated), var m when m.Header.Type == new CloudEventsType("io.paramore.task.deleted") => typeof(TaskDeleted), - _ => throw new ArgumentException( - $"No type mapping found for message with type {message.Header.Type}", - nameof(message)) + _ => throw new InvalidMessageAction( + $"No type mapping found for message with type {message.Header.Type}") }, groupId: "task-consumer-group", messagePumpType: MessagePumpType.Proactor From dfbcd383cefefd421e9a0866f2edaff9a2f590b5 Mon Sep 17 00:00:00 2001 From: Avtandil Ushikishvili Date: Fri, 2 Oct 2026 21:54:52 +0400 Subject: [PATCH 2/2] docs: record unmapped-type diagnosis and verification --- .../.confirm-approved | 0 .../0001-unmapped-type-guidance/.issue-number | 1 + .../0001-unmapped-type-guidance/bugfix.md | 80 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 bugfixes/0001-unmapped-type-guidance/.confirm-approved create mode 100644 bugfixes/0001-unmapped-type-guidance/.issue-number create mode 100644 bugfixes/0001-unmapped-type-guidance/bugfix.md diff --git a/bugfixes/0001-unmapped-type-guidance/.confirm-approved b/bugfixes/0001-unmapped-type-guidance/.confirm-approved new file mode 100644 index 0000000..e69de29 diff --git a/bugfixes/0001-unmapped-type-guidance/.issue-number b/bugfixes/0001-unmapped-type-guidance/.issue-number new file mode 100644 index 0000000..96ca988 --- /dev/null +++ b/bugfixes/0001-unmapped-type-guidance/.issue-number @@ -0,0 +1 @@ +4499 diff --git a/bugfixes/0001-unmapped-type-guidance/bugfix.md b/bugfixes/0001-unmapped-type-guidance/bugfix.md new file mode 100644 index 0000000..ee2e066 --- /dev/null +++ b/bugfixes/0001-unmapped-type-guidance/bugfix.md @@ -0,0 +1,80 @@ +# Bugfix: Reject unmapped request types in documentation examples + +**Linked Issue**: [BrighterCommand/Brighter#4499](https://github.com/BrighterCommand/Brighter/issues/4499) +**Status**: Verified — documentation-only correction +**Source baseline**: Brighter `7e897fdf2`; Docs `960ce6d`. + +## Symptom + +The documentation recommends throwing ArgumentException from getRequestType for an unknown message type, but the pumps acknowledge that message instead of rejecting it. The reported Service Bus emulator reproduction sends mapped, unknown, mapped messages; both mapped messages dispatch, while the unknown message disappears without reaching the DLQ. Returning null or throwing InvalidMessageAction rejects the unknown message instead. + +## Suspected Location + +Paths below are relative to the named repository. + +- Brighter: `src/Paramore.Brighter.ServiceActivator/Reactor.cs:552` and `Proactor.cs:559` invoke the type callback before the translation try. Null results become MessageMappingException at lines 554 and 561. +- Brighter: Reactor lines 347–374 and Proactor lines 379–406 distinguish InvalidMessageAction and MessageMappingException (reject and continue) from ordinary exceptions (log/count, then acknowledge). +- Brighter: `docs/adr/0061-reject_mapping_errors.md:32` and `:53` preserve deliberate catch-all acknowledgement. +- Docs: `contents/DynamicMessageDeserialization.md:135` and `:239` recommend ArgumentException; line 260 promises DLQ routing. +- Docs: `contents/RoutingMultipleMessageTypes.md:32`, `:119`, `:123`, `:150`, `:154`, `:204`, `:256`, `:296`, `:322` repeat the same callback guidance. +- Docs: duplicate callbacks in `contents/AgreementDispatcher.md:131`, `contents/CloudEventsSupport.md:201`, `contents/FAQ.md:265`, and `contents/V10MigrationGuide.md:662`. +- Docs: body routing at `contents/RoutingMultipleMessageTypes.md:140–145` can also fail on malformed JSON, a non-object root, or a non-string discriminator before reaching an explicit fallback. + +## Root-Cause Hypothesis + +**UNVERIFIED — to be proven or refuted in /bugfix:confirm:** examples use the wrong exception to request rejection. ArgumentException from the callback escapes the translation wrapper and reaches the deliberate acknowledge catch-all. InvalidMessageAction already requests rejection as Unacceptable in both pumps. + +The issue's code-wrapper suggestion is **UNVERIFIED**: wrapping callback exceptions could change their classification but must preserve deliberate actions and the general exception contract. The maintainer's documentation-only suggestion is **UNVERIFIED**: recommending InvalidMessageAction should express the rejection intent without changing runtime behavior. Correcting explicit throws alone may leave the body-parsing example's malformed-input cases unaddressed. + +## Confirmed Root Cause + +The documented callback throws an ordinary exception while promising rejection. Both pumps invoke getRequestType outside the translation exception wrapper. ArgumentException reaches the deliberate log/count/acknowledge path. InvalidMessageAction already rejects as Unacceptable and preserves the exception message as the reason. This is a documentation defect; the source trace does not justify changing every callback exception into a mapping failure. + +The maintainer explicitly favors this documentation correction: https://github.com/BrighterCommand/Brighter/issues/4499#issuecomment-5957512682 + +## Evidence + +Independent confirmation traced the callback through the catch chain in both pumps at the locations above. Reactor lines 351 and 430–432 preserve the reason and delegate rejection; Proactor line 383 does the equivalent. InMemoryMessageConsumer lines 236–243 use an invalid-message destination, fall back to a dead-letter destination, or discard when neither is configured. AzureServiceBusConsumer line 318 uses native dead-lettering. Therefore, an unconditional DLQ guarantee is incorrect across transports. + +Released-package verification completed: all 13 changed examples compiled against the Docs reference set with Brighter 10.7.0; their extracted callbacks passed 162 in-memory pump runs across Reactor and Proactor. Controls covered the prior ArgumentException behavior, null results, supported types, invalid/dead-letter/no destination configurations, and invalid body/header inputs. + +Triage found existing tests for mapper-thrown InvalidMessageAction, mapping errors, and deliberate catch-all acknowledgement. Their type callbacks return a valid type; no dedicated dynamic callback rejection test was found. A behavioral validation should compare ArgumentException, InvalidMessageAction, null, and valid resolution through both pumps, with a valid message following the invalid one. + +## Scope Notes + +Documentation-only changes in six BrighterCommand/Docs pages. Examples import Paramore.Brighter.Actions and request rejection with InvalidMessageAction. Body-based routing classifies malformed JSON and missing/non-string discriminators explicitly. Rejection destination guidance depends on transport configuration. Brighter runtime source remains unchanged. + +## Regression Test + +Completed against released Brighter 10.7.0, matching the Docs compile-gate package pins and the reported issue: + +1. Execute both real pumps with mapped, unknown, mapped messages and an unlimited unacceptable-message threshold. Compare the documented ArgumentException callback with InvalidMessageAction, null-result, and valid-resolution controls. Verify settlement, handler delivery, rejection reason, and continued consumption. +2. For InvalidMessageAction, verify configured invalid-message routing, dead-letter-only routing, and the no-destination case using in-memory transport configuration. +3. Exercise the body-routing example with malformed JSON, non-object root, non-string discriminator, missing discriminator, unknown string, and known string. +4. Compile changed examples against released packages; run the Docs page, link, symbol, and compilation checks applicable to the changes. Preserve existing gate baselines and report any unrelated pre-existing failures. + +Existing source tests provide supplementary evidence; new production behavior is not proposed. + +## Suggested-Fix Assessment + +- Documentation-only correction: CONFIRMED by source trace, maintainer intent, and released-package validation. +- Changing explicit throws alone: PARTIAL; malformed JSON and invalid discriminator shapes can throw before the fallback. +- Wrapping arbitrary callback exceptions in the pumps: would alter runtime policy and is outside the selected documentation scope. + +## Fix + +Updated 13 examples across the six listed pages to throw InvalidMessageAction for unsupported/missing types. Added the Actions imports, classified malformed body input, and replaced the unconditional DLQ promise with configuration-dependent guidance linked to Handler Failure. Unrelated ArgumentException descriptions remain unchanged. + +Compilation also required small repairs within touched examples: terminating statements, completing Kafka argument lists, importing RMQ.Async, and expressing SQS visibility settings through SqsAttributes with the required channel type. These repairs preserve the examples' intended behavior. + +## Validation + +- Thirteen changed examples compile against the released package reference set. +- Their exact callbacks pass 162 real Reactor/Proactor runs with in-memory transport, including the old ArgumentException control and continued processing of valid messages before/after the invalid message. +- Page lint: zero errors; 525 existing using-directive warnings, down from 537. +- Link check: zero broken links. +- Symbol check: zero findings. +- Repository compilation gate: zero findings, all 295 required blocks preserved, 17 existing skips; no baseline exemptions added. +- `git diff --check`: clean. + +The validation harness is local scratch work at `/private/tmp/brighter-4499-verification`, outside this change. Evidence logs are `/tmp/brighter-4499-runtime.log`, `/tmp/brighter-4499-pagelint.log`, `/tmp/brighter-4499-links.log`, `/tmp/brighter-4499-symbols.log`, and `/tmp/brighter-4499-blocks.log`. No broker integration claim is made; the fix changes documentation only.