# Protocol Buffers — Quickstart: 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.

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

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

```json
{
  "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`:

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

```yaml
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'`:

```proto
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.
