plresearch-org · edit-ai-opportunity-spaces-r5 · round 5
editEvent + drawer UXAnswers 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.
editEvent)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.
Three surfaces, progressive disclosure. None of them change how the page reads; history is peripheral by default.
| Surface | Where | What it answers |
|---|---|---|
| Byline | One line under the breadcrumb on every detail page | “Who last touched this, and when?” |
| Drawer | Right-side slide-in, opens from the byline’s “View history (N)” button | “Show me the full timeline — editors, times, what fields changed.” |
| Editor context | Same 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.
[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.
app.bsky.actor.getProfile; the display name falls back to @handle if no displayName.
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.
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.
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.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"
}
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.
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.
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.
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.
uri / rkey / cidThe 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.
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.
railway redeploy can ship a stale buildDuring 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.
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.
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.
/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-*.png … assets/05-*.png