You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Came up while planning YAML support (a protoyaml-go equivalent for buffa). Recording the analysis here so the option can be re-evaluated as both buffa and musli mature. This is not a near-term plan — the conclusion is "not now" — but the architectural fit is strong enough that it deserves a written case rather than a Slack thread.
Summary
buffa's three text-format paths today use three different mechanisms:
reuse the JSON serde derives behind a YAML carrier
buffa-yaml extension crate
musli is a serialization framework in the same vein as serde, by udoprog. It has two design properties that map directly onto problems buffa has worked around:
Mode-parameterized encode/decode. The same type can have different encoding rules per "mode" — selected by a type parameter, not ambient state.
First-class Context. Diagnostics and configuration are passed in, not smuggled through thread-locals or globals.
What musli's mode system fixes
The thread-local / global wart
serde's Deserialize trait has no context parameter. To vary parsing behaviour at runtime — e.g. ignore_unknown_enum_values, or accepting YAML-canonical float specials only when parsing YAML — buffa uses ambient state (buffa::json::JsonParseOptions):
std: a thread-local, scoped via with_json_parse_options(&opts, || ...).
no_std: a leaked Box behind an AtomicPtr, set once for process lifetime via set_global_json_parse_options, with a debug_assert! if options change after first set.
This works but is a recurring source of friction: the two APIs are mutually exclusive, the no_std variant has set-once semantics that surprise, and the container-filtering behaviour (repeated_enum/map_enum skip-unknown) is unavailable under no_std because it needs the scoped override.
musli's Decode trait is parameterized over a mode type:
Format variations become distinct mode types resolved at compile time:
structProtoJson;// strict protojson semanticsstructProtoYaml;// protoyaml-go-style lenienceimpl<'de,A:Allocator>Decode<'de,ProtoJson,A>forDuration{fndecode<D:Decoder<'de>>(d:D) -> Result<Self,D::Error>{// strict: only "1.5s"}}impl<'de,A:Allocator>Decode<'de,ProtoYaml,A>forDuration{fndecode<D:Decoder<'de>>(d:D) -> Result<Self,D::Error>{// also "1m30.5s"}}
Compiler-checked, monomorphized, no ambient state, no std/no_std split. The shared parts (most of the implementation) factor into a private helper that both modes call; only the divergent parsing arms differ.
The diagnostics gap
The value proposition of protoyaml-go over plain yaml.Unmarshal is its error reporting: every error carries file:line:col, the offending source line, and a ^ pointer. To do this it hand-walks the YAML AST tracking node spans alongside protoreflect field descriptors.
serde has no built-in path or span tracking. The serde_path_to_error crate bolts it on by wrapping the deserializer, but only path-level (no spans), and it cannot reach inside with-module or custom-Visitor code.
The derive macro emits enter_* calls automatically, and a tracing Context implementation collects them. A YAML format implementation that maps Mark to byte offsets gets protoyaml-go-equivalent diagnostics — line/col plus field path — without any per-message hand-written code.
Unifying textproto
buffa::text is a hand-written TextFormat trait because serde's data model is a poor fit for textproto (extension keys, [type.googleapis.com/...] Any syntax, field-name/field-number addressing). musli's data model is intentionally "does not speak Rust" — it is closer to a protocol-shaped abstract machine — which makes it a more natural fit for textproto's quirks. A mode::TextProto under the same framework would replace the bespoke trait and benefit from the same Context diagnostics.
What it costs
There is no musli-yaml
musli ships musli::storage, musli::wire, musli::descriptive, musli::json, musli::value, and musli-serde (a serde→musli bridge). There is no YAML format, first- or third-party (verified against crates.io as of 2026-05). Adopting musli for YAML means writing a YAML encoder/decoder from scratch, or wrapping an existing parser (e.g. saphyr) the way serde-saphyr does. That is a project in itself, not an integration step.
Migration surface
Replacing serde with musli for buffa's text formats touches every text-format-aware part of the codebase:
Component
Lines (approx.)
Change
buffa/src/json_helpers.rs
1773
Rewrite with-modules as per-mode Encode/Decode impls
buffa-types/src/*_ext.rs
~400 (serde portions)
Rewrite hand-written Serialize/Deserialize for WKTs
buffa-codegen attribute emission
spread across message.rs, oneof.rs
Replace #[serde(rename, alias, with, flatten, skip)] with #[musli(...)] equivalents
buffa-codegen custom Deserialize
generate_custom_deserialize
Reimplement against musli's MapDecoder
Conformance
n/a
Re-validate the full JSON suite
New musli-yaml format crate
~thousands
Write
Downstream API contract
Generated message structs currently derive serde::Serialize and serde::Deserialize. Downstream consumers depend on this for things buffa has nothing to do with — axum extractors, sqlx::types::Json, config crates, anything that takes T: Serialize. Removing the serde derives is a hard breaking change for those consumers.
Two non-options:
Coexist.#[derive(serde::Serialize, serde::Deserialize, musli::Encode, musli::Decode)] is legal, but then the protobuf-special encode/decode logic (camelCase mapping, int64-as-string, base64 bytes, WKT special forms, oneof flattening) exists twice. That is duplication, not migration. The maintenance burden of keeping two parallel implementations conformant is worse than the wart it's replacing.
Bridge.musli-serde lets a musli format drive serde traits, but in the JSON-out direction: a musli encoder serializes a serde-only type. It does not give serde-only consumers access to a musli-defined format. It cannot save the migration surface.
The realistic shape is a major version bump where buffa's text-format API changes from "your type is serde::Serialize" to "use buffa::json::to_string(&msg)". Downstream serde integrations would need a thin adapter.
no_std and allocator alignment
musli's Decode<'de, M, A> carries an Allocator parameter. buffa uses extern crate alloc for no_std heap types. These are compatible in principle but differ in idiom — buffa's types own their Vec/String allocations directly; musli's allocator parameter is for the framework's own internal buffers. The fit needs validation, particularly for the decode_view zero-copy path.
musli's stability trajectory
The bare version 0.0.149 is misleading. The author uses a single 0.0.x series for the umbrella musli crate, but the cadence and the recent split of musli-core tell a more nuanced story.
Release timeline for the musli crate:
2024-03 to 2024-04: 14 releases (0.0.107 → 0.0.122). Heavy churn — GAT migration, Context introduction, allocator rework.
2024-05 to 2025-04: 9 releases over a year (0.0.122 → 0.0.131). Slowing.
2025-08 to 2025-09: 18 releases in five weeks (0.0.132 → 0.0.149). Cleanup burst. Notable changes: unit → empty rename (in musli::value), System → Global allocator rename, #[non_exhaustive] additions, Value internals encapsulation, derive-macro restructuring ("remove type-specific attributes"), #[musli::trait_defaults] requirement for manual Decoder impls.
2025-09-14 to 2026-05:zero musli releases. Eight months of stability.
musli-core (the crate housing Encode, Decode, Encoder, Decoder, Context, and the mode machinery) was split out and given its own version series at 0.1.0 on 2025-09-10. It went 0.1.0 → 0.1.4 in eight days, then nothing. This is a deliberate stabilization signal — the trait surface is now versioned independently from the umbrella crate, and the author is implicitly committing to a 0.1.x compatibility window for it.
Diffing musli-core/src/de/decoder.rs and en/encoder.rs between 0.0.131 (2025-04) and 0.0.149 (2025-09) shows the changes are dominated by rustfmt re-indentation; the only functional change to the Decoder trait itself in that window is the #[musli::trait_defaults] requirement for manual implementors. The breaking changes are concentrated in musli::value (the buffered tree type), the allocator naming, and the derive macro.
Verdict: the trait surface buffa would build on (Encode/Decode/Encoder/Decoder/Context in musli-core) has been stable since September 2025 and is now versioned independently. The derive macro syntax churned more recently. Pinning musli-core and watching its changelog is viable. The risk is real but smaller than the version number suggests.
Recommendation
Not now. The case against is decisive in the near term:
The serde derives on generated types are part of buffa's public contract. Removing them breaks downstream consumers in ways unrelated to text formats.
The migration surface (json_helpers.rs, *_ext.rs, codegen attribute emission, custom-Deserialize codegen, conformance) is measured in thousands of lines.
There is no musli-yaml, so this does not unblock YAML support — it just changes what's underneath the YAML support that still has to be written.
musli-core is at 0.1.x. It is settling, but buffa publishing crates.io artifacts against a 0.x framework dependency means breaking bumps in musli are breaking bumps for buffa consumers.
Revisit when:
buffa is contemplating a 1.0 / 2.0 API break for unrelated reasons. The serde-derive removal cost is amortized across whatever else breaks.
musli-core reaches 1.0, or has a multi-year 0.1.x/0.2.x track record.
A musli-yaml exists, or buffa wants to invest in writing one (in which case writing it as a musli format is no more work than writing it as a serde format).
The JsonParseOptions thread-local / global design is causing measurable pain (today it is awkward but functional).
What to do instead, now
buffa-yaml on serde_norway, reusing the existing serde derives. The JsonParseOptions mechanism can grow a gated option (accept_yaml_float_literals) if a future carrier needs it; with serde_norway it is not needed — its scalar resolver delivers .inf / .nan to visit_f64 rather than as canonical strings, so buffa's existing float/double helpers already accept them.
This discussion was converted from issue #100 on June 12, 2026 00:47.
Heading
Bold
Italic
Quote
Code
Link
Numbered list
Unordered list
Task list
Attach files
Mention
Reference
Menu
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
-
Came up while planning YAML support (a
protoyaml-goequivalent for buffa). Recording the analysis here so the option can be re-evaluated as both buffa and musli mature. This is not a near-term plan — the conclusion is "not now" — but the architectural fit is strong enough that it deserves a written case rather than a Slack thread.Summary
buffa's three text-format paths today use three different mechanisms:
serde::{Serialize, Deserialize}derives +with-modulesbuffa::json_helpers,buffa-types/*_ext.rs, codegen#[serde(...)]attributesTextFormattraitbuffa::textbuffa-yamlextension cratemusli is a serialization framework in the same vein as serde, by udoprog. It has two design properties that map directly onto problems buffa has worked around:
Context. Diagnostics and configuration are passed in, not smuggled through thread-locals or globals.What musli's mode system fixes
The thread-local / global wart
serde's
Deserializetrait has no context parameter. To vary parsing behaviour at runtime — e.g.ignore_unknown_enum_values, or accepting YAML-canonical float specials only when parsing YAML — buffa uses ambient state (buffa::json::JsonParseOptions):std: a thread-local, scoped viawith_json_parse_options(&opts, || ...).no_std: a leakedBoxbehind anAtomicPtr, set once for process lifetime viaset_global_json_parse_options, with adebug_assert!if options change after first set.This works but is a recurring source of friction: the two APIs are mutually exclusive, the
no_stdvariant has set-once semantics that surprise, and the container-filtering behaviour (repeated_enum/map_enumskip-unknown) is unavailable underno_stdbecause it needs the scoped override.musli's
Decodetrait is parameterized over a mode type:Format variations become distinct mode types resolved at compile time:
Compiler-checked, monomorphized, no ambient state, no
std/no_stdsplit. The shared parts (most of the implementation) factor into a private helper that both modes call; only the divergent parsing arms differ.The diagnostics gap
The value proposition of
protoyaml-goover plainyaml.Unmarshalis its error reporting: every error carriesfile:line:col, the offending source line, and a^pointer. To do this it hand-walks the YAML AST tracking node spans alongsideprotoreflectfield descriptors.serde has no built-in path or span tracking. The
serde_path_to_errorcrate bolts it on by wrapping the deserializer, but only path-level (no spans), and it cannot reach insidewith-module or custom-Visitorcode.musli's
Contexttrait carries this natively:The derive macro emits
enter_*calls automatically, and a tracingContextimplementation collects them. A YAML format implementation that mapsMarkto byte offsets getsprotoyaml-go-equivalent diagnostics — line/col plus field path — without any per-message hand-written code.Unifying textproto
buffa::textis a hand-writtenTextFormattrait because serde's data model is a poor fit for textproto (extension keys,[type.googleapis.com/...]Any syntax, field-name/field-number addressing). musli's data model is intentionally "does not speak Rust" — it is closer to a protocol-shaped abstract machine — which makes it a more natural fit for textproto's quirks. Amode::TextProtounder the same framework would replace the bespoke trait and benefit from the sameContextdiagnostics.What it costs
There is no
musli-yamlmusli ships
musli::storage,musli::wire,musli::descriptive,musli::json,musli::value, andmusli-serde(a serde→musli bridge). There is no YAML format, first- or third-party (verified against crates.io as of 2026-05). Adopting musli for YAML means writing a YAML encoder/decoder from scratch, or wrapping an existing parser (e.g.saphyr) the wayserde-saphyrdoes. That is a project in itself, not an integration step.Migration surface
Replacing serde with musli for buffa's text formats touches every text-format-aware part of the codebase:
buffa/src/json_helpers.rswith-modules as per-modeEncode/Decodeimplsbuffa-types/src/*_ext.rsSerialize/Deserializefor WKTsbuffa-codegenattribute emissionmessage.rs,oneof.rs#[serde(rename, alias, with, flatten, skip)]with#[musli(...)]equivalentsbuffa-codegencustomDeserializegenerate_custom_deserializeMapDecodermusli-yamlformat crateDownstream API contract
Generated message structs currently derive
serde::Serializeandserde::Deserialize. Downstream consumers depend on this for things buffa has nothing to do with —axumextractors,sqlx::types::Json, config crates, anything that takesT: Serialize. Removing the serde derives is a hard breaking change for those consumers.Two non-options:
#[derive(serde::Serialize, serde::Deserialize, musli::Encode, musli::Decode)]is legal, but then the protobuf-special encode/decode logic (camelCase mapping, int64-as-string, base64 bytes, WKT special forms, oneof flattening) exists twice. That is duplication, not migration. The maintenance burden of keeping two parallel implementations conformant is worse than the wart it's replacing.musli-serdelets a musli format drive serde traits, but in the JSON-out direction: a musli encoder serializes a serde-only type. It does not give serde-only consumers access to a musli-defined format. It cannot save the migration surface.The realistic shape is a major version bump where buffa's text-format API changes from "your type is
serde::Serialize" to "usebuffa::json::to_string(&msg)". Downstream serde integrations would need a thin adapter.no_stdand allocator alignmentmusli's
Decode<'de, M, A>carries anAllocatorparameter. buffa usesextern crate allocforno_stdheap types. These are compatible in principle but differ in idiom — buffa's types own theirVec/Stringallocations directly; musli's allocator parameter is for the framework's own internal buffers. The fit needs validation, particularly for thedecode_viewzero-copy path.musli's stability trajectory
The bare version
0.0.149is misleading. The author uses a single0.0.xseries for the umbrellamuslicrate, but the cadence and the recent split ofmusli-coretell a more nuanced story.Release timeline for the
muslicrate:0.0.107→0.0.122). Heavy churn — GAT migration,Contextintroduction, allocator rework.0.0.122→0.0.131). Slowing.0.0.132→0.0.149). Cleanup burst. Notable changes:unit→emptyrename (inmusli::value),System→Globalallocator rename,#[non_exhaustive]additions,Valueinternals encapsulation, derive-macro restructuring ("remove type-specific attributes"),#[musli::trait_defaults]requirement for manualDecoderimpls.muslireleases. Eight months of stability.musli-core(the crate housingEncode,Decode,Encoder,Decoder,Context, and the mode machinery) was split out and given its own version series at0.1.0on 2025-09-10. It went0.1.0→0.1.4in eight days, then nothing. This is a deliberate stabilization signal — the trait surface is now versioned independently from the umbrella crate, and the author is implicitly committing to a0.1.xcompatibility window for it.Diffing
musli-core/src/de/decoder.rsanden/encoder.rsbetween0.0.131(2025-04) and0.0.149(2025-09) shows the changes are dominated by rustfmt re-indentation; the only functional change to theDecodertrait itself in that window is the#[musli::trait_defaults]requirement for manual implementors. The breaking changes are concentrated inmusli::value(the buffered tree type), the allocator naming, and the derive macro.Verdict: the trait surface buffa would build on (
Encode/Decode/Encoder/Decoder/Contextinmusli-core) has been stable since September 2025 and is now versioned independently. The derive macro syntax churned more recently. Pinningmusli-coreand watching its changelog is viable. The risk is real but smaller than the version number suggests.Recommendation
Not now. The case against is decisive in the near term:
json_helpers.rs,*_ext.rs, codegen attribute emission, custom-Deserializecodegen, conformance) is measured in thousands of lines.musli-yaml, so this does not unblock YAML support — it just changes what's underneath the YAML support that still has to be written.musli-coreis at0.1.x. It is settling, but buffa publishing crates.io artifacts against a0.xframework dependency means breaking bumps in musli are breaking bumps for buffa consumers.Revisit when:
musli-corereaches1.0, or has a multi-year0.1.x/0.2.xtrack record.musli-yamlexists, or buffa wants to invest in writing one (in which case writing it as a musli format is no more work than writing it as a serde format).JsonParseOptionsthread-local / global design is causing measurable pain (today it is awkward but functional).What to do instead, now
buffa-yamlonserde_norway, reusing the existing serde derives. TheJsonParseOptionsmechanism can grow a gated option (accept_yaml_float_literals) if a future carrier needs it; withserde_norwayit is not needed — its scalar resolver delivers.inf/.nantovisit_f64rather than as canonical strings, so buffa's existingfloat/doublehelpers already accept them.References
Encode<M>,Decode<'de, M, A>,Contexttrait documentationContextand why path/span tracking belongs in the frameworkBeta Was this translation helpful? Give feedback.
All reactions