Skip to main content
The Imports mapping step asks 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 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 (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

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:
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

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