---
title: Business rules
summary: Catalog of business rules enforced by this library.
status: draft
owner: SAIF Platform Team
last_reviewed: 2026-10-02
library: SAIF.NetCore.PingableService
reference_type: package
---

# Business rules

15 business rules govern ping request composition, concurrency, failure classification, registration, and host-wiring prerequisites.

## BR-01 Anonymous access to ping endpoint

| Field | Value |
| --- | --- |
| Statement | The ping endpoint is reachable without authentication or authorization. |
| Trigger | `GET /Ping` |
| Enforcement | `PingableController.Get`, `[AllowAnonymous]` |
| Outcome | Global authorization policies and fallback policies do not apply to `/Ping`. |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs` |

No in-repo test exercises `/Ping` against an active authorization requirement; the test hosts named `SecureStartup` and `SecureStartupWithScopedAndSingleton` configure a secure *configuration client*, not authorization, so they do not corroborate that `[AllowAnonymous]` overrides an active authorization requirement. [BR-01](#br-01-anonymous-access-to-ping-endpoint) remains verified directly from the `[AllowAnonymous]` attribute itself.

## BR-02 Run id defaults to a generated GUID

| Field | Value |
| --- | --- |
| Statement | If the caller supplies no run id, a new GUID string is generated and used for every hook, error message, and response. |
| Trigger | `guid == null` |
| Enforcement | `PingableController.Get` |
| Outcome | The run id is always present in the 200 body or the 500 message. |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs` |

## BR-03 Ping set defined by DI registration and MVC application parts

| Field | Value |
| --- | --- |
| Statement | The ping set is (a) every DI registration resolvable under the `IPingableService` service type — whether made via an `AddPingable*` convenience extension or an equivalent direct registration such as `services.AddScoped<IPingableService, T>()` — plus (b) every type in the snapshotted MVC application parts that is both an `IPingableService` and a `ControllerBase`. |
| Trigger | Each ping request |
| Enforcement | `PingableController.Get`; `ControllerRetriever.GetPingableControllerTypes` |
| Outcome | A service that implements `IPingableService` but is registered only under a *different* service type (for example `services.AddScoped<IMyService, T>()` with no registration under `IPingableService` itself) is **not** resolved by `GetServices<IPingableService>()` and so is **not** pinged, regardless of whether an `AddPingable*` extension was used. |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md), [OP-04](operations/op-04-enable-route-and-discover-controllers.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs`, `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Services/ControllerRetriever.cs` |

## BR-04 Concurrent ping execution via Task.WhenAll

| Field | Value |
| --- | --- |
| Statement | All targets are pinged concurrently via `Task.WhenAll`. The request succeeds only if every awaited ping task completes successfully; any awaited task that faults fails the whole request. |
| Trigger | Each ping request |
| Enforcement | `PingableController.Get`; `PingableController.RunAllPings` |
| Outcome | When one or more awaited `PingAsync` tasks fault, `RunAllPings` flattens them into one `PingServiceException` via `aex.Flatten()` ([BR-10](#br-10-ping-failure-returns-500-with-run-id-and-message)). A synchronous failure during target resolution or during `Task.WhenAll(tasks)`'s own enumeration of the targets (rather than a faulted *awaited* task) is not part of this aggregation — it is caught and wrapped individually by `Get`'s own `catch (Exception ex)` ([BR-05](#br-05-resolution-and-construction-failures-count-as-ping-failures)); see [OP-01](operations/op-01-ping-the-api.md) step 6 and [OP-05](operations/op-05-raise-ping-failure.md). |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs` |

## BR-05 Resolution and construction failures count as ping failures

| Field | Value |
| --- | --- |
| Statement | A failure to construct or resolve any pingable target (constructor exception, missing dependency), or a target that throws synchronously before returning a `Task`, is a ping failure. |
| Trigger | Resolution in `GetServices`/`GetService`, or synchronous enumeration of the `PingAsync` projection |
| Enforcement | `PingableController.Get` (`catch (Exception ex)`) |
| Outcome | 500 `PingServiceException` wrapping the resolution or synchronous exception individually — distinct from the `Task.WhenAll` aggregation path in [BR-04](#br-04-concurrent-ping-execution-via-task.whenall). |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs` |

## BR-06 PingServiceException is not wrapped twice

| Field | Value |
| --- | --- |
| Statement | A `PingServiceException` that reaches `PingableController.Get`'s `try`/`catch` directly — the one `RunAllPings` constructs from a faulted `Task.WhenAll` aggregation, or one thrown synchronously and already of that type — is not wrapped again. |
| Trigger | `PingServiceException` reaching `Get`'s `catch (PingServiceException p) { throw; }` block |
| Enforcement | `PingableController.Get` |
| Outcome | The original ping exception (with the flattened aggregate, if any) propagates unchanged. This does **not** apply to a `PingServiceException` thrown by a nested `IPingableService`/pingable-controller implementation that faults as part of the awaited batch — `Task.WhenAll` aggregates that into an `AggregateException`, and `RunAllPings` wraps the aggregate into a *new* outer `PingServiceException`. |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs` |

## BR-07 Ping exceptions are failures unless vetoed

| Field | Value |
| --- | --- |
| Statement | By default, any exception thrown by `Ping` is a failure. Implementers can veto it by returning `false` from `IsThrowablePingError`. When it is a failure, `OnPingFailure` runs and, provided `OnPingFailure` completes without itself throwing, the original exception is rethrown unchanged; if `OnPingFailure` (or `IsThrowablePingError`) itself throws, that new exception propagates instead and the original exception is not rethrown. |
| Trigger | Exception in `Ping` |
| Enforcement | `IPingableService.PingAsync`; `IPingableService.IsThrowablePingError` (default `true`) |
| Outcome | The task faults, which leads to [BR-04](#br-04-concurrent-ping-execution-via-task.whenall). |
| Enforcing operation(s) | [OP-02](operations/op-02-default-ping-pipeline.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/IPingableService.cs` |

## BR-08 Non-throwable ping errors count as success

| Field | Value |
| --- | --- |
| Statement | An exception that `IsThrowablePingError` classifies as non-throwable is swallowed, and, provided `OnPingSuccess` completes without itself throwing, the ping counts as **successful**. `OnPingSuccess` is invoked; if `OnPingSuccess` itself throws, the task faults with that new exception despite the original `Ping` exception having been swallowed. |
| Trigger | `IsThrowablePingError(ex)` returns `false` |
| Enforcement | `IPingableService.PingAsync` (fall-through) |
| Outcome | `OnPingSuccess` runs even though `Ping` threw. |
| Enforcing operation(s) | [OP-02](operations/op-02-default-ping-pipeline.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/IPingableService.cs` |

## BR-09 Successful run returns 200 with run id

| Field | Value |
| --- | --- |
| Statement | A successful run returns HTTP 200 with the run id as the body. Each target's `OnPingSuccess` runs after its `Ping`. |
| Trigger | All pings complete without a throwable error |
| Enforcement | `PingableController.Get`; `IPingableService.PingAsync` |
| Outcome | `Ok(guid)` |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md), [OP-02](operations/op-02-default-ping-pipeline.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs`, `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/IPingableService.cs` |

## BR-10 Ping failure returns 500 with run id and message

| Field | Value |
| --- | --- |
| Statement | A ping failure is reported with HTTP status 500 and the message `"Ping error: {guid}\n{message}"`, with the original exception (or the flattened `AggregateException`) as the inner exception. |
| Trigger | Any failure path |
| Enforcement | `PingServiceException` constructor |
| Outcome | `StatusCode = 500`, `ContentType = "text/plain"` (inherited default from `SAIF.NetCore.ServiceContracts` `HttpStatusCodeException`). |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md), [OP-05](operations/op-05-raise-ping-failure.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Exceptions/PingServiceException.cs` |

## BR-11 Pingable registration requires TService to extend IPingableService

| Field | Value |
| --- | --- |
| Statement | A service can be registered as pingable under a given `TService` only if `TService` extends `IPingableService` and its implementation implements that service type. The `AddPingable*` extensions register it under both `TService` and `IPingableService`, both at the same lifetime (Transient, Scoped, or Singleton depending on which `AddPingable*` method is called); the `IPingableService` entry always resolves through the `TService` registration via a factory and casts the result to `TImplementation`. For Transient this yields two independent instances per resolution, not shared identity; for Scoped and Singleton the `IPingableService` entry resolves to the identical instance as `TService` within that scope or for the application's lifetime. An equivalent effect can also be achieved with direct registration calls, but only if the `IPingableService` registration reproduces this same forwarding-through-`TService` behavior (rather than merely registering the same implementation type under both services independently, which would not share identity at Scoped/Singleton lifetime and would defeat the purpose of the pattern). |
| Trigger | `AddPingable*` call, or equivalent direct dual registration that reproduces the forwarding behavior |
| Enforcement | `IServiceCollectionExtensions.AddPingableTransient/Scoped/Singleton` generic constraints and bodies |
| Outcome | Compile-time error if the `AddPingable*` constraints are violated. Two registrations on success (one under `TService`, one under `IPingableService` via the forwarding factory), sharing instance identity at Scoped/Singleton lifetime but not at Transient lifetime; see [Service registration](service-registration.md) and [OP-03](operations/op-03-register-pingable-service.md). |
| Enforcing operation(s) | [OP-03](operations/op-03-register-pingable-service.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Extensions/IServiceCollectionExtensions.cs` |

## BR-12 Logging is optional

| Field | Value |
| --- | --- |
| Statement | Logging is optional. If no `ISaifLogService` is registered, hooks receive `null`. |
| Trigger | Controller construction |
| Enforcement | `PingableController` constructor (`GetService<ISaifLogService>()`) |
| Outcome | No failure when logging is absent. |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs` |

## BR-13 WithPingableController enables route and controller discovery

| Field | Value |
| --- | --- |
| Statement | `WithPingableController()` is the supported convenience path for both enabling the `/Ping` route and registering `IControllerRetriever` for controller-based pinging. Only application parts present **when it is called** are scanned; this is a property of the application-part snapshot `WithPingableController()` reads, not of any particular convenience method used elsewhere to register `IPingableService` instances. An equivalent effect — adding `PingableController`'s assembly as an MVC application part and registering an `IControllerRetriever` implementation directly — can also satisfy these same two prerequisites without calling `WithPingableController()`. |
| Trigger | Host MVC configuration |
| Enforcement | `MvcBuilderExtensions.WithPingableController` |
| Outcome | Without `WithPingableController()` or an equivalent explicit application-part registration, `/Ping` is not routed and `IControllerRetriever` is not registered, so `PingableController` fails MVC activation. Application parts registered after the snapshot is taken are not scanned.* |
| Enforcing operation(s) | [OP-04](operations/op-04-enable-route-and-discover-controllers.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Extensions/MvcBuilderExtensions.cs` |

## BR-14 Controller discovery requires controllers registered as services

| Field | Value |
| --- | --- |
| Statement | Pingable *controllers* are resolved from DI by concrete type, so the controller-discovery channel only works for a given host when the host has registered controllers as services (`AddControllersAsServices()`, or an equivalent explicit DI registration for each pingable controller's concrete type) — this is a prerequisite for the controller-discovery channel specifically, independent of whether any `IPingableService` was registered via `AddPingable*` or direct registration. If the host has no pingable controllers to discover, this prerequisite has no effect. |
| Trigger | Each ping request, when at least one pingable controller exists |
| Enforcement | `ControllerRetriever.GetControllers` (`provider.GetService(type)`) |
| Outcome | For a host with at least one pingable controller, a missing `AddControllersAsServices()`/equivalent registration leads to a `null` entry and a `NullReferenceException` on `PingAsync`, which surfaces as a 500 (via [BR-05](#br-05-resolution-and-construction-failures-count-as-ping-failures)). |
| Enforcing operation(s) | [OP-01](operations/op-01-ping-the-api.md), [OP-04](operations/op-04-enable-route-and-discover-controllers.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Services/ControllerRetriever.cs` |

## BR-15 IPingableService resolution casts through TService registration

| Field | Value |
| --- | --- |
| Statement | The `IPingableService` registration always resolves the instance through the `TService` registration and casts it to `TImplementation`. If a later registration replaces `TService` with a different implementation type, the cast fails at ping time. For Transient and Scoped lifetimes, the pinged instance is a separate (or scope-shared) instance resolved for the ping request. |
| Trigger | Ping-time resolution |
| Enforcement | `IServiceCollectionExtensions` factory lambdas |
| Outcome | `InvalidCastException`, which surfaces as a 500 via [BR-05](#br-05-resolution-and-construction-failures-count-as-ping-failures), if `TService` is later re-registered with a different implementation.* |
| Enforcing operation(s) | [OP-03](operations/op-03-register-pingable-service.md), [OP-01](operations/op-01-ping-the-api.md) |
| Source | `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Extensions/IServiceCollectionExtensions.cs` |

## Related documentation
- [Index](index.md)
- [Overview](overview.md)
- [Architecture](architecture.md)
- [Dependencies](dependencies.md)
- [Operations and processes](operations-processes.md)
- [Security and authorization](security-authorization.md)
- [Service registration](service-registration.md)
- [API reference](api/index.md)

> **Note:** Statements marked with `*` were inferred from code structure, naming
> conventions, or contextual evidence where direct confirmation was not available
> in source artifacts.

## Sources
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Controllers/PingableController.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/IPingableService.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Exceptions/PingServiceException.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Extensions/IServiceCollectionExtensions.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Extensions/MvcBuilderExtensions.cs`
- `src/SAIF.NetCore.PingableService/SAIF.NetCore.PingableService/Services/ControllerRetriever.cs`
