AI-Proposed, Gate-Approved: Safe LLM Schema Mapping
Large language models are genuinely good at proposing field mappings — and genuinely terrible at being trusted blindly. CoreModels resolves that tension architecturally: the AI proposes a mapping plan, and the same deterministic validation gate that checks human-written plans accepts or rejects it. What survives the gate is a replayable artifact you can execute forever without the model in the loop.
AI-Proposed, Gate-Approved: Safe LLM Schema Mapping
Large language models are genuinely good at proposing field mappings — and genuinely terrible at being trusted blindly. CoreModels resolves that tension architecturally: the AI proposes a mapping plan, and the same deterministic validation gate that checks human-written plans accepts or rejects it. What survives the gate is a replayable artifact you can execute forever without the model in the loop.
The problem with a fluent guesser
Show an LLM two schemas and it will happily tell you that full_name maps to customer_name, that signup_date is probably the enrollment timestamp, and that the leftover column is fine to drop. Much of the time it is right. The trouble is the rest of the time: invented fields, silently relaxed constraints, and a confident tone that does not correlate with correctness. Prompting harder does not fix this. Reviewing every proposal by hand does not scale.
Our answer at CoreModels is architectural rather than behavioral. In our transformation engine, an AI never applies a mapping. It proposes a plan — a structured description of the mapping — and that plan is handed to the exact validation gate that checks plans authored by humans or inferred by heuristics. The gate's rules for the AI path are strict and simple:
- One gate for every author. The same validation applies whether the plan came from a person, from label matching, or from a model. There is no AI-shaped side door.
- At most one repair attempt. If the gate rejects the proposal, the proposer gets a single chance to repair it. A rejected repair is a rejection — the gate is never relaxed to let a second-draft plan through.
- Confidence is never trusted. The model self-reports a confidence score (0–1) on each operation it proposes. It is advisory metadata for the human reading the result. The gate ignores it entirely.
- Execution is deterministic. Once a plan passes the gate, the engine executes it mechanically and returns it to you as a replayable artifact. The creativity ends at the proposal; everything after is machinery.
Three ways to author a mapping
The mapping endpoints — schema/map, schema/mapImport, and data/map — all accept the same mapping object with three kinds:
{
"kind": "inferred", // inferred (default) | explicit | ai
"guide": null, // explicit: the SIA mapping-guide JSON (as a string); ai: optional free-text guidance
"caseInsensitive": true // inferred only (default true)
}
inferred(the default) matches source elements to the target hint by label and type. No guide needed.explicittakes an authored SIA mapping guide — full human control, covered below.aiasks a server-side Claude proposer to draft the plan, optionally steered by free-text guidance inguide.
Whichever kind authors the plan, it passes through the one universal validation gate, executes deterministically, and comes back to you in the response.
An AI-assisted mapping call
Say you have an application-facing Customer schema and you want it mapped onto your warehouse's dimensional naming, emitted as Postgres DDL. First, the two inputs as local files.
customer.schema.json — the source:
{
"$id": "Customer",
"type": "object",
"title": "Customer",
"properties": {
"customer_key": { "type": "integer" },
"full_name": { "type": "string" },
"signup_date": { "type": "string", "format": "date-time" },
"status": { "type": "string", "enum": ["draft", "active", "closed"] }
},
"required": ["customer_key", "full_name"]
}
dim-customer.hint.json — the target hint, describing the shape you want to land on:
{
"$id": "DimCustomer",
"type": "object",
"title": "DimCustomer",
"properties": {
"customer_id": { "type": "integer" },
"customer_name": { "type": "string" },
"enrolled_at": { "type": "string", "format": "date-time" },
"customer_status": { "type": "string" }
},
"required": ["customer_id", "customer_name"]
}
Now the call. POST graph/transform/schema/map/{projectId} is stateless — nothing is written to the project; every call is inherently a dry run. Replace coremodels.example.com with your CoreModels host, and note that the free-text guide is where you tell the proposer what the fields mean:
PROJECT_ID="0123456789abcdef0123456789abcdef"
curl -sS -X POST "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg src "$(cat customer.schema.json)" \
--arg hint "$(cat dim-customer.hint.json)" \
'{
sourceFormat: "jsonschema",
sourceSchema: $src,
targetFormat: "sql",
vendor: "postgres",
targetHintFormat: "jsonschema",
targetHintSchema: $hint,
mapping: {
kind: "ai",
guide: "Map onto the DimCustomer hint. full_name is the customer display name; signup_date is the enrollment timestamp; status is our customer lifecycle state."
}
}')" | tee response.json
What comes back is the standard transform envelope: the mapped schema in the target format, the validated plan, and an honest ledger of everything that did not survive the trip. Abridged:
{
"success": true,
"lossiness": [
{
"kind": "ConstraintRelaxation",
"path": "Element[DimCustomer::customer_status]",
"explanation": "Postgres has no inline enum; emitted as VARCHAR (the allowed-value constraint is not enforced)."
}
],
"errors": [],
"schema": "CREATE TABLE \"DimCustomer\" (\n \"customer_key\" INTEGER NOT NULL,\n \"full_name\" VARCHAR(255) NOT NULL,\n \"signup_date\" TIMESTAMP,\n \"status\" VARCHAR(255)\n);",
"plan": { "operations": [ /* the validated mapping plan — store this verbatim */ ] }
}
(The plan re-homes the elements onto the hint's ids — hence the ledger path DimCustomer::customer_status — and the mapped type takes the hint's name, while the emitted columns keep their source labels.)
Three things deserve emphasis.
success: true does not mean "nothing changed." It means "it ran." Always read lossiness. Every record names its kind, the path it applies to, and a plain-language explanation. There are four kinds: StructuralDrop (something had no home in the target and was left out), TypeApproximation (a value or type was represented by a close-but-not-exact target type), ConstraintRelaxation (a rule such as required, max-length, or enum could not be enforced and was relaxed), and SemanticNarrowing (meaning was narrowed or guessed). Here, the source status enum could not ride into Postgres DDL as an enforced constraint, and the engine says so instead of pretending otherwise.
The plan is the product. It is the gate-approved mapping as data. Store it next to the schemas it maps — in version control, in your pipeline config, wherever your other reviewed artifacts live.
Rejection is a first-class outcome. If the proposal fails the gate and the single repair attempt fails too, you get success: false and errors carrying path-qualified records — [{ "path": "...", "message": "..." }] — telling you exactly what was unacceptable. You are never handed a plan the gate did not pass.
Replay it: the model leaves the loop
Reproducibility is the payoff of the plan-based design. POST graph/transform/plan/execute/{projectId} replays a stored plan against source content — statelessly, deterministically, and with no AI involved. Same plan plus same source gives the same output, a guarantee we pin with tests. The plan travels as a string:
jq -r '.plan' response.json > customer-to-dim.plan.json
curl -sS -X POST "https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data "$(jq -n \
--arg src "$(cat customer.schema.json)" \
--arg plan "$(cat customer-to-dim.plan.json)" \
'{
sourceFormat: "jsonschema",
sourceSchema: $src,
plan: $plan,
targetFormat: "sql"
}')"
Note what is not required here: no Anthropic key, no Editor role — plan/execute is a Viewer-role endpoint. The model was needed once, at proposal time. From then on your pipeline replays a reviewed, versioned artifact. If the source schema changes and the plan no longer fits, that is a visible failure to investigate, not a silent re-guess.
Full control: the explicit SIA guide
When you want to dictate the mapping rather than have it proposed, use kind: "explicit" and pass an SIA mapping guide as the guide string. The guide has four top-level keys: autoMatchByMapsTo (auto-pair constructs that declare the same cross-standard mapsTo URI — the guide's backbone), fieldMappings (the multi-field computations mapsTo alone cannot express, each naming a field transform from the registry via transformName), taxonomyDirectives (directives for controlled vocabularies), and drops (source ids you deliberately leave behind — turning silent loss into a recorded decision). Ids are the IR element ids, not bare labels. A minimal guide:
{
"autoMatchByMapsTo": true,
"fieldMappings": [
{ "sourceElementIds": ["Person::first_name", "Person::last_name"],
"targetElementIds": ["Person::full_name"],
"transformName": "JoinName" }
],
"drops": []
}
The gate treats explicit guides with the same rigor as AI proposals: unknown keys are rejected with a path-carrying error rather than ignored. A typo in your guide is a loud failure, not a quietly skipped instruction. The three kinds also compose across a workflow: start with ai to get a first draft, read the plan and the lossiness ledger, then graduate to an explicit guide once the mapping is settled policy.
The same over MCP
Everything above is available to agents through the CoreModels MCP server at /mcp (OAuth-protected; unauthenticated requests get a 401 pointing at the resource metadata, and spec-compliant clients complete discovery and the PKCE flow automatically). For Claude Code:
claude mcp add --transport http coremodels https://go.coremodels.io/mcp
The tool is transform_schema — a stateless schema-to-schema transformation through the same mapping engine, returning schema plus replayable plan plus lossiness ledger. Its arguments mirror the HTTP surface, with the mapping kind flattened to mappingKind:
{
"graphProjectId": "0123456789abcdef0123456789abcdef",
"sourceFormat": "jsonschema",
"sourceSchema": "<contents of customer.schema.json>",
"targetFormat": "sql",
"vendor": "postgres",
"targetHintFormat": "jsonschema",
"targetHintSchema": "<contents of dim-customer.hint.json>",
"mappingKind": "ai",
"guide": "Map onto the DimCustomer hint. full_name is the customer display name; signup_date is the enrollment timestamp."
}
The tool schema is strict (additionalProperties: false — misspelled arguments are rejected, in the same spirit as the guide gate), and it declares an open-world hint: mappingKind=ai calls the Anthropic API server-side. Decode formats are jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | odm; encode formats swap odm for synapse. In practice you rarely hand-write this JSON — you ask your agent to map one schema onto another, it calls the tool, and the gate stands between its enthusiasm and your output either way.
Requirements, honestly
The AI mapping kind has real prerequisites, and we would rather you know them up front:
- A server-configured Anthropic key. The proposer runs server-side and reads its key from deployment configuration —
Transform:Anthropic:ApiKey,Anthropic:ApiKey, or theANTHROPIC_API_KEYenvironment variable. No key configured, noaikind;inferredandexplicitwork regardless. - Project membership.
schema/mapis a Viewer-role endpoint forinferredandexplicit, butmappingKind=airequires Editor or Admin membership in the project — on both the HTTP surface and MCP. Proposing meaning is a privileged act, and we gate it like one. - Your schema content is sent to the Anthropic API, server-side. The source schema, the target hint, and your guidance text are what the proposer reasons over. If a schema must not leave your environment's approved processors, use
inferredorexplicitfor it. - Nothing is written.
schema/mapandplan/executeare stateless; every call is inherently a dry run. The write-capable variant,schema/mapImport, maps onto the project's own schema and is designed dry-run-first (Admin to apply; the dry run itself is Viewer, andaistill requires Editor). The same mapping kinds also drivedata/mapfor cross-schema record migration acrossjson,csv,jsonld,sql, andavrodata.
The pattern generalizes beyond schemas, but the principle never changes: AI proposes, the gate disposes, and the artifact that survives is one you can read, version, and replay. That is what it takes for an LLM to earn a place in a schema pipeline — not trust, but architecture that makes trust unnecessary.
To go further, see the CoreModels docs and quickstarts for the full transform API surface and MCP tool reference.