# Protobuf schema export

Export a desired schema to a Protobuf Edition 2023 definition whose field numbers stay wire compatible as the schema changes.

Source: https://docs.ptah.run/v0.8.1/schema/protobuf/

Project the desired schema Ptah parses — a YAML, HCL, SQL, or DBML file named
by `--schema-file`, or Go annotations under `--root-dir` — into a Protobuf definition:
one message per table, one enum per enum column, and scalar fields for foreign
keys. Unlike the OpenAPI and GraphQL targets on
[API schema export](../export/), this one is stateful. Protobuf field numbers
are permanent wire identifiers, so the file Ptah wrote last time is read back
and used as the source of every number it already pins.

Ptah emits messages and enums, not Protobuf services or remote procedure calls
(RPCs). It does not create a server, access database rows, or implement
authorization. Publishing the generated file still reveals the selected table
and column names, translated types, and enum values. It does **not** carry the
comments the source schema supplies unless you ask for them with
[`--proto-comments all`](#control-what-the-contract-says).

Prerequisites: a built `ptah` binary and a desired schema as a YAML, HCL, SQL,
or DBML file or a directory of Go annotations. `buf` and `protoc` are needed
only if you want to lint or compatibility-check the result yourself.

:::caution[Wire compatibility is not API safety]
Compatibility checks prevent accidental field-number reuse and detect selected
wire or JSON contract breaks. They do not decide whether a field is appropriate
for an API. Under the default field policy, every selected column not declared
with `api_expose: none` enters its generated message.
:::

## Generate the first file

Start from `schema.yaml`:

```yaml
tables:
  products:
    columns:
      id: { type: SERIAL, primary: true }
      name: { type: VARCHAR(255), not_null: true }
      price: { type: "DECIMAL(10,2)", not_null: true }
      status:
        type: ENUM
        enum: [active, inactive]
        not_null: true
      tags: { type: "TEXT[]" }
      created_at: { type: TIMESTAMPTZ, not_null: true }
      updated_at: { type: TIMESTAMP }
```

Export it:

```bash
ptah schema export --to protobuf \
  --schema-file schema.yaml \
  --out ./proto/acme/inventory/v1/schema.proto \
  --proto-package acme.inventory.v1 \
  --go-package github.com/acme/inventory/gen/inventory/v1
```

Expected output includes:

```text
warning: ./proto/acme/inventory/v1/schema.proto: no previous export found at ./proto/acme/inventory/v1/schema.proto; field numbering starts from 1 and is not compatible with any previously published .proto
warning: products.price: exact numeric mapped to string; Protobuf has no decimal type and float/double would lose precision
warning: products.tags: nullable array column exported as repeated; protobuf cannot distinguish SQL NULL from an empty list
warning: products.updated_at: timezone-ambiguous timestamp mapped to string; google.protobuf.Timestamp is only used for types with explicit time-zone semantics
Exported Protobuf schema to ./proto/acme/inventory/v1/schema.proto
Exported 1 message(s), 7 field(s), 1 enum(s)
bootstrapped new compatibility history
4 export warning(s) reported
```

Diagnostics go to stderr; the summary goes to stdout. `bootstrapped new
compatibility history` appears only on the first run, when no previous file
exists to pin numbers from.

The generated `proto/acme/inventory/v1/schema.proto`:

```proto
edition = "2023";

package acme.inventory.v1;

import "google/protobuf/timestamp.proto";

option go_package = "github.com/acme/inventory/gen/inventory/v1";

message Product {
  int32 id = 1;
  string name = 2;
  string price = 3;
  ProductsStatus status = 4;
  repeated string tags = 5;
  google.protobuf.Timestamp created_at = 6;
  string updated_at = 7;
}

enum ProductsStatus {
  PRODUCTS_STATUS_UNSPECIFIED = 0;
  PRODUCTS_STATUS_ACTIVE = 1;
  PRODUCTS_STATUS_INACTIVE = 2;
}

// Code generated by ptah schema export --to protobuf; DO NOT EDIT.
// ptah:protobuf-export-version=2
// ptah:content-sha256=a1a7afeeb1a361c2257b52b6c46386c591a045294f5d7b39cf15693b5025f952
```

The three header lines sit at the foot of the file, not the top. As the file's
leading comment they are its leading detached comment, and `protoc-gen-go`
copies that comment verbatim to the top of every `.pb.go` it writes — putting
Ptah's content digest into your generated Go and making its first
`Code generated by` line name `ptah` rather than `protoc-gen-go`.

Version 2 is the only format read or written. A file written by an older Ptah
carries the same three lines at the **top** and declares
`ptah:protobuf-export-version=1`; that layout is retired, and such a file is
refused rather than migrated:

```text
error: unsupported ptah protobuf export version: file declares export version 1, this Ptah reads and writes only 2; delete the file and export again to start a new compatibility history
```

Deleting it starts a new compatibility history, so field numbers are allocated
from 1 again. If the old `.proto` was published, re-pin the numbers by hand or
regenerate consumers against the new file — Ptah is pre-general-availability
and does not carry a reader for the retired layout.

The output passes `buf lint` with the `STANDARD` rule set as long as every
table, column and enum label is already a legal identifier. Message names are
PascalCase, field names are `lower_snake_case`, and every enum carries the
required zero value under its own `ENUM_NAME_` prefix.

Identifiers that are not legal are sanitized rather than rejected: characters
outside `[A-Za-z0-9_]` become `_`, and a leading digit gains an `_` prefix, so
a column `2fa_enabled` becomes the field `_2fa_enabled`. Ptah warns twice in
that case — once that the name was changed, and once that `buf lint` reports
`FIELD_LOWER_SNAKE_CASE` for the result. Rename the column if you need a
lint-clean file.

## Commit the generated file

`--out` is required for `--to protobuf`, and the file it names is not a
disposable artifact. It is the compatibility state: the record of which column
holds which field number, and which numbers have been retired.

:::caution
Commit the generated `.proto`. If it is missing on the next run, Ptah has
nothing to pin numbers from, restarts at 1, and prints `bootstrapped new
compatibility history` — a silently incompatible history for any consumer still
holding the old numbers.
:::

Ptah protects that state in both directions:

- The file carries a `ptah:content-sha256` header covering everything else in
  it. A hand edit makes the next export refuse rather than renumber around your
  change.
- The write is atomic. A refusal, a render failure, or an invalid generated
  file leaves the previous version byte-identical on disk.

## Use Go annotations

Go annotations use `--root-dir`, exactly as they do on
[`ptah schema render`](../../reference/native-commands/):

```bash
ptah schema export --to protobuf \
  --root-dir ./models \
  --out ./proto/acme/inventory/v1/schema.proto \
  --proto-package acme.inventory.v1
```

The source format never reaches the generated file: YAML, HCL, or Go sources
describing the same tables and the same API metadata produce the same `.proto`,
down to the field numbers and content digest. SQL and DBML produce the same
result for storage semantics, but cannot author API names, type overrides, or
exposure. Moving between sources that express the same schema therefore does
not restart the numbering history.

`--from` declares the file's format and is checked against its extension, so
`--from yaml --schema-file schema.sql` is refused rather than parsed as the
wrong format. Leave `--from` unset and the extension decides.

## Flags

| Flag | Required | Meaning |
| --- | --- | --- |
| `--to protobuf` | yes | Selects this target. |
| `--from` | no | Format of the `--schema-file` value: `go` (default), `yaml`, `hcl`, `sql`, or `dbml`. Checked against the file extension; leave it unset to take the format from the extension. |
| `--root-dir` | no | Directory scanned for Go annotations (default `.`). |
| `--schema-file` | no | YAML, HCL, SQL, or DBML schema file to export instead of Go annotations. Repeatable; merged with `--root-dir` when both are given. |
| `--out` | yes | Destination `.proto`. Also the compatibility state read back on the next run. |
| `--proto-package` | yes | Protobuf package, `lower_snake.case` segments, for example `acme.inventory.v1`. |
| `--go-package` | no | Emitted as `option go_package`. |
| `--include-tables` | no | Comma-separated allowlist of tables. |
| `--exclude-tables` | no | Comma-separated denylist, applied after the allowlist. |
| `--proto-type-removal` | no | `error` (default), `tombstone`, or `drop`. See [Handle removals and incompatible changes](#handle-removals-and-incompatible-changes). |
| `--proto-on-incompatible-change` | no | `error` (default) or `renumber`. |
| `--proto-on-name-reuse` | no | `error` (default) or `release`. |
| `--proto-on-field-removal` | no | `error` (default) or `reserve`. See [Handle removals and incompatible changes](#handle-removals-and-incompatible-changes). |
| `--proto-split` | no | `none` (default) or `table`. See [Split the export across files](#split-the-export-across-files). |
| `--proto-on-type-move` | no | `error` (default) or `relocate`. |
| `--proto-comments` | no | `none` (default) or `all`. See [Control what the contract says](#control-what-the-contract-says). |

`--title` belongs to `openapi-v3` and is ignored here with a warning. Setting
`--proto-package`, `--go-package`, or a policy flag to a value that would do
something is an error on any other target, not a silent no-op:

```text
error: --proto-package is only supported with --to protobuf
```

Ptah cannot see your `buf` module root, so path and package conventions are
reported as warnings, never errors. A package without a version segment, a file
name that is not `lower_snake_case.proto`, or an output directory that does not
mirror the package each produce one warning naming the `buf lint` rule it
trips.

## Why Edition 2023

Ptah emits `edition = "2023"`, not Edition 2024 and not `proto3`. No
wire-relevant feature default differs between the two editions, and Edition
2024 breaks this target in three independent ways:

- **It rejects ordinary database identifiers.** Edition 2024 turns on
  `features.enforce_naming_style = STYLE2024`. A column named `address_1`
  becomes a hard compile error — `Field name address_1 contains style violating
  underscores` — as does an enum label such as `tier_1`. Those names are legal
  under Edition 2023 and `proto3`, and a schema exporter cannot rename its
  input.
- **No tagged parser supports it.** Ptah reads the previous export with
  `bufbuild/protocompile`, whose newest tagged release caps at Edition 2023.
- **It changes generated Go for every consumer.** Edition 2024 switches
  `protoc-gen-go` to the Opaque API for the whole file, a source-breaking
  change Ptah has no business making on a downstream team's behalf.

Under editions, `reserved` names are bare identifiers (`reserved nickname;`),
the inverse of the quoted form `proto2` and `proto3` use.

Reservations are read back and written out as ranges, never as the numbers a
range covers. A range is accepted at any width the field-number space allows,
so a previous file carrying `reserved 20000 to 536870911;` round-trips
unchanged and contiguous retired numbers are written as `reserved 2 to 8;`
rather than one entry each.

## Type mapping

The lookup is dialect-agnostic, so the Postgres and MySQL spellings Ptah emits
normalize to the same result. Arrays map to `repeated` of the element type.

| Ptah type | Protobuf |
| --- | --- |
| `SMALLINT`, `INT`, `SERIAL`, `MEDIUMINT`, `YEAR` | `int32` |
| `BIGINT`, `BIGSERIAL` | `int64` |
| the same, with `UNSIGNED` | `uint32` / `uint64` |
| `BOOLEAN` | `bool` |
| `REAL`, `FLOAT4` | `float` |
| `DOUBLE PRECISION`, `FLOAT8` | `double` |
| `DECIMAL(p,s)`, `NUMERIC`, `MONEY` | `string` (lossy) |
| `VARCHAR(n)`, `CHAR(n)`, `TEXT`, `UUID`, `INET`, … | `string` |
| `BYTEA`, `BLOB`, `BINARY`, `BIT` | `bytes` |
| `JSON`, `JSONB` | `google.protobuf.Value` |
| `TIMESTAMPTZ` | `google.protobuf.Timestamp` |
| `TIMESTAMP`, `DATETIME` | `string` (lossy) |
| `DATE`, `TIME`, `TIMETZ` | `string` (lossy) |
| enum column | generated `enum` type |
| array column | `repeated` of the element mapping |

An unrecognized column type maps to `string` and emits a warning, so an
unresolved custom type is visible rather than silently wrong.

The three lossy rows are lossy because the stored type does not say enough. A
`TIMESTAMP` gets `string` rather than `google.protobuf.Timestamp` because
nothing in the schema states its time zone — but the author knows, and can say
so with YAML or HCL `api_type: TIMESTAMPTZ` / `api_type = "TIMESTAMPTZ"`, or
Go annotation `api_type="TIMESTAMPTZ"`. That publishes the well-typed field and
its import. See [Types in the contract](../export/#types-in-the-contract). The
override picks a row of this table, or names a declared enum; it cannot
introduce a Protobuf type that is neither, and one the export cannot produce
fails the export rather than defaulting to `string`.

Once declared, the published type is independent of the column: re-typing the
column underneath a pinned `api_type` is invisible on the wire, while changing
the `api_type` is a wire-incompatible change and is handled by the policy below.

## What the projection loses

Protobuf describes a message on the wire, not a table in a database. Four
schema guarantees do not survive the crossing, and three of them announce
themselves as export warnings:

- **`NOT NULL` disappears.** Editions have no `required`; every singular field
  has explicit presence and may be absent. The generated `.proto` carries no
  trace of which columns are mandatory, so validate that in your service, not
  from the schema.
- **`repeated` cannot be null.** An array column becomes `repeated`, which
  represents an empty list and a SQL `NULL` identically. A nullable array
  warns: `nullable array column exported as repeated; protobuf cannot
  distinguish SQL NULL from an empty list`.
- **Exact numerics become strings.** Protobuf has no decimal type, and
  `float`/`double` would silently lose precision on money, so `DECIMAL`,
  `NUMERIC`, and `MONEY` map to `string`.
- **Timezone-ambiguous timestamps become strings.**
  `google.protobuf.Timestamp` is a fixed instant in Coordinated Universal Time
  (UTC), so only types with explicit time-zone semantics use it. `TIMESTAMP`
  without a time zone, `DATE`, and `TIME` map to `string`.

Non-column objects — views, triggers, functions, row-level security (RLS)
policies, standalone indexes — are not part of a message definition and are not
emitted. Foreign keys keep their scalar column and gain no relation field;
Protobuf has no relation concept.

## Control what the contract says

Table and column comments are written for whoever maintains the database. They
are copied into the generated definition by default, and `protoc-gen-go` copies
them again into the `.pb.go` it produces, so a note meant for a migration
reviewer ends up in a published package.

Starting from a model whose comments say more than a consumer should read:

```go
//ptah:schema:table name="users" comment="Internal: sharded by tenant_id; see runbook RB-42"
type User struct {
	//ptah:schema:field name="id" type="SERIAL" primary="true"
	ID int64

	//ptah:schema:field name="email" type="VARCHAR(255)" not_null="true"
	Email string

	//ptah:schema:field name="password_hash" type="VARCHAR(255)" not_null="true" comment="bcrypt hash, never expose"
	PasswordHash string
}
```

They are kept out of the file by default. `--proto-comments all` is what puts
them in; the default run needs no flag:

```bash
ptah schema export --to protobuf \
  --root-dir ./models \
  --out ./proto/acme/inventory/v1/schema.proto \
  --proto-package acme.inventory.v1
```

```proto
// Code generated by ptah schema export --to protobuf; DO NOT EDIT.
// ptah:protobuf-export-version=1
// ptah:content-sha256=410a512e42ff37d9e9e53485b91e2f7c96a17dac2aeb2e8113e497611da0d476
edition = "2023";

package acme.inventory.v1;

message User {
  int32 id = 1;
  string email = 2;
  string password_hash = 3;
}
```

Three properties are worth stating precisely:

- **It is not compatibility state.** Comments are never read back from the
  previous file — only numbers and reservations are — so turning the flag on or
  off never moves a field number. In the file above `password_hash` is still
  `3`, and dropping the flag again restores both comments byte for byte.
- **It is all-or-nothing.** There is no per-table or per-column form. A table
  comment can carry exactly the internal detail a column comment can, so a
  partial switch would advertise a boundary the contract does not have.
- **Ptah's own lines stay.** The three header lines and the tombstone rationale
  are not source prose: they describe the generated file rather than the
  database. Suppressing the rationale would leave a bare `reserved` block with
  nothing to explain it.

`all` remains the default. Flipping it would rewrite every already published
`.proto` on the next export, which is exactly the review this flag exists to
make explicit rather than automatic.

## Keep numbers stable

Field numbers follow the field's **published** name, not its position — the
`proto_name` where one is declared, `api_name` otherwise, and the column name
when neither is. Reordering
columns produces no diff, and a new column takes the next number above
everything the message has ever used — including retired numbers, so a number
is never recycled.

The table-level `proto_name` is a message-name stem: Ptah singularizes and
PascalCases `invoice_records` into `InvoiceRecord`. The column-level
`proto_name` is the exact lower-snake-case field name. An explicit value that
does not produce a valid message name, or an invalid field value such as
`amountMinor`, fails before the compatibility file is written.

That is what makes a storage rename survivable. Declare the published name once
and the column underneath can change without touching the wire. Each authoring
format spells the same identity directly:

**YAML**

```yaml
columns:
  billing_amount_minor:
    type: INTEGER
    api_name: amount
# renamed later to invoice_total_cents with api_name unchanged
```

**HCL**

```hcl
column "billing_amount_minor" {
  type     = integer
  api_name = "amount"
}
# renamed later to "invoice_total_cents" with api_name unchanged
```

**Go annotations**

```go
//ptah:schema:field name="billing_amount_minor" api_name="amount" type="INTEGER"
// renamed later to:
//ptah:schema:field name="invoice_total_cents" api_name="amount" type="INTEGER"
```

`int32 amount = 2;` before and after, and nothing is reserved, because no
identity was retired.

`proto_name` scopes that identity to this export. It exists because Protobuf's
naming rules are not the other formats': `buf lint` wants `lower_snake_case`,
where a GraphQL field is conventionally camelCase, so a schema that publishes
`amountMinor` in GraphQL declares `proto_name="amount_minor"` here rather than
picking one spelling for both. A pinned `proto_name` absorbs a change to the
column **and** to the shared `api_name` — the number follows the published name,
and the published name is this one.

Which also means it carries the same weight. Changing the `proto_name`, or the
`api_name` when no `proto_name` is declared, is the opposite case — it retires
an identity consumers hold, so it is refused by default and handled by
[Handle removals and incompatible changes](#handle-removals-and-incompatible-changes).
The two identities are explained on
[API schema export](../export/#names-in-the-contract).

Starting from this message:

```proto
message Item {
  int32 id = 1;
  string label = 2;
}
```

Moving `label` above `id` and adding a `sku` column yields:

```proto
message Item {
  int32 id = 1;
  string label = 2;
  string sku = 3;
}
```

Types, fields, and enum values are always written sorted, so the diff of a
generated file shows only what actually changed.

## Split the export across files

`--proto-split=table` writes one file per exported table next to `--out`, named
after the message it holds. `--out` keeps holding every generated enum, and the
table files import it:

```bash
ptah schema export --to protobuf \
  --root-dir ./models \
  --out ./proto/acme/shop/v1/schema.proto \
  --proto-package acme.shop.v1 \
  --proto-split table
```

```text
Exported Protobuf schema to ./proto/acme/shop/v1/schema.proto
Exported Protobuf schema to ./proto/acme/shop/v1/customer.proto
Exported Protobuf schema to ./proto/acme/shop/v1/order.proto
Exported 2 message(s), 9 field(s), 1 enum(s) across 3 file(s)
```

Every file of the set carries its own `ptah:content-sha256` header, so a hand
edit to any one of them makes the next export refuse and name that file. The
`--out` file additionally records the rest of the set, on a fourth line of the
same header block at the foot of the file:

```proto
// Code generated by ptah schema export --to protobuf; DO NOT EDIT.
// ptah:protobuf-export-version=2
// ptah:content-sha256=…
// ptah:protobuf-export-files=customer.proto,order.proto
```

That inventory is what the next run reads the set back through. Commit every
file of it: they are one compatibility state, not one file plus some artifacts.
Deleting a member that the inventory still names is refused rather than treated
as a deleted table.

:::caution
The inventory line, not the version number, is what marks a set: every file of a
multi-file export declares the same `ptah:protobuf-export-version=2` a
single-file export does. The line sits inside the digest-covered header block,
so editing it out to shrink the set is refused as a modified file rather than
read as a smaller export.
:::

### Moving a table between files

The file a message lives in is where its field numbers are recorded, so turning
`--proto-split` on for a schema that already has a baseline moves every message
out of `--out`. Handled as a removal plus an addition that restarts the message
at field number 1 — precisely the incompatibility the baseline exists to
prevent — so it is refused:

```text
error: types would move between files: message "Customer" from "schema.proto" to "customer.proto"; message "Order" from "schema.proto" to "order.proto"; a move is indistinguishable from a removal plus an addition, which restarts field numbering at 1, so pass --proto-on-type-move=relocate to carry the pinned numbering into the new file
```

`--proto-on-type-move=relocate` performs the move, carrying every pinned number
and every reservation into the new file:

```bash
ptah schema export --to protobuf \
  --root-dir ./models \
  --out ./proto/acme/shop/v1/schema.proto \
  --proto-package acme.shop.v1 \
  --proto-split table \
  --proto-on-type-move relocate
```

A relocated message keeps its history, so `buf breaking` with `WIRE_JSON`
reports nothing across the split.

When a table disappears and `--proto-type-removal=drop` abandons its
compatibility guarantees, the file that held it has nothing left and is deleted:

```text
Removed ./proto/acme/shop/v1/customer.proto
```

With `tombstone`, the file stays and holds the tombstone instead.

## Handle removals and incompatible changes

Three schema edits cannot be projected safely without a decision. Each has a
policy flag that defaults to `error`, so Ptah refuses and names the flag rather
than choosing your compatibility trade-off for you.

### A table or enum disappears

Protobuf can reserve a field number and a field name, but it cannot reserve a
top-level type name. A table that is dropped and later recreated would restart
at 1 and collide with numbers old consumers still hold.

Removing the `legacy_carts` table from this file:

```proto
message LegacyCart {
  int32 id = 1;
  string token = 2;
}

message Product {
  int32 id = 1;
  string name = 2;
}
```

fails by default:

```text
error: types removed from the source schema: LegacyCart; protobuf cannot reserve a top-level type name, so choose --proto-type-removal=tombstone to retain them for wire compatibility or =drop to abandon it
```

With `--proto-type-removal tombstone`, the message stays as an empty shell with
everything it ever held reserved:

```proto
// Removed from the source schema; retained for wire compatibility.
message LegacyCart {
  reserved 1 to 2;
  reserved id, token;
}

message Product {
  int32 id = 1;
  string name = 2;
}
```

`--proto-type-removal drop` deletes the type and warns that its numbers are no
longer reserved and its wire compatibility is abandoned. Choose it only when no
consumer holds the old type. A tombstoned enum keeps its synthesized zero value,
because `protoc` rejects an enum with no values.

### A column's type changes

Changing `quantity` from `INT` to `BIGINT` changes the field from `int32` to
`int64`, which readers of the old type cannot decode:

```proto
message Order {
  int32 id = 1;
  int32 quantity = 2;
  string note = 3;
}
```

```text
error: field "quantity" on message "Order" changed from int32 to int64, which is not wire compatible; pass --proto-on-incompatible-change=renumber to reserve the old number and allocate a new one
```

`--proto-on-incompatible-change renumber` performs the wire-safe equivalent of
a delete plus an add: the old number is reserved and the field is reallocated
above the high-water mark.

```proto
message Order {
  int32 id = 1;
  string note = 3;
  int64 quantity = 4;

  reserved 2;
}
```

The field keeps its name, so the JSON name `quantity` now binds to number 4
instead of 2, and consumers must be updated to read the new number. Renumbering
is a deliberate break, which is why it is never the default.

### A removed column comes back

When a column is dropped, its number and its name are both reserved:

```proto
message Customer {
  int32 id = 1;
  string email = 2;

  reserved 3;
  reserved nickname;
}
```

Protobuf refuses to compile a file that reuses a reserved name, so adding
`nickname` back fails:

```text
error: field "nickname" on "Customer" is reserved because it was previously removed, and protobuf refuses to reuse a reserved name; pass --proto-on-name-reuse=release to drop the name reservation (its number stays reserved) and abandon JSON-name compatibility for it
```

`--proto-on-name-reuse release` drops the name reservation and keeps the number
reservation, so the column returns at a fresh number:

```proto
message Customer {
  int32 id = 1;
  string email = 2;
  string nickname = 4;

  reserved 3;
}
```

The JSON name `nickname` now binds to number 4 instead of 3. That is a real
JSON incompatibility, reported once by `buf breaking` and by the export
warning.

## Check compatibility with buf

Ptah's guarantee is wire and JSON compatibility, which is exactly the
`WIRE_JSON` rule set. Pin your `buf` configuration to it:

```yaml
version: v2
breaking:
  use:
    - WIRE_JSON
```

A correctly reserved deletion passes:

```bash
buf breaking . --against ../previous
```

:::caution
Do not use the `FILE` category on a generated schema. `FILE` treats any removed
field as breaking even when its number and name are properly reserved, so a
dropped column fails the check that Ptah's reservations exist precisely to
satisfy:

```text
acme/shop/v1/schema.proto:8:1:Previously present field "3" with name "nickname" on message "Customer" was deleted.
```

No schema-driven generator can satisfy `FILE`, because a column that no longer
exists cannot keep producing a field.
:::

## Failure modes

Every check below runs before anything is written, and each exits with code
`2`, leaving the previous file untouched. See
[Exit codes](../../reference/exit-codes/).

| Symptom | Cause |
| --- | --- |
| `--out is required for --to protobuf: the previously generated file is the compatibility state` | `--out` omitted. |
| `--proto-package is required for --to protobuf` | `--proto-package` omitted. |
| `invalid --proto-package "Acme.Shop": expected lower_snake.case segments…` | Package is not `lower_snake.case`. |
| `output file was not generated by ptah` | `--out` points at a file Ptah did not write. Choose another path. |
| `output file was modified since it was generated: recorded …, computed …` | The `.proto` was hand-edited, or a merge left conflict markers in it. Revert it, then change the Go model instead. |
| `unsupported ptah protobuf export version: file declares export version 3, this Ptah reads and writes only 2; delete the file and export again…` | Written by a newer Ptah. Upgrade rather than downgrade the file. |
| `unsupported ptah protobuf export version: file declares export version 1, this Ptah reads and writes only 2; delete the file and export again…` | The retired layout, which put the header at the top of the file. Delete it and export again. |
| `output file is not valid protobuf: …` | The file no longer parses and its digest was re-stamped over the broken content. Restore it from version control. |
| `output file declares a different protobuf package: file declares "acme.shop.v1", --proto-package is "acme.other.v1"` | `--proto-package` changed. Renaming a package is a new file, not an edit. |
| `customer.proto: output file was modified since it was generated: …` | A member of a `--proto-split=table` set was hand-edited. The named file is the one to restore. |
| `… lists customer.proto as part of its export set, but that file is missing` | A member of the set was deleted. Restore it, or delete the whole set. |
| `types would move between files: …` | A type changed files. See [Moving a table between files](#moving-a-table-between-files). |

## Limitations

Every bullet below ends with either the issue that tracks it or the reason it is
permanent, so nothing on this list is an unowned gap.

- A live database is not a source. `--root-dir` and `--schema-file` cover Go
  annotations, YAML, HCL, SQL, and DBML; there is no database URL for this
  target, so
  run [`ptah introspect`](../../start/adopt-an-existing-database/) first to
  generate annotated models from an existing database and export those. That
  split is permanent because a wire contract is reviewed from a file in version
  control, and introspection is the reviewable step that turns a database into
  one.
- A Protobuf message carries no direction of its own, so a column declared
  `api_expose="read"` or `api_expose="write"` is in the message either way. Only
  `api_expose="none"` removes it. The read and write shapes differ on the
  OpenAPI and GraphQL targets, which have a vocabulary for them, and this is
  permanent because the wire format has one message per type and it is used for
  both directions.
- Database identifiers determine generated API identifiers after Protobuf name
  normalization only when the schema declares neither `proto_name` nor
  `api_name`. Pin one of those API identities before a storage rename to keep
  the message or field name stable. Renaming both storage and published
  identity reads as one field removed and another added, so it is refused by
  default like every other contract change; pass
  `--proto-on-field-removal=reserve` to retire the old number and name. This is
  permanent because only an explicitly retained API identity can distinguish a
  storage rename from a real contract removal and addition.
- Additive compatibility is not the same as intentional exposure. Adding a
  column to a selected table adds a field on the next export under the default
  field policy; pass `--api-field-policy=allowlist` so only columns declaring
  `api_expose` are exported and a new column enters nothing until it is
  declared. Withholding a column a previous export published retires its number
  and reserves its name, exactly as removing it does, so pass
  `--proto-on-field-removal=reserve` the first time. That the default stays
  permissive is permanent because changing it would rewrite the contract of
  every schema exported before the policy existed, which is the one thing a
  wire format must not do.
- The generated definition contains no authorization or tenant-isolation
  semantics. RLS policies are not emitted, and their presence in the database
  does not define who may use a message field. Inferring API authorization from
  row-level security is an explicit non-goal of
  [#904](https://github.com/stokaro/ptah/issues/904). This is permanent because
  a policy is evaluated by the database against a session, and a published
  `.proto` has no session to evaluate it against.
- Comment suppression is all-or-nothing.
  [`--proto-comments all`](#control-what-the-contract-says) copies every comment
  the source schema supplies, and there is no per-table or per-column form.
  The default is `none`, so passing `all` means reviewing table and column
  comments for internal implementation details or sensitive operational
  information first — publishing them is a decision, not an accident. The
  all-or-nothing shape is permanent because a table comment can
  carry exactly the internal detail a column comment can, so a partial switch
  would report a boundary the published contract does not have.
- Every generated enum stays in the `--out` file, even under
  `--proto-split=table`, and the table files import it. One export set is also
  always one `--proto-package`; splitting a schema across several packages is
  still several exports with disjoint `--include-tables` sets. Keeping enums
  together is permanent because a single named Ptah enum can back columns of
  several tables at once and protobuf cannot declare one type twice, so an enum
  has no single table to follow.
- Two tables that map to the same message name fail the export naming both.
  Give one a distinct `proto_name` or `api_name`, or exclude it. A table and an enum that produce
  the same name fail the same way. The refusal is permanent because any
  automatic disambiguation, a numeric suffix included, would depend on table
  order and silently change a published API identity.
- A table with no exportable columns is skipped with
  `table has no exportable columns; message omitted`. Protobuf has no way to
  tell an empty message apart from a type retained only for its reservations.
  This is permanent because writing the empty message instead would make the
  next run read it as a type that left the schema: a previous file carrying
  `message AuditMarker {}` for a table still present in the source is refused
  with `types removed from the source schema: AuditMarker`.

## Next steps

- Need OpenAPI or GraphQL from the same models? [API schema export](../export/).
- Not sure which columns a source produces? Review [YAML](../yaml/),
  [HCL](../hcl/), [DBML](../dbml/), or [Go annotations](../go-annotations/).
- Wiring the export into a pull-request gate? [Continuous integration](../../testing/ci/).
