Key takeaways
- com.atproto.repo.putRecord is an upsert on the triple (repo, collection, rkey) — writing the same rkey twice updates the existing record instead of creating a duplicate, which is exactly the retry-safety a publish call needs.
- The app.bsky.feed.post lexicon declares its record key format as "tid", and the reference PDS validates a supplied rkey against that format before writing — a UUID passes putRecord's general syntax rule (1–512 chars of a-zA-Z0-9._:~-) but fails the stricter TID schema check with InvalidRecordError, an HTTP 400 that most adapters would then classify as permanent and non-retryable.
- A TID is a 64-bit integer (top bit 0, 53 bits of microseconds, a 10-bit clock id) rendered in a base32-sortable alphabet — SkedCast derives 63 bits from a SHA-256 hash of the post's own idempotency key instead of a real clock reading, so the same target always regenerates the same rkey.
- This is deliberate over using a real timestamp: the only time available at publish is the post's scheduled time or the current instant, and the current instant differs between the original attempt and any retry of it — which would defeat determinism entirely.
Bluesky putRecord, rkey and TID: why createRecord with a random id is not idempotent
com.atproto.repo.createRecord and com.atproto.repo.putRecord answer with the same shape, {uri, cid}, which makes it tempting to treat them as interchangeable and just pass a random id. They are not interchangeable for retry-safety: createRecord with a fresh random rkey on every call — or no rkey at all, letting the server mint one — creates a new record every time it is called, including on a retry of a call whose first attempt actually succeeded. putRecord is the one that behaves as an upsert on (repo, collection, rkey): call it twice with the same three values and the second call updates the first call's record instead of creating a second one.
Why the rkey cannot just be the post's UUID
The obvious next move is to pass the target's own UUID as the rkey — putRecord's general syntax rule for a record key (1–512 characters of a-zA-Z0-9._:~-) happily accepts a UUID's shape. It is still wrong. The app.bsky.feed.post lexicon separately declares its key format as "tid", and the reference Personal Data Server validates a supplied rkey against that specific key schema before writing (schema.keySchema.safeValidate(rkey) → InvalidRecordError on failure) — a UUID rkey therefore comes back as an HTTP 400, and an adapter that classifies a 400 as a permanent, non-retryable failure (as most reasonably do) would mean every Bluesky post fails outright.
Making a deterministic rkey that is still TID-shaped
A TID ("timestamp identifier") is a 64-bit integer — top bit fixed at 0, then 53 bits historically meant to be microseconds, then a 10-bit clock id — rendered through a base32-sortable alphabet into a fixed 13-character string. SkedCast's derivation takes 63 significant bits from a SHA-256 hash of the post target's own idempotency key, rather than an actual clock reading, so the "timestamp" the resulting TID decodes to is deliberate nonsense. That is harmless because the key schema validates a TID as SYNTAX only — length and alphabet, never plausibility — and nothing in Bluesky's own read path derives meaning from an rkey's encoded time: the AppView orders posts by the record's own createdAt/indexedAt fields, and the public post URL treats the key as an opaque token.
Why not encode a real timestamp in the high bits and hash only the clock-id portion, the more "honest" TID? Because the only time value available at publish is the target's scheduled time, or the current instant if unscheduled — and the current instant is different on the original attempt and on any retry of it. Encoding real time into the key would make the key different on each attempt, defeating the entire point of a deterministic key. An obviously synthetic TID that stays constant across retries beats a plausible-looking one that silently stops being deterministic.
Collision risk, honestly stated
63 bits of hash, scoped to a single repo, leaves a real but small collision surface: an account publishing at a sustained cap of 1,000 posts a day for a century accumulates on the order of 3.7×10⁷ records, putting the birthday-paradox collision probability at roughly 7×10⁻⁵ over that entire century of continuous posting. A collision, if it ever happened, would overwrite one post's record with another's — a bad but bounded outcome, not a data leak, and vanishingly unlikely at any realistic posting volume.
FAQ
- Does Bluesky's API support idempotent posting?
- Yes, uniquely among the major social APIs examined here — com.atproto.repo.putRecord is a real upsert keyed on (repo, collection, rkey), so calling it twice with the same three values updates one record instead of creating two.
- Why does using a UUID as the Bluesky record key fail?
- The app.bsky.feed.post lexicon declares its record-key format as a TID, and the server validates a supplied key against that specific schema — a UUID passes the general character-set rule but fails the stricter TID check and is rejected with an HTTP 400.
- What is a TID in the AT Protocol?
- A 64-bit integer — a fixed top bit, originally 53 bits of microseconds, then a 10-bit clock id — rendered as a fixed 13-character string in a base32-sortable alphabet, used as the record key format for feed posts and other repo records.
- Does the record key need to encode a real timestamp?
- No — the key schema validates a TID's syntax only, never whether the encoded time is plausible, and nothing in Bluesky's read or display path derives meaning from it, so a deterministic hash-derived value works as well as a real clock reading.
- Is putRecord the same as createRecord with a chosen key?
- No — createRecord with a repeated key is documented as failing (the record already exists), while putRecord explicitly upserts: same key twice updates the record in place, which is the behavior a safe publish retry needs.
- How likely is a record-key collision?
- Very low at realistic volumes — an account posting at 1,000/day for a full century accumulates roughly 3.7×10⁷ records, putting the collision probability around 7×10⁻⁵ over that entire span, and a collision would overwrite one record rather than expose data.