You can use a simple little pattern to edit records while preserving history and still support full deletion. This works for both Bluesky posts and custom lexicon, however there are complications and social complexities.
On @ATProtocol, every user gets a dedicated database to store their records. When we post, comment, like, or perform almost any action, they become records in our own database. You may be wondering how this works, and the short answer is more like a database than git, even though there is a Merkle tree, commits, and signed hashes.
Editing and deleting records can help us understand some of the technical and social designs behind the @ATProtocol data plane. Currently, even though record edits are supported by @ATProtocol, Bluesky has not enabled them in their App View. We'll talk about why and how we can get the best of both worlds, edit history and full purge deletion. To enable this, we need only add a single field to any record. There is a complete example towards the end for those who just want to see the code, but do I hope you find the journey more interesting than the destination.
Anatomy of an @ATProtocol Record
A record has just a few key parts
- Location (uri) and the hash (cid)
- A value with a $type identifier
- Any number of additional properties of arbitrary types and depth
{ // strongRef "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhuswbz3ww2w", "cid": "bafyreigoovsuaba5dg6mgpljvvvlof63hvooujvjnbwei46o3bui67uhui", // record type & data "value": { "$type": "com.verdverm.test.record", // arbitrary content "text": "test msg 1" // ... } }
Let's break this down a bit more. The uri is composed of three parts
- The DID, a Decentralized Identifier for the account
- The NSID, a Namespaced Identifier for the record lexicon
- The RKey, the Record Key in the user's database
The cid is the fingerprint for the content and chains up to the data repository root. I will use repository and database interchangeably. There's a bit of both git and relational database at work, technically and in the documentation. If you are curious, the underlying database is SQLite and every user gets one. You can learn more on the ATProtocol docs. The combination of a uri and a cid fingerprint are referred to as a strongRef.
The record value also contains the same NSID for the $type which is referred to as a Collection in the database. All records of the same type are grouped together and can be CRUD'd as usual. Technically, records should conform to the lexicon that their $type domain points at using the same DID method to an account that has published the lexicon as a record. However, many parts of the system skip validation and records are open by default. Being open means we can add any extra fields to a record that we want. This is allowed according to the spec and will be important for our edit with history scheme.
How Edits and Deletes Work Today
To understand how edits and deletes work, we have to look at both @ATProtocol and Bluesky because Bluesky has made some short-term choices while the community works out long-term protocol decisions.
Deletes are easier to understand because they are a single operation and remove content instead of modifying it. When a user deletes a record, all traces are removed from the database. This delete action is then broadcast to the network and good denizens will also perform the deletion. This is by design in @ATProtocol, as part of the social contract. Users want a way to permanently remove their content from the network, a reasonable ask for sure! I would however not be surprised to learn that LexusNexus and multiple state actors are simply recording everything...
Things are a bit different with edits, mostly due to the social implications. When a user edits a record, it replaces the previous content. Unlike git, we cannot go back and look at previous commits to the data repository, as this would mean record deletes are not truly deleted. Not only would this break the social contract with users, it would mean @ATProtocol could not remove illegal content like CSAM.
Edits come with further social complexities. If a post is edited, should likes and reposts be reset? What happens if I comment on or like a post and later the author changes the text? This can be used as a social attack, making it look like someone supported something truly awful. Imagine the possibilities and consequences in today's hyper reactionary environment gone plaid!
At this point you may be asking, if edits are supported by @ATProtocol, then what is Bluesky doing? They save the first record, only display the original post in their App View, and have no edit feature in their App View. It is trivial to edit records with an alternative App View or a bit of code. If you are going to support edits, a lot of UI affordance decisions need to be made. Do you have to edit within a fixed time, how long? Do you reset likes or detach reposts, do you hide or warn? Do you show the latest or the associated, is history available? Bluesky is trying to be thoughtful with their decision and so it is taking longer than users would like.
Regardless of what Bluesky decides, any other App View can make a different choice. All apps are running on a shared social graph and data plane. This brings real competition to social media applications. Compare that to what we have today, a few corporations making the choices for all of us on their private networks. At the limit, what @ATProtocol really provides is true user choice and competition. @ATProtocol breaks social media down into plug-n-play components for Identity, Data Host, App View, Algorithms, and Moderation.
Identity on @ATProtocol follows you across the network and components. Your account handle and login, follows and relations, your preferences for algorithms and moderation. You get to choose where your data and account are managed and you can migrate providers any time. App Views are effectively a browser to the network and different social media apps are different ways of creating, presenting, and experiencing the shared social graph and data plane. Algorithms, or Feeds as they are called in @ATProtocol, can be made by anyone using any techniques they wish. Moderation is stackable allowing you to combine multiple providers who give configuration options to inform App Views how to handle labeled records.
Taken together, these allow you to curate a personal experience or set of experiences on social media. Each component is a choice at the user level while also being on a shared fabric. If we can make this happen, this would be great for humanity and we can take back control of our digital interactions and online consumption. We will have enabled real competition and zero cost to switching.
How will monetization work? No one knows and that is a good thing.
@ATProtocol changes the game.
Adding Edit History to @ATProtocol
To add edit history to @ATProtocol we do two things:
- copy-on-write the current record to a new record
- make a two-way connection by extending the record
The new record will get an $orig: #strongRef field and the main record will get a $hist: [...#strongRef] list. We also add created_at and updated_at for good measure.
When we edit the main record, the uri will be the same while the cid changes. This means that likes, comments, and reposts remain attached to the main record. The cid in the copy, as well as those in comments and friends, will now point to a main record that no longer exists. In practice, with our scheme here, we can reconstruct previous version of the record and we can detect changes to the cid when handling affordances in the App View.
Note, we could put the new content in the new record if wanted to detach the current likes, comments, and reposts. It may also create an interesting social pressure to not edit if you don't want to lose your internet points.
{ "records": [ { // strongRef, recorded in the main record under $hist "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv4tc3wzu2e", "cid": "bafyreidggkqhadounq5y4d5who2f57xdy75mbegsdeu5bj3an4itui3wbe", "value": { "$type": "com.verdverm.test.record", "text": "test msg 1", // point to the record copied from that no longer exists "$orig": { // strongRef "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv4tbqd2c2j", "cid": "bafyreihoc72wceiatwzjzifa3b2zmj7eu7vqei6fmltunevsuwbxl7ew7u" // this cid is gone and no longer retrievable }, "created_at": "2025-02-11T07:23:10.702Z", "updated_at": "2025-02-11T07:23:11.087Z" } }, { // strongRef "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv4tbqd2c2j", "cid": "bafyreib3bjgccr7sfe6ozei6nwe36qw5ei3yvvuqsgx5xqh55euzue36sq", "value": { "$type": "com.verdverm.test.record", "text": "test msg 2", // point to the prior version copies "$hist": [ { // strongRef to record above "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv4tc3wzu2e", "cid": "bafyreidggkqhadounq5y4d5who2f57xdy75mbegsdeu5bj3an4itui3wbe" } ], "created_at": "2025-02-11T07:23:10.702Z", "updated_at": "2025-02-11T07:23:11.282Z" } } ], "cursor": "3lhv4tbqd2c2j" }
In order to add the extra $orig and $hist fields we need two functions.
async function copyRecord(repo, collection, rkey) { const r = await getRecord(repo, collection, rkey) const n = { ...r.value, // store a strongRef to record copied from "$orig": { uri: r.uri, cid: r.cid } } const c = await createRecord(repo, collection, n) return [c, r.value] } async function updateRecord(repo, collection, rkey, recordUpdates) { // copy record const [copy, orig] = await copyRecord(repo, collection, rkey) // init history if (!orig["$hist"]) { orig["$hist"] = [] } // add strongRef to record copy orig["$hist"].push({uri: copy.uri, cid: copy.cid}) // copy in updated content for (const [key, value] of Object.entries(recordUpdates)) { orig[key] = value } // replace the record in data repo return putRecord(repo, collection, rkey, orig) }
In order to add the created_at and updated_at fields we wrap the create and put functions.
async function createRecord(repo, collection, record) { const now = new Date().toISOString() if (!record.created_at) { record.created_at = now } record.updated_at = now const r = await authd.com.atproto.repo.createRecord({ repo, collection, record, }) return r.data } async function putRecord(repo, collection, rkey, record) { record.updated_at = new Date().toISOString() const r = await authd.com.atproto.repo.putRecord({ repo, collection, rkey, record, }) return r.data }
We can then run a little test to show off our editing with history.
const coll = "com.verdverm.test.record" // !!!!! // be careful not to delete all your Bluesky posts if you try editing them await delCollection(handle, coll) // !!!!! await testHistory() const r = await getCollection(handle, coll) console.log(JSON.stringify(r, null, " ")) async function testHistory() { const a = await createRecord(handle, coll, { text: "test msg 1" }) const rkey = getRkey(a) const b = await updateRecord(handle, coll, rkey, { text: "test msg 2" }) const c = await updateRecord(handle, coll, rkey, { text: "test msg 4" }) }
Which has the following output
{ "records": [ { "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv64skdfv25", "cid": "bafyreidc5kv6wexekqspreqdpjjq3ubnid77j7zjogten4kgdq4scxgixm", "value": { "text": "test msg 2", "$hist": [ { "cid": "bafyreifspve5loo7nq37mbhb6jfojzrmyod5xly4sr5bonqferg2esvzb4", "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv64rultn2j" } ], "$orig": { "cid": "bafyreiggh5g4zkgtw7ll62kad2nii5thuejyip36yc2oyf36c6kycpxawu", "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv64rifvh2s" }, "$type": "com.verdverm.test.record", "created_at": "2025-02-11T07:46:22.955Z", "updated_at": "2025-02-11T07:46:24.072Z" } }, { "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv64rultn2j", "cid": "bafyreifspve5loo7nq37mbhb6jfojzrmyod5xly4sr5bonqferg2esvzb4", "value": { "text": "test msg 1", "$orig": { "cid": "bafyreict2cadgjrgrrnzblr2nbbufuq4frvxyvcrqu4u2hwr77edvzpd2y", "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv64rifvh2s" }, "$type": "com.verdverm.test.record", "created_at": "2025-02-11T07:46:22.955Z", "updated_at": "2025-02-11T07:46:23.360Z" } }, { "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv64rifvh2s", "cid": "bafyreianwy4bvgc5batazimnnkip24kkwktwp6r2wu3twgjgbfpw43tp5a", "value": { "text": "test msg 4", "$hist": [ { "cid": "bafyreifspve5loo7nq37mbhb6jfojzrmyod5xly4sr5bonqferg2esvzb4", "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv64rultn2j" }, { "cid": "bafyreidc5kv6wexekqspreqdpjjq3ubnid77j7zjogten4kgdq4scxgixm", "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhv64skdfv25" } ], "$type": "com.verdverm.test.record", "created_at": "2025-02-11T07:46:22.955Z", "updated_at": "2025-02-11T07:46:24.263Z" } } ], "cursor": "3lhv64rifvh2s" }
Full Code
import { AtpAgent } from '@atproto/api' const handle = process.env.BLUESKY_USERNAME const password = process.env.BLUESKY_PASSWORD // Bluesky has 'App Passwords' you should use // They are effectively an API key // two agents, public and authd const agent = new AtpAgent({ service: 'https://bsky.social', }) const authd = new AtpAgent({ service: 'https://bsky.social', }) const l = await authd.login({ identifier: handle, password: password, }) // custom Collection, const coll = "com.verdverm.test.record" // !!!!! // be careful not to delete all your Bluesky posts if you try editing them await delCollection(handle, coll) // !!!!! await testHistory() const r = await getCollection(handle, coll) console.log(JSON.stringify(r, null, " ")) async function testHistory() { const a = await createRecord(handle, coll, { text: "test msg 1" }) const rkey = getRkey(a) const b = await updateRecord(handle, coll, rkey, { text: "test msg 2" }) const c = await updateRecord(handle, coll, rkey, { text: "test msg 4" }) } // == Helper Funcs == async function copyRecord(repo, collection, rkey) { const r = await getRecord(repo, collection, rkey) const n = { ...r.value, // store a strongRef to record copied from "$orig": { uri: r.uri, cid: r.cid } } const c = await createRecord(repo, collection, n) return [c, r.value] } async function updateRecord(repo, collection, rkey, recordUpdates) { // copy record const [copy, orig] = await copyRecord(repo, collection, rkey) // init history if (!orig["$hist"]) { orig["$hist"] = [] } // add strongRef to record copy orig["$hist"].push({uri: copy.uri, cid: copy.cid}) // copy in updated content for (const [key, value] of Object.entries(recordUpdates)) { orig[key] = value } // replace the record in data repo return putRecord(repo, collection, rkey, orig) } function getRkey(record: any) { return record.uri.split("/").splice(-1)[0] } async function getCollection(repo, collection) { const r = await agent.com.atproto.repo.listRecords({ repo, collection, }) return r.data } async function delCollection(repo, collection) { const data = await getCollection(repo, collection) for (const r of data.records) { const rkey = getRkey(r) await delRecord(repo, collection, rkey) } } async function createRecord(repo, collection, record) { const now = new Date().toISOString() if (!record.created_at) { record.created_at = now } record.updated_at = now const r = await authd.com.atproto.repo.createRecord({ repo, collection, record, }) return r.data } async function getRecord(repo, collection, rkey) { const r = await agent.com.atproto.repo.getRecord({ repo, collection, rkey, }) return r.data } async function putRecord(repo, collection, rkey, record) { record.updated_at = new Date().toISOString() const r = await authd.com.atproto.repo.putRecord({ repo, collection, rkey, record, }) return r.data } async function delRecord(repo, collection, rkey) { const r = await authd.com.atproto.repo.deleteRecord({ repo, collection, rkey, }) return r.data }
Remarks
We should note and acknowledge this solution is not without issue. There are race conditions if two clients try to edit the same record. We do not have transactions, so there is no ability to cancel half way through. We are editing and creating records at will, and that applies to our copies and history too. It would take protocol changes and PDS support to do this right.
This method however does still support deleting a record and its full history, aligning with the capability users demand. It also works with any record on @ATProtocol as long as it does not use the $orig ror $hist fields, which could also be namespaced.
We can find various methods out in the wild. GitHub allows unlimited edits and provides full history. Reddit, Discord, and Slack allow edits, but only show that a message was edited. Twitter and HN allow edits for a short time, while Bluesky does not allow them yet.
Edits and history, and how to display or moderate them, should be left up to the user (imo). It's a free protocol though where apps and communities can also decide to have collective rules, governance, and the extent that they allow individual choice withing the group.
My Take
Humans naturally form into groups of all shapes, sizes, and color each with their own customs, rules, and quirks. At the same time, each of us is also independent beings with our own preferences and tolerances.
Our digital platforms should reflect and enable that. @ATProtocol enables us to rebuild all of social media and more to align with this. This is what I'm building towards with @Blebbit.
Conference
The first @ATProtocol Community & Developer Conference is next month in Seattle, WA
Get your tickets!
Early bird tickets are now officially available for #ATmosphereConf Join us March 22nd & 23rd in Seattle, Washington to meet community builders, app makers, and anyone else interested in improving #atproto An initial batch of 50 discounted tickets is available now.
— AT Protocol Fan Account (@atprotocol.dev) February 7, 2025 at 1:02 PM
[image or embed]