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:
ready_to_commit.probability >= IMPORT_JEV_MIN_CONFIDENCE, and- 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
- the mapping score sits in the top half of the scale (
>= 1.5of0..3), and unsatisfiedRequiredFieldsreturns empty — a deterministic check, not part of Jev’s answer at all.
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 asEVENT 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, orPREVIEWED; anything else is409. The update is a compare-and-set on the status, so a commit that claims the job first also surfaces as409. target_typemust be a registry target (400otherwise).target_event_idreplaces the picked gig of an event-child target (see Importing into existing gigs), andnullclears 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_revisionit read, so a retarget that lands between that read and the claim makes the commit409. - Responds with the same
imports.JobRecordenvelope as the job GET. - Writes an
import_retargetedaudit 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_idon create (JSON or multipart) or on retarget names the gig for the whole file, stored onimport_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_idnames each row’s gig, by its Soundcheck event ID or by theSPREADSHEETexternal ID an events import gave it.
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:
- An exact synonym hit keeps its field.
- 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.
- Confident Jev picks fill the remaining columns, most confident first.
- Fuzzy synonym hits fill whatever is still open.
- 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.
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).