plresearch-org · edit-ai-opportunity-spaces-r5 · round 5

Round 5: per-page edit history via editEvent + drawer UX

@daviddao

Answers the question “who edited what on this page?” Each admin edit now writes an org.plresearch.editEvent record to the editor’s own PDS (not plresearch.org’s), cryptographically binding authorship to the DID that performed the edit. Detail pages surface this as a byline under the breadcrumb; clicking it opens a right-side drawer with the full timeline.

Date: 2026-04-20 · Target: open-compute-networks · Previous: r4 (pass) · pass


Headline result

1
New lexicon (editEvent)
2 / 2
Audit records reached PDS
3
UX surfaces (byline, drawer, editor-context)
1
Bug found mid-run & fixed

Headline. r5 adds an end-to-end audit trail: lexicon → indexer → write-on-PUT → read API → byline → drawer. The key design decision is that each editEvent is written to the editor’s repo via their OAuth session, not plresearch.org’s (which uses an app password for the content writes). That means attribution is a property of the editor’s own PDS and is tamper-evident — plresearch.org can’t silently rewrite who edited a record. Verified on prod: two sequential edits as satyam2.climateai.org, both audit records landed on climateai.org’s PDS, indexer caught them up, drawer renders both with avatars from the public Bluesky profile API.

UX design

Three surfaces, progressive disclosure. None of them change how the page reads; history is peripheral by default.

SurfaceWhereWhat it answers
BylineOne line under the breadcrumb on every detail page“Who last touched this, and when?”
DrawerRight-side slide-in, opens from the byline’s “View history (N)” button“Show me the full timeline — editors, times, what fields changed.”
Editor contextSame byline is rendered inside the inline editor“Before I start editing, am I stepping on someone’s work?”

Editor handles in both surfaces link to their bsky.app/profile/<handle> for community identity; timestamps are relative with absolute on hover.

Architecture

[Editor browser]  ---PUT /api/opportunity-spaces/[rkey]-->  [Next.js route]
                                                               |
                                                 putRecord as plresearch.org
                                                 (app password) → content updated
                                                               |
                                                 createRecord as editor
                                                 (OAuth agent) → editEvent on editor's PDS
                                                               |
                                       (indexer firehose sees both records)
                                                               |
                       [Indexer (Railway)] — lex-gql → GraphQL (orgPlresearchEditEvent)
                                                               |
         [Browser] --GET /api/edit-history?target=at://…--> [Indexer GraphQL] → byline + drawer

Writing to the editor’s own repo means plresearch.org can’t forge an attribution — app-password access only controls plresearch.org’s own data.

Walkthrough

Detail page before any edit events have been logged
01Immediately after deploying r5, the detail page looks the same as before: no byline rendered because there are 0 audit events yet (the component returns null when the list is empty). Clean graceful-degradation behaviour.
Byline appears after one edit
02After one tagline edit, the byline renders between breadcrumb and title: Bluesky avatar + “Last edited by Satyam Two · 17 min ago · View history (1)”. Avatar is fetched client-side from app.bsky.actor.getProfile; the display name falls back to @handle if no displayName.
History drawer with one edit, noisy diff (pre-fix)
03Drawer opens from the right on “View history”. First entry shows Satyam Two, relative timestamp, and changed-field chips. Small bug visible here: uri and rkey are reported as changed — they’re indexer-synthesized fields on the “prior” snapshot that don’t exist on the outgoing record. Captured & fixed mid-run — see FIX1 below.
Same byline appears inside the inline editor
04The shared OpportunitySpaceEditor renders the same byline at the top of the edit form. Before you change anything, you see who last edited and when — prevents stepping on a co-editor’s work.
Drawer with 2 edits — the newer one has a clean diff
05After pushing the diff-filter fix (commit eee2c9d) and performing a second edit, the drawer now shows two entries. The fresh one (2 min ago) reports only tagline — noise-free. The older pre-fix entry (22 min ago) stays untouched as a historical artifact; audit records are meant to be immutable.

Verification (xrpc)

Each edit lands on the editor’s PDS, not plresearch.org’s:

$ curl -sG "https://climateai.org/xrpc/com.atproto.repo.listRecords" \
    --data-urlencode "repo=did:plc:cpoagodpqrgs4t7thi5z37uf" \
    --data-urlencode "collection=org.plresearch.editEvent" | jq '.records[].value | {editedAt, changedFields}'
{ "editedAt": "2026-04-20T21:25:38.359Z", "changedFields": ["tagline"] }
{ "editedAt": "2026-04-20T21:05:16.820Z", "changedFields": ["uri", "tagline", "rkey"] }

Indexer query via our API:

$ curl -s "https://www.plresearch.org/api/edit-history?target=at://did:plc:pgwr6hkosgznfl5nz7egajei/org.plresearch.opportunitySpace/ai--open-compute-networks" | jq '.count, .events[0]'
2
{
  "uri": "at://did:plc:cpoagodpqrgs4t7thi5z37uf/org.plresearch.editEvent/3mjxenxasrk2e",
  "did": "did:plc:cpoagodpqrgs4t7thi5z37uf",
  "target": "at://did:plc:pgwr6hkosgznfl5nz7egajei/org.plresearch.opportunitySpace/ai--open-compute-networks",
  "editor": "did:plc:cpoagodpqrgs4t7thi5z37uf",
  "editorHandle": "satyam2.climateai.org",
  "changedFields": ["tagline"],
  "editedAt": "2026-04-20T21:25:38.359Z"
}

Findings

P1 · End-to-end attribution works

Editor performs an edit → audit record appears on their PDS within ~5 seconds → indexer sees it via firehose → byline updates on the target page on next revalidate. No coordination with plresearch.org’s repo required.

P2 · Forgery-resistant by construction

Each editEvent is signed by the editor’s own repo key. plresearch.org’s app-password agent has no access to climateai.org’s PDS and therefore cannot create an entry attributed to satyam2.climateai.org. This is a stronger attribution story than a centralised audit log on Vercel could offer.

P3 · Graceful degradation

When the indexer is down, the byline component silently returns null — the page reads fine. When there are zero events for a target (just-deployed feature, or a never-touched record), same behaviour. When the Bluesky profile API fails, rows fall back to plain @handle text.

P4 · Three-surface UX lands cleanly

Byline is a single muted line — doesn’t compete with the title. Drawer uses the existing z-50 overlay pattern from the admin login, closes on Esc / backdrop, and locks body scroll. Editor-surface byline provides context for co-editors. No dedicated history page needed in v1.

FIX1 · Diff over-reported uri / rkey / cid

The first version of diffFields() in src/lib/audit.ts compared the incoming record against the IndexerOpportunitySpace type returned by the GraphQL query, which injects synthetic uri, rkey, did, cid fields not present on the outgoing record. Every edit therefore reported those three fields as changed.

Fixed in commit eee2c9d by extending the ignored set in the diff helper. Subsequent edits (screenshot 05’s newer row) correctly reported only the real mutations. Old audit entries are preserved as-is — rewriting them would break the immutability story.

W1 · Firehose events written during an indexer restart can be missed

Shipping the new lexicon required redeploying the Railway indexer, and the tap firehose consumer starts at the restart time — not at its previous cursor. An editEvent written during that 2-minute window won’t show up in GraphQL until someone hits POST /backfill { "dids": […] }. This round’s first audit entry had to be backfilled that way.

Not new to r5 — this affects every lexicon the indexer tracks — but worth noting. A follow-up could have tap persist its firehose cursor to SQLite so restarts resume from where they left off.

W2 · railway redeploy can ship a stale build

During this round’s indexer redeploy, the first railway redeploy briefly showed the new lexicon, then reverted to the old one on a subsequent deployment. Had to fall back to railway up --detach (upload the local working directory directly) to force a rebuild. Documenting for next time.

I1 · Initial-import events not yet logged

The 15 existing opportunity-space records were imported in commit bb88ffa (Mar 25) without an audit entry. Detail pages for those records currently show the first post-r5 editor as “last edited by,” not the original importer. Could be backfilled by writing synthetic editEvent records dated from commit time. Not blocking — creation audit-logging is a separate feature.

I2 · No per-edit diff, only field names

The drawer shows which fields changed but not what they changed to. A richer view would show before/after snippets or link to a commit-style diff page. Adding it requires storing either the full before/after snapshots or a structured patch in the editEvent record; defer to a later round if anyone asks.

Reproducing

Content URL/areas/ai-robotics/opportunity-spaces/open-compute-networks/ Editorsatyam2.climateai.org (on climateai.org PDS) Lexiconplresearch-indexer/lexicons/org/plresearch/editEvent.json Write siteEditor’s own PDS via getAuthenticatedAgent() in src/lib/audit.ts Read APIGET /api/edit-history?target=<at-uri> — unauthenticated, 60s ISR cache on the indexer tag UI componentsrc/components/EditHistoryByline.tsx (client, client-fetches profiles from bsky.app) Verify on PDScurl -sG https://climateai.org/xrpc/com.atproto.repo.listRecords --data-urlencode repo=did:plc:cpoagodpqrgs4t7thi5z37uf --data-urlencode collection=org.plresearch.editEvent Previous roundsr1 (fail) · r2 (pass) · r3 (pass) · r4 (pass) Screenshotsassets/01-*.pngassets/05-*.png