Skip to content

feat: add pub.leaflet lexicons - #165

Merged
s-adamantine merged 5 commits into
mainfrom
add-leaflet-lexicons
Apr 7, 2026
Merged

feat: add pub.leaflet lexicons#165
s-adamantine merged 5 commits into
mainfrom
add-leaflet-lexicons

Conversation

@Kzoeps

@Kzoeps Kzoeps commented Mar 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds all pub.leaflet lexicons (blocks, pages, richtext) vendored from the Leaflet protocol
  • Includes pub.leaflet.richtext.facet which was missing but referenced by blockquote, header, and text blocks
  • Adds app.bsky.richtext.facet shim for ATProto richtext compatibility
  • Updates SCHEMAS.md to include the new lexicons

Summary by CodeRabbit

  • New Features
    • Adds rich-text facet support (links, mentions, tags, formatting, footnotes) and many new content block types (images, code, blockquote, header, lists, button, poll, iframe, website) plus a linear-document page model with alignment.
  • Documentation
    • Updated generation docs to remove shim step and note vendored external lexicons.
  • Tests
    • Added validation tests exercising external lexicons.
  • Chores
    • Removed shim generation and related dependencies.

@changeset-bot

changeset-bot Bot commented Mar 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 238f8a1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hypercerts-org/lexicon Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Mar 4, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@aspiers has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 26 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 3 minutes and 26 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 612fcf4c-bcf4-44d8-8080-99d148a1afb0

📥 Commits

Reviewing files that changed from the base of the PR and between 8657f1d and 238f8a1.

📒 Files selected for processing (3)
  • SCHEMAS.md
  • scripts/generate-schemas.js
  • tests/validate-external-lexicons.test.ts
📝 Walkthrough

Walkthrough

Adds vendored external lexicons (app.bsky.richtext.facet, pub.leaflet.*) plus corresponding JSON schema files, removes shim generation and @atcute dependencies, updates generation tooling, and introduces tests that validate usage of the new lexicons at runtime.

Changes

Cohort / File(s) Summary
Changeset & Docs
/.changeset/add-pub-leaflet-lexicons.md, SCHEMAS.md, AGENTS.md, docs/AUTO_GENERATION.md
Adds a changeset entry, inserts comprehensive schema listings for new lexicons, and updates docs to remove shim-generation steps and note vendored external lexicons.
ATProto Richtext Lexicon
lexicons/app/bsky/richtext/facet.json
Adds app.bsky.richtext.facet lexicon: main (index, features), mention, link, tag, and byteSlice defs for byte-range indexed rich-text facets.
Leaflet Block Lexicons
lexicons/pub/leaflet/blocks/*.json
Adds many pub.leaflet.blocks.* lexicons (blockquote, bskyPost, button, code, header, horizontalRule, iframe, image, math, orderedList, page, poll, text, unorderedList, website) with property schemas, unions, nested-list types, and blob/uri constraints.
Leaflet Page & Richtext Lexicons
lexicons/pub/leaflet/pages/linearDocument.json, lexicons/pub/leaflet/richtext/facet.json
Adds pub.leaflet.pages.linearDocument (blocks union, alignment tokens, quote/position types) and pub.leaflet.richtext.facet (byteSlice, link, didMention, atMention, code, highlight, underline, strikethrough, id, bold, italic, footnote).
Generation Tooling & Scripts
package.json, scripts/create-shims.sh (removed), scripts/generate-exports.js
Removes shim script and @atcute/* dependencies from package.json; updates gen-api to skip shim step; improves import-name generation in generate-exports.js.
Tests
tests/validate-external-lexicons.test.ts
Adds Vitest test suite exercising generated validators and runtime validation for records using vendored Leaflet description and Bluesky facet structures; includes positive and negative cases.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • daviddao
  • s-adamantine

Poem

🐇 I vendored lexicons, neat and bright,
I hopped through JSON all through the night,
Blocks and facets in tidy array,
Validators smile at break of day,
Hooray — the schemas now take flight! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Lexicons Styleguide Compliance ⚠️ Warning 18 newly added lexicon files are missing required root-level description field per ATProto Lexicon Style Guide. Add root-level description field to each lexicon file documenting external origin and vendored status.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change—adding pub.leaflet lexicons—which aligns with the primary objective of vendoring Leaflet protocol lexicons and making them available at runtime.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Lexicon Documentation Sync ✅ Passed PR maintains proper synchronization between JSON lexicons and documentation files. SCHEMAS.md comprehensively updated with all 18 new lexicons with full documentation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-leaflet-lexicons

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 and usage tips.

@Kzoeps
Kzoeps force-pushed the add-leaflet-lexicons branch from 27e6e7c to ffd3bde Compare March 4, 2026 12:23

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lexicons/pub/leaflet/blocks/orderedList.json`:
- Around line 9-12: The startIndex property in orderedList.json currently allows
non-positive integers; update the "startIndex" schema (the property named
"startIndex") to enforce positivity by adding a lower bound (e.g., add
"minimum": 1) so only integers >=1 are valid while keeping the existing "type":
"integer" and description.

In `@lexicons/pub/leaflet/pages/linearDocument.json`:
- Around line 86-94: The schema currently allows negative integers for path
indices and text offsets; update the "block" array item schema and the "offset"
property in linearDocument.json to enforce non-negative values by adding a
minimum: 0 constraint so both the "block" items (the path indices) and the
"offset" (text offset) cannot be negative.

In `@lexicons/pub/leaflet/richtext/facet.json`:
- Around line 54-56: Add the proper format constraints to the URI fields in the
facet schema: update the link.uri property to include "format": "uri" so it
validates as an RFC-3986 URL, and change the atMention.atURI property's format
from "uri" to "at-uri" so it enforces the AT URI pattern
(at://AUTHORITY[/COLLECTION[/RKEY]]); locate and edit the properties named
"link.uri" and "atMention.atURI" in the lexicon to apply these format updates.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8c9d85c2-f279-4d2b-96f9-2b37fd2f8969

📥 Commits

Reviewing files that changed from the base of the PR and between 82ee279 and ffd3bde.

📒 Files selected for processing (20)
  • .changeset/add-pub-leaflet-lexicons.md
  • SCHEMAS.md
  • lexicons/app/bsky/richtext/facet.json
  • lexicons/pub/leaflet/blocks/blockquote.json
  • lexicons/pub/leaflet/blocks/bskyPost.json
  • lexicons/pub/leaflet/blocks/button.json
  • lexicons/pub/leaflet/blocks/code.json
  • lexicons/pub/leaflet/blocks/header.json
  • lexicons/pub/leaflet/blocks/horizontalRule.json
  • lexicons/pub/leaflet/blocks/iframe.json
  • lexicons/pub/leaflet/blocks/image.json
  • lexicons/pub/leaflet/blocks/math.json
  • lexicons/pub/leaflet/blocks/orderedList.json
  • lexicons/pub/leaflet/blocks/page.json
  • lexicons/pub/leaflet/blocks/poll.json
  • lexicons/pub/leaflet/blocks/text.json
  • lexicons/pub/leaflet/blocks/unorderedList.json
  • lexicons/pub/leaflet/blocks/website.json
  • lexicons/pub/leaflet/pages/linearDocument.json
  • lexicons/pub/leaflet/richtext/facet.json

Comment thread lexicons/pub/leaflet/blocks/orderedList.json
Comment thread lexicons/pub/leaflet/pages/linearDocument.json
Comment thread lexicons/pub/leaflet/richtext/facet.json
@Kzoeps
Kzoeps requested review from aspiers and s-adamantine March 4, 2026 12:38
@Kzoeps
Kzoeps force-pushed the add-leaflet-lexicons branch from 55cd4b8 to ffd3bde Compare March 4, 2026 14:23

@aspiers aspiers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As discussed a while back, I'm not yet convinced that including copies of third party lexicons is needed or the best way. We were already importing these and I thought it was working fine, e.g. see https://github.com/hypercerts-org/hypercerts-lexicon/blob/main/scripts/create-shims.sh. This seems to be a large part of the reason https://github.com/mary-ext/atcute exists.

That said, I could be wrong! Worth doing some research on how other projects handle this.

@aspiers

aspiers commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

(Also, the checks are failing.)

Copy link
Copy Markdown
Contributor

should we close this PR for now?

@daviddao

Copy link
Copy Markdown
Contributor

I'm worried about third-party lexicons, we don't control the leaflet domain and can't publish and validate it. I prefer hosting it under our own hypercerts domain

@holkexyz

holkexyz commented Apr 1, 2026

Copy link
Copy Markdown
Member

Do we need this? @s-adamantine @aspiers

@aspiers

aspiers commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

I investigated whether @atcute/leaflet could solve the runtime validation gap without vendoring.

TL;DR: it can't. The @atcute ecosystem and @atproto/lexicon use completely separate validation runtimes with no bridge between them.

What @atcute/leaflet provides:

  • TypeScript types (Main, Block, etc.) — compile-time only
  • Its own validation schemas (mainSchema, blockSchema) built on @atcute/lexicons's v.object() / v.variant() system
  • It does not ship raw lexicon JSON files, and its validation system has zero connection to @atproto/lexicon's Lexicons class

The actual bug: Our @atproto/lexicon runtime validator throws LexiconDefNotFoundError whenever it encounters a record with description (refs pub.leaflet.pages.linearDocument#main) or shortDescriptionFacets (refs app.bsky.richtext.facet). This affects activity, collection, attachment, and measurement. The existing tests never catch this because they only populate required fields and these fields are optional.

Why the shim approach was always type-only: scripts/create-shims.sh generates TypeScript re-exports from @atcute/leaflet and @atcute/bluesky. These satisfy the compiler but don't register anything into the Lexicons runtime. The same is true for com.atproto.repo.strongRef — it works at runtime because we vendor lexicons/com/atproto/repo/strongRef.json, not because of @atcute.

So this PR's approach (vendoring the JSONs into lexicons/) is the correct pattern — it's the same thing we already do for com.atproto.repo.strongRef. That said, the PR could be improved:

  1. No tests are added exercising the previously-broken validation paths
  2. It may be possible to vendor only the subset actually referenced (linearDocument + transitive deps) rather than all files
  3. CI checks need fixing

@aspiers

aspiers commented Apr 4, 2026

Copy link
Copy Markdown
Contributor

Regarding @daviddao's concern about depending on third-party lexicons we don't control:

Firstly, worth noting this concern is more relevant for third-party lexicons like Leaflet. The app.bsky.richtext.facet refs we use for shortDescriptionFacets are a separate, and presumably (hopefully!) an even more stable dependency as part of Bluesky's own core schema.

This is a perfectly reasonable concern so thank you for raising. I think the risk is very low and well-mitigated. Here's why (and BTW this is not pure AI slop, I thought about this carefully then asked the AI to sanity check it and help with wording!):

Breaking changes are essentially ruled out by the ATProto spec. As we are already painfully aware from our own experience, the Lexicon Evolution rules mean breaking changes require a new NSID. If Leaflet violated this, it would break every app in the ATmosphere that uses their lexicons, not just us. Unlike Hypercerts, the Leaflet lexicons are already mature, widely recognised, and have been in broad use for quite a long time already. We also know the Leaflet team personally since Vancouver and could ask them directly for extra assurances on this.

Apps compile schemas in at build time, not at runtime. It would be very unusual for an app to dynamically resolve lexicons from ATProto at runtime on every use. Schemas are baked in via codegen (lex gen-api) or npm packages. Even ATProto PDSes default to "optimistic validation" (fail-open) for unknown lexicons. So even in a worst case where Leaflet somehow changed or unpublished their lexicons on ATProto, apps would continue working with whatever version they compiled against. And any app that did dynamically resolve lexicons at runtime would presumably be doing so because it expected them to change — which would be a weird assumption for a stable schema.

Standard dependency management handles the rest. We pin @atcute/leaflet to ^1.0.x via SemVer. Major version bumps (which would signal breaking changes) don't auto-update. Any app that blindly updates major versions of all dependencies without testing is going to break regardless of whether Leaflet lexicons are involved.

Re-hosting under our own domain would actually break interoperability. The linearDocument schema's block field is a union of 15 block types (pub.leaflet.blocks.text, pub.leaflet.blocks.header, etc.). Per the ATProto spec, union variants require $type in the stored data. So every description in every existing record contains data like:

{
  "blocks": [{
    "block": {
      "$type": "pub.leaflet.blocks.text",
      "plaintext": "Hello",
      "facets": [...]
    }
  }]
}

If we forked these to org.hypercerts.blocks.text etc., the $type strings in all existing stored records would no longer match the schema's union refs — invalidating every record with a description. The same applies to Leaflet's richtext facet features (pub.leaflet.richtext.facet#bold, #italic, #link, etc.), which are also union variants with $type baked into stored data.

If something did go wrong, we'd still have an upgrade path at least for our top-level description fields. Since they use bare ref (not union), the $type discriminator doesn't appear at the top level of the record data — only in the schema definition. So we could point our schemas at a forked namespace for the top-level ref. (And for strongRef fields, there are no typing constraints on the referenced record anyway.) The problem is that the contents of the linearDocument (blocks, facets) are unions with pub.leaflet.* NSIDs baked into the data all the way down. In practice this means we'd need to vendor and republish the Leaflet schemas under their original NSIDs (which we could do in our npm package and vendored JSONs — apps don't resolve from ATProto at runtime) or accept the existing NSID as-is.

This is also an argument against the earlier idea (PR #181, now closed) of widening description to a union — that would additionally bake the NSID into the top-level record data, removing even the limited migration flexibility we currently have.

That said, we could probably use https://panproto.dev and the lexicon lensing idea to solve even the stickiest of migration problems.

In summary:

  • the risk of Leaflet breaking their lexicons seems very low, and we have the relationship to assess this risk directly
  • apps are insulated by standard build-time compilation and SemVer practices
  • re-hosting under our own namespace would probably make things worse by breaking all existing records and interoperability with other Leaflet-based apps
  • there are various options for emergency migrations if we really needed them

Finally, from a more philosophical perspective, we are placing a bet on ATProto, which I see as more or less equivalent to placing a bet on the ATmosphere community and the vision of interoperability. If everyone in the community published their own lexicons in silos without reusing lexicons from others, then personally I would be much less optimistic about ATProto's chances of success. If the lensing idea gains traction in the future then this could change for the better, but we're not there yet.

Kzoeps and others added 2 commits April 4, 2026 16:28
- Update vendored leaflet lexicons to match upstream (add footnote
  facet, checklist support in list blocks)
- Remove create-shims.sh and @atcute/{leaflet,bluesky} dependencies;
  vendored JSONs now provide both types and runtime validation
- Fix duplicate export names in generate-exports.js for external
  lexicon namespaces (BSKY_RICHTEXT_FACET vs LEAFLET_RICHTEXT_FACET)
- Add validate-external-lexicons.test.ts covering description and
  facet validation for activity, collection, attachment, measurement
- Update AGENTS.md and docs/AUTO_GENERATION.md to remove shim refs
@aspiers
aspiers force-pushed the add-leaflet-lexicons branch from 95a3003 to 8657f1d Compare April 4, 2026 16:40

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/validate-external-lexicons.test.ts`:
- Around line 11-23: The fixture leafletDescription currently has an empty
facets array which skips exercising the pub.leaflet.richtext.facet lexicon; add
a minimal facet object into leafletDescription.blocks[0].block.facets such that
each entry is a ref with $type "pub.leaflet.richtext.facet" (and the minimal
required properties per the lexicon, e.g., an id/name or value field) so the
tests that reuse leafletDescription in positive cases will exercise
pub.leaflet.richtext.facet registration at runtime; update any positive test
usages to reuse this shared facet fixture.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 21382cb3-a835-4d6f-ab67-8a24a9c35edd

📥 Commits

Reviewing files that changed from the base of the PR and between ffd3bde and 8657f1d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (26)
  • .changeset/add-pub-leaflet-lexicons.md
  • AGENTS.md
  • SCHEMAS.md
  • docs/AUTO_GENERATION.md
  • lexicons/app/bsky/richtext/facet.json
  • lexicons/pub/leaflet/blocks/blockquote.json
  • lexicons/pub/leaflet/blocks/bskyPost.json
  • lexicons/pub/leaflet/blocks/button.json
  • lexicons/pub/leaflet/blocks/code.json
  • lexicons/pub/leaflet/blocks/header.json
  • lexicons/pub/leaflet/blocks/horizontalRule.json
  • lexicons/pub/leaflet/blocks/iframe.json
  • lexicons/pub/leaflet/blocks/image.json
  • lexicons/pub/leaflet/blocks/math.json
  • lexicons/pub/leaflet/blocks/orderedList.json
  • lexicons/pub/leaflet/blocks/page.json
  • lexicons/pub/leaflet/blocks/poll.json
  • lexicons/pub/leaflet/blocks/text.json
  • lexicons/pub/leaflet/blocks/unorderedList.json
  • lexicons/pub/leaflet/blocks/website.json
  • lexicons/pub/leaflet/pages/linearDocument.json
  • lexicons/pub/leaflet/richtext/facet.json
  • package.json
  • scripts/create-shims.sh
  • scripts/generate-exports.js
  • tests/validate-external-lexicons.test.ts
💤 Files with no reviewable changes (1)
  • scripts/create-shims.sh
✅ Files skipped from review due to trivial changes (16)
  • lexicons/pub/leaflet/blocks/page.json
  • docs/AUTO_GENERATION.md
  • lexicons/pub/leaflet/blocks/math.json
  • lexicons/pub/leaflet/blocks/horizontalRule.json
  • lexicons/pub/leaflet/blocks/button.json
  • AGENTS.md
  • lexicons/pub/leaflet/blocks/website.json
  • lexicons/pub/leaflet/blocks/image.json
  • lexicons/pub/leaflet/blocks/code.json
  • lexicons/pub/leaflet/blocks/poll.json
  • lexicons/pub/leaflet/blocks/bskyPost.json
  • lexicons/pub/leaflet/blocks/iframe.json
  • lexicons/pub/leaflet/blocks/text.json
  • lexicons/app/bsky/richtext/facet.json
  • lexicons/pub/leaflet/blocks/orderedList.json
  • lexicons/pub/leaflet/pages/linearDocument.json
🚧 Files skipped from review as they are similar to previous changes (6)
  • .changeset/add-pub-leaflet-lexicons.md
  • lexicons/pub/leaflet/blocks/header.json
  • lexicons/pub/leaflet/blocks/blockquote.json
  • lexicons/pub/leaflet/blocks/unorderedList.json
  • lexicons/pub/leaflet/richtext/facet.json
  • SCHEMAS.md

Comment thread tests/validate-external-lexicons.test.ts
Fixes gen-schemas-md output to use backticks around MIME types in
accept fields, improving readability and avoiding Prettier escaping
`image/*` to `image/\*`.
@aspiers
aspiers force-pushed the add-leaflet-lexicons branch from 84b1f95 to 137a044 Compare April 4, 2026 16:53
aspiers added 2 commits April 4, 2026 16:54
Add a bold facet to the leafletDescription fixture so the test
exercises the pub.leaflet.richtext.facet lexicon at runtime, not
just an empty array.
When a lexicon has properties: {} (e.g. horizontalRule), the gen
script emitted a trailing blank line after the Properties heading
plus another from the section separator, producing a double blank
line that Prettier collapsed but CI's raw gen output preserved.
Also wraps MIME types in accept fields with backticks.
@aspiers
aspiers force-pushed the add-leaflet-lexicons branch from 7b7fce1 to 238f8a1 Compare April 4, 2026 17:07
@daviddao

daviddao commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

Comprehensive analysis: why description should be an open union

I built an interactive analysis that validates all three schema options against every org.hypercerts.claim.activity record on the network:

👉 https://daviddao.github.io/lexicon-demo/

207 records. 140 with string descriptions (v1), 27 with Leaflet documents (v2), 33 null. Real data, real validation logic traced from indigo's validation.go. I want to respond to @aspiers's points specifically, because they're well-reasoned but I think several don't hold up under scrutiny.


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

@aspiers's strongest technical claim is that widening `ref` → `union` would "bake the NSID into the top-level record data, removing even the limited migration flexibility we currently have."

@s-adamantine already caught this in #181 — every existing Leaflet record already contains:

```json
"description": {
"$type": "pub.leaflet.pages.linearDocument",
"blocks": [...]
}
```

The ATProto spec says `$type` "should not be included" for ref'd objects (spec), but implementations include it anyway. The data is identical whether the schema uses `ref` or `union`. The union changes how the validator interprets the data, not the data itself. Since `$type` already matches what the union would list, existing records pass under both schemas.

The "migration flexibility" argument assumes you'd want to redirect the ref to a structurally compatible schema under a different NSID. That migration path has never been used in the ATProto ecosystem. It requires an exact structural clone, is still flagged as `[ref-change] WARN` by `goat lex breaking`, and doesn't help with nested block types anyway.


2. The risk-flexibility contradiction

The counter-argument simultaneously claims:

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

These contradict. If the risk is very low, you don't need migration flexibility. If you need migration flexibility, the risk isn't as low as claimed. You can't use "Leaflet is safe" to dismiss the union and "ref gives you an escape hatch" as a safety net.


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

@aspiers says apps compile schemas at build time, making runtime resolution irrelevant. True for end-user apps. But three other categories resolve NSIDs at runtime:

  1. Hyperscan — the primary discovery tool. `hyperscan.dev/api/lexicon/{nsid}` resolves schemas dynamically. If Leaflet's schema can't be resolved, the description field shows up incomplete in every schema browser.

  2. AI coding agents — the `/agents/lexicon/{nsid}` API is designed for agents to fetch schemas at runtime. This analysis itself was built by an AI agent that used those exact endpoints. As agents become a primary way records are created, runtime NSID resolution moves from "dev tooling" to "production critical path."

  3. Hyperindex — 92 lexicon-specific GraphQL query fields at `api.hi.gainforest.app/graphql`, generated from resolved schemas at runtime.

The PDS "fail-open" means records get accepted. But accepted ≠ discoverable ≠ indexable. A record that passes PDS ingestion but can't be resolved by Hyperscan, understood by an AI agent, or indexed by Hyperindex is effectively invisible.


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

"We know the Leaflet team personally since Vancouver" — personal relationships are valuable but they're not architecture. People leave companies. Maintainers burn out. The ATProto spec itself has a crisis clause: "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."

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


5. Open unions are the spec-designed evolution mechanism

The ATProto spec explicitly says:

"By default unions are 'open', meaning that future revisions of the schema could add more types to the list of refs."

And from indigo's breaking.go: ref changes in open unions are not flagged as breaking. Only closed unions get the `union-closed-refs` error.

This means:

  • Add a new document format in 2027 → add a ref to the open union. Non-breaking.
  • Leaflet ships a breaking change → remove their ref. Existing records pass as unknown open union types. Non-breaking.
  • Want to deprecate Leaflet gradually → both types coexist. Remove old ref when ready. Non-breaking at every step.

With `ref`, every one of these scenarios requires a breaking `type-change`. The spec designed open unions for exactly this use case.


6. We're building for multiple years, not months

@aspiers says "you can always widen from ref to union later." But that widening IS the breaking `type-change` we're trying to avoid. Every record written under `ref` is another record that must survive the eventual migration. The cost of deferral grows monotonically.

Over a multi-year protocol lifetime, the probability of needing multiple description formats approaches certainty. A better document lexicon could emerge. Leaflet could evolve in a direction that doesn't fit our needs. We could want a simpler plaintext format for lightweight records.

With `ref`, each of these forces a breaking change, ecosystem-wide coordination, and "one more migration." With an open union, they're all routine additions and removals. Zero breaking changes across the entire lifecycle.


7. The nested blocks argument is real but orthogonal

"The nested blocks have `$type: pub.leaflet.blocks.text` baked in regardless." True. But this is a separate problem from the top-level schema choice. With `union`, you can at least accept new records in a non-Leaflet format while old Leaflet records degrade. With `ref`, you can't accept any new format without a breaking change — even if blocks are already broken at the nested level.

The union doesn't fix blocks. But it doesn't make them worse either, and it solves the top-level evolution problem that the ref can't.


Summary

`ref` (current) open `union` (proposed)
Existing 27 Leaflet records ✅ Pass ✅ Pass (same data, $type already present)
Add new format in 2027 ❌ Breaking type-change ✅ Add ref, non-breaking
Leaflet ships breaking change ❌ No control ✅ Remove ref, non-breaking
Gradual format migration ❌ All-or-nothing ✅ Coexist and deprecate
Schema evolution over years ❌ Every change is a crisis ✅ Routine additions/removals
Third-party resilience Social trust only Structural + social trust

The full interactive analysis with code paths, real record data, and spec citations is at https://daviddao.github.io/lexicon-demo/

@daviddao

daviddao commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

I also want to flag that the initial merge by @aspiers, which overrode description from a string to a Leaflet ref, created an unnecessary breaking change for 140 existing records. At the time, there was opportunity to explore less disruptive alternatives — for example, adding an optional richDescription field to preserve backward compatibility. For future schema decisions, it would be great to weigh additive options before landing changes that force migrations at scale.

@s-adamantine s-adamantine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

matches the published lexicons, lgtm

@aspiers aspiers left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM now

@s-adamantine
s-adamantine merged commit 7a226d8 into main Apr 7, 2026
6 checks passed
@s-adamantine
s-adamantine deleted the add-leaflet-lexicons branch April 7, 2026 19:42
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.

5 participants