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.
Generate the first file
Section titled “Generate the first file”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:
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/v1Expected 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 .protowarning: products.price: exact numeric mapped to string; Protobuf has no decimal type and float/double would lose precisionwarning: products.tags: nullable array column exported as repeated; protobuf cannot distinguish SQL NULL from an empty listwarning: products.updated_at: timezone-ambiguous timestamp mapped to string; google.protobuf.Timestamp is only used for types with explicit time-zone semanticsExported Protobuf schema to ./proto/acme/inventory/v1/schema.protoExported 1 message(s), 7 field(s), 1 enum(s)bootstrapped new compatibility history4 export warning(s) reportedDiagnostics 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=a1a7afeeb1a361c2257b52b6c46386c591a045294f5d7b39cf15693b5025f952The 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 historyDeleting 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
Section titled “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.
Ptah protects that state in both directions:
- The file carries a
ptah:content-sha256header 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
Section titled “Use Go annotations”Go annotations use --root-dir, exactly as they do on
ptah schema render:
ptah schema export --to protobuf \ --root-dir ./models \ --out ./proto/acme/inventory/v1/schema.proto \ --proto-package acme.inventory.v1The 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 protobufPtah 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
Section titled “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 namedaddress_1becomes a hard compile error —Field name address_1 contains style violating underscores— as does an enum label such astier_1. Those names are legal under Edition 2023 andproto3, 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-goto 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
Section titled “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. 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
Section titled “What the projection loses”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 NULLdisappears. Editions have norequired; every singular field has explicit presence and may be absent. The generated.protocarries no trace of which columns are mandatory, so validate that in your service, not from the schema.repeatedcannot be null. An array column becomesrepeated, which represents an empty list and a SQLNULLidentically. 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/doublewould silently lose precision on money, soDECIMAL,NUMERIC, andMONEYmap tostring. - Timezone-ambiguous timestamps become strings.
google.protobuf.Timestampis a fixed instant in Coordinated Universal Time (UTC), so only types with explicit time-zone semantics use it.TIMESTAMPwithout a time zone,DATE, andTIMEmap tostring.
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
Section titled “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:
//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:
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=410a512e42ff37d9e9e53485b91e2f7c96a17dac2aeb2e8113e497611da0d476edition = "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_hashis still3, 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
reservedblock 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
Section titled “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
columns: billing_amount_minor: type: INTEGER api_name: amount# renamed later to invoice_total_cents with api_name unchangedHCL
column "billing_amount_minor" { type = integer api_name = "amount"}# renamed later to "invoice_total_cents" with api_name unchangedGo 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.
Split the export across files
Section titled “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:
ptah schema export --to protobuf \ --root-dir ./models \ --out ./proto/acme/shop/v1/schema.proto \ --proto-package acme.shop.v1 \ --proto-split tableExported Protobuf schema to ./proto/acme/shop/v1/schema.protoExported Protobuf schema to ./proto/acme/shop/v1/customer.protoExported Protobuf schema to ./proto/acme/shop/v1/order.protoExported 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.protoThat 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.
Moving a table between files
Section titled “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:
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:
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 relocateA 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.protoWith tombstone, the file stays and holds the tombstone instead.
Handle removals and incompatible changes
Section titled “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
Section titled “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:
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 itWith --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.
A column’s type changes
Section titled “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:
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.
A removed column comes back
Section titled “A removed column comes back”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.
Check compatibility with buf
Section titled “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:
version: v2breaking: use: - WIRE_JSONA correctly reserved deletion passes:
buf breaking . --against ../previousFailure modes
Section titled “Failure modes”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. |
Limitations
Section titled “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-dirand--schema-filecover Go annotations, YAML, HCL, SQL, and DBML; there is no database URL for this target, so runptah introspectfirst 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"orapi_expose="write"is in the message either way. Onlyapi_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_namenorapi_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=reserveto 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=allowlistso only columns declaringapi_exposeare 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=reservethe 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
.protohas no session to evaluate it against. - Comment suppression is all-or-nothing.
--proto-comments allcopies every comment the source schema supplies, and there is no per-table or per-column form. The default isnone, so passingallmeans 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
--outfile, 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-tablessets. 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_nameorapi_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 carryingmessage AuditMarker {}for a table still present in the source is refused withtypes removed from the source schema: AuditMarker.
Next steps
Section titled “Next steps”- Need OpenAPI or GraphQL from the same models? API schema export.
- Not sure which columns a source produces? Review YAML, HCL, DBML, or Go annotations.
- Wiring the export into a pull-request gate? Continuous integration.