Description
Nothing in EmDash records who made a content change. Every change is kept (revisions, plus the audit-log plugin), but the actor is not.
Three things combine to produce this, measured on a production site (0.36.0, Cloudflare Workers + D1, 15,000 revisions across 2,400 articles):
1. Revisions saved from the admin have author_id = NULL. revisions.author_id exists, and RevisionRepository.create() writes input.authorId ?? null. But PUT /_emdash/api/content/{collection}/{id} only passes an authorId that the client put in the body (_collection_/_id_.mjs, the updateBody branch), and the admin editor never sends one. The route has locals.user in hand and does not use it. Result on our site: 14,881 revisions with a null author, the newest written minutes ago from the admin editor.
2. The MCP does attribute, but by reassigning ownership. The MCP content tools send authorId: userId on every create and update. That does populate revisions.author_id (1,138 rows on our site, all from the MCP), but authorId on a content update is also the entry's owner column: handleContentUpdate writes it to ec_{collection}.author_id. So every entry the MCP touches has its owner silently changed to whoever holds the token. On our site, 432 articles are now "owned" by the one account that ran a bulk MCP pass, which is what content:edit_own / content:publish_own are evaluated against. The same field is being used for "who wrote this entry" and "who made this revision", and those are different questions.
3. Plugin hooks receive no actor. content:beforeSave / content:afterSave are invoked with { content, collection, isNew }. An audit-log plugin can record what changed and when, but not by whom, and there is no other way for it to find out.
The upshot for a multi-editor site: complete history, zero attribution. Two editors and an integration are writing to the same 2,400 entries and nobody can answer "who changed this?".
Expected
- The REST content routes (and the inline-editing save path) set the revision author from the session user when the client does not supply one, the same way the MCP already does.
- Revision attribution is stored separately from entry ownership, so attributing a revision never reassigns the entry. Concretely:
revisions.author_id should be "who saved this revision", and ec_*.author_id should only change when someone explicitly changes the owner.
- Hook payloads carry the acting user (id and role at least), so plugins can log it.
Steps to reproduce
- Sign in to the admin as user A; open a published entry in a collection with revisions; edit the body; wait for autosave.
SELECT author_id FROM revisions ORDER BY id DESC LIMIT 1 → NULL.
- Connect the MCP with a token belonging to user B;
content_update the same entry with any small change.
- The new revision has
author_id = B (good), and ec_<collection>.author_id is now B as well: the entry's owner has changed without anyone asking for it.
- Add a plugin with a
content:afterSave hook and log its argument: no user field.
Environment
- emdash version: 0.36.0
- Astro version: 7.2.10
- Runtime: Cloudflare Workers with D1
- Deployment: production
- OS: macOS
Logs / error output
-- revisions grouped by whether an author was recorded, production site, 2026-09-03
author null: 14,881 rows first 2026-08-28 00:17 last 2026-09-03 01:51 (admin editor)
author set: 1,138 rows first 2026-09-02 00:56 last 2026-09-03 02:33 (MCP only)
-- ec_articles.author_id after one bulk MCP pass by a single user
NULL: 1,575 <that user>: 432 two other users: 60, 1
Description
Nothing in EmDash records who made a content change. Every change is kept (revisions, plus the audit-log plugin), but the actor is not.
Three things combine to produce this, measured on a production site (0.36.0, Cloudflare Workers + D1, 15,000 revisions across 2,400 articles):
1. Revisions saved from the admin have
author_id = NULL.revisions.author_idexists, andRevisionRepository.create()writesinput.authorId ?? null. ButPUT /_emdash/api/content/{collection}/{id}only passes anauthorIdthat the client put in the body (_collection_/_id_.mjs, theupdateBodybranch), and the admin editor never sends one. The route haslocals.userin hand and does not use it. Result on our site: 14,881 revisions with a null author, the newest written minutes ago from the admin editor.2. The MCP does attribute, but by reassigning ownership. The MCP content tools send
authorId: userIdon every create and update. That does populaterevisions.author_id(1,138 rows on our site, all from the MCP), butauthorIdon a content update is also the entry's owner column:handleContentUpdatewrites it toec_{collection}.author_id. So every entry the MCP touches has its owner silently changed to whoever holds the token. On our site, 432 articles are now "owned" by the one account that ran a bulk MCP pass, which is whatcontent:edit_own/content:publish_ownare evaluated against. The same field is being used for "who wrote this entry" and "who made this revision", and those are different questions.3. Plugin hooks receive no actor.
content:beforeSave/content:afterSaveare invoked with{ content, collection, isNew }. An audit-log plugin can record what changed and when, but not by whom, and there is no other way for it to find out.The upshot for a multi-editor site: complete history, zero attribution. Two editors and an integration are writing to the same 2,400 entries and nobody can answer "who changed this?".
Expected
revisions.author_idshould be "who saved this revision", andec_*.author_idshould only change when someone explicitly changes the owner.Steps to reproduce
SELECT author_id FROM revisions ORDER BY id DESC LIMIT 1→NULL.content_updatethe same entry with any small change.author_id = B(good), andec_<collection>.author_idis nowBas well: the entry's owner has changed without anyone asking for it.content:afterSavehook and log its argument: no user field.Environment
Logs / error output