Ten Minutes With proto3: Your First CoreModels Transform
You have a `.proto` file. It is the truth for a service, and now somebody outside that service needs the same shape — as JSON Schema for a validation step, as a data contract for a review, as a table for analytics. The usual answer is to retype it. This article replaces the retyping with one HTTP call, and then spends most of its length on the part that matters more: reading what the conversion cost you.
Ten Minutes With proto3: Your First CoreModels Transform
You have a .proto file. It is the truth for a service, and now somebody outside that service needs
the same shape — as JSON Schema for a validation step, as a data contract for a review, as a table
for analytics. The usual answer is to retype it. This article replaces the retyping with one HTTP
call, and then spends most of its length on the part that matters more: reading what the conversion
cost you.
Everything below is stateless. The route we use decodes, maps, encodes, and hands the result back;
nothing is written to any project. You need a host (https://coremodels.example.com throughout), a
bearer token in $TOKEN, a CoreModels project id in $PROJECT_ID (32 hex characters — it scopes
authorization only, and Viewer access is enough), plus curl and jq.
The file
Save this as shipment_event.proto. It is a small, ordinary proto3 file that happens to exercise
the constructs that behave interestingly in any conversion: a package, a well-known import, an
explicit optional, a repeated field, an enum, and two scalars whose wire semantics no other
format carries.
syntax = "proto3";
package acme.logistics;
import "google/protobuf/timestamp.proto";
message ShipmentEvent {
string shipment_id = 1;
uint64 sequence = 2;
ShipmentStatus status = 3;
optional string carrier_note = 4;
repeated string package_ids = 5;
google.protobuf.Timestamp occurred_at = 6;
bytes signature = 7;
}
enum ShipmentStatus {
SHIPMENT_STATUS_UNSPECIFIED = 0;
PICKED_UP = 1;
IN_TRANSIT = 2;
DELIVERED = 3;
}
The format key is protobuf, with proto as an accepted alias. It works in both directions — the
key is valid as a source and as a target — and its scope is a single proto3 file. Proto2 is not
accepted, and we say so rather than half-parsing it.
The one rule, then the call
One rule before the call, because it is the most common stumble. The default mapping strategy,
inferred, matches your source against a target hint and declines rather than guesses when
there is none. Call without a hint and you get success: false with the error The inference resolver requires a target IR to match against.
For a straight format conversion, the hint you want is the file itself. That produces an identity plan — every message, field, and enum maps to its own counterpart — and leaves the format work to the target coder.
The schema travels inside JSON as a string, so build the body with jq --rawfile instead of
escaping quotes by hand:
jq -n --rawfile p shipment_event.proto '{
sourceFormat: "protobuf",
sourceSchema: $p,
targetFormat: "jsonschema",
targetHintFormat: "protobuf",
targetHintSchema: $p,
mapping: { kind: "inferred" }
}' > request.json
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 @request.json > result.json
That is the whole transform. The response is one envelope: success, lossiness, errors, the
produced schema, and the executed plan.
Read the schema
jq '.schema' result.json. For JSON-shaped targets the field holds a JSON object:
{
"type": "object",
"properties": {
"shipment_id": { "type": "string" },
"sequence": { "type": "integer" },
"status": {
"enum": ["SHIPMENTSTATUSUNSPECIFIED", "PICKEDUP", "INTRANSIT", "DELIVERED"]
},
"carrier_note": { "type": "string" },
"package_ids": { "type": "array", "items": { "type": "string" } },
"occurred_at": { "type": "string" },
"signature": { "type": "string" }
},
"required": ["shipment_id", "sequence", "status", "signature"]
}
Read it against the input and every decision is visible.
The message became the object; each field became a property under its wire name. repeated became
an array. google.protobuf.Timestamp became a date-time value, which JSON Schema carries as a
string. The enum became an inline controlled list.
The required list is the interesting part, and it is proto3 semantics being preserved rather than
invented. A plain scalar or enum field in proto3 has implicit presence — it always carries a value
— so shipment_id, sequence, status, and signature decode as required. The explicitly
optional field carrier_note tracks presence and does not. Neither does occurred_at, because
message-typed fields (including the well-known ones) track presence too. And package_ids is a
collection, which carries its own emptiness. Most hand conversions collapse that distinction; this
one keeps it.
One cosmetic surprise deserves an explanation. The enum values arrive as SHIPMENTSTATUSUNSPECIFIED
rather than SHIPMENT_STATUS_UNSPECIFIED. Internally each term has an alphanumeric id and a label;
the label holds the exact wire spelling, and a JSON Schema target emits ids. Convert to a proto3
target instead and the underscores come back, because that coder emits labels. If the downstream
consumer cares about the exact symbols, this is the line to check.
Read the ledger
jq '.lossiness' result.json:
[
{ "kind": "SemanticNarrowing",
"path": "imports",
"explanation": "import \"google/protobuf/timestamp.proto\" is preserved verbatim but not resolved; types it defines decode as approximations." },
{ "kind": "TypeApproximation",
"path": "ShipmentEvent.sequence",
"explanation": "proto3 uint64 encoding semantics are not modelled; approximated as Integer with the verbatim type preserved." },
{ "kind": "TypeApproximation",
"path": "ShipmentEvent.signature",
"explanation": "proto3 bytes has no IR equivalent; approximated as String with the verbatim type preserved." }
]
Three entries, each with a kind, an exact path, and a plain-English explanation.
The two TypeApproximation records are the honest cost of leaving proto3's type system. uint64
became an integer: the unsignedness is not modeled anywhere else. bytes became a string. Note the
second half of both explanations — the verbatim type preserved. The original token rides along in
the annotation bag, so a conversion back to proto3 re-emits uint64 and bytes exactly rather than
guessing. The approximation is real for the JSON Schema consumer and reversible for the proto3 one.
The SemanticNarrowing on imports says the import clause was kept but not resolved: we parse one
file, not an include graph. That is why a type from another file decodes as a string with its
original name retained, and why google.protobuf.Timestamp — the one well-known message we treat
specially — becomes a real date-time.
Form the habit now: success: true means "it ran", never "nothing changed." The four kinds you
will meet are StructuralDrop (no home in the target), TypeApproximation (a close-but-not-exact
type), ConstraintRelaxation (a rule that could not be enforced), and SemanticNarrowing (meaning
narrowed or guessed). Treat the list as a review checklist, not an error report.
Change one word
Only targetFormat changes between targets. Ask for odcs — a Bitol Open Data Contract Standard
v3 YAML contract — and the same source comes back as:
apiVersion: v3.1.0
kind: DataContract
id: acmeLogistics
name: acme.logistics
version: 1.0.0
status: active
schema:
- name: ShipmentEvent
properties:
- name: shipment_id
logicalType: string
required: true
- name: sequence
logicalType: integer
required: true
- name: status
logicalType: string
required: true
- name: carrier_note
logicalType: string
- name: package_ids
logicalType: array
items:
logicalType: string
- name: occurred_at
logicalType: timestamp
- name: signature
logicalType: string
required: true
The ledger grows by two entries, both ConstraintRelaxation: Taxonomy[ShipmentStatus] — "ODCS has
no enum/controlled-list construct; the taxonomy is not represented" — and
Element[ShipmentEventStatus], whose reference to that taxonomy "was approximated as string." The
status field exists in the contract; the guarantee that it only holds one of four values does not.
Now you know to state it another way.
The full target list is jsonschema | shex | avro | jsonld | sql | osi | osi-json | owl | linkml | protobuf | odcs | synapse.
And back again
Because protobuf is also a target format, setting "targetFormat": "protobuf" returns proto3
text in the schema field as a string — read it with jq -r '.schema':
syntax = "proto3";
package acme.logistics;
import "google/protobuf/timestamp.proto";
message ShipmentEvent {
string shipment_id = 1;
uint64 sequence = 2;
ShipmentStatus status = 3;
optional string carrier_note = 4;
repeated string package_ids = 5;
google.protobuf.Timestamp occurred_at = 6;
bytes signature = 7;
}
enum ShipmentStatus {
SHIPMENT_STATUS_UNSPECIFIED = 0;
PICKED_UP = 1;
IN_TRANSIT = 2;
DELIVERED = 3;
}
Package, import clause, field numbers, the optional label, the exact scalar tokens, the enum
symbols with their numbers — all back where they started. For a comment-free file like this one,
already in the encoder's own layout, the round trip is byte-identical; comments are consumed by the
tokenizer and are not part of the schema, so they do not come back.
Where to go next
Two continuations. If the schema should become a governed model rather than a converted file, POST /graph/transform/schema/import/{projectId} with {"format": "protobuf", "schema": "<the .proto text>"} writes its messages, fields, and enums into a project — that route writes, so it needs
Admin. And if you would rather have an agent do this, the same engine is an MCP tool named
transform_schema taking the same fields, with a flat mappingKind: "inferred" argument in place
of the mapping object.
The transform section of the CoreModels documentation has the full endpoint list, the role each one needs, and ready-to-paste bodies for every format.