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

# Jev as the UEF integration layer

> Source-agnostic evaluate criteria for anything that lands in UEF: which published target (Choice), how confident the mapping is (Score), and whether it is ready to commit (Boolean).

Soundcheck does not build a native connector per source system. Every inbound
path — a spreadsheet, a Planning Center or Master Tour export, a season folder,
a partner's UEF bundle, a CheckAI extraction from a contract — is turned into
[Universal Event Format](https://docs.soundchecklive.io/features/imports/universal-event-format.md) by an
*extract* step, and then [Jev](https://vercel.com/ai-gateway/models/jev)
(TypeSafe AI's evaluation-only model) referees the result with the same three
typed questions. Jev cannot generate text: it picks from options we give it
and returns probabilities. It can never invent a UEF type, a field path, or a
cell value.

This page is the source-agnostic contract those questions share. It is the
engineering companion to PRO-388 (product home). The first land, live on dev
behind a flag, is the spreadsheet path — see
[Imports Jev decisions](https://docs.soundchecklive.io/engineering/backend/imports-jev-decisions.md) (ENG-811)
for that path's exact state format, rules, and configuration. The CheckAI
side is `checkai/docs/jev-criteria-routing.md` (ENG-810). Shared helpers so
other paths can ask the same questions are ENG-829; the UEF bundle path landed
the first slice of that lift (see **Next**).

## The split

| Layer | Does | Does not |
| - | - | - |
| **Extract** — Imports mapper, `folderIngest`, CheckAI `read_event_details`, a partner feed | Propose a source → UEF field map and the values | Skip a human gate; decide whether its own proposal is good enough |
| **Jev** (this page) | Choice: which published target the data is; Score: how confident the mapping is; Boolean: ready to commit vs ask | Invent targets or fields; write free-form strings; approve, commit, or send anything |
| **Ontology** — `targetRegistry.ts` + `uefCatalog.ts` | Own the catalog: which targets exist, their paths, which are required | Change per source |
| **Writer** — `importTransformer.ts` → `importWriter.ts`; file ingestion reaches the same writer through `ingestCommit.ts` | Turn a committed UEF document into rows | Read Jev's answer |

Jev sits after extract and before the human gate. Its answer is advisory
context for the operator (or the agent), never a decision the pipeline acts on
by itself.

## Source paths and where the referee sits

| Source | Extract | Where Jev runs | Status |
| - | - | - | - |
| Spreadsheet upload or paste (CSV, XLSX, TSV, TXT) | Heuristic or AI column mapper in `importPipeline.ts`; on the heuristic path Jev also picks a field per column (`IMPORT_JEV_MAPPING_ENABLED`) | After `suggestMapping`, in the map call `POST …/imports/{jobId}/map`; result is the additive `decision_model` field | **On by default** wherever a gateway key is set; `IMPORT_JEV_DECISIONS_ENABLED=false` is the kill switch. See [Imports Jev decisions](https://docs.soundchecklive.io/engineering/backend/imports-jev-decisions.md). |
| Coming-from exports (Planning Center, Back On Stage, Master Tour, Stagera, LASSO) | Same as spreadsheet: the guided-export docs produce a CSV, there is no source-specific mapper | Same map call | Same flag. Native OAuth for these stays gated by PRO-307. |
| Season folder / multiple files | `folderIngest.ts` infers a target per file from headers (synonyms), then filenames; produces an ordered plan the operator reviews | Per file, once each file becomes a job and is mapped | Same flag. No folder-level question yet (see **Next**). |
| UEF JSON bundle — partner feeds, n8n CRM sync, workspace-export round trip | None: the caller already wrote UEF | `POST …/imports/uef`, per document, concurrently with that document's preview; each document result gains the additive `decision_model` field and one `imports.uef_decision` log line | **Wired behind `IMPORT_UEF_JEV_ENABLED`.** Document-level state builder; see [Imports Jev decisions → UEF documents](https://docs.soundchecklive.io/engineering/backend/imports-jev-decisions.md#uef-documents). |
| CheckAI extraction (contracts, call sheets, chat) | `read_event_details`, and ingestion batches on the language model, stored as UEF 2.0 proposals (see **File ingestion**) | MCP `preview_import` / `import_file` / `commit_import`, and `commit_ingestion_file` / `commit_ingestion_batch` for ingestion, all confirmation-gated | **Not wired.** Slice 3 in `jev-criteria-routing.md`; the questions here are the ones it should reuse. |
| Event link (`POST …/imports/url-event-preview`) | context.dev scrape with a fixed JSON schema plus schema.org JSON-LD, mapped to gig fields and sections (run of show, venue, organizer, links, requirements, cover image) by fixed code in `urlEventExtraction.ts`. The full reading is kept per link in `url_event_snapshots` (JSON-LD, model output, extraction, raw and cleaned description, image address) | Only the gig type: a Choice over the workspace's own Event Type labels plus `NONE` (`urlEventGigType.ts`), asked when no label name matches the page outright. The state carries the full public page context: title, stated kind, format, venue, organizer, agenda and the whole page description (up to the 6000-character state cap). The answer is a suggestion the operator can untick | **On by default** with a gateway key; `URL_EVENT_IMPORT_JEV_ENABLED=false` is the kill switch. The page's keys are our own schema, so there is no key → field guess to referee |
| Gig name on the create form (`POST …/paste-fill/gig-type`) | None: the operator typed the name | The same gig type Choice as the event link (`suggestGigType` in `urlEventGigType.ts`), with the name as the only evidence, asked when no label name appears in it outright. The web form fills the type once typing pauses, and only while the operator has not picked one | Behind the `paste-fill` flag; `PASTE_FILL_JEV_ENABLED` and the paste-fill confidence and timeout settings apply |
| Chat Confirm Card readiness, cascade prefilter | — | classifier.dev (hosted) | PRO-389. Same Choice/Score/Boolean language, different runtime; one referee per decision, never two. |
| Typed provider APIs (Luma events, Flex, HubSpot via n8n CRM sync) | None: fields come from the provider's published schema, mapped by fixed code (`lumaEventToSyncEvent`, the n8n sync payload) | Not run | **Out of scope by decision (2026-09-25).** Jev has nothing to pick or referee when no column or key is guessed; there is no source → field ambiguity. A provider path that starts guessing (free-form custom fields, user-defined columns) comes back in scope. |

## The Choice set

The `target` Choice is the set of **published UEF ingress targets** and nothing
else. This was locked on 2026-09-18 (PRO-388, Ontology Architect comment),
with Leads added on 2026-09-22, and is enforced in code: `IMPORT_TARGET_CRITERIA` in `importDecisionModel.ts` is a
`Record<ImportTargetChoice, string>` derived from `IMPORT_TARGETS`, so a target
added to the registry without a criterion is a compile error, and a criterion
for a target that is not in the registry is one too.

| Ontology name | Choice key | UEF path prefix | Required to commit |
| - | - | - | - |
| Event | `EVENT` | `events[].` | `title`, `event_date` (both hard-required) |
| Crew | `MEMBER` | `members[].` | `email` |
| Venue | `VENUE` | `venues[].` | `name` |
| Client | `CUSTOMER` | `customers[].` | `name` |
| Inventory | `INVENTORY` | `items[].` | `name` |
| Songs | `SONG` | `songs[].` | `title` |
| Setlists | `SETLIST` | `setlists[].` | `title`, and `song_title` or `song_external_id` |
| Leads | `LEAD` | `leads[].` | `name`, `email`, `event_date`, `event_type`, `location`, `deal_stage` |

Required paths are read from `uefCatalog.ts` (`required: true`), not
maintained here; `hardRequiredPaths` marks the ones a waiver can never clear.

**Two escapes** are always offered, because a Choice with no way out forces
the nearest target onto data that belongs to none:

| Key | Meaning | Effect |
| - | - | - |
| `OTHER` | The rows describe a shape Soundcheck has no ingress target for: departments, workflows, phases, series, work items, invoices, timesheets | `matches_declared: false` → `ask_operator` |
| `INSUFFICIENT_EVIDENCE` | Too few columns or values are shown to tell | `matches_declared: false` → `ask_operator` |

The out-of-criteria list in `OTHER` is the Ontology lock's own list (Dept /
Workflow / Phase / Series / Work Item). Those shapes are not targets, and Jev
is told so explicitly rather than left to pick `MEMBER` for a department
roster.

**Nested shapes.** The catalog publishes `events[].members[]`,
`events[].shifts[]` and `events[].pack_items[]` today. `schedule_items` is
named in the lock as "nested when shipped" but does not exist in
`uefCatalog.ts` yet, so it is not a Choice option and no criterion references
it. A nested shape is
never a top-level Choice: an events sheet with crew columns is `EVENT`, and
the members are part of its completeness, not a second target.

## The three questions

The wording that ships is in `IMPORT_DECISION_QUESTIONS`; this is what each
question means independent of source.

**`target` — Choice over the set above.** "Which target does this data
actually describe?" Judged from the shape of the data (columns, value types,
nesting), not from the target the operator or partner *declared*. The declared
target is in the state so the answer can agree or disagree with it;
`matches_declared` in the result records which.

**`mapping_confidence` — Score, 4 levels.** "How well does the proposed
source → catalog-field map fit the chosen target?" Levels describe situations,
low to high: most columns unmapped or mis-mapped · obvious columns mapped but
several meaningful ones wrong or missing · nearly everything plausible with one
or two doubts · every meaningful column on the right field. The float is a
weighted mean over levels, thresholded by host code; it is not P(correct).

**`ready_to_commit` — Boolean.** "Is this settled enough to move on to
preview and commit without another look at the mapping?" The answer is P(true).
It is judged against the *required* fields for the target, never against how
much of the full catalog was used — most catalog fields have no reason to
appear in any one source. Whether a required field is actually satisfied
(mapped, defaulted, or waived) is computed deterministically by the host and
stated in the state as fact; Jev is told that line is the authority.

The three are batched in one `evaluate` call and combined in host code. They
are not folded into one score, and Jev is never asked to combine them.

## The recommendation rule

`ready_to_commit.recommendation` is `proceed` only when every signal agrees:

1. `ready_to_commit.probability` is at or above the confidence floor, **and**
2. the chosen target is the declared target *and* carries a probability at or
   above the same floor (no distribution counts as not confident; an escape
   never matches), **and**
3. `mapping_confidence` sits in the top half of its scale, **and**
4. the deterministic required-field check is empty.

Anything else is `ask_operator`. Confidence only ever makes the outcome more
cautious; a high answer never skips the preview, the confirmation, or the
operator.

## The state contract for a new source path

To ask the referee, a path builds one `state` string. Whatever the source, it
must carry:

| Line | Why |
| - | - |
| Declared target | So `target` can agree or disagree with it |
| Source fields, by header name | Header text is sent by default so Jev can map; `IMPORT_JEV_SEND_HEADERS=false` sends positional tokens instead |
| Required catalog fields, then the other catalog fields, for the declared target | So completeness is judged against what must be there, not the whole catalog |
| Required fields still unmet, computed by the host | The authority for `ready_to_commit`; never left to Jev |
| Proposed map: source token → catalog path (or `(unmapped)` / `(not a catalog field)`), with the extractor's own confidence when it has one | Only catalog paths travel as written; anything client-writable is tokenised |
| A few sample rows, values redacted to type tokens | Value *types* are evidence; values are not sent |

Caps: 5 rows, 6000 characters. Cell values never leave (`scrubValue`), and
the call runs with zero data retention, no retries, and a hard timeout.

For a source that has no column map — a UEF bundle, a CheckAI extraction — the
same lines still apply with "field present in the document" in place of
"column mapped": the declared `target_type`, which required paths are set,
which optional paths are set, and type-redacted samples of a few entries.
`importUefDecisionModel.ts` is that second state builder for UEF bundles; it
asks the same questions and uses the same recommendation rule. The question
wording names both cases ("columns or fields") so one set serves both.

## File ingestion

Files uploaded to a gig are read by the analyzer, merged per batch, reviewed,
and committed. The analyzer and merge model keep their flat output schemas;
`ingestionPipeline.ts` and `mergeExecutor.ts` convert that output to a UEF
2.0 proposal (`schema_version` 2.x) before it is stored. A proposal holds one
gig: `events[0].days[].run_of_show[]` cues with `start` / `end` clocks,
`events[0].shifts[].crew[]`, `events[0].ledger_items[]`, and `leads[]`.

**Selection is by document path.** Review and commit name rows in `selected`:
`$.events[0].days[1].run_of_show[2]`, `$.events[0].shifts[3].crew[0]`,
`$.events[0].ledger_items[4]`, `$.leads[1]`. A path that names no row is a
400\. Batch reviews list cues dated outside the gig in `out_of_range_paths`.
The old `schedule_idx` / `member_idx` / `ledger_idx` / `lead_idx` request
fields are gone; a request that still sends one is a 400, and so is an
`edited` (or field-coverage `uef`) document that is not UEF 2.0.

**Stored proposals must be UEF 2.0.** Reviews, commits and field coverage
read only a 2.0 proposal. A file extraction or batch merge stored before 2.0
(one `npm run ingest:convert-proposals` did not convert) fails closed with a
409 `PROPOSAL_PREDATES_UEF2`. Retry the file to analyze it again (retry
accepts a ready file only when its proposal predates 2.0); for a batch, retry
its files and re-merge. A merge whose inputs include such a file fails with
the same message. The conversion script carries its own 1.0 reader; nothing else in
api-events reads 1.0.

**Commit uses the shared import writer.** `ingestCommit.ts` and
`ingestDocument.ts`, inside one transaction that holds
`lockNonArchivedEvent` (an archived gig is a 409):

1. Keep the selected paths of the stored proposal.
2. Stamp the gig from the target `Event`: title and dates, used for parsing
   only. Ingestion never writes the gig header.
3. Drop crew with no email or phone, reported as skipped.
4. Move cues on the dateless placeholder day onto the gig's first day.
5. Parse with `readUefDocument`, then write cues, invitations, ledger lines,
   and leads with the shared pieces (`applyImportedEventChildrenTx`,
   `commitLeadsTx`).

A selected row that is invalid on its own data is skipped, and every other
selected row still commits. Skipped rows come back in `errors` with their
`path`, and `error_rows` counts them. These rows are skipped:

* a cue with a bad start time, a cue that ends at or before its start, and a
  cue dated outside the gig;
* a crew member with no email or phone;
* a lead with no email, and a ledger line with no type or description, sent
  in an edited document;
* a ledger line refused on its own fields (type, amount, status).

Anything else rolls the whole commit back: a database error, event-field
drift (`EventFieldDriftError`), an archived or missing gig, old imported
ledger lines that cannot be reconciled with the new ones, a writer failure
for a whole day, a lead the shared lead writer refuses, and a document
problem that no single row owns. The stored committed copy and the
contributing-file list hold only the rows that were written. A commit with
skipped rows is committed; a commit where every selected row was skipped and
nothing else changed leaves the file `READY_FOR_REVIEW` and the batch merge
`READY`.

Preview runs the same plan through `previewImportIntoEvent`, so its counts
use the commit's rules. Venue and location go through
`applyEventFieldsInTx` in the same transaction: the keys in
`event_field_keys`, or only empty fields when the caller sends none.

## Rules that hold on every path

* **Fail open.** Flag off, no gateway key, timeout, gateway error, or an
  answer outside the option set all resolve to `null`, and the response is
  byte-identical to the flag being off.
* **Advisory only.** The answer is context. `commit_import`, `import_file`,
  MCP confirmation tokens, and the wizard's preview → commit steps are
  untouched. A high score is not consent.
* **Asymmetric.** Low confidence adds caution; high confidence removes none.
* **No invented types.** Choice options are Ontology-published ids only. New
  formats and sources add extract coverage, never new headers or targets.
* **One referee per decision.** A decision is judged by Jev on the Gateway or
  by classifier.dev, not both.
* **Jev never generates.** Extraction and drafting stay on the language model
  or the heuristic mapper. Jev may *pick* a catalog field for a column from a
  closed option list (`chooseFieldsWithJev`), and otherwise only judges what
  the extractor produced.
* **Every ingestion path uses Jev.** A new path that maps outside data into
  our schema calls `chooseFieldsWithJev` (key/column → field) or asks the
  referee questions here, through `shared/jev/jevEvaluate.ts`. A typed provider API
  whose fields are mapped by fixed code is not such a path (see the table
  above). On by default
  when a gateway key is set; the per-path `*_JEV_ENABLED=false` is a kill
  switch, not an opt-in.

## Rollout

Since 2026-09-25 every Jev layer defaults on wherever `AI_GATEWAY_API_KEY` is
set; each path's `*_JEV_ENABLED=false` turns it off. Answers only add a field
to the response and a `jev.decision` log line, so they fail open by
construction. Configuration, the header opt-in, and
the tuning history are in
[Imports Jev decisions → Configuration](https://docs.soundchecklive.io/engineering/backend/imports-jev-decisions.md#configuration).
Roll a new path out the same way: shadow first (log the answer, change
nothing), read the decisions against real files, then surface it.

## Next

* **Shared helpers (ENG-829), remainder.** Every Jev call now goes through
  `shared/jev/jevEvaluate.ts`. The question set, the state caps, and the
  recommendation rule (`recommendImportDecision` / `judgeImportDecision`) are
  shared by the spreadsheet and UEF paths; paste-fill and pricing-knowledge
  still carry their own question sets.
* ~~**Document-level referee for `POST …/imports/uef`.**~~ Done: behind
  `IMPORT_UEF_JEV_ENABLED`, fail open, first 10 documents per request.
* **Folder-level question.** `folderIngest` detection is deterministic; a
  Score over the whole plan (every file recognised, load order sound) is the
  natural next Choice/Score once per-file decisions are trusted.
* **Pin the model id** (`IMPORT_JEV_MODEL_ID`) to a dated version once
  thresholds are tuned, so a model update cannot move them silently.


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