Skip to main content
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 (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

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

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:

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

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.