# Cached Call Tracking — Design

**Date**: 2026-05-19
**Status**: Approved
**Topic**: Trackable IDs for cached external system calls and cached database writes

## Problem

`ExternalSystem.CachedCall()` and `Database.CachedWrite()` are fire-and-forget: a
script gets no handle back, cannot confirm delivery, and an operator cannot tie a
parked S&F message to a known business operation. `Notify.Send()` already returns a
trackable `NotificationId`. The goal is to give cached external/database calls the
same first-class traceability, under a tracking model unified across all three
store-and-forward producers.

## Decision

Add a trackable ID to cached calls via **Approach B — a sibling central component
(`Site Call Audit`) plus shared tracking contracts in Commons**. The Notification
Outbox is left unchanged; unification lives in shared types and a consistent script
API, not in a merged table or component.

### Why a sibling, not a merged component

Delivery locality is the decisive constraint:

- **Notifications** are *central-delivered*: sites store-and-forward them to the
  central cluster, which delivers via SMTP. The `NotificationOutboxActor` runs a
  dispatcher loop. Central becomes the source of truth after handoff.
- **Cached calls / DB writes** are *site-delivered*: the external system or database
  often sits on the site's own network and is unreachable from central. The site's
  S&F Engine must always own delivery, and the **site remains the source of truth**
  for status. Central audit is an eventually-consistent mirror.

Merging both into one component (Approach A) would put a dispatcher loop that is live
for some rows and dormant for others into a single component, hiding a real
architectural difference. Approach B expresses the difference honestly while still
giving scripts a unified ID model and `Status()` API.

## Unified tracking model

### `TrackedOperationId`

A GUID, defined in Commons, generated **caller-side at the site at call time**. It is
both the tracking handle returned to the script and the idempotency key for telemetry
sent to central. `Notify.Send()`'s existing `NotificationId` is the notification-domain
name for this same type — no behavior change for notifications.

### Script API

| Call | Returns |
|---|---|
| `ExternalSystem.CachedCall(system, method, params)` | `TrackedOperationId` |
| `Database.CachedWrite(name, sql, params)` | `TrackedOperationId` |
| `Notify.Send(...)` | `TrackedOperationId` (unchanged) |
| `Tracking.Status(id)` | unified status record (status, retry count, last error, key timestamps) |

`Tracking.Status(id)` is the unified accessor. `Notify.Status(id)` is retained as a
thin alias for backward compatibility.

### Status lifecycle

`Pending → Retrying → Delivered / Parked / Failed / Discarded`

- **Delivered** — succeeded. A cached call that succeeds on its first immediate
  attempt goes straight here and never enters the S&F buffer.
- **Parked** — transient retries exhausted; awaiting manual action.
- **Failed** — permanent failure (e.g. HTTP 4xx). The error is *also* returned
  synchronously to the calling script, exactly as today; the record captures it.
  This is the one state beyond the notification lifecycle.
- **Discarded** — operator discarded a parked operation.

There is no `Forwarding` state for cached calls — that exists only because
notifications hand off to central. For cached calls, `Tracking.Status(id)` is always
answered site-locally and authoritatively.

## Site-side architecture

### Site-local operation tracking table

A new SQLite table alongside the existing S&F buffer DB. One row per
`TrackedOperationId`, created the moment the script issues the cached call,
regardless of outcome:

- Fields: kind, target summary (system+method, or DB name), status, retry count,
  last error, created/updated/terminal timestamps, source provenance
  (instance/script).
- This table is the **status record**. The S&F buffer remains purely the **retry
  mechanism**; a buffered message references its `TrackedOperationId`.
- Immediate success writes a terminal `Delivered` row directly here, with nothing
  placed in the S&F buffer.
- `Tracking.Status(id)` reads this table — local, authoritative, available even when
  central is unreachable.
- Retention: terminal rows purged after a configurable window (default 7 days; the
  site holds live operational state, central holds long-term audit).

### Telemetry to central

On every lifecycle transition (`Created → Retrying → Delivered/Parked/Failed/
Discarded`) the site emits a telemetry event over the existing site→central channel:
`TrackedOperationId`, kind, summary, status, retry count, last error, timestamps,
source site. Best-effort, at-least-once, idempotent on the ID.

### Reconciliation

Because telemetry is best-effort, the central side periodically (and on reconnect)
pulls "all tracking rows changed since cursor X" per site. Missed telemetry
self-heals. The site never depends on central; central converges to the site.

### Carried-over rules (unchanged)

- Tracking rows, like buffered messages, are not cleared on instance deletion.
- Cached-call idempotency remains the caller's responsibility — a retry can still
  double-deliver.

## Central — Site Call Audit component (new component #22)

### `SiteCalls` table (central MS SQL)

Sibling of the `Notifications` table. One row per `TrackedOperationId`: source site,
kind, target summary, status, retry count, last error, created/updated/terminal
timestamps. Fed only by site telemetry and reconciliation pulls.

Ingestion is **insert-if-not-exists**, then **upsert-on-newer-status**. The lifecycle
is monotonic, so status only advances, never regresses — making at-least-once and
out-of-order telemetry harmless. Daily purge of terminal rows after a configurable
window (default 365 days, mirroring `Notifications`).

### `SiteCallAuditActor`

Singleton on the active central node. Ingests telemetry, runs the periodic
reconciliation pulls, computes KPIs, and relays Retry/Discard commands to sites.

It is **not a dispatcher** — the crucial difference from `NotificationOutboxActor`.
Central has no path to a site's external systems or databases; this component is an
audit sink, a query surface, and a command relay only.

### KPIs

Point-in-time from the `SiteCalls` table, global and per-site, mirroring the
Notification Outbox KPI shape: buffered count (`Pending`+`Retrying`), parked count,
failed-last-interval, delivered-last-interval, oldest-pending age, and stuck count
(`Pending`/`Retrying` older than a configurable threshold, default 10 minutes —
display-only, no alerting).

## Central→site command path (Retry / Discard)

Parked operations live in the site's S&F buffer, so Retry/Discard from the Central UI
must travel down to the owning site:

- New ClusterClient command/control messages, central→site:
  `RetryParkedOperation(TrackedOperationId)` and
  `DiscardParkedOperation(TrackedOperationId)`, riding the existing per-site
  ClusterClient.
- The site applies the command to its S&F buffer / tracking table, then emits normal
  telemetry reflecting the new state (`Retrying`, or `Discarded`).
- Central never directly mutates the `SiteCalls` row. It sends the command and lets
  the resulting telemetry update the audit row — the site stays the single source of
  truth.
- If the site is offline, the command fails fast and the UI surfaces a
  "site unreachable" message.

## Central UI

New page — **Site Calls** — in the same nav group as the Notification Outbox page:

- Covers cached calls only: `ExternalCall` + `DatabaseWrite`. Notifications keep their
  existing dedicated Notification Outbox page.
- Queryable list filtered by site, kind, status, and time range. Columns: timestamp,
  site, kind, target summary, status badge, retry count, last error.
- Retry / Discard actions on `Parked` rows, issuing the central→site commands above.
- Headline KPI tiles on the Health dashboard alongside the existing Notification
  Outbox tiles. Stuck rows get a display-only badge — no escalation.
- Custom Blazor Server + Bootstrap components, consistent with the rest of the
  Central UI.

## Error handling & edge cases

- **Telemetry loss** — reconciliation pull self-heals; central is explicitly
  eventually-consistent.
- **Out-of-order / duplicate telemetry** — monotonic-status upsert keyed on
  `TrackedOperationId` makes both harmless.
- **Permanent failure on a cached call** — error returned synchronously to the script
  (unchanged) and recorded as terminal `Failed`.
- **Site offline during Retry/Discard** — command fails fast; UI says so; the audit
  row is unchanged until confirming telemetry arrives.
- **Cached-call double-delivery** — still the caller's responsibility; the idempotency
  note stays in the ESG doc.
- **Instance deletion** — tracking rows and buffered messages survive, per the
  existing S&F rule.

## Affected documents

- **New**: `docs/requirements/Component-SiteCallAudit.md`
- `Component-ExternalSystemGateway.md` — `CachedCall`/`CachedWrite` return
  `TrackedOperationId`; `Failed` state; `Tracking.Status`.
- `Component-StoreAndForward.md` — site-local tracking table, telemetry emission,
  reconciliation, `TrackedOperationId` on buffer entries.
- `Component-SiteRuntime.md` — Script Runtime API: return types and
  `Tracking.Status(id)`.
- `Component-Communication.md` — telemetry channel and
  `RetryParkedOperation`/`DiscardParkedOperation` commands.
- `Component-Commons.md` — `TrackedOperationId`, unified status enum, telemetry
  message contracts.
- `Component-ConfigurationDatabase.md` — `SiteCalls` table, EF mapping, migration.
- `Component-CentralUI.md` — new Site Calls page.
- `Component-HealthMonitoring.md` — KPI tiles on the dashboard.
- `Component-NotificationService.md` / `Component-NotificationOutbox.md` — note the
  shared `TrackedOperationId` model and `Notify.Status` alias.
- `README.md` — component table updated to 22 components.
- `CLAUDE.md` — component list and Key Design Decisions.

## Out of scope

- A CLI surface for site-local Retry/Discard (can be added later if needed).
- Merging notifications into the Site Calls page or a unified outbox component.
- Routing cached-call delivery through central.
