Proto schema inheritance#

Date:

2026-08

discussion

Motivation#

Protocol Buffers does not support message inheritance. Related messages therefore repeat common fields or introduce a nested base message:

message Derived {
    Base base = 1;
    ...
}

Composition is portable but changes the wire path and the API: value.name() becomes value.base().name(). It is inconvenient when one message is a strict specialization of another, for example when QuantizedTensorProto adds quantization semantics to the bytes and metadata already carried by StructProto.

onnx-light owns its message classes and parser rather than relying on generated Protobuf C++ classes. It can therefore support inheritance in its schema and C++ API while keeping a conventional, flattened Protobuf wire representation.

Scope#

The proposal defines single inheritance for message fields. It provides:

  • inherited fields and accessors in the C++ API;

  • one flattened wire message containing base and derived fields;

  • source-level reuse in onnx-light declarations;

  • an equivalent ordinary .proto schema for other implementations.

It does not define runtime-polymorphic protobuf fields. A field declared as StructProto still contains that wire message; it does not automatically accept every derived message. Explicit oneof branches or a common value category remain necessary for polymorphic containers.

Schema syntax#

The conceptual syntax is:

message StructProto {
    int32 type = 1;
    optional StructTypeProto struct_type = 2;
    bytes raw_data = 3;
    repeated StringStringEntryProto external_data = 4;
    string name = 5;
    string doc_string = 6;
}

message QuantizedTensorProto extends StructProto {
    repeated int64 dims = 7;
    int32 quantized_type = 8;
    optional QuantizationProto quantization = 9;
}

The equivalent standard Protobuf declaration is flattened:

message QuantizedTensorProto {
    int32 type = 1;
    optional StructTypeProto struct_type = 2;
    bytes raw_data = 3;
    repeated StringStringEntryProto external_data = 4;
    string name = 5;
    string doc_string = 6;
    repeated int64 dims = 7;
    int32 quantized_type = 8;
    optional QuantizationProto quantization = 9;
}

No inheritance marker appears on the wire. Existing Protobuf tooling can use the flattened declaration without understanding the onnx-light extension.

Rules#

Single inheritance#

A message has at most one direct base. Chains are permitted, but the schema compiler must reject cycles. Multiple inheritance is excluded because field and oneof resolution would become ambiguous.

Field numbers#

Base and derived fields share one field-number namespace:

  • a derived field number may not collide with any ancestor field;

  • an inherited reserved number or name remains reserved;

  • changing the base after publishing a derived message may not claim a number already used by that derived message;

  • removing an inherited field reserves its number and name in every descendant.

A practical convention is to allocate a field-number range to the base before publishing descendants. The checker must nevertheless validate the complete flattened hierarchy rather than rely on ranges.

Fields and oneofs#

Inherited fields retain their original presence, default, packed encoding, documentation, and oneof membership. A derived message:

  • may add new fields and new oneofs;

  • may not change an inherited field type, number, default, or cardinality;

  • may not add alternatives to an inherited oneof;

  • may not hide or redeclare an inherited field name.

These restrictions make flattening deterministic.

Wire compatibility#

Serialization emits inherited and local fields at their original field numbers. Field order remains irrelevant, as in Protobuf.

The bytes of a derived payload can be parsed using the base schema: derived fields are unknown fields. This does not create substitution between message-typed fields because the enclosing field still declares one concrete message type. Furthermore, round-tripping through a reader that discards unknown fields loses the derived portion.

Inheritance must therefore not be used as a substitute for an explicit oneof or TypeProto category.

C++ API#

The desired API uses normal public inheritance:

class QuantizedTensorProto : public StructProto {
public:
  // Inherited:
  // name(), doc_string(), raw_data(), external_data(), ...

  // Local:
  const std::vector<int64_t>& dims() const;
  int32_t quantized_type() const;
};

The primary purpose is field and accessor reuse. It does not require virtual serialization or ownership through base pointers. The current onnx_light::Message API is non-polymorphic, and this proposal should not add a vtable to every message merely to support schema inheritance.

Copying, clearing, equality, hashing, printing, and Python bindings operate on the complete flattened message. Passing a derived object by value as its base still causes normal C++ slicing and must be avoided.

Implementation in onnx-light#

Current declarations use BEGIN_PROTO or BEGIN_PROTO_NOINIT; both make every message inherit directly from Message. Serialization and parsing in onnx.cc explicitly enumerate each message’s fields.

The declaration layer can add:

BEGIN_PROTO_DERIVED(QuantizedTensorProto, StructProto, "...")
FIELD_REPEATED(int64_t, dims, 7, "Logical tensor shape.")
FIELD_DEFAULT(int32_t, quantized_type, 8, -1, "Quantization type index.")
FIELD_OPTIONAL(QuantizationProto, quantization, 9, "Inline quantization type.")
END_PROTO()

BEGIN_PROTO_DERIVED expands to a class inheriting from the supplied base and declares the same serialization API as an ordinary message.

Serialization helpers#

Size computation and writing can process base fields before local fields:

SerializeBaseFields(size, stream, options);
SIZE_REPEATED_FIELD(size, options, stream, dims)
...

WriteBaseFields(stream, options);
WRITE_REPEATED_FIELD(options, stream, dims)
...

Parsing cannot call Base::ParseFromStream and then parse local fields: the base parser consumes the complete message and skips fields it does not know. Instead, parsing uses one loop and delegates individual fields:

while (stream.NotEnd()) {
  const auto field = stream.next_field();
  if (ParseLocalField(field, stream, options))
    continue;
  if (ParseBaseField(field, stream, options))
    continue;
  SkipUnknownField(field, stream, options);
}

Each generated message therefore needs field-level helpers for:

  • serialized-size accumulation;

  • writing;

  • parsing one field;

  • printing;

  • copying and clearing;

  • equality or hashing when supported.

The same helpers can later reduce duplication in ordinary non-derived messages.

Unknown fields#

onnx-light currently skips fields it does not model. Consequently, parsing a derived payload as its base and serializing it again cannot be expected to preserve derived fields. Schema inheritance does not change this behavior. Unknown-field preservation would be an independent feature.

Bindings#

nanobind registrations for a derived message should declare its C++ base so Python inherits the same accessors:

nb::class_<QuantizedTensorProto, StructProto>(m, "QuantizedTensorProto");

The flattened standard-Protobuf Python class will not expose Python inheritance, but it will expose the same fields. Behavioral compatibility is defined by fields and wire encoding, not by the host-language class hierarchy.

Schema generation and validation#

onnx-light should maintain one inheritance-aware schema description and derive two outputs:

  • onnx-light C++ declarations with real inheritance;

  • ordinary flattened .proto declarations for interoperability.

Generation must fail when:

  • the base is missing, incomplete, or creates a cycle;

  • inherited and local numbers or names collide;

  • a descendant redefines an inherited field;

  • an inherited oneof is extended;

  • a reserved name or number is reused;

  • the flattened declaration differs from the checked wire schema.

Compatibility policy#

Adding inheritance to existing published messages is safe only if the flattened fields and numbers remain unchanged. Moving an existing nested base message to inherited fields changes wire numbers and is a migration, not a refactor.

For new messages, inheritance is wire-compatible from the beginning when the flattened schema is treated as normative. For existing QuantizedTensorProto and StructProto proposals, field numbers should be finalized only after deciding whether inheritance is used.

Recommendation#

onnx-light can support schema inheritance, but it should remain a declaration and API convenience with explicit flattening. Recommended constraints are:

  • single inheritance only;

  • no field overriding;

  • no extension of inherited oneofs;

  • no implicit polymorphic message fields;

  • flattened Protobuf schema as the normative wire contract;

  • non-virtual C++ messages unless runtime polymorphism is independently justified.

Under these rules, QuantizedTensorProto extends StructProto removes duplicated storage metadata without introducing a new or incompatible wire format.