> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Admin Attention Engine

> Deterministic correlators over audit logs and open items find what changed and is still unfinished; Jev ranks it; admins give feedback. Computed on read.

The attention engine answers one question for an org owner or admin: **what
changed on an upcoming gig that nobody has finished dealing with?** It is the
join of two things the platform already has:

* **Audit logs** say what changed (`audit_log`, EXPLICIT rows).
* **Open items** say what is not finished (`db/openItems.ts`, crew offers,
  checklist tasks, ledger items).

Deterministic correlators find the overlap. [Jev](https://docs.soundchecklive.io/engineering/backend/imports-jev-decisions.md)
(`typesafe-ai/jev`) only ranks what they found. CheckAI explains it through the
`get_attention_items` MCP tool. Resolving anything goes through the existing
confirm-gated MCP tools, never through this feature.

Linear: PRO-431. Code: `api-events/src/systems/attention/`,
`api-events/src/db/attentionFacts.ts`, `api-events/src/db/attentionFeedback.ts`.

## Owner decisions (V1)

1. **Compute on read.** Nothing about an item is stored and nothing runs on a
   schedule. Only viewer feedback persists.
2. **Surfaces:** the web Home card (`AttentionBrief`) and a read-only member MCP
   tool. No mobile.
3. **Deterministic correlators find; Jev ranks.** A correlator emits a candidate
   with its evidence and a rule default class. Jev may assign a different class
   and a confidence. If Jev is off, fails, times out, or answers below the
   confidence floor, the rule class stands (`classified_by: "rule"`). Jev never
   discovers items, never writes text, and never mutates.
4. **Stale call sheets are deferred.** Call sheets are not persisted, so there is
   no signal to correlate.

## Pipeline

```text theme={null}
GET /organizations/:orgId/attention
  └─ loadAttentionFacts          one org-scoped query per fact family (no N+1)
  └─ buildAttentionEventContexts start instant per gig in its own timezone
  └─ runAttentionCorrelators     five pure functions → AttentionCandidate[]
  └─ classifyAttentionCandidates Jev on the top 25, concurrency 4, 1s budget, fail open
  └─ drop IGNORE, hide this viewer's dismissals, sort
```

Facts cover ACTIVE, DRAFT and UNARCHIVED gigs whose start is within the next
**30 days** and that have not ended. The audit lookback is **14 days**, read with
the static `origin = 'EXPLICIT'` literal and a bounded `created_at` window so the
partitioned table prunes (see `db/orgActivityLog.ts`). Every query binds the
caller's `organizationId`; the unit tests assert it for each fact family.

A gig's start is its `startTime` when set, otherwise the start of its civil day
in its timezone (event → organization → the default zone). All time arithmetic
is `Temporal.Instant`.

## Correlators

| Key | Fires when | Rule class | Suggested action |
| - | - | - | - |
| `crew_times_changed_after_notify` | A crew-offer day's `times_changed_at` is later than the recipient's last successful notice (a `SENT` row's `delivery_attempted_at`; a failed re-send never moves it), or the recipient was never reached (`FAILED`); the recipient is ACCEPTED or PENDING, the day was not declined, and they have not answered that day since the change | NEEDS\_ATTENTION; BLOCKING ≤ 48 h | `notify_affected_crew` |
| `replacement_unconfirmed` | A `member_removed` / `member_declined` / `crew_offer_declined` audit row in the lookback, and one of the gig's OPEN or FILLED offers still has a short day (see below) | NEEDS\_ATTENTION; BLOCKING ≤ 72 h | `review_staffing` |
| `guest_count_changed_open_tasks` | An `event_updated` row whose `changes` include `guest_count` with a material delta (≥ 25 guests or ≥ 10 %), and at least one incomplete checklist task | NEEDS\_ATTENTION | `review_open_tasks` |
| `billing_terms_changed_deposit_outstanding` | An `event_updated` row whose `changes` include a billing-terms field (prices, deposit, schedule, surcharge, addendum), and an UNPAID or FAILED RECEIVABLE on the gig | NEEDS\_ATTENTION | `review_billing` |
| `staffing_short_near_event` | The gig starts within 7 days and has unanswered invites or a short offer day | WATCH; NEEDS\_ATTENTION ≤ 72 h; BLOCKING ≤ 24 h | `review_staffing` |

Staffing is judged **per offer day**, not from the offer rollup. For each
upcoming `crew_offer_days` row the loader counts ACCEPTED and PENDING
recipient-days against the day's target: an open shift's `capacity` (falling
back to `max_acceptances`), or a job offer's `seats_needed` (per-day override,
then offer-level). A day is short when accepted is below the target, or, with no
target, while replies are pending. So a multi-day shift whose rollup says FILLED
still surfaces the one day that is short, and the evidence names each short day.
The offer evidence is stamped with the latest of the offer's, its days', and its
recipients' and recipient-days' `updated_at` / `responded_at`, so any staffing
change produces a new fingerprint.

A correlator suppresses itself once the situation resolves: the recipient
answered after the change or was re-notified, the seat filled, the tasks were
completed, the receivable was paid. Those are the "noisy" cases in
`attentionCorrelators.test.ts`.

Guest-count and billing-terms changes already write field-level `changes` on
`event_updated` rows (`buildEventUpdateAuditEntries` and
`putEventBillingTermsOperation`), so V1 needed no new audit emission.

## Identity and dismissal

* `key` is a hash of the correlator, the gig, and the primary entity (the offer
  for crew time changes, the gig otherwise). It is stable while the situation
  exists.
* `fingerprint` is a hash of the sorted evidence ids and their timestamps. New
  evidence changes it.

Feedback is **per viewer**. An item is hidden for a user when their latest
feedback for its `key` has the same `fingerprint` and an outcome other than
`USEFUL`. A new fingerprint brings it back.

## Jev

`attentionEvaluator.ts` asks two questions per candidate:

* `classification`: Choice over `BLOCKING | NEEDS_ATTENTION | WATCH | FYI | IGNORE`.
* `importance`: Score 0–3.

`confidence` is the chosen class's probability when Jev returns one, otherwise
`importance / 3`. Below `ATTENTION_JEV_MIN_CONFIDENCE` the rule class stands.
Items Jev classifies `IGNORE` are dropped server-side.

The `state` Jev sees is a compact JSON of the candidate with **no PII**: the
correlator key, the rule class, hours to the gig, the viewer role
(`owner | admin`), counts, changed field names with a delta bucket (money moves
as a percentage bucket, never an amount), and days unresolved. No names, emails,
ids or amounts. The test `sends only non-PII counts, buckets and timing` pins
that. Calls use `maxRetries: 0`, `AbortSignal.timeout`, and gateway zero data
retention. The classify step also races the worker pool against a one-second
budget (`ATTENTION_JEV_BUDGET_MS`, not an env var). Once that budget ends, workers
stop taking candidates. Calls already in flight finish and can fill the cache, but
their answers are not applied to that response, so those candidates keep their rule
class. A candidate that throws keeps its rule class too, and the brief still returns.
A finished Jev answer is cached in process (at most 256 entries, keyed by
the non-PII state) and can apply on a later brief; in-flight calls are not aborted. Each brief
logs `attention.jev.classified` and records the `attention.jev.classify` timing
for the portion the request waited.

## Configuration

`attentionEngineConfig()` in `api-events/src/config/config.ts`:

| Variable | Default | Meaning |
| - | - | - |
| `ATTENTION_JEV_ENABLED` | on | Ask Jev to rank. Also needs `AI_GATEWAY_API_KEY`; set `false` to force rule classes. |
| `ATTENTION_JEV_TIMEOUT_MS` | 2500 | Per-candidate timeout (1–10000). |
| `ATTENTION_JEV_MIN_CONFIDENCE` | 0.6 | Below this the rule class stands. |

### Availability

The attention engine is generally available to every org owner and admin. There
is no feature flag on the web card or the API routes; the owner/admin check
below is the only gate.

## HTTP

| Route | Result |
| - | - |
| `GET /api/v1/organizations/:orgId/attention` | `AttentionBriefWire` — `{ generated_at, upcoming_event_count, truncated, items }` |
| `POST /api/v1/organizations/:orgId/attention/feedback` | Body `{ key, fingerprint, outcome, classification, classified_by, confidence }` → 201 `{ key, fingerprint, outcome, created_at }` |

Both are org **owner/admin** only (`authorizeOrgAdmin`), checked first, so a
non-member or a foreign org id gets the guard's response and never loads org
data. The feedback route re-derives the item's evidence from the current brief and
stores it with the classification the admin reviewed (`classification`,
`classified_by`, `confidence` from the loaded item). It does not re-run Jev, so
the stored class is the one the admin saw.
`truncated` is true when the org exceeds the 500-event or 1,000-audit-record
scan cap, so the brief covers only part of the org. It returns 404 when the key
is not in the viewer's current brief and 409 `STALE_ATTENTION_ITEM` when the
fingerprint no longer matches. Outcomes: `USEFUL`, `RESOLVED`, `DISMISSED`,
`NOT_IMPORTANT`, `ALREADY_HANDLED`.

Items sort by class severity, then gig proximity, then confidence.

## Feedback table

`attention_feedback` (`AttentionFeedback`): `organization_id`, `user_id`,
`item_key`, `fingerprint`, `correlator`, `event_id`, `classification`,
`classified_by`, `confidence`, `outcome`, `snapshot` (the item as shown),
`created_at`, indexed on `(organization_id, user_id, item_key)`. The rows double
as evaluation data: Jev's class, the evidence, and the admin's verdict. Pushing
them into the external evals store is PRO-392, out of scope here.

## Surfaces

* **Web:** `web/src/app/(app)/home/components/AttentionBrief.tsx`, above Rounds
  for org owners and admins. Each row shows the reason, the gig,
  affected people or records, a "Why am I seeing this?" evidence list linking to
  the gig tab that holds each record, a navigation-only next step, and the
  feedback menu (optimistic SWR update).
* **MCP:** `get_attention_items` (read-only, `organization_id?`, `limit?`)
  returns the items with evidence ids so CheckAI can cite provenance.

## Out of scope (V1)

Suggested open items, stale call sheets, role relevance beyond owner/admin,
mobile, any mutation or action execution, and exporting feedback to the evals
store.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.