Ship the Plan, Not the Script: Avro Pipelines That Replay
Here is a job every streaming platform eventually has. An internal topic carries a rich record; a partner, a public catalog, or another business unit gets a redacted projection of it. Somebody writes a script. The script knows which fields to drop and which to rename, and that knowledge lives nowhere else. Six months later the source schema gains a field, the script silently passes it through, and the first person to notice is on the other side of the boundary.
Ship the Plan, Not the Script: Avro Pipelines That Replay
Here is a job every streaming platform eventually has. An internal topic carries a rich record; a partner, a public catalog, or another business unit gets a redacted projection of it. Somebody writes a script. The script knows which fields to drop and which to rename, and that knowledge lives nowhere else. Six months later the source schema gains a field, the script silently passes it through, and the first person to notice is on the other side of the boundary.
CoreModels takes a different position: the mapping is a first-class artifact. Every transform returns the plan it executed as JSON — reviewable, committable, replayable — and execution is deterministic, so the same plan over the same source produces the same bytes every run. This article builds an Avro pipeline on those two properties.
Three mapping kinds, applied to Avro
The engine surface is POST graph/transform/schema/map/{projectId} (and the identical engine behind
the transform_schema MCP tool). Its mapping object takes one of three kinds:
inferred(default) — label and type matching against a target hint.caseInsensitivedefaults totrue, which is what you want when camelCase Avro fields meet an upper-case or snake_case target: against a hint whose fields areBASKETIDandGRANDTOTAL, the default produces three operations; flip it tofalseand only the record itself matches, leaving one. Every operation is stamped"origin": "Inferred".explicit— you author a mapping guide:autoMatchByMapsTo,fieldMappings(each{sourceElementIds, targetElementIds, transformName}),taxonomyDirectives,drops. This is the kind that suits Avro best, for a reason specific to the format — see below.ai— a server-side Claude proposer drafts the plan and the same validation gate accepts or rejects it, with at most one repair attempt. It needs a server-configured Anthropic key and Editor/Admin membership, and it sends schema content to the Anthropic API. Reserve it for the first draft of a gnarly mapping — then freeze the returned plan and never call the proposer again.
Whichever kind produced it, the plan is what you version. Strategies are for authoring; pipelines replay.
Why explicit fits Avro: x-maps-to
Avro carries no IRIs of its own, but the specification tolerates unknown attributes in schema JSON,
and CoreModels reads four of them (x-sia-role, x-sia-priority, x-sia-instruction, and
x-maps-to) on records and on fields. x-maps-to is the one that earns its keep: with
autoMatchByMapsTo: true, source and target constructs that share an IRI are aligned even when their
labels differ — so a mapping survives a rename on either side.
Here is the redaction job. CheckoutEvent.avsc, the internal record:
{
"type": "record",
"name": "CheckoutEvent",
"namespace": "com.acme.checkout",
"doc": "One completed checkout, as published to the payments topic.",
"x-maps-to": { "acme": "https://data.acme.example/checkout-event" },
"fields": [
{ "name": "basketId", "type": "string",
"x-maps-to": { "acme": "https://data.acme.example/checkout-event/basket" } },
{ "name": "grandTotal", "type": "double",
"x-maps-to": { "acme": "https://data.acme.example/checkout-event/total" } },
{ "name": "riskScore", "type": ["null", "double"] },
{ "name": "operatorNotes", "type": ["null", "string"] },
{ "name": "completedAt", "type": { "type": "long", "logicalType": "timestamp-millis" },
"x-maps-to": { "acme": "https://data.acme.example/checkout-event/completed-at" } }
]
}
CheckoutEvent.public.avsc, the shape partners are promised — same record name, same IRIs, fewer
fields:
{
"type": "record",
"name": "CheckoutEvent",
"namespace": "com.acme.public",
"x-maps-to": { "acme": "https://data.acme.example/checkout-event" },
"fields": [
{ "name": "basketId", "type": "string",
"x-maps-to": { "acme": "https://data.acme.example/checkout-event/basket" } },
{ "name": "grandTotal", "type": "double",
"x-maps-to": { "acme": "https://data.acme.example/checkout-event/total" } },
{ "name": "completedAt", "type": { "type": "long", "logicalType": "timestamp-millis" },
"x-maps-to": { "acme": "https://data.acme.example/checkout-event/completed-at" } }
]
}
Keep the record name the same on both sides when the target format is Avro. The encoder roots the
output document on the record the source was rooted on; rename it in the mapping and you get The schema has no root type to encode as an Avro record.
And redact.guide.json:
{
"autoMatchByMapsTo": true,
"drops": ["CheckoutEvent.riskScore", "CheckoutEvent.operatorNotes"]
}
jq -n --rawfile s CheckoutEvent.avsc \
--rawfile h CheckoutEvent.public.avsc \
--rawfile g redact.guide.json '{
sourceFormat: "avro", sourceSchema: $s,
targetFormat: "avro",
targetHintFormat: "avro", targetHintSchema: $h,
mapping: { kind: "explicit", guide: $g }
}' \
| curl -sS -X POST \
"https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- > redact-result.json
The schema is the published record: basketId, grandTotal, completedAt, with the namespace,
doc string, logical type, and every x-maps-to intact — and no riskScore, no operatorNotes. The
plan is six operations: two Drops, a TypeMapping, and three ElementMappings, each stamped
"origin": "Explicit". The drops carry their own reason inside the plan:
{ "kind": "Drop", "origin": "Explicit",
"sourceNodeId": "CheckoutEvent.riskScore", "sourceKind": "Element",
"reason": "Dropped by the SIA guide.",
"declaredLossiness": [
{ "kind": "StructuralDrop", "path": "Element[CheckoutEvent.riskScore]",
"explanation": "Dropped by the SIA guide." } ] }
A reviewer reading redact.plan.json in a pull request sees exactly what will happen to every field
before it happens to any of them. Commit it next to the guide.
One counting note while you are here: the response ledger aggregates decode, plan, gate, execute, and
encode lossiness, and the gate and the executor each carry the plan's declared drops — so a single
dropped field can appear twice. Deduplicate on (kind, path, explanation) before you report a number.
Replay is the steady state
From here the pipeline never re-derives the mapping:
jq '.plan' redact-result.json > redact.plan.json
jq -n --rawfile s CheckoutEvent.avsc --rawfile p redact.plan.json '{
sourceFormat: "avro", sourceSchema: $s,
plan: $p,
targetFormat: "avro"
}' \
| curl -sS -X POST \
"https://coremodels.example.com/graph/transform/plan/execute/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @-
The plan travels as a string — hence --rawfile. The hint is optional on replay, because the plan
already names its targets, and the output is identical either way. The stored plan still passes the
same universal gate on every run: it earns no shortcut for having been approved once.
That gate is what makes replay safe in CI. Rename basketId to basket_id upstream and the next
replay fails loudly instead of quietly emitting a two-field record:
{ "path": "plan.Operations[3](ElementMapping).SourceElementIds",
"message": "Source element 'CheckoutEvent.basketId' does not resolve." }
Operation index, operation kind, and the id that vanished. That is a drift alarm, delivered by the same call that does the work.
Batch conversion, and a gate on the ledger
Deterministic output means diffs are meaningful, which means a directory of registry schemas can be converted on every change and reviewed like code:
mkdir -p out
for f in schemas/*.avsc; do
name=$(basename "$f" .avsc)
jq -n --rawfile s "$f" '{
sourceFormat: "avro", sourceSchema: $s,
targetFormat: "jsonschema",
targetHintFormat: "avro", targetHintSchema: $s,
mapping: { kind: "inferred" }
}' \
| curl -sS -X POST \
"https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @- > "out/$name.result.json"
jq '.schema' "out/$name.result.json" > "out/$name.schema.json"
jq '.lossiness' "out/$name.result.json" > "out/$name.ledger.json"
done
Run that twice over the same inputs and the outputs are byte-identical, which is the property that
makes git diff a real review surface. Then gate on the ledger rather than on success alone —
success: true only means the transform ran. A refund schema whose amount is an Avro decimal
produces this, every time:
[
{ "kind": "TypeApproximation", "path": "Element[Refund.amount]",
"explanation": "Avro decimal precision/scale not modelled; approximated as Double." }
]
Money losing its scale is exactly the kind of thing a build should say out loud:
fail=0
for r in out/*.result.json; do
bad=$(jq '[.lossiness[] | select(.kind == "StructuralDrop" or .kind == "TypeApproximation")] | length' "$r")
if [ "$bad" -gt 0 ]; then
echo "REVIEW: $r"
jq -r '.lossiness[] | " [\(.kind)] \(.path): \(.explanation)"' "$r"
fail=1
fi
done
exit $fail
For the redaction pipeline you would allow precisely the drops the committed plan declares and fail on anything else — the point being that the permitted loss is written down in a reviewed artifact, and everything beyond it stops the line.
The gate also reviews your guide
Three failures worth knowing before they happen in CI, each returned inside the normal envelope with a path:
- A typo in the guide is rejected, not ignored:
guide.dropz: Unknown mapping-guide key 'dropz'. Known keys: autoMatchByMapsTo, fieldMappings, taxonomyDirectives, drops. - A drop that does not name a real source construct is rejected:
guide.Drops: Drop target 'CheckoutEvent.nosuchfield' is not a source construct. - A multi-field
fieldMappingsentry (split/join) needs a registeredtransformName; leave it out and the guide is rejected —A multi-field mapping (split/join) must name a field transform from the registry.— and a name the registry does not know reaches the gate asField transform '…' is not registered.The built-in vocabulary isSplitName,JoinName,CoerceStringToDateTime,CoerceDateTimeToString,CoerceIntegerToDouble, andCoerceDoubleToInteger. For plain 1:1 alignment, preferautoMatchByMapsTo— that is what the annotations are for.
And one thing the engine does for you, on inferred mappings: a source field that the plan neither maps
nor drops is declared rather than dropped in silence — StructuralDrop at
Type[CheckoutEvent].CheckoutEvent.riskScore, "Element is a member of the mapped type but no
operation maps or drops it." Run the redaction with inferred instead of a guide and you get the same
published record, with the two omissions in the ledger instead of in the plan. Both are honest; the
guide is what makes them intentional.
The shape of the whole pipeline
- Author once. Derive the plan with
inferred,explicit, orai, whichever matches how well you can state the mapping. Read it: every heuristic is marked byorigin, every loss bydeclaredLossiness. - Review and commit. Plan and guide go into the repo. Human judgement happens once, visibly, instead of on every run, invisibly.
- Replay forever. CI calls
plan/executeon each source change. The gate catches drift; determinism makes diffs trustworthy; the ledger keeps residual loss auditable. - Regenerate deliberately. When the source genuinely evolves, re-derive and diff the plan — a far smaller and far more honest object than a diff of generated schemas.
Conversions stop being something your pipeline does and become something your pipeline has. The
transform API documentation specifies the request shapes for schema/map and plan/execute, the
guide grammar, and the validation gate's contract.