Skip to content
PtahPtah

Protobuf schema export

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, 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.

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.

Start from schema.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:

Terminal window
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:

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:

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:

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.

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

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.

Go annotations use --root-dir, exactly as they do on ptah schema render:

Terminal window
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.

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.
--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.
--proto-split no none (default) or table. See 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.

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

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.

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.

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

Protobuf describes a message on the wire, not a table in a database. Four things 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.

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:

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

Terminal window
ptah schema export --to protobuf \
--root-dir ./models \
--out ./proto/acme/inventory/v1/schema.proto \
--proto-package acme.inventory.v1
// 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.

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

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

HCL

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

Go annotations

//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. The two identities are explained on API schema export.

Starting from this message:

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

Moving label above id and adding a sku column yields:

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.

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

Terminal window
ptah schema export --to protobuf \
--root-dir ./models \
--out ./proto/acme/shop/v1/schema.proto \
--proto-package acme.shop.v1 \
--proto-split table
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:

// 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.

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:

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:

Terminal window
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:

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

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

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.

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:

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

fails by default:

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:

// 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.

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

message Order {
int32 id = 1;
int32 quantity = 2;
string note = 3;
}
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.

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.

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

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:

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:

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.

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

version: v2
breaking:
use:
- WIRE_JSON

A correctly reserved deletion passes:

Terminal window
buf breaking . --against ../previous

Every check below runs before anything is written, and each exits with code 2, leaving the previous file untouched. See 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.

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