Skip to content

feat: field metadata option + cross-language registry generators (supersedes #905)#952

Draft
jamesarich wants to merge 4 commits into
masterfrom
jamesarich/field-metadata
Draft

feat: field metadata option + cross-language registry generators (supersedes #905)#952
jamesarich wants to merge 4 commits into
masterfrom
jamesarich/field-metadata

Conversation

@jamesarich

Copy link
Copy Markdown
Contributor

What & why

Some schema fields carry app/UI-relevant metadata — e.g. GPIO pins that only matter on DIY hardware, which apps want to hide on pre-assembled boards. This adds a first-class, structured way to declare that metadata in the schema and consume it from every client, with no reflection and no runtime cost.

Supersedes #905 (@jp-bennett's experiment). That PR proved the idea with a scalar (meshtastic.diy_only) option; this generalizes it into one extensible FieldMetadata message carried in a single FieldOptions extension, plus the generators that actually expose it to consumers.

How it works

  • Source of truthmeshtastic/field_metadata.proto: a structured FieldMetadata message (diy_only, admin_only, min/max_value, unit) carried in one extend google.protobuf.FieldOptions { field_metadata = 51001 }. config.proto's rx_gpio/tx_gpio are annotated { diy_only: true }.
  • Why codegen, not runtime reflection — nanopb (firmware), Wire (KMP), and prost (Rust) all strip options at runtime, so reflection can't serve them. Build-time codegen is the only mechanism that works for every consumer (the same approach Google's SDK generators use for google.api.field_behavior).
  • Two generators, one shape:
    • KMP/Wire — a Wire SchemaHandler in packages/kmp/buildSrc emits a FieldMetadataRegistry into the published artifact.
    • C / Python / TypeScript / Rust / Swifttools/protoc-gen-fieldmeta, a protoc/buf plugin (reads the option dynamically; generic over FieldMetadata's fields).
  • Each target gets a typed accessor (idiomatic per language) plus a dynamic get(type, tag) fallback. Where the language can extend the real generated message type it does (Kotlin companion extension, Swift type extension); TS/Python use a namespace, Rust a module, C a lookup table.

Accessing it (example: rx_gpiodiy_only)

Language Accessor
Kotlin Config.PositionConfig.rx_gpio.diy_only
Swift Config.PositionConfig.rxGpio.diyOnly
TypeScript FieldMeta.Config.PositionConfig.rxGpio.diyOnly
Python fm.Config.PositionConfig.rx_gpio["diy_only"]
Rust fm::config::position_config::RX_GPIO.diy_only
C meshtastic_field_metadata_get("meshtastic.Config.PositionConfig", 8)->diy_only

Adding metadata later

Schema-only: add a scalar field to FieldMetadata (next field number) → every target regenerates automatically. No generator/build changes, no new extension number consumed. Non-scalar attributes are a hard error at generation time. (Documented in field_metadata.proto's header.)

Testing performed

  • buf lint / buf build / buf breaking vs master — clean (additive change).
  • tools/protoc-gen-fieldmeta: gofmt / go vet / go test green (~92% coverage). Tests include an in-memory, buf-faithful end-to-end decode, the scalar-only guard, value rendering (incl. integral floats), string escaping, and multi-message cases.
  • Compiled the generated output for all six targets: KMP (compileKotlinJvm), C (clang -Wall -Wextra), Python, Swift (swiftc -typecheck), TypeScript (tsc --strict), Rust (rustc -D warnings).
  • buf generate produces field_metadata_registry.ts, re-exported from mod.ts as FieldMeta.

Draft — open items / for review

  • Wired and verified for the in-repo TS + KMP packages. The C/Python/Swift/Rust outputs are compile-verified but go live once each consumer repo adds the plugin to its codegen (recipes in tools/protoc-gen-fieldmeta/README.md).
  • @jp-bennett — keen on your review since this builds on Experminental PR for adding metadata to protobufs #905, especially: the FieldMetadata shape (which attributes to standardize), the 51001 extension number (keep it in the 50000–99999 in-house range, or request a global-registry number for a public schema?), and the Swift integration assumptions (validated against a stub, not yet real swift-protobuf output).
  • The full multi-target packages/kmp build runs in CI on this PR.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow pull-request / build (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed❌ failed (23)Jul 19, 2026, 8:48 PM

@jamesarich

Copy link
Copy Markdown
Contributor Author

Heads-up on the red build check: it's failing only on buf format for meshtastic/powermon.proto — unrelated to this PR. buf-action runs the latest buf (now 1.71.0), which reformats that file's block comments; master's last green run predated 1.71.0, so it's repo-wide drift. This PR's own files (config.proto, field_metadata.proto) are format-clean. Fixed separately in #953 — once that merges, this PR's check clears on a rebase onto master. Keeping this draft focused on the field-metadata design for review. /cc @jp-bennett

Introduces a structured (meshtastic.field_metadata) custom field option and build-time generators that expose it as reflection-free static accessors in every consumer language: KMP/Wire (Wire SchemaHandler in packages/kmp/buildSrc) and C/Python/TypeScript/Rust/Swift (the tools/protoc-gen-fieldmeta plugin). The TS target is wired into buf.gen.yaml and re-exported from mod.ts as FieldMeta.

Generalizes the #905 experiment: one extensible FieldMetadata message carried in a single FieldOptions extension, so adding an attribute is a schema-only change (scalar-only, enforced at generation time) with no new extension number and no generator changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jamesarich
jamesarich force-pushed the jamesarich/field-metadata branch from c52ced6 to 007d7b2 Compare June 17, 2026 23:36
@garthvh

garthvh commented Jul 15, 2026

Copy link
Copy Markdown
Member

Validated the Swift side from the Meshtastic-Apple end — two findings.

1. The Swift integration assumption holds against real swift-protobuf output (closing the "validated against a stub" open item):

  • Built protoc-gen-fieldmeta from this branch (go test green), generated FieldMetadataRegistry.swift via protoc over this branch's schema.
  • Dropped it into Meshtastic-Apple's real MeshtasticProtobufs package (swift-protobuf 1.36.1, the app's pinned version) next to the actual generated config.pb.swiftswift build clean.
  • An external consumer file typechecks against the built module:
let isDiy: Bool? = Config.PositionConfig.rxGpio.diyOnly                 // typed accessor ✓
FieldMetadataRegistry.get("meshtastic.Config.PositionConfig", tag: 8)   // dynamic lookup ✓
var config = Config.PositionConfig()
config.rxGpio = 7                                                        // real instance property
(config.rxGpio, Config.PositionConfig.rxGpio.diyOnly)                    // static + instance coexist ✓

That last line was the collision case worth worrying about (generated static var rxGpio alongside the real instance property rxGpio) — Swift resolves both cleanly. The README's gen_protos.sh recipe is accurate as written.

2. A pure-Swift implementation of the generator produces byte-identical output. As an experiment in keeping the Apple pipeline Go-free, I wrote the same generator (~180 lines) on SwiftProtobufPluginLibrary — the library protoc-gen-swift itself is built on:

  • customOptionExtensions exposes (meshtastic.field_metadata) as a typed value on field.options — no manual descriptor/unknown-fields work.
  • Type paths and property names come from SwiftProtobufNamer/NamingUtils — the same naming engine that generated the app's .pb.swift — so rx_gpio → rxGpio (and less obvious cases like pm10_standard) can't drift from real generated code by construction, rather than by reimplementing the casing rules.
  • Output over this branch's schema is byte-for-byte identical to the Go plugin's (diff clean), and it passes the same MeshtasticProtobufs build + consumer typecheck.
  • Parity kept: schema-driven struct shape (new scalar attributes flow through), the scalar-only hard error. One difference: value-literal emission is an explicit switch over the current attributes with a loud failure on unknowns (one-line to extend), vs. the Go plugin's fully dynamic rendering — could be made dynamic too.

Happy to contribute it as tools/protoc-gen-fieldmeta-swift on this branch (or a follow-up) if there's appetite for a first-party no-Go path for swift-protobuf consumers — otherwise it can live in Meshtastic-Apple alongside gen_protos.sh. Either way, the design here works for Swift: 👍 from the Apple side.

@jamesarich

Copy link
Copy Markdown
Contributor Author

feel free to push changes here @garthvh

@garthvh garthvh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it will work, I have swift tooling

Swift-native sibling of tools/protoc-gen-fieldmeta for consumers whose
codegen toolchain is already swift-protobuf based (e.g. Meshtastic-Apple),
so adopting field metadata doesn't add Go to their pipeline.

Built on SwiftProtobufPluginLibrary (the library protoc-gen-swift itself
uses): type paths and property names come from SwiftProtobufNamer /
NamingUtils, matching the consumer's real generated code by construction;
the option arrives typed via customOptionExtensions. Output is
byte-identical to protoc-gen-fieldmeta's target=swift over this repo's
schema (verified on swift-protobuf 1.36.1 and 1.38.1), and the generated
registry compiles inside Meshtastic-Apple's real MeshtasticProtobufs
package with an external consumer typecheck (typed accessor, dynamic
lookup, static/instance member coexistence).

Includes the plugin's own generated field_metadata.pb.swift binding
(regeneration recipe in the README), a cross-link from the Go plugin's
Apple section, and a .gitignore entry for the tool's .build/.
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 760c97a3-b902-48af-bb04-3aec4cff4ab6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jamesarich/field-metadata

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@garthvh

garthvh commented Jul 15, 2026

Copy link
Copy Markdown
Member

Pushed the Swift implementation onto this branch as offered: tools/protoc-gen-fieldmeta-swift (aede581).

  • Same registry, byte-identical output to protoc-gen-fieldmeta's target=swift (verified on swift-protobuf 1.36.1 and 1.38.1) — the two are interchangeable; this one keeps swift-protobuf consumers Go-free.
  • Naming comes from SwiftProtobufPluginLibrary's own SwiftProtobufNamer/NamingUtils (the engine protoc-gen-swift uses), and the option arrives typed via customOptionExtensions.
  • Includes the plugin's generated field_metadata.pb.swift binding (regen recipe in its README), a cross-link from the Go plugin's Apple section, and a .gitignore entry for its .build/.
  • One deliberate divergence, documented in the README: value emission is an explicit per-attribute switch that fails loudly on an unhandled attribute (one-line to extend) instead of fully dynamic rendering — happy to make it dynamic if you'd rather have exact behavioral parity there.

Feel free to reshape/drop anything that doesn't fit the PR's direction.

@garthvh

garthvh commented Jul 15, 2026

Copy link
Copy Markdown
Member

Can we mirror the deprecated field info I can't currently read here?

jamesarich and others added 2 commits July 15, 2026 17:55
Fields marked `[deprecated = true]` carry that flag in FieldOptions, which
every protobuf runtime strips — so apps can't read deprecation at runtime,
the same gap this registry already closes for custom attributes like diy_only.

Mirror the standard `deprecated` option into the registry as a `deprecated`
attribute on FieldMetadata, populated by the generators from the field's own
`[deprecated = true]` (never set by hand). Every existing and future deprecated
field flows in automatically, with no annotation.

- field_metadata.proto: add `optional bool deprecated = 6`, documented as
  generator-managed. It must be a real field (not a synthetic generator
  attribute) because the KMP registry instantiates the Wire-generated
  FieldMetadata type.
- protoc-gen-fieldmeta (Go): read FieldOptions.deprecated and upsert the
  attribute; deprecated-only fields now get entries. Emitters stay generic.
- protoc-gen-fieldmeta-swift: mirror field.options.deprecated; align entry and
  accessor ordering to the Go plugin so output stays byte-identical across many
  message types (previously only trivially identical with one message type).
  Regenerate the bundled field_metadata.pb.swift.
- FieldMetadataRegistryHandler (KMP/Wire): mirror field.isDeprecated.

Guard the generators in CI — previously the Go tests never ran there and the
Go/Swift byte-identity was hand-checked only:
- .github/workflows/field-metadata.yml: run the Go plugin tests + vet, and diff
  the Go and Swift generators' output to enforce byte-identity.
- protoc-gen-fieldmeta-swift/scripts/verify-parity.sh: the reusable parity check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The `deprecated` attribute is generator-managed — mirrored from the field's
standard `[deprecated = true]` option. A hand-set value inside
(meshtastic.field_metadata) previously produced silently divergent output:
the Go and Kotlin/Wire generators emitted it while the Swift plugin ignored
it (breaking byte-identity), and the schema carried two sources of truth for
the same fact.

Make it a hard error at generation time in all three generators, with the
same message naming the field and the fix. Verified: both protoc plugins
reject a hand-set fixture identically; parity over the real schema remains
byte-identical; Go tests cover true/false/redundant hand-set cases.

Also document the Go plugin's `target=swift` assumption that files set
`option swift_prefix = ""` (all meshtastic protos do) — without it,
swift-protobuf prefixes generated type names and the emitted extensions
would reference nonexistent types; the parity CI catches that divergence.

The bundled field_metadata.pb.swift regen picks up the proto comment change
(comments only, no functional difference).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants