The Data
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.
Validation Analysis
Three Schemas, One Field
| Record Type | Verdict |
|---|
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.
| Record Type | Verdict |
|---|
Current state: This is the schema as published today. The 140 old string records already fail. All new records use the Leaflet format.
| Record Type | Verdict |
|---|
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.
Side by Side
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 |
Protocol Sovereignty
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?
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.
| 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 blocks → content |
❌ 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. |
ref — no recoveryunion — graceful degradationIf Leaflet disappears tomorrow:
goat lex breaking reports: no breaking changes (open union ref removal is safe).
$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).#descriptionString — fully under hypercerts control.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:
xyz.neweditor.document — better rich text, better embed support, better mobile performance. You want to adopt it.
refdescription 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.unionxyz.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.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.
reftype-change to escape the dependency — which breaks everything again.unionreftype-change — to move to a union or your own type. Three breaking changes to your users in two years, each requiring ecosystem-wide coordination.uniongoat 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 Other Side
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.
Breaking changes are ruled out by spec.
The ATProto Lexicon Evolution rules are explicit:
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.
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":
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.
goat lex pull, not running applications.
Widening to union removes migration flexibility.
This is the most technically subtle point. The ATProto spec makes a critical distinction between ref and union:
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 #refVersus for unions:
$type field indicating the variant type."— atproto.com/specs/lexicon #unionWith 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 dataunion — $type in data is the discriminatorref, 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.
The nested blocks are Leaflet regardless.
Inside every Leaflet document, the block field is a union of 15 block types:
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.
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.
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.
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.
$type values in stored data make forking prohibitively expensive.
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.
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."
Critical Analysis
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.
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
requiredfields,propertiestypes, 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 breakingstill 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:
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:
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.
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."
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.
"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:
The spec has a crisis clause because crises happen. The question is whether your schema provides a recovery path when they do.
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.
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.
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.
Conclusions
The Verdict
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.
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.
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.
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.
"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.
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.
The honest answer: it depends on your time horizon.
If you're optimizing for the next 6–12 months: keep the ref. Leaflet is stable, the ecosystem knows how to consume it, and simplicity has value. The risks are well-mitigated by build-time compilation and SemVer.
If you're optimizing for a multi-year protocol: move to an open union. The $type is already in the data so the migration cost is lower than it appears. You get the spec-designed evolution mechanism, structural resilience against third-party changes, and the ability to add new formats without ever shipping a breaking change again. The 140 old string records are broken under both options — handle them in the application layer regardless.