/adding-record-editing-with-history-to-atprotocol

Adding Record Editing with History to ATProtocol

Feb 11, 2025

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. There are Lexicon that determine what records operations look like and PDS servers implementing what are essentially CRUD operations on account repos and records. This works more like a key/value store than git when it comes to changing records with updates (edits). In other words, the PDS only stores the current version of the record. Prior versions are lost to the ether.

This is unless we build on top of these basic crud operations. We can use copy-on-write, two-way references, a couple of extra fields to hold the references, and some wrapper functions to perform the sequence of operations. We can do this because, while lexicon communicate the structure, a record is open, meaning we can add arbitrary extra content.

By exploring this record editing capability with history extension, we can gain a deeper understanding into 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. Deletes are complete and final by design, because users should decide these kinds of things. We'll talk about why and how we can get the best of both worlds, edit history and full (purge) deletion.

@blebbit/flexicon provides a maintained project for this concept (and more).

Anatomy of an @ATProtocol Record

An ATProto record three key parts

  1. uri: the at:// location of the record
  2. cid: a content hash of the record
  3. value: an object with a $type identifier and any number of additional, arbitrary properties.

Two notes

  1. uri && cid == strongRef
  2. $type is the lexicon schema for value
{
  // strongRef
  "uri": "at://did:plc:2jtyqespp2zfodukwvktqwe6/com.verdverm.test.record/3lhuswbz3ww2w",
  "cid": "bafyreigoovsuaba5dg6mgpljvvvlof63hvooujvjnbwei46o3bui67uhui",

  // record type & data
  "value": {
    // the NSID to a lexicon doc of record type
    "$type": "com.verdverm.test.record",

    // arbitrary content
    "text": "hello reader!"
    // ...
  }
}

Let's break this down a bit more. The uri is composed of three parts

  1. The DID, a Decentralized Identifier for the account
  2. The NSID, a Namespaced Identifier for the record lexicon
  3. 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.

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 near zero cost to switching.


Adding Edit History to @ATProtocol

To add edit history to @ATProtocol we do two things:

  1. copy-on-write the current record to a new record
  2. 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 createdAt and updatedAt for good measure.

We'll create a simplified DX around the steps and data needed to do this. Our wrappers and stubs will all take an object that mirrors the arguments to the wrapped calls as well as an agent object which is assumed to already be authenticated. We'll also create a few handy helpers for setting up an agent while we're at it.

Step 0. Helpers for setting up the agent

In order to write records, we will need an authenticated client, which also means we need to know what PDS the account is on. @Blebbit provides a service where we can query by DID or Handle and it will return some basic facts about the account. Our agent helpers will wrap this service and the agent creation code to provide ready-to-go agents.

import { AtpAgent } from '@atproto/api';

export async function lookupUserInfo(handleOrDID: string) {
  const url = "https://plc.blebbit.dev/info/" + handleOrDID
  const response = await fetch(url,{
    headers: {
      accept: "application/json"
    }
  })
  if (response.status !== 200) {  
    const text = await response.text()
    throw new Error("lookupUserInfo err: " + response.status + " " + text)
  }
  const data = await response.json()
  return data
}

export function publicAgent() {
  return new AtpAgent({
    service: "https://public.api.bsky.app",
  })
}

export async function createAgent(handleOrDID: string) {
  const info = await lookupUserInfo(handleOrDID)
  const agent = new AtpAgent({
    service: info.pds,
  })
  return agent
}

export async function createAuthdAgent(handleOrDID: string, password: string) {
  const info = await lookupUserInfo(handleOrDID)
  const agent = new AtpAgent({
    service: info.pds,
  })
  await agent.login({
    identifier: info.handle,
    password: password,
  })
  return agent
}

Step 1. Wrap and stub the functions we need

Before we go into the details, let's outline the helper functions for editing and history we are going to need.

  • getRecord

  • createRecord

  • deleteRecord

  • updateRecord (we'll implement this in the next section)

// Gets a record with optional history
export async function getRecord({
  agent, repo, collection, rkey, cid, includeHistory = false,
}: {
  agent: AtpAgent, repo: string, collection: string, rkey: string, cid?: string, includeHistory?: boolean,
}) {
  // get the main record
  const i: any = {
    repo,
    collection,
    rkey,
  }
  if (cid) {
    i.cid = cid
  }
  const r = await agent.com.atproto.repo.getRecord(i)

  // possibly get the record history
  if (includeHistory) {
    // ...
  }

  return r
}


// createRecord is called when we write the first version of a record
export async function createRecord({
  agent, repo, collection, record
}: {
  agent: AtpAgent, repo: string, collection: string, record: any,
}): Promise<any> {
  // add timestamps to incoming record
  const now = new Date().toISOString()
  if (!record.createdAt) {
    record.createdAt = now
  }
  record.updatedAt = now
  
  // write the record to PDS
  return agent.com.atproto.repo.createRecord({
    repo,
    collection,
    record,
  })
}

// deleteRecord will delete a record and optionally the history
export async function deleteRecord({
  agent, repo, collection, rkey, includeHistory = true
}: {
  agent: AtpAgent, repo: string, collection: string, rkey: string, includeHistory?: boolean,
}) {
  // get the latest repo commit
  const c = await getLatestRepoCommit({ agent, did: repo })
  const swapCommit = c.data.cid

  // add the current record to the writes
  var writes: any[] = [{
    $type: 'com.atproto.repo.applyWrites#delete',
    collection,
    rkey,
  }]

  // get latest record to get the history and add to writes
  if (includeHistory) {
    ...
  }

  // delete all records in one commit
  const i = {
    repo,
    writes, 
    swapCommit,
  }
  try {
    // We use applyWrites instead of deleteRecord to ensure all are deleted in one shot
    const r = await agent.com.atproto.repo.applyWrites(i)
    return r
  }
  catch (e) {
    throw e
  }
}

Step 2. Implement the history logic

Before we fill in the full history logic, there are two other internal helper functions we need.

  • putRecord
  • copyRecord
// wrapper around atproto putRecord which also updates the updatedAt field
export async function putRecord({
  agent, repo, collection, rkey, swapCommit, swapRecord, record
}: {
  agent: AtpAgent, repo: string, collection: string, rkey: string, swapCommit?: string, swapRecord?: string, record: any
}) {
  // set current time to updatedAt
  record.updatedAt = new Date().toISOString()

  // build up payload
  let i: PutRecordInputSchema = {
    repo,
    collection,
    rkey,
    record,
  }
  if (swapCommit) {
    i.swapCommit = swapCommit
  }
  if (swapRecord) {
    i.swapRecord = swapRecord
  }

  return agent.com.atproto.repo.putRecord(i)
}

export async function copyRecord({
  agent, repo, collection, rkey, cid,
}: {
  agent: AtpAgent, repo: string, collection: string, rkey: string, cid?: string,
}) {
  const r = await getRecord({ agent, repo, collection, rkey, cid })
  const copy = {
    ...r.data.value,
    // use $orig to store a strongRef to the current record
    "$orig": { uri: r.data.uri, cid: r.data.cid }
   }
  const c = await createRecord({ agent, repo, collection, record: copy })
  return [c, r]
}

updateRecord is the core helper function

export async function updateRecord({
  agent, repo, collection, rkey, swapCommit, swapRecord, recordUpdates
}: {
  agent: AtpAgent, repo: string, collection: string, rkey: string, swapCommit?: string, swapRecord?: string, recordUpdates: any,
}) {
  // copy record
  const [copyResp, origResp] = await copyRecord({ agent, repo, collection, rkey, cid: swapRecord })
  const copy = copyResp.data
  const orig = origResp.data.value

  // init history
  if (!orig["$hist"]) {
    orig["$hist"] = []
  }
  // add strongRef to record just created copy
  // by pushing, $hist is ordered by time
  orig["$hist"].push({uri: copy.uri, cid: copy.cid})

  // copy in updated content from recordUpdates
  for (const [key, value] of Object.entries(recordUpdates)) {
    orig[key] = value
  }

  try {
    // replace the record in data repo
    const i = { 
      agent,
      repo, collection, rkey,
      swapCommit,
      swapRecord: swapRecord || origResp?.data?.cid,
      record: orig
    }
    const putResp = await putRecord(i)
    return putResp
  } catch (e) {
    // what if the copy passes but the put fails?
    // we need to delete the copy
    const { rkey: copyRkey } = splitAtURI(copyResp.data.uri)
    const d = { agent, repo, collection, rkey: copyRkey, includeHistory: false }
    await deleteRecord(d)
    throw e
  } 
}

Now we can complete the includeHistory conditionals in our stubbed helpers from before.

// Gets a record with optional history
export async function getRecord({
  agent, repo, collection, rkey, cid, includeHistory = false,
}: {
  agent: AtpAgent, repo: string, collection: string, rkey: string, cid?: string, includeHistory?: boolean,
}) {
  // get the main record
  const i: any = {
    repo,
    collection,
    rkey,
  }
  if (cid) {
    i.cid = cid
  }
  const r = await agent.com.atproto.repo.getRecord(i)

  // possibly get the record history
  if (includeHistory) {
    if (r.data.value["$hist"]) {

      // iterate over history, adding the record value to the $hist entries
      for (const h of r.data.value["$hist"] as any[]) {
        const { rkey: hRkey } = splitAtURI(h.uri)
        const hr = await getRecord({agent, repo, collection, rkey: hRkey, cid: h.cid, includeHistory: false})
        h["value"] = hr.data.value
      }
    }
  }

  return r
}

// deleteRecord will delete a record and optionally the history
export async function deleteRecord({
  agent, repo, collection, rkey, includeHistory = true
}: {
  agent: AtpAgent, repo: string, collection: string, rkey: string, includeHistory?: boolean,
}) {
  // get the latest repo commit
  const c = await getLatestRepoCommit({ agent, did: repo })
  const swapCommit = c.data.cid

  // add the current record to the writes
  var writes: any[] = [{
    $type: 'com.atproto.repo.applyWrites#delete',
    collection,
    rkey,
  }]

  // get latest record to get the history and add to writes
  if (includeHistory) {
    // get the current record
    const r = await getRecord({ agent, repo, collection, rkey })
    if (r.data.value["$hist"]) {

      // iterate over history, adding to the writes list
      for (const h of r.data.value["$hist"] as any[]) {
        const { rkey: hRkey } = splitAtURI(h.uri)
        writes.push({
          $type: 'com.atproto.repo.applyWrites#delete',
          collection,
          rkey: hRkey,
        })
      }
    }
  }

  // delete all records in one commit
  const i = {
    repo,
    writes, 
    swapCommit,
  }
  try {
    // We use applyWrites instead of deleteRecord to ensure all are deleted in one shot
    const r = await agent.com.atproto.repo.applyWrites(i)
    return r
  }
  catch (e) {
    throw e
  }
}

Step 3. Examples with testing


Note, a rework of this post is underway, this got deployed as part of testing some CI :]

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
        },
        "createdAt": "2025-02-11T07:23:10.702Z",
        "updatedAt": "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"
          }
        ],
        "createdAt": "2025-02-11T07:23:10.702Z",
        "updatedAt": "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",
        "createdAt": "2025-02-11T07:46:22.955Z",
        "updatedAt": "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",
        "createdAt": "2025-02-11T07:46:22.955Z",
        "updatedAt": "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",
        "createdAt": "2025-02-11T07:46:22.955Z",
        "updatedAt": "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. What if a platform could enable user choice across all of @ATProtocol at all levels? Does it have sub-features that look like an app dev platform or low-code for custom experience?

Our digital platforms should reflect and enable that for people of all skill levels. @ATProtocol enables us to rebuild all of social media and more to align with this. This is what I'm building towards with @Blebbit.

References and Related

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.

[image or embed]

— AT Protocol Fan Account (@atprotocol.dev) February 7, 2025 at 1:02 PM