AT Protocol & RSS v2

A Superset, Not a Replacement


disclaimer: Read as found issues, discussed (with self and multiple agents lol), then updated and shipped!

Auditing My Own Table

A few weeks ago I wrote about how AT Protocol and RSS aren't really rivals. RSS keeps working as a distribution format, AT Protocol takes over as the source of truth; signed records, content addressing, a firehose instead of polling. A superset, not a replacement.

That post ended with a little table, five rows mapping Podcasting 2.0 tags to their AT Protocol equivalents.

It read clean and like something that already existed.

It didn't, not all of it anyway.

So I did the thing I probably should've done before publishing that table in the first place. I opened up VoxPort's actual lexicon and checked every claim against the real schema. Not "does this make sense conceptually", like I was thinking while writing the first blog, but "is there a field for this, right now, that a firehose consumer could actually read." Some of it held up, some of it didn't. This post is the audit..

Grading my table

So let's get into this. Five rows, one at a time, checked against what the real VoxPort lexicon instead of what sounded right when I was writing part one.

podcast : chapters -> chapters array. Also holds up. It's an array embedded directly in the episode record like I previously mentioned in our first blog.

"chapter": {
      "type": "object",
      "required": ["title","startTime"],
      "properties": {
        "title": {"type": "string","maxLength": 256},
        "startTime": {"type": "integer", "description": "Start time in seconds"},
        "endTime": {"type": "integer", "description": "End time in seconds(optional)"},
        "image": {"type": "string", "format": "uri"}
      }
    }

Title, start time, end time, optional image. One thing that was caught: VoxPort's recorder tracks a couple things the lexicon can't hold. A chapter link URL and a hidden flag; so those get dropped on the way to the PDS. Then on the RSS side the chapters ship as a <podcast:chapters> pointer to a JSON file rather than inline, which is what the spec wants anyway. Row stands. Lossy at the edges, and that's now written down.

** The chapter url and hidden fields have been added to the episode lexicon (revision 3) and are now sent to the PDS. The recorder no longer loses this data on the way to the record.

podcast : transcript -> transcript object.

This is the first place I actually caught myself. I described it as "a structured object in the episode record" singular. Here's what was actually in the schema:

"transcript": {
      "type": "object",
      "properties": {
        "type": {"type": "string","knownValues": ["text/plain", "text/html", "application/srt", "application/json"] },
        "url": {"type": "string", "format": "uri"},
        "language": {"type": "string", "format": "language"}

One object. One url. One language. Podcasting 2.0 has never worked that way. The real podcast:transcript tag allows multiple entries per episode, an SRT for one language, a plain-text version for another, a JSON transcript for accessibility tooling, all attached to the same item. My schema can't hold more than one of those at a time.

Then I found something else. I went looking for where transcripts actually flow through the app and the answer is, they don't. The importer parses podcast:transcript off incoming feeds and then throws it away; there's no database column for it, and the RSS generator never emits it. So it isn't "one transcript instead of many." It's a field that exists in the schema and nowhere else in the pipeline. "Structured object" implied more than what was there.

** Fixed and deployed. The episode lexicon is now at revision 3 with a transcript array (multiple entries, each with uri , type , language, and rel for closed captions). The singular transcript field is kept for backward compatibility. A new Episode.transcripts database column stores the parsed data, the importer captures all podcast:transcript tags from incoming feeds, the PDS write path sends them, and the RSS generator emits them. The Studio UI also lets creators add, edit, and remove transcripts per episode. MIME types were corrected to match the spec (application/x-subrip instead of application/srt , plus text/vtt ).

podcast:person -> DID + handle

This is the one that stings a bit. There was no person field in the episode or series record at all.. Not simplified, not partial, just absent.. If someone had gone looking for the field that backs up that row of the table, they'd have found nothing.

One more thing worth being aware/precise about while on this topic: "cryptographically verified" was slightly the wrong phrase. A DID proves someone controls a specific cryptographic key tied to a specific identity they've built. It doesn't verify that identity is a particual real-world person, the same way a Bluesky handle doesn't get "verified" by anyone. Its just consistently, provably you across everything you post. Still a massive upgrade over an RSS feed's free-text author tag that anyone can type in. I just want to describe it accurately instead of oversized ( I hope I'm ACTUALLY describing it accurately )

** Fixed and deployed. Both the episode lexicon (revision 3) and the series lexicon (revision 2) now have a persons array. Each entry supports name, role, handle, (format: handle, not a full AT-URI), did , group (podcast Taxonomy Project), url and image. The name maxLength is 128 per the spec (not 256). Database columns were added to both Episode and PodcastSeries. The importer parses channel-level and item-level podcast:person tags. The PDS write path sends them. The RSS generator emits <podcast:person> at both the channel and item level. The Studio UI lets creators manage persons on both episodes and series.

podcast:soundbite → soundbites array

Same story as person. Described, not built. Not parsed, not stored, not emitted.

** Fixed and deployed. The episode lexicon (revision 3) now has a soundbite array. Each entry has startTime and duration as string fractional seconds (e.g. "73.0" , "1234.5") matching the Podcasting 2.0 spec exactly; not integer milliseconds (which I almost did..), which would have been a precision compromise. title is optional per the spec. A new Episode.soundbites database column stores the parsed data, the importer captures podcast:soundbite tags, the PDS write path sends them, the RSS generator emits them, and the Studio UI lets creators add and edit soundbites per episode.

podcast:guid → AT URI — this one I got wrong twice

I gave this row a green light in my first draft of this post. Then I dug deeper into our feed generator.

Wrong thing #1: an AT URI is not content-addressed.

An AT URI ( at://did/collection/rkey ) is a stable identity pointer — it deliberately does not change when you edit the record. The content-addressed thing is the CID, and the whole point is that it does change on every edit. Those are opposite properties and I collapsed them into one sentence. Stability and content addressing come from two different primitives, and a GUID wants the stable one.

Wrong thing #2, the bigger one: podcast:guid isn’t the tag I was describing at all.

The Podcasting 2.0 podcast:guid is a channel-level identifier for the whole podcast — a UUIDv5 derived from the feed URL. The per-episode identifier is plain old RSS . I mapped a show-level tag onto an episodelevel concept. VoxPort emits neither podcast:guid at the channel level today. And then, checking the episode we do emit, I found the actual bug and it’s the one that matters most, because it breaks the promise I made loudest in part one.

** Both issues fixed and deployed. The RSS feed now emits <podcast:guid> at the channel level (the series lexicon rev 2 has a podcastGuid field with UUID pattern validation, and the import path sends it to the PDS). The episode <guid> bug is fixed, check out the next section.

The GUID problem, which is much worse than the enclosure problem

Here’s what part one promised, twice:

If a podcaster tries VoxPort and later decides to go back to raw RSS, their episodes keep the same GUIDs. Subscribers don’t see duplicates. The migration is reversible with no lock-in.

Here’s what the code does. When you import an existing feed, VoxPort carefully preserves the source GUID in a field called importedGuid , and writes it into your AT Protocol record. Good. That part is real, and I was right about it.

But every episode row also gets a guid column that defaults to a freshly generated UUID. And the RSS generator emits that one:

{episode.guid} // fresh UUID, not importedGuid 

So: import your back catalog, point your subscribers at VoxPort’s generated feed, and every app on earth sees an entirely new set of GUIDs. Not a subtle drift, your whole back catalog reappears as new episodes. The exact duplicate-episode scenario I said couldn’t happen.

The migration data was preserved perfectly and then not used at the one place it existed for. The fix is a one-line change in the feed mapper, emit importedGuid ?? guid, plus a regression test that walks import → record → generated feed and asserts the GUID survives the trip. The reason it took an audit to find is that nothing errors. The feed is valid. Every GUID is unique and stable going forward. It’s just not the same GUID the rest of the world already knows your episode by, and no validator on earth can tell you that.

That’s the lesson I’d actually pull out of this whole exercise: the claims most likely to be wrong are the ones no error message will ever contradict.

** Fixed and deployed to production. The feed now emits importedGuid ?? guid . Four regression tests in packages/rss/src/feed.test.ts verify the round-trip: import -> record -> generated feed, asserting the original GUID survives. The importedGuid field is now selected from Prisma in the RSS route and passed through to the feed generator.

** Production impact: Reviewed the production database before deploying. One imported series exists with 9 episodes, but all are in DRAFT or ARCHIVED status, zero are PUBLISHED. The live RSS feed has no <item> elements, so the GUID flip has zero subscriber impact.

The enclosure problem, restated more honestly

This one wasn’t in part one at all. I found it while reading the RSS generator I described as step four of the migration diagram, the one that “constructs an open RSS XML feed using the indexed database records.”

RSS’s <enclosure> tag requires three attributes: url, length, and type. Not optional. Here’s the audio side of the episode schema:

"audio": { "type": "blob", "accept": ["audio/mpeg", "audio/mp4", "audio/wav", "audio/ogg", "audio/aac"], "maxSize": 524288000 }, "audioUrl": { "type": "string", "format": "uri", "description": "External audio URL (if not using blob)" } 

audio is a typed blob, so it carries a MIME type and a byte size for free. audioUrl carries nothing; no length, no type, just a string.

My first draft of this section claimed my generator therefore “had no way to build a valid enclosure tag.” Not true, and the distinction turns out to be the interesting part. The generator reads the Postgres row, not the AT Protocol record, and Postgres has both a mime and an audioSize column; so it always emits all three attributes. What it does when those are missing is fall back to type="audio/mpeg" and length="0" , and the importer never populates audioSize at all. So every imported or linked episode goes out with length="0". Some apps shrug at that. Some don’t.

The sharper version of the problem: the record can’t carry what the feed requires. VoxPort gets away with it by reading its own database. Any third party consuming my firehose records and generating a feed from them cannot, the size and type simply aren’t in there. Which is a pretty direct indictment of my own “AT Protocol is the source of truth” framing. If the record can’t reconstruct the feed without my database, the database is still the source of truth.

The fix is boring: audioLength and audioType alongside audioUrl , populated at import time and required at publish time when a podcaster goes the external-URL route. Plus actually capturing enclosure length on import.

** Partially fixed and deployed. The importer now captures enclosure.length into audioSize for both the initial import path (mapItemToEpisode) and the sync path (syncFeed). Imported episodes no longer go out with length=0 .

** The lexicon side is also done; audioLength and audioType fields are in the episode lexicon (rev 3), and the import publish path (publishToAtproto) sends them to the PDS from episode.audioSize and episode.mime. The browser publish path (publishEpisodetoPDS) also accepts and sends them.

** What's still not done: audioLength/ audioType are not yet required at publish time for external-URL route. They're optional fields. Making them required would be a breaking lexicon change, so it's deferred until there's a validation layer (more on validation section below).

The asterisk on portability I should have written the first time

Part one, on portability:

You can move your Personal Data Server (PDS) and bring your entire podcast catalog.

The records, yes. The audio, no.

For episodes uploaded through VoxPort, audioUrl points at VoxPort’s own storage. Your DID moves, your episode records move, your followers follow your DID and every one of those records points back at a URL I control. Audio never gets uploaded to the PDS as a blob (cover art does; audio doesn’t, the files are far too big for that to be reasonable). So “no lock-in” is true at the metadata layer and quietly false at the media layer.

I don’t have a clean fix for this one, which is exactly why it deserves saying out loud rather than being smoothed over. The options I’m weighing: publishing an alternate-enclosures list in the record so a feed can name mirrors, or an explicit “take my audio with me” export that rewrites the URLs to your new host. Real design work, not a field addition. It’s on the plan as its own pass.

** Still open. This is a design problem, not a schema problem, and it's still on hold. The lexicon now carries audioLength and audioType so a firehose consumer can at least reconstruct a valid enclosure; but the audio bytes themselves still live on VoxPort's R2 storage. Media portability remains its own design pass.

Same category of correction, on the “wax seal” metaphor I used for content addressing: the CID seals the record and the metadata. It does not seal the audio, because the audio is a URL, not a blob. If someone swaps the bytes at that URL, no CID changes. The signature covers the repo commit and the record it contains, and that’s a genuinely strong guarantee about who published what metadata. It is not a guarantee about the audio you’re hearing. I overstated the reach of that seal.

One more precision fix while I’m in here: I said “every record that a user creates is cryptographically signed.” In atproto, the signature lives on the repository commit, over the Merkle tree root, not on each record individually. Records inherit their integrity from that commit chain. The end guarantee is what I claimed; the mechanism isn’t quite what I described.

What the audit found that wasn’t in the table at all

Two more, both from reading code rather than schemas.

Episode edits can strip required fields from the record. The update path builds a partial record $type , updatedAt , and whatever changed and sends it via putRecord , which is a full replace, not a patch. So an edit can drop series , title , and createdAt : the three fields the lexicon marks required. It also writes a description field that doesn’t exist in the lexicon (the create path correctly maps that to summary ) and, in one branch, assigns a plain URL string to audio , which is declared as a blob.

None of that throws. Nobody validates it. live.voxport.* is my lexicon, not a lexicon any PDS knows, so the PDS accepts whatever I hand it. Publishing a schema and then not validating against it is just documentation with extra steps and that realization is worth more to me than any single field on this list.

** updatedEpisodeInPDS has been rewritten to use a read-modify-write pattern: it fetches the current record via getRecord, merges the changes, then putRecords the complete record with swapRecord for optimistic concurrency (same pattern updatedSeriesInPDS already used). The description -> summary mapping is fixed. The audio -> audioURL assignment is fixed. Retry-on-CID-mismatch logic is added.

** The broader point is that the PDS doesn't validate live.voxport.* records and nothing is checking; is still true. Client-side lexicon validation before PDS writes is a separate project that would prevent the next class of bugs. It's still on the plan, not yet shipped.

series.categories is dead. The lexicon declares a flat array of up to five category strings. The database stores a single category string. The publish path never sends categories at all. And Apple’s real taxonomy is nested — category and subcategory pairs — which the web app already models correctly, meaning the lexicon is the least accurate of the three representations I have of the same concept.

** Partially fixed. The import publish path (publishToAtproto) now sends categories to the PDS; it was the one place that was completely silent. The flat-array-vs-nested-taxonomy problem is still open. Changing the type of an existing field violates lexicon evolution rules, so a new categoriesRefs field with nested structure would need to be added in a future revision. That's a design pass, not a quick fix.

Podping — where that actually stands

In part one I mentioned Podping as something VoxPort could wire up, so RSS apps get pushed updates instead of polling, running alongside the AT Protocol firehose. Straight answer: it’s still in the “could” stage. Nothing shipped. It appears in my planning docs and nowhere in my source. I’m not letting it quietly become implied-done just because it shares a post with fields that did get built. When it’s real, it gets its own writeup.

What I’m putting on hold, deliberately

  • podcast:license — no license field on episode or series. Low ecosystem usage so far; revisiting on demand.
  • podcast:funding / podcast:value (value4value, Lightning tipping) — genuinely interesting, and also decentralized infrastructure, which is why it deserves its own design pass instead of a bolted-on field.
  • podcast:liveItem — live status ties naturally to the firehose, which is exactly why I want the modeling deliberate rather than rushed.
  • podcast:medium — podcast/music/video feed types. Not urgent while VoxPort is podcast-only.

And one thing coming off the hold list: itunes:owner . The generated feed already emits it, and series.author / series.email cover the record side — basically the same distinction iTunes draws. One less gap than I thought.

The Scorecard
blog one claimed
Reality (at audit time)
Status now
chapters array in the record
True (drops link URL and hidden flag)
Fixed — chapter url and hidden now in lexicon rev 3 and sent to PDS
transcript structured object
One transcript max, and never stored or emitted
Fixedtranscripts array in lexicon rev 3, DB column, import parses, PDS writes, RSS emits, Studio UI
person → DID + handle
Did not exist
Fixedpersons array in episode rev 3 + series rev 2, DB columns, import parses, PDS writes, RSS emits, Studio UI
soundbites array
Did not exist
Fixedsoundbites array in lexicon rev 3, DB column, import parses, PDS writes, RSS emits, Studio UI
podcast:guid → AT URI
Wrong tag, wrong primitive, and the emitted GUID isn't stable across imports
Fixed — channel-level <podcast:guid> now emitted; episode <guid> now uses importedGuid ?? guid
"GUIDs stay stable, zero lock-in"
False today, generated feed emits a fresh UUID
Fixed — feed emits importedGuid ?? guid, regression tests pass, zero prod impact (no published imported episodes)
"Move your PDS, bring your catalog"
Records move; audio stays on VoxPort's storage
Still open — design pass needed for media portability
CID as a seal on the episode
Seals the metadata record, not the audio bytes
Still accurate — no change needed, this was a correction not a bug
Podping alongside the firehose
Not built
Still not built

One row of the original table survives intact. That is not the outcome I expected when I sat down to check my own work. But it’s a better post than the one where I quietly patched the lexicon and hoped nobody compared notes.

What was found beyond the table

Issue found in audit
Status
Episode update path strips required fields
Fixed — read-modify-write, summary not description, audioUrl not audio
series.categories never sent to PDS
Partially fixed — import path now sends it; nested taxonomy still a design pass
Enclosure length="0" for imported episodes
Fixed — importer captures enclosure.length into audioSize
Lexicon not validated before PDS write
Still open — client-side validation is a separate project

The plan, in the order I did it

An audit that ends at “here’s what’s broken” is just a nicer way of not fixing it. So here’s what I actually shipped.

The three bugs; fixed first, because they were wrong, not merely missing

1. GUID fix : The RSS feed now emits importedGuid ?? guid instead of a fresh UUID. Regression tests walk import -> record -> feed and assert the GUID survives the round trip. Deployed to production with zero subscriber impact (no published imported episodes exist).

2. Episode update path: Rewritten to read-modify-write; fetch the current record, merge the changes, then putRecord the complete record. Dropped the phantom description field (mapped to summary instead) and fixed the string-into-a-blob-field assignment (audioUrl goes to audioUrl, not audio). Added retry-on-CID-mismatch.

3. Enclosure length: The importer now captures enclosure.length into audioSize on both the initial import path and the sync path. No more length="0" for imported episodes.

The schema work: additive lexicon revisions

Episode lexicon revision 2 to 3: persons, soundbites, transcripts array (kept alongside the existing singular transcript for backward compatibility), audioLength and audioType next to audioUrl, and chapter url / hidden so the recorder stops losing data on the way to the PDS.

Series lexicon revision 1 to 2: persons and podcastGuid (UUID pattern validated).

An atproto-pro(skill I created some time ago) review caught four issues in the initial schema, all fixed before shipping:

  • transcript: url and type made required (spec compliance); MIME types corrected (application/x-subrip not application/srt; added text/vtt); added rel field for closed captions
  • person: name maxLength reduced to 128 (spec says 128, not 256); added group field from the Podcast Taxonomy Project
  • soundbite: changed startTime/duration from integer milliseconds to string fractional seconds (e.g. "73.0") matches the spec exactly with no precision loss; title made optional, startTime/duration required
  • person: handle uses format: "handle" (the username portion, e.g. alice.bsky.social(http://alice.bsky.social)), not format: "at-uri"

The pipeline: wired end-to-end

Four new nullable JSON columns (Episode.transcripts, Episode.persons, Episode.soundbites, PodcastSeries.persons). The RSS parser captures all three Podcasting 2.0 tags with keepArray: true for multi-value support. Three helper functions map the parser output to the lexicon shape. The PDS write path sends all new fields. The RSS generator emits <podcast:transcript>, <podcast:person>, and <podcast:soundbite> at the item level, plus <podcast:person> at the channel level. The API exposes the new fields in the episode DTO. The Studio UI lets creators add, edit, and remove persons, soundbites, and transcripts on episodes, and persons on series.

The backfill

A one-time script triggered syncFeed for every imported series to populate the new fields from the source feeds. Result: one imported series in production, zero episodes updated; the source feed has no Podcasting 2.0 tags. Correct behavior; nothing to backfill.

What's still on hold (and why that's OK)

  • Lexicon validation before PDS write : the PDS doesn't validate live.voxport.* records. Adding client-side validation is a separate project that would prevent the next class of bugs. It's the most important thing on this list that isn't done yet.
  • Media portability : audio URLs point to VoxPort's R2. Real design work, not a field addition.
  • Value4value / Lightning : podcast:value. Design pass needed.
  • LiveItem : podcast:liveItem. Ties to firehose modeling. Design pass needed.
  • Nested category taxonomy : categoryRefs with Apple's real category/subcategory pairs. Design pass needed.
  • podcast:license : low ecosystem usage.
  • podcast:medium : not urgent while podcast-only.

Everything in the additive column is genuinely additive. New optional fields, no type changes, no renames, no removals, revision numbers bump instead of breaking anything already published. If you tried VoxPort, imported a feed, and walked away, none of the schema work affects you retroactively. The GUID fix is the only one that changes what already-published feeds look like, and it changes them back to what they should have been.

As always: if I've misspoken anywhere here, call me out. I'd rather get it right in public than get it wrong quietly. Turns out that goes for my own schemas too.

Give VoxPort a try if you're a podcaster, and if you're a listener, tag your favorite shows to get them on the platform.

Hope for good health and happiness for all!