diff --git a/docs/plans/2026-11-09-unified-outbox-page-decision.md b/docs/plans/2026-11-09-unified-outbox-page-decision.md new file mode 100644 index 00000000..f80a1288 --- /dev/null +++ b/docs/plans/2026-11-09-unified-outbox-page-decision.md @@ -0,0 +1,75 @@ +# Decision: unified notifications + site-calls outbox page (ScadaBridge#44) + +**Date:** 2026-11-09 · **Decision:** **recommend against** building a unified page now. Keep +the two report pages. +**Origin:** deferred-work register row 17 → Gitea #44. That row deferred M9 decision D3 +(`2026-06-18-m9-templates-authoring-design.md`), whose revisit trigger is "operator confusion +reports". + +## What exists today (verified against `main`) + +| | Notification Report `/notifications/report` | Site Calls `/site-calls/report` | +|---|---|---| +| Owner of the queue | **Central**: the notification outbox is a central table, and central delivers. | **Site**: site store-and-forward delivers. Central's `SiteCalls` table is an audit mirror. | +| Query message | `NotificationOutboxQueryRequest` | `SiteCallQueryRequest` | +| Paging | Offset (`PageNumber`/`PageSize`, `TotalCount`) | Keyset (`AfterCreatedAtUtc` + `AfterId`); no total count | +| Id | `string` NotificationId (N-form GUID text) | `Guid` TrackedOperationId | +| Status lifecycle | Pending → Retrying → Delivered / Parked / Discarded | Submitted → Forwarded → Attempted → Delivered / Parked / Failed / Discarded | +| Row provenance | list name, subject, source site **and instance** | channel (`ApiOutbound`/`DbOutbound`), target, HTTP status, source site; **no instance** column exists | +| Retry / Discard on Parked rows | Central acts on its own row. | Central **relays** the action to the owning site, which has its own `SiteUnreachable` outcome. | +| Deep link `?correlationId=` | yes (Flow Debugger, Audit Log) | yes (Flow Debugger, Audit Log) | +| Role gate | `RequireDeployment` | `RequireDeployment` | +| KPIs | Notification KPIs page plus Health-dashboard tiles | Site-call trends on the page plus Health-dashboard tiles | + +The Health dashboard already shows the Notification Outbox and Site Call KPI groups next to +each other. That is the existing "one place to see what is stuck". + +## Evaluation + +A unified page would need one row model and one pager over two sources that disagree on every +axis that matters to the grid: + +1. **Paging cannot be merged cheaply.** Interleaving an offset-paged source with a keyset-paged + source by `CreatedAt` needs a two-cursor merge with no total count. Alternatively, one of + the two repository queries would have to be reworked. Either change touches the central query + actors, not just the UI. +2. **Status filters do not line up.** The two lifecycles share only Delivered, Parked and + Discarded. A unified status dropdown either hides real states (Retrying, Forwarded, + Attempted, Failed) or shows options that match only one source. +3. **The actions have different semantics.** Retrying a notification is a local central write. + Retrying a site call is a relay that can fail because the site is unreachable. Putting both + behind one button makes the more fragile action look the same as the safe one. A confirm + dialog that has to explain this per row type is exactly the confusion the issue wants to + avoid. +4. **The columns barely overlap.** The only columns both sources share are site, status, + retry count, last error and created-at. Everything an operator needs to act on is + type-specific: list and subject for a notification, target and HTTP status for a site call. +5. **No trigger has fired.** The revisit trigger is operator confusion reports. None is + recorded: #44 carries no report, and no other ScadaBridge issue mentions the outbox or + site-call pages (searched 2026-11-09). Both pages are + discoverable from the nav rail, from the Health tiles and from Flow Debugger / Audit Log + deep links. +6. **Cost.** The existing pages are about 860 and 420 lines plus their bUnit suites + (`NotificationReportPageTests`, `SiteCallsReportPageTests`, detail-modal and drill-in + tests). A third page would duplicate filters, a detail modal and the action flows for both + types. Or it would replace the two pages and require reworking their tests and deep links. + That is not "small and clearly beneficial", the bar #44 set for implementing it. + +## Recommendation + +**Do not build the unified page.** M9's reasoning still holds on today's code, and the trigger +for revisiting it has not fired. + +If operators do report confusion, start with these cheaper options before any union view: + +- **Cross-links:** a "Site Calls →" link on the Notification Report header and the reverse. + This is a one-line change per page. +- **One "Stuck items" summary card** on the Health dashboard. It would combine the two + existing KPI snapshots, which are already loaded there, into a single "N stuck: X + notifications, Y site calls" card that deep-links to each page with the stuck filter + pre-applied. Site Calls already accepts `?stuck=true`. The Notification Report reads only + `?correlationId=` today, so it would need the same small `?stuck=` seed. The card reuses + the existing queries and needs no new paging model. + +Revisit a true union page only if both of these prove insufficient. At that point the +precondition is a shared keyset cursor on both repositories (point 1), designed first.