009: One declaration describes a type everywhere¶
Status: accepted, implemented 2026-08-27 (the v0.9 tree). The implementation record: Declared types. Two details refined at implementation and noted below in place.
Context¶
A user's own record type crosses five of the engine's surfaces, and today each crossing is declared separately:
| Declaration | What consumes it |
|---|---|
struct Trade { ... } |
the C++ type itself |
Codec<Trade> |
state values, the network bridges' row fallback |
register_type<Trade>("name", codec) |
the channel registry, cluster-wide by string name |
CLINK_ARROW_FIELDS(Trade, ...) |
Arrow schema: columnar wire, Parquet, state-as-data |
SchemaVersionTrait<Trade> |
state schema evolution |
Five statements of one fact. Four are mechanical, and the heaviest is the
codec: in examples/heavy_pipeline_job.cpp, nine lines of struct carry
forty-eight lines of hand-written Codec<T> whose body is nothing but a
field-by-field walk in declaration order. Nothing in it is a decision,
and every hand-written copy of it is a chance for the encode and decode
sides to disagree.
The raw material for deriving all of it already exists.
CLINK_ARROW_FIELDS(T, a, b) expands to descriptors built by
make_arrow_field_descriptor("a", &T::a): each carries the field name
and the pointer to member, so the field's C++ type is recoverable at
compile time. The columnar machinery already consumes those descriptors
recursively (fixed-width integers, float, double, bool and
std::string as leaves; std::optional, std::vector, std::map and
nested described structs as composites), and the plain register_type
already auto-selects the generated columnar batcher for any described
type. The metadata is in place; it simply stops short of the codec, the
registration name and the evolution gate.
Decision¶
One field-list declaration per type, and everything else derived from it:
struct Trade {
std::int64_t id{};
std::string symbol;
double price{};
};
CLINK_FIELDS(Trade, id, symbol, price);
flowchart TD
D["CLINK_FIELDS declaration"] --> S["Arrow schema + columnar batcher"]
D --> C["derived Codec - state values and row wire"]
D --> R["registration default - channel name from the type"]
D --> G["shape fingerprint - gates state restore"]
CLINK_FIELDS is CLINK_ARROW_FIELDS renamed, because what it declares
stops being Arrow-specific; the old name remains as a deprecated alias.
The macro stays the adapter form - it annotates an existing struct
rather than defining one - and the trait it populates
(ArrowFields<T>::descriptors()) is the stable seam. The macro is one
frontend onto that trait; when C++26 static reflection is usable on our
toolchains, a reflection-based frontend can populate the same trait with
no macro and nothing downstream changes.
What the declaration derives:
- Arrow schema and columnar batcher. Exists today, unchanged.
Codec<T>. Generated field-by-field in declaration order, withencode_intoso the hot path appends without temporaries. The byte format is specified, not incidental (below).- Registration defaults.
register_type<Trade>(reg)with the channel name defaulting to the type's declared name, and the codec and batcher supplied by the trait. The explicit-name and explicit-codec overloads remain for types that need them. - A shape fingerprint. A compile-time hash over the ordered
(field name, wire kind)sequence, recursive into composites. Snapshots already carryschema_version_v<T>(a hand-bumped integer defaulting to 1); the fingerprint rides beside it, and a restore that sees a changed fingerprint under an unchanged version refuses instead of misreading bytes. The explicit version keeps expressing migration intent; the fingerprint is the gate that catches the bump nobody made. As implemented it rides a NEW snapshot metadata key (clink.state_fingerprints), because the snapshot format contract already declares added metadata keys a compatible change - simpler and safer for downgrade readers than the eof-guarded binary tail this record first sketched. A completed migration clears the migrated slots' stamps, which is what reduces the bind-time rule to "stored differs from live means refuse" with no version consultation.
The derived encoding, specified¶
The moment state persists through the generated codec, its layout is a durability contract, so it is written down and frozen rather than left as an implementation detail:
- fixed-width integers: little-endian, natural width;
boolone byte (0 or 1);float/doubleas the same-width integer via bit cast std::string: u32 little-endian length, then the bytesstd::optional<E>: one presence byte, then the payload when presentstd::vector<E>: u32 little-endian count, then the elementsstd::map<K, V>: u32 little-endian count, then key/value pairs in the map's key order (deterministic by construction)- nested described structs: their fields, recursively, in declared order
- no per-record header: schema version and fingerprint live in the slot metadata, as today
This deliberately matches the idiom the hand-written codecs already use
(little-endian fixed-width, u32 length-prefixed strings), so migrating a
type whose hand codec followed the idiom does not rewrite its bytes.
Decode fails closed: truncation yields nullopt, never a partial value.
Because field order in the declaration is now semantic - reordering the
macro's arguments rewrites every checkpoint byte - the encoding joins the
compatibility-domain inventory (docs/internals/protocol-compatibility.md)
on day one: a frozen-bytes fixture under tests/fixtures/, exercised by
tests/test_format_fixtures.cpp, written by one build and readable by
every later one. The fingerprint gate then turns the innocent-reorder
mistake from silent corruption into a refused restore.
A completeness guard¶
The adapter form's one genuine hazard is the field somebody forgot: a
member missing from the list is silently absent from the wire, the
snapshot and every Parquet file. For aggregate types the arity is
checkable without reflection (the brace-initialisability probe), so the
macro gains a static_assert that the descriptor count equals the
member count, with an explicit opt-out macro for the rare partial
description done on purpose. Non-aggregates cannot be counted before
C++26 and keep today's behaviour.
Alternatives rejected¶
A defining macro (CLINK_RECORD(Trade, (int64_t, id), ...)), which
restates each field's type. The compiler already knows the types through
the member pointers, so the type list adds a second source of truth that
can drift, and a defining macro cannot annotate a type that already
exists - the common case in an established codebase - and fights
templates, private members, methods and non-default constructors.
Diagnostics land inside the macro expansion instead of on the user's
line.
Silent aggregate reflection (deriving fields with no declaration at all, PFR-style). Field names are load-bearing here - they are the Arrow column names and the future SQL surface - and a silent derivation makes adding or reordering a struct member a silent change to persisted bytes. The explicit list is one line, and that line is the statement of intent a durability format deserves. Under C++26 reflection an implicit frontend may become an opt-in convenience; the explicit form stays the recommendation for any type that persists.
Consequences¶
- Adopting a type costs one line, not five declarations and ~50 lines of
codec. The measured acceptance case:
heavy_pipeline_job.cpploses its two hand codecs entirely, and a consumer example runs one declared type through wire, keyed state, Parquet and a restore. - A second encoding family enters the compatibility inventory and must be honoured forever. That is the price of deriving a durability format, and it is paid once, in fixtures, rather than per type.
- The generated codec must not cost throughput on the paths that keep
hot state: parity with a representative hand codec on
clink_serde_benchis an acceptance gate, not an afterthought. - The macro survives until reflection arrives. That is accepted: the trait seam means the macro's eventual removal is a frontend swap, not a migration.
Delivery (v0.9)¶
- Derived
Codec<T>+ the encoding fixture + the inventory entry, with serde-bench parity measured. - Registration defaults and the fingerprint gate at restore, including the snapshot-tail evolution and its fixture.
- The
CLINK_FIELDSrename (alias kept), examples and consumer examples migrated, docs updated - capabilities row only when shipped.
Each phase lands with its tests and fixtures in the same change, per the repository's working conventions.