The Corpus

207 records of type org.hypercerts.claim.activity exist on the network. The description field used to be a string. In March 2026 it changed to a Leaflet document ref.

That broke 140 existing records. They have bare strings where the schema now expects a structured object. They were valid when they were written.

Someone proposed a union type to support both formats. Turns out that doesn't work either — ATProto unions need objects with a $type field. A bare string fails no matter what you put in the refs list.

207
Records on Network
140 String description (v1)
27 Leaflet document (v2)
33 Null / missing
7 Unsampled
String Record (V1) · at://did:plc:u7h3dstby64di67bxaotzxcz/…/3mfevymjb6da4
Loading…
Leaflet Record (V2) · at://did:plc:yefshllfwjcwuurpwuvyugum/…/3mimvv5leyk25
Loading…

Three Schemas, One Field

140 pass · 27 fail · 33 skip — Old records work, new Leaflet records break
Loading…
Record TypeVerdict

Note: The v1 string schema is the historical baseline. Moving away from it (to ref or union) is a [type-change] ERROR per goat lex breaking. Once you ship a breaking change, you can't undo it without another one.

27 pass · 140 fail · 33 skip — New records work, old string records break
org.hypercerts.claim.activity — description field
Loading…
pub.leaflet.pages.linearDocument
Loading…
Record TypeVerdict

Current state: This is the schema as published today. The 140 old string records already fail. All new records use the Leaflet format.

27 pass · 140 fail · 33 skip — Same as (b). Union doesn't help old strings.
org.hypercerts.claim.activity — description field
Loading…
descriptionString sub-def + pub.leaflet.pages.linearDocument
Loading…
Record TypeVerdict

The punchline: The union sounds like it would help old string records, but ATProto union validation requires {"$type": "...", ...} objects. A bare string like "kjldsafjlkasdj" can't satisfy a union. The result is identical to schema (b) — 140 failures, 27 passes, 33 skipped.

Comparison

Metric (a) String (b) Leaflet Ref (c) Union
Records passing 140 / 200 27 / 200 27 / 200
Records failing 27 140 140
Old string records (140) ✓ Pass ✗ Fail ✗ Fail
New Leaflet records (27) ✗ Fail ✓ Pass ✓ Pass
Null / missing records (33) ✓ Pass ✓ Pass ✓ Pass
Breaking from v1 (string) — baseline type-change ERROR type-change ERROR
Breaking from v2 (ref) type-change ERROR — current type-change ERROR
Forward compatibility Breaks new records All new records valid All new records valid
Migration effort Revert (impossible) None — current High + still breaks old
Third-party dependency None Full — Leaflet controls validation Partial — can remove Leaflet ref
Recovery if Leaflet disappears N/A Requires breaking type-change Remove ref (non-breaking)
Recommendation Historical only ⚠ Current — but single point of failure ✓ Best long-term option

Third-Party Risk

The description field points to pub.leaflet.pages.linearDocument — a schema that Leaflet controls, not hypercerts. Validators resolve it at runtime: DNS lookup → DID → PDS → schema record. If any step in that chain fails, validation fails. What happens if Leaflet goes away?

DNS Lookup
_lexicon.leaflet.pub
TXT Record
did=did:plc:btxrwcaeyodrap5mnjw2fvmz
PDS Endpoint
chanterelle.us-west.host.bsky.network
Schema Record
pub.leaflet.pages.linearDocument

If Leaflet's DNS entry changes, their DID is deactivated, their PDS goes offline, or the schema record is deleted — resolution fails and validation stops.

Failure Scenarios
Scenario (b) Ref to Leaflet (c) Open Union
Leaflet is healthy ✅ All 27 leaflet records pass ✅ All records pass
Leaflet PDS goes down temporarily ❌ All records fail — ref resolution fails with "schema not found in catalog" (if not cached) ❌ Leaflet-typed records fail (same for listed refs)
Leaflet unpublishes schema permanently ❌ Dead protocol dependency — ALL records with description fail validation forever. No recovery without breaking type-change. ⚠️ Recovery path: remove pub.leaflet… from union refs → existing Leaflet records pass as unknown open union variants. New records use #descriptionString.
Leaflet ships breaking schema change ❌ Existing records may silently break. No way to opt out. ⚠️ Same impact, but can remove Leaflet ref to stop validating against their changed schema.
Need to add a new description format ❌ Requires type-change (ref → union), which is a breaking change. ✅ Add new ref to open union — not a breaking change per goat lex breaking.
Need to stop depending on Leaflet entirely ❌ Requires breaking type-change. ✅ Remove Leaflet ref, add own types. Open union ref changes are non-breaking.
A better document lexicon emerges in 2027
e.g. xyz.neweditor.document
❌ Requires breaking type-change (ref → union) just to support a second format. Every indexer, client, and validator must update simultaneously. ✅ Add xyz.neweditor.document to union refs. Non-breaking — clients adopt the new format at their own pace.
Leaflet ships its own breaking change
e.g. renames blockscontent
❌ Hypercerts has no say. All existing records silently break against the new Leaflet schema. Users experience breakage you didn't choose and can't prevent. ⚠️ Remove Leaflet ref from union → existing records pass as unknown open union types. Add updated Leaflet ref or own type. You choose when your users see the change.
Gradual migration between formats
deprecate old, adopt new over months
❌ Impossible — ref points to exactly one type. Migration is all-or-nothing. ✅ Both old and new types coexist in the union. Deprecate gradually. Remove old ref when ready. Non-breaking at every step.
The Code Path (Critical)
ref — no recovery
// indigo/atproto/lexicon/validation.go case SchemaRef: next, err := cat.Resolve(v.fullRef) if err != nil { return err // ← HARD FAIL // no fallback, no graceful degradation } return validateData(cat, next.Def, d, flags)
open union — graceful degradation
// indigo/atproto/lexicon/validation.go func validateUnion(...) { // if $type doesn't match any listed ref: if closed { return error // closed unions: hard fail } // OPEN union: try to resolve unknown $type def, err := cat.Resolve(t) if err != nil { // by default, pass unknown types return nil // ← GRACEFUL DEGRADATION } return validateData(cat, def.Def, d, flags) }
Open Union Ref Changes Are Non-Breaking
// indigo/lex/lexlint/breaking.go case lexicon.SchemaUnion: // Can't change between open and closed: if !eqOptBool(l.Closed, r.Closed) → ERROR // Ref changes in CLOSED unions: if l.Closed != nil && *l.Closed { if !reflect.DeepEqual(l.Refs, r.Refs) → ERROR } // OPEN unions: ref changes are NOT breaking! // Adding or removing refs = safe evolution
Recovery Scenario Walkthrough

If Leaflet disappears tomorrow:

1
Existing leaflet-typed records fail validation (same as ref).
2
Hypercerts publishes a schema update:
"description": { "type": "union", "refs": ["#descriptionString"], "closed": false }
goat lex breaking reports: no breaking changes (open union ref removal is safe).
3
Existing leaflet records now have $type: "pub.leaflet.pages.linearDocument" which doesn't match any listed ref → falls through to open union handling → passes silently (unknown type in open union).
4
New records use #descriptionString — fully under hypercerts control.
Who Controls the Breaking Changes?

The real problem with ref isn't that Leaflet might disappear — it's that you stop controlling when your users experience breaking changes. Who decides when things break?

Three scenarios that could play out over the next two years:

2027 Q1
A new document standard emerges. The ATProto ecosystem converges on xyz.neweditor.document — better rich text, better embed support, better mobile performance. You want to adopt it.
With ref
You must change description from ref → leaflet to ref → neweditor. This is a [ref-change] WARN in goat lex breaking, but far worse in practice: all 27 existing Leaflet records instantly fail validation. Every indexer, every client, every tool breaks. You've traded one third-party dependency for another.
With open union
Add xyz.neweditor.document to the refs array. Non-breaking change. New records use the new format; old Leaflet records continue validating. Clients adopt at their own pace over months.
2027 Q3
Leaflet ships a breaking change. They rename blocks to content and bump their schema. They had good reasons. But your 27 existing records — and any new ones written between Q1 and Q3 — now fail against Leaflet's updated schema.
With ref
You wake up to broken validation. You didn't ship anything. You didn't change your schema. But your users' records are invalid. You have zero control over the timeline. Your only option is a breaking type-change to escape the dependency — which breaks everything again.
With open union
Remove old Leaflet ref from the union. Existing Leaflet records pass as unknown open union types (graceful degradation). Add the updated Leaflet ref or your own alternative. You choose the timeline. You ship when ready. Your users never see unexpected breakage.
2028
You're ready to drop Leaflet entirely. The new editor is mature, migration tools exist, and you want a clean protocol with no external dependencies.
With ref
You've already been through two breaking changes (the original type-change and the Leaflet breakage). Now you need a third — another type-change — to move to a union or your own type. Three breaking changes to your users in two years, each requiring ecosystem-wide coordination.
With open union
Remove Leaflet from the refs. Add your own type if you haven't already. goat lex breaking: no issues. Zero breaking changes across the entire timeline. Users never noticed a thing.

With ref, every evolution requires a breaking change you can't control or time. With an open union, you add and remove types without breaking anything, on your own schedule.

The union was never about backward compatibility with old string records. It's about who decides when things break. With a ref, any third party can break your protocol on their schedule. With an open union, you add new types when ready, remove old ones when safe, and your users don't hit breaking changes they didn't choose. The question isn't whether Leaflet will change. It's whether you want a say when it does.

The Case for Keeping the Ref

The union case sounds good. But there's a counter-argument worth taking seriously — it comes from the ATProto spec, from how apps actually ship, and from what interoperability means in practice.

1

Breaking changes are ruled out by spec.

The ATProto Lexicon Evolution rules are explicit:

"The basic principle is that all old data must still be valid under the updated Lexicon, and new data must be valid under the old Lexicon. Types can not change. If larger breaking changes are necessary, a new Lexicon name must be used."— atproto.com/specs/lexicon #lexicon-evolution

If Leaflet violated this, they would break every app in the ATmosphere that uses their lexicons — not just Hypercerts. The Leaflet lexicons are mature, widely used, and maintained by a team with direct relationships in the ecosystem.

Verdict: Correct. The spec provides strong structural protection. Breaking changes require a new NSID — there is no mechanism to silently break an existing lexicon.
2

Apps compile schemas at build time, not at runtime.

The DNS resolution chain shown above — while technically accurate — describes how tools like goat and the ResolvingCatalog work. Production apps use codegen (lex gen-api) or npm packages (@atcute/leaflet) that bake schemas in at build time. The ATProto spec confirms this with "optimistic validation":

"Optimistic validation (default): if the PDS does not know it, and can't resolve it, the record is allowed to be created. This is also referred to as 'Fail-Open'."— atproto.com/specs/lexicon #validation-options

Even if Leaflet unpublished their schema record, apps would continue working with whatever version they compiled against. The DNS chain is a concern for development tooling, not for production systems.

Verdict: Correct. The demo's DNS resolution chain overstates the runtime risk. In practice, schema resolution failures would affect goat lex pull, not running applications.
3

Widening to union removes migration flexibility.

This is the most technically subtle point. The ATProto spec makes a critical distinction between ref and union:

"When a ref points to an object type, the type of the encoded data is unambiguous. This means that the $type field should not be included in encoded data as a discriminator."— atproto.com/specs/lexicon #ref

Versus for unions:

"All the variants must be represented by a CBOR map and must include a $type field indicating the variant type."— atproto.com/specs/lexicon #union

With ref, the validator resolves the target from the schema definition — it doesn't look at $type in the data. If you later need to point to a structurally compatible schema under a different NSID, you change the ref target. The validator follows the new ref. Existing data works as long as the structure matches. This is a [ref-change] WARN, not an ERROR.

With union, the $type in the stored data becomes the discriminator. The validator matches it against the listed refs. You can't transparently redirect — the NSID is locked into every record.

ref — validator ignores $type in data
// validation.go — SchemaRef handler case SchemaRef: // resolves from SCHEMA DEFINITION, not data next, err := cat.Resolve(v.fullRef) return validateData(cat, next.Def, d, flags) // validateObject does NOT check $type // → you can change the ref target and // existing data still validates
union$type in data is the discriminator
// validation.go — validateUnion handler typeVal, ok := obj["$type"] // REQUIRED t, ok := typeVal.(string) for _, ref := range s.fullRefs { if ref != t { continue } // must MATCH // ... } // → $type is locked into stored data // can't transparently redirect
Verdict: Correct — and significant. This is the strongest technical argument against the union. With ref, you retain the ability to migrate to any structurally compatible schema. With union, the NSID is baked into every record's data as the $type discriminator.
4

The nested blocks are Leaflet regardless.

Inside every Leaflet document, the block field is a union of 15 block types:

{ "blocks": [{ "block": { "$type": "pub.leaflet.blocks.text", // ← baked into data "plaintext": "Hello", "facets": [...] // → pub.leaflet.richtext.facet } }] }

These $type values — pub.leaflet.blocks.text, pub.leaflet.blocks.header, pub.leaflet.blocks.image, etc. — are union variants baked into the stored data of every existing record, regardless of whether the top-level description field uses ref or union.

If Leaflet's block schemas disappeared, the top-level union would not save you — the nested block validation would still fail. Forking to org.hypercerts.blocks.text would not help either, because the $type in every existing record says pub.leaflet.blocks.text.

Verdict: Correct. The Leaflet dependency runs deeper than the top-level description field. A top-level union provides no protection for the nested block types. The dependency on pub.leaflet.* is structural, not just a schema reference.
5

SemVer and standard dependency management handle the rest.

Pinning @atcute/leaflet to ^1.0.x via SemVer means major version bumps (which signal breaking changes) don't auto-update. This is identical to how every other dependency in the JavaScript ecosystem works. Any app that blindly updates major versions without testing will break regardless of Leaflet.

Verdict: Correct. This is standard engineering practice, not specific to Leaflet or ATProto.
6

Re-hosting under your own namespace makes things worse.

Forking Leaflet's schemas to org.hypercerts.* would break all existing records (the stored $type values wouldn't match), lose interoperability with other Leaflet-based apps, and create a maintenance burden of keeping a parallel schema hierarchy in sync — or deliberately diverging.

Verdict: Correct. The $type values in stored data make forking prohibitively expensive.
7

Interoperability is the point of ATProto.

If every project published its own siloed lexicons without reusing shared schemas, the AT Protocol's value proposition — a web of interoperable applications — collapses. Using Leaflet's document model means any app that understands Leaflet documents can render hypercert descriptions. That's not a weakness; it's the system working as designed.

Verdict: This is a values argument, not a technical one — but it's a strong one. The bet on ATProto is fundamentally a bet on shared infrastructure.

The case for keeping ref is coherent: Leaflet breaking is unlikely (spec rules, social trust, maturity), the impact is well-mitigated (build-time compilation, SemVer, fail-open PDS), and the union actually reduces migration flexibility by baking the NSID discriminator into stored data. The strongest version of this argument isn't "Leaflet won't break" — it's "the union makes it harder to recover if anything does."

Where the Counter-Arguments Break Down

The counter-arguments make sense on the surface. But a few of them contradict each other, and some rest on assumptions nobody has tested.

The risk-flexibility contradiction.

The counter-argument simultaneously claims:

  • Point #1: "The risk of Leaflet breaking is very low."
  • Point #3: "The ref gives migration flexibility if you need to redirect."

These contradict. If the risk is very low, you don't need migration flexibility — the ref's theoretical redirect capability is a solution to a problem that allegedly doesn't exist. If you do need migration flexibility, the risk isn't as low as claimed. You can't simultaneously use "Leaflet is safe" to dismiss the union and "ref gives you an escape hatch" as a safety net.

Internal contradiction. The counter-argument uses low risk to argue against the union, then uses migration flexibility (only needed if risk materializes) to argue for the ref.

The ref's "migration flexibility" is theoretical — and untested.

The counter-argument's strongest technical point is that ref lets you redirect to a structurally compatible schema by changing the ref target. But for this to actually work, you'd need:

  • A schema under a different NSID with identical required fields, properties types, and nested structure
  • All nested unions (the 15 block types, richtext facets) to be compatible with the new schema
  • Every consumer to re-resolve and accept the changed ref
  • goat lex breaking still flags this as [ref-change] WARN

In practice, you'd need an exact clone of pub.leaflet.pages.linearDocument under a different NSID. This migration path has never been used in the ATProto ecosystem. It exists in the validator code but has no real-world precedent.

Compare to the union's evolution path: add a new ref (non-breaking, no flag), remove an old ref (non-breaking for open unions). This is the mechanism the ATProto spec explicitly designed for schema evolution:

"By default unions are 'open', meaning that future revisions of the schema could add more types to the list of refs."— atproto.com/specs/lexicon #union
The ref's flexibility is theoretical; the union's is the spec-designed evolution mechanism.

The $type is already in the data. The union doesn't "bake" anything new.

The counter-argument's central claim is that widening to union "bakes the NSID discriminator into stored data." But every existing Leaflet record already contains:

"description": { "$type": "pub.leaflet.pages.linearDocument", // ← ALREADY HERE "blocks": [...] }

The ATProto spec says $type "should not be included" for ref'd objects — but implementations include it anyway. Every single one of the 27 Leaflet records has $type in the description. The data is identical whether the schema uses ref or union.

What the union changes is how the validator interprets the data — not the data itself. And since the $type already matches what the union would list (pub.leaflet.pages.linearDocument), existing records pass under both schemas. The "cost" of union is purely about future validator behavior, not about existing data.

Overstated. The NSID is already in the data. The union changes validator behavior, not stored records. In practice, the migration from ref to union would be transparent for all 27 existing Leaflet records.

"Build-time compilation" doesn't apply to the three most critical consumer categories.

The counter-argument says apps compile schemas at build time, making runtime resolution irrelevant. That's true for one category: end-user apps built with codegen. But three other categories all resolve NSIDs at runtime, and they matter more to the ecosystem than any single app.

1. Discovery infrastructure. Hyperscan — the primary explorer for the hypercerts ecosystem — resolves NSIDs dynamically. Its /api/lexicon/{nsid} endpoint does runtime resolution. When someone visits hyperscan.dev/lexicon/org.hypercerts.claim.activity, the schema is fetched live. If Leaflet's schema can't be resolved, the description field's type information is incomplete in every schema browser, documentation tool, and discovery interface.

2. AI coding agents. The Hyperscan agent API at /agents/lexicon/{nsid} is designed for AI agents to fetch and understand schemas at runtime. The guides at /agents/guides/create-hypercert assume agents resolve schemas live to learn how to create records. This analysis itself was built by an AI agent that fetched the schema at runtime via these exact endpoints. As AI agents become a common way records are created and queried, runtime NSID resolution moves from "development tooling" to "production critical path."

# Actual runtime NSID resolution paths used in this session: curl https://www.hyperscan.dev/agents/lexicon/org.hypercerts.claim.activity → Schema fetched dynamically, including description field's ref to Leaflet curl https://www.hyperscan.dev/api/lexicon/pub.leaflet.pages.linearDocument → Leaflet schema resolved at runtime to understand the ref target curl -X POST https://api.hi.gainforest.app/graphql → 92 lexicon-specific query fields, generated from resolved schemas goat get at://did:plc:.../org.hypercerts.claim.activity/... → Record fetched with runtime schema resolution for validation

3. Indexers. The Hyperindex at api.hi.gainforest.app exposes 92 lexicon-specific GraphQL query fields. It dynamically understands record schemas to index, query, and serve data. When the counter-argument says "apps would continue working," it means your compiled app. But the indexer that all those apps query, the relay that syncs records, and any new application trying to understand the ecosystem — they all resolve schemas at runtime.

The PDS "fail-open" validation means records get accepted without validation. But "accepted" doesn't mean "discoverable." A record that passes PDS ingestion but can't be fully resolved by Hyperscan, understood by an AI agent, or indexed by Hyperindex is effectively invisible to the ecosystem.

The build-time argument applies to one consumer category. The three others — discovery tools, AI agents, and indexers — all resolve NSIDs at runtime. These are not edge cases; they are the primary means by which the ecosystem discovers, understands, and queries hypercert records.

"Leaflet won't break" is social trust, not protocol guarantee.

The ATProto spec says types can't change and breaking changes require new NSIDs. But this is a social norm enforced by tooling, not a protocol-level enforcement. The PDS doesn't prevent updating a schema record with breaking content. goat lex breaking catches violations in CI — but only if the schema author has CI configured.

The counter-argument says "we know the Leaflet team personally." Personal relationships are valuable but they're not architecture. People leave companies. Companies pivot. Maintainers burn out. Open source projects get abandoned. Depending on social trust for protocol stability is the same category of risk that ATProto was designed to eliminate with its portability and decentralization guarantees.

The spec itself acknowledges this fragility:

"In a crisis, such as unintentional loss of DNS control to a bad actor, the protocol ecosystem could decide to disregard this chain of authority."— atproto.com/specs/lexicon #authority-and-control

The spec has a crisis clause because crises happen. The question is whether your schema provides a recovery path when they do.

The risk is low but not zero, and the mitigations are social, not structural. "We know the team" is not a substitute for schema-level resilience.

The nested blocks argument proves too much.

"The nested blocks are Leaflet regardless, so a top-level union doesn't help." This is factually correct — but it proves too much. By this logic:

  • You should never use any third-party lexicon, because nested types create permanent dependencies
  • But point #7 says "interoperability is the point" — so you should use shared lexicons
  • These can't both be arguments for keeping the ref

The nested lock-in is a real constraint, but it's orthogonal to the top-level schema choice. With union, you can at least accept new records in a non-Leaflet format (via #descriptionString) while old Leaflet records degrade. With ref, you can't accept any new format without a breaking change — even if Leaflet's blocks are already broken at the nested level.

The union doesn't fix the nested problem. But it doesn't make it worse either — and it solves a different problem (top-level format evolution) that the ref can't solve at all.

True but irrelevant to the top-level choice. The nested dependency is the same under both schemas. The union provides escape hatches that the ref doesn't, even if neither fixes blocks.

The counter-argument assumes a static world. ATProto is designed for evolution.

The deepest assumption in the counter-argument is that today's choices are permanent and sufficient. "Leaflet is good enough. We don't need other formats. The risk is low. Keep it simple."

But the ATProto spec was designed with evolution in mind. Open unions exist for exactly this. The spec says they allow "future revisions of the schema" to "add more types." The entire Lexicon Evolution section is about safe change over time.

The question isn't "do we need multiple description formats today?" — it's "will we ever?" Over a multi-year protocol lifetime, the probability approaches certainty. And at that point, with ref, you'll need a breaking type-change to get the flexibility that a union provides from day one.

The counter-argument says "you can always widen from ref to union later." But that widening IS the breaking change they're trying to avoid. The cost doesn't go away — it grows with every record written under the ref schema.

Deferring the type-change doesn't eliminate it — it makes it more expensive. Every record written under ref is another record that will need to survive the eventual ref → union migration.

The counter-arguments aren't wrong on the facts. Leaflet probably won't break. Apps do compile at build time. SemVer works. But the arguments rest on probability ("Leaflet is unlikely to break") and social trust ("we know the team"), not structural guarantees. The union provides structural guarantees — the ability to evolve without breaking changes — using the mechanism the ATProto spec designed for exactly this. The question is whether your protocol's resilience depends on a third party's continued good behavior, or on your own schema design.

The Verdict

1

Neither option fixes the 140 old string records.

ATProto unions require objects with $type. Old records have bare strings. No schema choice can retroactively fix this. Handle them at the application layer by falling back to shortDescription.

2

The $type migration flexibility argument is overstated.

The counter-argument's strongest point — that ref lets you redirect to a different schema because the validator ignores $type — is technically true but practically untested. It requires a structurally identical clone schema, is still flagged as [ref-change] WARN, and doesn't help with nested block types. Meanwhile, $type is already present in all 27 existing Leaflet records, so switching to a union changes validator behavior but not stored data.

3

The build-time compilation argument has a blind spot.

End-user apps compile schemas at build time. But the three most critical consumer categories operate at runtime: Hyperscan (the primary discovery tool, resolving NSIDs dynamically), AI coding agents (fetching schemas via /agents/lexicon/{nsid} to understand how to create records), and Hyperindex (92 GraphQL fields generated from resolved schemas). "Fail-open" PDS acceptance means records exist but may not be discoverable, understandable, or indexable by the infrastructure that makes the ecosystem work.

4

The nested block dependency is real — but orthogonal.

Every Leaflet document contains $type: "pub.leaflet.blocks.text" etc. baked into data. This is a structural dependency that neither ref nor union can solve. But the union provides top-level format evolution (adding/removing types without breaking changes) that the ref cannot — even if blocks are a separate problem.

5

"Leaflet probably won't break" is probability, not architecture.

The ATProto spec's evolution rules, SemVer, and personal relationships make Leaflet breakage unlikely. But the spec itself has a crisis clause. Social trust mitigates risk; schema design eliminates it. The union uses ATProto's own designed mechanism for safe evolution.

6

Deferring the type-change doesn't eliminate it.

"You can always widen to union later" — but that widening IS a breaking type-change. Every record written under ref is another record that must survive the eventual migration. If you'll ever need multiple description formats (and over a protocol's lifetime, the probability approaches certainty), the cost of deferral grows monotonically.