> ## 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.

# Imports Jev decisions

> Advisory mapping-confidence, target, and ready-to-commit decisions on the Imports map call, and the rules that keep them from changing anything.

The Imports mapping step asks [Jev](https://vercel.com/ai-gateway/models/jev)
(TypeSafe AI's `typesafe-ai/jev` on the Vercel AI Gateway) three typed questions
about a mapping the pipeline has already produced. Jev is evaluation-only: it
returns answers with probabilities and cannot generate text, so it can never
invent a cell value, a header, or a field path. ENG-810's
[`checkai/docs/jev-criteria-routing.md`](https://github.com/soundchecklive/soundcheck-mobile-app/blob/dev/checkai/docs/jev-criteria-routing.md)
is the sibling write-up for the CheckAI side; this page covers ENG-811, the
api-events side. The source-agnostic criteria every extract path shares —
the locked target Choice set, the state contract, and where each source path
stands — live in [Jev as the UEF integration layer](https://docs.soundchecklive.io/engineering/backend/jev-uef-integration-layer.md)
(PRO-388).

Code: `api-events/src/systems/imports/importDecisionModel.ts` (pure: questions,
state builder, recommendation rules), `importUefDecisionModel.ts` (pure: the
state builder for UEF documents, see **UEF documents**),
`importDecisionEvaluator.ts` (the gateway binding and the deps it hands the
model), and `importJevConfig()` in `api-events/src/config/config.ts` (every
environment read).

## The three decisions

| Key | Jev type | What it answers |
| - | - | - |
| `mapping_confidence` | Score, 4 levels | How well the proposed column → catalog-field mapping fits the declared target. Level 0 is "most columns unmapped or mis-mapped"; level 3 is "every meaningful column maps to the right field". |
| `target` | Choice over `IMPORT_TARGETS` keys plus two escapes | Which import target the sheet actually describes (`EVENT`, `LEAD`, `INVENTORY`, `MEMBER`, `VENUE`, `CUSTOMER`, `SONG`, `SETLIST`, `PACK_ITEM`), or `OTHER` (the rows describe a shape no target stores — departments, workflows, phases, series, work items, invoices, timesheets) or `INSUFFICIENT_EVIDENCE` (too thin to tell). The target descriptions are derived from `targetRegistry.ts`, so a new target is a compile error until it is described here too. An escape is never `matches_declared`, so it always resolves to `ask_operator`; the binding still returns `null` for anything outside this set. |
| `ready_to_commit` | Boolean | P(the mapping is complete and unambiguous enough to move on to preview/commit) vs. "an operator should review it first". |

## What Jev sees

`buildImportDecisionState` builds the whole `state` string. It contains the
declared target, the source columns, the catalog field paths for that target,
the proposed column → field mapping with each column's existing mapper
confidence, and at most **5** sample rows — capped at **6000** characters
overall.

**Cell values never leave.** Every value goes through `scrubValue` from
`importMapperAi.ts`, the same redaction the AI mapper applies, so values appear
only as type tokens (`<email>`, `<date:YYYY-MM-DD>`, `<decimal>`, `<text>`).

**Mapping targets are checked against the catalog.** `uef_field` is
client-writable — a caller can submit its own mapping — so only a path that is
in the declared target's catalog is sent as written. Anything else becomes
`(not a catalog field)`, and an empty one `(unmapped)`, whether or not header
text is enabled.

**Header text is sent by default (since 2026-09-25).** Jev now also *maps*
columns (see **Jev field mapping** below), and with positional tokens alone it
can place little beyond email, phone, and date columns. Header names travel to
Jev with `zeroDataRetention`; cell values never do — they stay type tokens.
`IMPORT_JEV_SEND_HEADERS=false` restores the old posture, in which columns are
rendered as the same positional `col_N` tokens `scrubHeaders` produces — in the source-columns line, on the left side of each
mapping line, and as the sample-row keys. A mapping column that is not one of
the job's headers renders as `col_?`; two columns sharing a header name consume
their own positions in order, so `col_N` always means position *N*.

Catalog field paths (`uef_field`) are ours, not the operator's, and always
travel as written — otherwise the Score question would have nothing to judge
against.

**Required catalog fields are called out separately from the rest.** A live
smoke test (2026-09-17, pass 2) found `ready_to_commit` unreachable even on a
clean, fully-correct 5-column EVENT mapping — probability 0.14–0.25 against
the 0.75 floor. The cause: the EVENT catalog alone has 43 fields (2 required),
and the state originally listed them all as one undifferentiated "Catalog
fields for this target" line, so Jev judged completeness against the whole
catalog rather than against what a 5-column sheet could plausibly supply.
The state now splits `Required catalog fields for this target` from `Other
catalog fields for this target` (normal to leave unmapped).

**Whether a required field is actually satisfied is never left to Jev.** The
first fix attempt asked the model to judge a required field "met" whenever no
source column looked like it could plausibly supply it — reviewers (Greptile
P1, Codex P2) correctly flagged that this doesn't match how the transformer
actually decides: `events[].title` and `events[].event_date` are
*hard*-required (`hardRequiredPaths`) and can never be satisfied by "the data
doesn't have it" reasoning, only by a mapped column or a `field_defaults`
entry; other required fields can additionally be cleared by
`mapping.waived_required`, but only if they aren't hard-required.
`unsatisfiedRequiredFields` in `importDecisionModel.ts` computes this
deterministically — mirroring `importTransformer.ts`'s `requireMappedField`
exactly — and the result appears in the state as a `Required fields still
unmet by this mapping` line. The question tells Jev this line is the
authority, not something to re-derive; `scoreImportMapping` additionally
gates `recommendation` on it directly, so a mistaken "yes" from Jev can never
promote a mapping past a field the commit would actually reject.

`IMPORT_JEV_SEND_HEADERS=false` is the kill switch that sends tokens instead of
header text. The trade-off: with tokens the model judges the mapping from field
paths, value types, and column positions alone, which is a much weaker signal
for both `mapping_confidence` and `target`; turning it on makes the answers
sharper at the cost of sending operator-authored header strings to the gateway.
Values stay redacted either way. The question instructions tell the model which
mode it is in.

## Rules

**Fail open.** `scoreImportMapping` returns `null` when the feature is off, when
the evaluator resolves to `null` (timeout, gateway error, no gateway key), or
when it throws. The map response is then byte-identical to a response with the
flag off.

**Advisory only.** The result never changes `mapping`, never blocks the map
call, and never touches commit gating. `commit_import` stays
confirmation-gated; a high score is not consent.

**Asymmetric.** Confidence may only make the recommendation *more* cautious.
`ready_to_commit.recommendation` is `proceed` only when all four hold:

1. `ready_to_commit.probability >= IMPORT_JEV_MIN_CONFIDENCE`, **and**
2. the chosen target equals the declared target *and* carries a probability at
   or above the same floor — a missing distribution counts as not confident,
   **and**
3. the mapping score sits in the top half of the scale (`>= 1.5` of `0..3`),
   **and**
4. `unsatisfiedRequiredFields` returns empty — a deterministic check, not part
   of Jev's answer at all.

Anything else is `ask_operator`.

## Response shape

The map endpoint (`POST /api/v1/organizations/{orgId}/imports/{jobId}/map`)
adds `decision_model` to its `200` body **only** when a result exists:

```json theme={null}
{
  "import_job_id": "…",
  "status": "MAPPED",
  "mapping": { "…": "…" },
  "ai_generated": true,
  "decision_model": {
    "mapping_confidence": { "score": 2.8, "level": 3, "levels": 4 },
    "target": { "choice": "EVENT", "confidence": 0.94, "matches_declared": true },
    "ready_to_commit": { "probability": 0.9, "recommendation": "proceed" }
  }
}
```

`target.confidence` is `null` when Jev returned no distribution — the key is
always present. The shape is the named OpenAPI component
`importv1.ImportDecisionModel` (generated client type
`Importv1ImportDecisionModel`), shared by the map response and each UEF
document result; `target.confidence` is `nullable: true`, and `target.choice` /
`ready_to_commit.recommendation` are enums.

## UEF documents

`POST /api/v1/organizations/{orgId}/imports/uef` has no mapping step, so it
cannot use the column state. `importUefDecisionModel.ts` builds a document
state instead and asks the same three questions under the same label, through
the same recommendation rule (`judgeImportDecision`).

**What Jev sees for a document.** `summarizeUefDocument` walks only the
declared target's catalog paths over the document's entries (`events`,
`leads`, `items`, `org_members`, `venues`, `customers`, `songs`, `setlists`).
Keys outside the catalog never travel, and values are type tokens via
`scrubValue` (lists and objects become `<list>` / `<object>`). The state
carries the declared target, the entry count, the required catalog paths, a
`Required fields still unmet by this document` line (a required path absent on
at least one entry, or every required path when there are no entries), the
present catalog paths with how many entries carry each, up to 5 redacted
sample entries, and last the absent optional paths. The same 6000-character
cap applies. The catalog uses flat paths for two targets whose documents nest,
so the walker aliases `members[].positions` → `positions[].name` and the
`setlists[].set_name` / `song_*` paths → `sets[].name` / `sets[].songs[].*`.

**Wiring.** `importUefOperation` starts one judgement per document for the
first **10** documents (after tier sorting) before the preview loop, and awaits
them only after the previews (and, on commit, the writes) finish, so latency is
max(preview + commit, Jev), never the sum. Each judgement fails open on its
own: disabled, no key, timeout, a thrown error, or a document past the cap
leaves that document's result without `decision_model`. Nothing about the
preview/commit control flow, the 400 invalid-rows path, the 409
partial-commit path, or the status codes depends on the referee.

Each judged document emits one `imports.uef_decision` info log with
`org_id`, `target_type`, `target_choice`, `target_matches_declared`,
`recommendation`, `mapping_confidence_score`, `ready_probability`,
`entry_count`, and `unmet_required_count`. No field values or keys beyond
those are logged. This is the observability for direct-commit callers (n8n,
partner feeds) who never read the response field.

## Retargeting a job

When Jev says a sheet declared as `EVENT` is really `VENUE`, the wizard calls
`POST /api/v1/organizations/{orgId}/imports/{jobId}/retarget` with
`{ "target_type": "VENUE" }`. The job's raw rows, headers, and sample rows are
kept (they are target-independent), so the operation only switches
`target_type`, clears `mapping`, `preview`, `mapping_id`, and `error`, bumps
`preview_revision` (so an in-flight preview cannot land), and returns the job to
`PARSED`. The next map call suggests a mapping for the new target.

* Allowed from `PARSED`, `MAPPED`, or `PREVIEWED`; anything else is `409`. The
  update is a compare-and-set on the status, so a commit that claims the job
  first also surfaces as `409`.
* `target_type` must be a registry target (`400` otherwise).
* `target_event_id` replaces the picked gig of an event-child target (see
  **Importing into existing gigs**), and `null` clears it. Omitted, it keeps the
  current pick while the new target is still an event-child target, so a sheet
  switch or a rename does not lose the gig.
* A commit claims the job only at the `preview_revision` it read, so a retarget
  that lands between that read and the claim makes the commit `409`.
* Responds with the same `imports.JobRecord` envelope as the job GET.
* Writes an `import_retargeted` audit row (ORGANIZATION scope). The operation
  and its HTTP handler live in their own modules
  (`importRetargetOperation.ts`, `importRetargetHttpHandler.ts`) for the same
  audit-coverage reason as the row edit.

## Importing into existing gigs

`PACK_ITEM` is the first event-child target. Each row is one gear line for a gig
that already exists, so the job needs a way to find that gig. The ontology marks
such a target with `event_ref_field` (`pack_items[].event_external_id`), and the
generator emits `ONTOLOGY_EVENT_CHILD_TARGET_KEYS` and
`ONTOLOGY_EVENT_REF_FIELDS` for api-events and web.

A row is placed in one of two ways:

* **A picked gig.** `target_event_id` on create (JSON or multipart) or on
  retarget names the gig for the whole file, stored on
  `import_jobs.target_event_id`. Create and retarget refuse it on an org target,
  for a gig outside the organization or scheduled for deletion (`400`), and for
  an archived gig (`409`).
* **A Gig column.** A column mapped to `pack_items[].event_external_id` names
  each row's gig, by its Soundcheck event ID or by the `SPREADSHEET` external ID
  an events import gave it.

Preview and commit refuse a job that has neither (`400`). The map step refuses a
`field_defaults` entry on the gig reference, because a saved mapping is
re-applied by header signature and would replay one gig onto every later file.
A row whose gig is missing, ambiguous, scheduled for deletion, archived, or
different from the picked gig fails with its own reason. It is never moved into
another gig.

Placed rows are grouped by gig in file order. Each gig is planned with the
into-event preview, which returns a `create`, `duplicate` or `error` verdict per
line. Commit runs one transaction per gig. It takes the `crm_sync` lock and then
the gig's row lock, as `commitEvents` does, and writes only the lines planned as
`create`. If the writer does anything else, that gig rolls back and its rows
fail. A re-run converges, because a line whose item is already on the gig is a
duplicate.

Folder classification and workspace export cover org targets only. The web
wizard does not offer `PACK_ITEM` yet.

## Configuration

| Var | Default | Meaning |
| - | - | - |
| `IMPORT_JEV_DECISIONS_ENABLED` | on | `0` / `f` / `false` turns the decisions off; anything unrecognised keeps the default. Also requires a real `AI_GATEWAY_API_KEY` — `resolveAiGatewayApiKey` rejects blanks and the Terraform `PLACEHOLDER` sentinel. |
| `IMPORT_JEV_MIN_CONFIDENCE` | `0.75` | Probability floor for `proceed` (0–1). Blank, non-numeric, or out-of-range falls back to the default. |
| `IMPORT_JEV_TIMEOUT_MS` | `1500` | Per-call budget (1–10000). A slower answer is abandoned and the call fails open. |
| `IMPORT_JEV_SEND_HEADERS` | on | Sends operator header text instead of `col_N` tokens to both the decisions and the field mapper; `false` switches back to tokens. See **What Jev sees**. |
| `IMPORT_JEV_MAPPING_ENABLED` | on | Jev picks a catalog field for each column when the heuristic mapper runs (see **Jev field mapping**). Same key requirement. |
| `IMPORT_JEV_MAPPING_TIMEOUT_MS` | `4000` | Budget for the one batched field-mapping call (1–10000). |
| `IMPORT_UEF_JEV_ENABLED` | on | Enables the per-document referee on `POST …/imports/uef` (see **UEF documents**), independent of `IMPORT_JEV_DECISIONS_ENABLED`. Same key requirement; shares the confidence floor and timeout above. On in dev, qa, and prod Terraform. |

All seven are read through `importJevConfig()` in `api-events/src/config/config.ts`,
the only file in the service that reads `process.env`. It is called per request,
not baked in at boot.

## The Jev binding

`api-events/src/systems/shared/jev/jevEvaluate.ts` is the only place api-events
calls the AI SDK `experimental_evaluate` API (`ai` ≥ 7.0.103); every Jev
decision in the service goes through `jevEvaluate()`, which also logs one
`jev.decision` line (decision, outcome, latency) per call. It builds a gateway provider with
`AI_GATEWAY_API_KEY` and asks `typesafe-ai/jev` with a hard timeout, no
retries, and `zeroDataRetention`. The mapper and ingestion keep using the
hand-rolled Messages client; Jev cannot generate text and never goes near it.

The binding returns `null` when no real key is configured, when the call throws
or times out, and when Jev picks a target that is not an import target.
`AI_GATEWAY_BASE_URL` is not applied here: it overrides the Messages host, and
the evaluation API lives under a different gateway path.

## Jev field mapping

Jev cannot write a mapping, but it can pick one: `chooseFieldsWithJev`
(`shared/jev/fieldChoice.ts`) asks one Choice question per column — the options
are the target's catalog fields plus `IGNORE` and `UNSURE` — in a single
batched call capped at 40 columns. An answer below `IMPORT_JEV_MIN_CONFIDENCE`,
or with no probability, counts as unsure.

`mergeFieldChoices` combines those picks with the synonym heuristic:

1. An exact synonym hit keeps its field.
2. A fuzzy synonym hit keeps its field unless Jev confidently placed that
   column somewhere else — Jev never takes a field away from a column it did
   not also re-place.
3. Confident Jev picks fill the remaining columns, most confident first.
4. Fuzzy synonym hits fill whatever is still open.
5. In imports, the synonym matcher then runs again over the columns still
   unmapped, so a field Jev freed can go to the next column that fits it.

No field is assigned twice. When Jev is off or returns nothing, the mapping is
byte-identical to the heuristic one. In imports this runs wherever
`suggestMapping` falls back to the heuristic (AI mapper off, or its call
failed); the same helper maps the synonym-based paths outside imports.

## The AI mapper (`importMapperAi.ts`)

This is a separate, older feature that predates Jev — a Go-parity port, not
part of ENG-811 — but it shares the header-token convention above and the two
are easy to conflate, so it gets a section here too.

`IMPORT_AI_MAPPING_ENABLED` (`api-events/src/systems/imports/importMapperAi.ts`,
read directly from `process.env` — this one predates the config.ts rule) lets
`suggestMapping` call Claude (`anthropic/claude-opus-4.7` by default, via the
hand-rolled Messages client, not Jev — Jev only *judges* the mapping this
produces) to propose the column → field mapping itself, instead of the
deterministic heuristic (synonym list, flat `confidence: 0.5`). It was never
set in any environment's Terraform until 2026-09-18, so every import mapped
with the heuristic until then.

**It shipped with the same default as the decision layer: `col_N` tokens, not
header text.** Unlike the decision layer, that default makes the mapper close
to useless. With only positional tokens and value-type tokens (`<email>`,
`<date:YYYY-MM-DD>`, …) to go on, Claude can map little beyond columns whose
*values* are self-describing — a 12-column gig sheet with a `Client Email` and
`Client Phone` column but nothing else machine-typed comes back mapping just
those two, at confidence 0.5, and every other column empty. The heuristic
mapper, reading the same sheet's header names, maps all twelve. `IMPORT_MAPPER_SEND_HEADERS`
(`1` / `t` / `true`, default off, config.ts) opts a column's real header text
back in — cell values are still always redacted to type tokens via `scrubValue`
either way. Turn it on somewhere the AI mapper is meant to do real work.

**Fails open and now says so.** `suggestMapping` catches any AI failure —
including a well-formed reply that maps nothing, which used to persist as an
empty mapping — and falls back to the heuristic. The fallback now logs a
warning (`imports: AI mapper fell back to heuristic`) with the target type,
column count, and a normalized reason, so a systemic failure (bad model id,
expired gateway key) is visible in the logs instead of silently downgrading
every import.

The reason is never the raw error message. `callAnthropicStructured`'s errors
embed the gateway's HTTP response body verbatim, uncapped beyond 2048
characters and unredacted — with `IMPORT_MAPPER_SEND_HEADERS` on, a
validation-style error from the gateway could otherwise echo back the very
header text this page is about keeping out of the logs. `fallbackLogReason`
in `importPipeline.ts` classifies instead: our own static messages pass
through verbatim (no interpolated content by construction), a gateway HTTP
error keeps only its status code (`ai-gateway responded 400`), a malformed
model reply (`validateMappingColumnEntry`'s own throws — a non-object column
entry, or a column field of the wrong type) keeps only which shape was wrong,
and anything else collapses to its error class name.

**`ai_generated` means what it says.** The map response's `ai_generated` field
used to be `!body.mapping && !saved_mapping` — true whenever the caller hadn't
supplied or reused a mapping, whether or not the AI mapper actually ran or
succeeded. It is now `suggestMapping`'s own `usedAi`: true only when Claude
produced the mapping that shipped, false for the heuristic, whether by choice
(flag off) or fallback (AI failed or mapped nothing).

| Var | Default | Meaning |
| - | - | - |
| `IMPORT_AI_MAPPING_ENABLED` | off | Also requires a real `AI_GATEWAY_API_KEY` (`isAiGatewayConfigured`). |
| `ANTHROPIC_MODEL` | `anthropic/claude-opus-4.7` | Gateway model id override. |
| `IMPORT_MAPPER_SEND_HEADERS` | off | Opt-in to sending operator header text instead of `col_N` tokens (config.ts `importMapperSendHeaders`). |


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