Your Post Exists Before Anyone Can See It: Field Notes From Running Third-Party PDS Accounts on ATProto

本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。

Data points below are annotated with the log files they came from (evidence/… = our local operations evidence tree for 2026-10-14). Every call was made with free, public APIs — nothing in this article required a paid tier. At 22:55:40 UTC on October 14th, a post left one of our test accounts. We could read it back, character for character, from the server that stores it. Anonymous, unauthenticated, plain HTTPS GET. And yet for the next forty minutes, as far as the main Bluesky app’s search and feeds were concerned, that post did not exist. This is not a bug report. It is a field note about what actually happens when you run accounts on a third-party Personal Data Server (PDS) — the architecture decision more self-hosters are going to make as ATProto federation matures — and about the exact moment where the network’s view of your data diverges from the data itself.

以下数据点均标注了其来源的日志文件(evidence/… = 我们 2026 年 10 月 14 日的本地操作证据树)。所有调用均通过免费的公共 API 完成,本文内容无需付费层级。10 月 14 日协调世界时 22:55:40,一条帖子从我们的测试账户发出。我们可以从存储它的服务器上逐字读取它。匿名、未经身份验证、纯 HTTPS GET 请求。然而在接下来的四十分钟里,就 Bluesky 主应用的搜索和信息流而言,该帖子并不存在。这不是一份错误报告,而是一份关于在第三方个人数据服务器(PDS)上运行账户时实际情况的现场记录——随着 ATProto 联邦化的成熟,越来越多的自托管用户将做出这种架构决策——同时也记录了网络对数据的视图与数据本身产生分歧的确切时刻。

Two servers, two truths ATProto separates two jobs that older platforms bundle into one: The PDS holds your repository — every post, like, and reply as signed records. If your PDS serves it over com.atproto.repo.getRecord, it exists. Full stop. The AppView (the big aggregation/indexing layer that apps actually query) crawls the firehose and builds timelines, threads, search, and profile pages out of it. Clients mostly talk to #2. Operators mostly reason about #1. The gap between those two perspectives is where we spent this evening.

两台服务器,两个真相。ATProto 将旧平台捆绑在一起的两项工作分离开来:PDS 负责托管你的存储库——每一条帖子、点赞和回复都作为已签名的记录存在。如果你的 PDS 通过 com.atproto.repo.getRecord 提供了该记录,那么它就是存在的,毋庸置疑。AppView(应用程序实际查询的大型聚合/索引层)则通过抓取信息流来构建时间线、对话串、搜索和个人资料页面。客户端主要与后者交互,而运营者主要关注前者。我们今晚正是花费时间在研究这两种视角之间的差距。

The measurements We created fresh posts on two accounts hosted on two different third-party PDSs (one under our own domain), then interrogated both layers through the night. Here is the timeline, with the raw response logs each number comes from: T+0 — creation succeeds everywhere it should. com.atproto.repo.createRecord returns HTTP 200 for both accounts within seconds of each other (22:55:40Z and 22:57:03Z per the record createdAt values). Evidence: evidence/daily_ops/20261014T2250Z/raw/bluesky_readback.json.

测量过程:我们在两个托管于不同第三方 PDS(其中一个在我们自己的域名下)的账户上创建了新帖子,并在整晚对这两个层级进行了查询。以下是时间线,以及每个数字所对应的原始响应日志:T+0——创建在所有应有的地方均成功。com.atproto.repo.createRecord 在两个账户上均在几秒钟内返回了 HTTP 200(根据记录的 createdAt 值,分别为 22:55:40Z 和 22:57:03Z)。证据:evidence/daily_ops/20261014T2250Z/raw/bluesky_readback.json。

T+2–3 min — the origin already serves strangers. An anonymous getRecord against the PDS returned the new post byte-for-byte identical to what we wrote (verbatim_match_text: true), and listRecords showed it among the account’s records. On the very same pass, the public AppView’s app.bsky.feed.getAuthorFeed returned literally {“feed”:[]} for one account, and InvalidRequest: Profile not found for the other. Same minute, same content, two contradictory answers — because they were answers to different questions. Evidence: evidence/daily_ops/20261014T2250Z/raw/225845_05_appview_getAuthorFeed_jonas_resp.txt (the empty feed) and evidence/daily_ops/20261014T2250Z/raw/225911_09_appview_getAuthorFeed_elena_resp.txt (the 400).

T+2–3 分钟——源服务器已经可以为陌生人提供服务。针对 PDS 的匿名 getRecord 请求返回了与我们所写内容逐字节一致的新帖子(verbatim_match_text: true),且 listRecords 显示它已包含在账户记录中。在同一次检查中,公共 AppView 的 app.bsky.feed.getAuthorFeed 对一个账户返回了 {“feed”:[]},对另一个账户则返回了 InvalidRequest: Profile not found。同一分钟,相同内容,却得到了两个矛盾的答案——因为它们回答的是不同的问题。证据:evidence/daily_ops/20261014T2250Z/raw/225845_05_appview_getAuthorFeed_jonas_resp.txt(空信息流)和 evidence/daily_ops/20261014T2250Z/raw/225911_09_appview_getAuthorFeed_elena_resp.txt(400 错误)。

T+6 min — an independent recheck confirms the split. A fresh shell, fresh session, no shared state: all four new records (two posts, two replies) anonymously visible via listRecords on their respective PDS origins, while the author-feed index had still caught up with neither. Evidence: independent_recheck block in evidence/daily_ops/20261014T2250Z/verdict.json.

T+6 分钟——独立复核证实了这种分裂。使用全新的 shell、全新的会话,无共享状态:所有四条新记录(两条帖子,两条回复)均可通过各自 PDS 源上的 listRecords 匿名可见,而作者信息流索引仍未同步任何一条。证据:evidence/daily_ops/20261014T2250Z/verdict.json 中的 independent_recheck 代码块。

T+39 min — origin unchanged, index still catching up. We re-probed at 23:35Z. Origin: getRecord 200, createdAt verbatim. Index: author feed still empty / still “Profile not found”, and a reply we’d posted into a large account’s thread was still absent from that thread’s view. Notably, identity resolution did work by then — resolveHandle mapped the handle to its DID — so the layers catch up unevenly, identity first, content later. Evidence: evidence/content/2026-10-14/fresh_probe_2328Z/appview_feed_jonas_recheck.json and siblings.

T+39 分钟——源服务器未变,索引仍在追赶。我们在 23:35Z 进行了重新探测。源服务器:getRecord 返回 200,createdAt 一致。索引层:作者信息流依然为空或显示“Profile not found”,且我们发布到大账户对话串中的回复在对话视图中依然缺失。值得注意的是,此时身份解析已经生效——resolveHandle 已将句柄映射到其 DID——因此各层级的同步速度并不一致,身份先行,内容滞后。证据:evidence/content/2026-10-14/fresh_probe_2328Z/appview_feed_jonas_recheck.json 及其相关文件。

For contrast, the same evening we published an article on dev.to through its official API: POST returned 201, and an anonymous GET of the public URL returned 200 essentially immediately (article id 4853041, evidence/daily_ops/20261014T2250Z/verdict.json). Centralized platforms fuse storage and visibility into one step. Federated ones deliberately don’t — and the seam shows exactly there.

作为对比,当晚我们通过官方 API 在 dev.to 上发布了一篇文章:POST 请求返回 201,且对公共 URL 进行匿名 GET 请求几乎立即返回了 200(文章 ID 4853041,evidence/daily_ops/20261014T2250Z/verdict.json)。中心化平台将存储和可见性融合为一步,而联邦化平台则刻意将其分开——裂痕恰恰显现在这里。

Why this bites operators If you automate anything on top of a third-party PDS — a scheduler, a bot, a cross-poster — you will eventually write a “post it and verify it landed” step. Verify against the AppView, and during indexing lag you will conclude your automation failed, then retry, and double-post. We’ve seen this exact failure shape in other ecosystems; ATProto just makes the boundary crisper because the two layers have clean names and clean APIs.

为什么这会困扰运营者:如果你在第三方 PDS 之上进行任何自动化操作——比如调度器、机器人或跨平台发布工具——你最终都会编写一个“发布并验证是否成功”的步骤。如果你针对 AppView 进行验证,在索引延迟期间,你会得出自动化失败的结论,进而重试,导致重复发布。我们在其他生态系统中也见过这种完全相同的失败模式;ATProto 只是让这种边界更加清晰,因为这两个层级拥有明确的名称和简洁的 API。

The triage order that never lies: Origin first. getRecord (single record) or listRecords (collection) directly against the PDS, unauthenticated if you like. This is the ground truth about whether your write happened. Index second, with patience. getAuthorFeed, getPostThread, and friends are eventual-consistency views. Treat them like a cache with an unpredictable TTL for young accounts. Positive control always. Before declaring your brand-new account broken, run the same probe against a long-established account. If the old account resolves and yours doesn’t, that’s indexing lag, not breakage. Skipping this step is how “it works for everyone else” turns into hours of pointless debugging.

永远不会撒谎的分类顺序:首先是源服务器。直接针对 PDS 进行 getRecord(单条记录)或 listRecords(集合)查询,如果愿意,甚至可以不进行身份验证。这是关于你的写入操作是否发生的根本事实。其次是索引层,请保持耐心。getAuthorFeed、getPostThread 等接口属于最终一致性视图。对于新账户,请将它们视为具有不可预测 TTL(生存时间)的缓存。始终进行正向对照。在宣布你的全新账户损坏之前,请对一个长期存在的账户进行相同的探测。如果旧账户可以解析而你的不行,那是索引延迟,而非故障。跳过这一步,就是为什么“别人都能用”会变成数小时无意义调试的原因。

One more practical note: a reply is just another record on your repo pointing at a parent on someone else’s. Until indexing catches up, the parent’s thread page won’t show it — which means the person you replied to can’t see it either. Engagement has the same latency problem as publishing.

还有一个实际注意事项:回复仅仅是你存储库中的另一条记录,指向别人存储库中的父记录。在索引同步完成之前,父记录的对话页面不会显示它——这意味着你回复的人也看不到它。互动与发布一样,都存在同样的延迟问题。

The honest takeaway Day one, self-sovereignty on ATProto is real at the storage layer: your records are served, publicly and anonymously, from a box you control, and nobody can unilaterally rewrite them. Reach, however, is rented from indexers until they index you — and for a young third-party-PDS account, “until” is measured in tens of minutes, not seconds. None of this is an argument against running your own PDS. It is an argument for knowing which question you’re asking: “does my data exist?” — ask your PDS. “can people see it?” — ask the AppView, and expect the answer to ripen.

诚实的结论:在 ATProto 上,存储层面的自我主权在第一天就是真实的:你的记录从你控制的服务器上公开且匿名地提供服务,没有人可以单方面重写它们。然而,触达能力是从索引器那里租来的,直到它们为你建立索引——对于一个年轻的第三方 PDS 账户来说,“直到”是以数十分钟而非数秒来衡量的。这并不是反对运行你自己的 PDS,而是为了让你知道你在问什么问题:“我的数据存在吗?”——去问你的 PDS;“人们能看到它吗?”——去问 AppView,并等待答案成熟。