TL;DR
Every signed Change blob now carries an optional message string, analogous to a git commit message. Refs no longer carry a message field. The publish UI, CLI, activity feed, and Versions panel all read the message off the Change rather than the Ref.
Why Change, not Ref
The first iteration of this feature stored message on the Ref blob. We reversed that decision before merge.
The unit of "what changed" in this system is the Change blob — like a commit in git. A Ref is a pointer/branch that names a path and points at heads. Tombstone Refs and redirect/republish Refs do not introduce new content; they only re-aim a path. There is no content delta to describe on those Refs, so a message field there has no consistent meaning.
Putting the message on the Change also matches the git mental model the rest of the API already leans on:
Change ≈ commit (signed, identified by content, has a message).
Ref ≈ branch ref (a moving pointer, not a description).
This change keeps message scope narrow and meaningful: only content changes carry one. Tombstones and redirects do not.
Storage shape
On the Go side, Message is a top-level field of blob.Change:
type Change struct {
BaseBlob
Genesis cid.Cid `refmt:"genesis,omitempty"`
Deps []cid.Cid `refmt:"deps,omitempty"`
Depth int `refmt:"depth,omitempty"`
Body ChangeBody `refmt:"body,omitempty"`
Message string `refmt:"message,omitempty"`
}Top-level (sibling of Body/Deps/Depth) was preferred over nesting inside ChangeBody. The body is reserved for the document operations themselves; metadata about the publish event lives at the same level as Genesis, Deps, and Depth.
The omitempty CBOR tag means changes without a message produce identical bytes to before this feature existed — backward-compatible by construction.
For SQL-side queryability, the indexer copies Message into structural_blobs.extra_attrs->>'message', alongside title and metadata. This lets the API surface the message without re-decoding the blob.
API surface
Proto schema changes (proto/documents/v3alpha/documents.proto):
Added
string message = 9;onCreateDocumentChangeRequest(kept from the original Ref-message branch — same field number, different semantics).Added
string message = 7;onPrepareChangeRequestso the daemon can embed the message into the unsigned Change blob it returns to client-side signers.Added
string message = 5;onDocumentChangeInfosoGetDocumentChangeandListDocumentChangessurface it.Removed
string message = 9;fromCreateRefRequest. Field number reserved (reserved 9;).Removed
string message = 9;fromRef. Field number reserved (reserved 9;).
Reserving the field numbers prevents accidental reuse if anyone rebuilds an older client and tries to populate them.
Client SDK (frontend/packages/client/src):
createChangeOpsandcreateDocumentChangeacceptmessage?: string. When set, it is added to the unsigned Change CBOR top-level.createVersionRef,createTombstoneRef,createRedirectRefno longer acceptmessage. Removed entirely — not deprecated, since the branch hasn't shipped.signDocumentChangeno longer acceptsmessage. The message is expected to be already embedded in the unsigned Change bytes (either by the daemon'sPrepareChangehandler, or by a client-sidecreateChangeOpscall).
Display surfaces
Activity feed (
activity-service.ts,feed.tsx):loadRefEventresolves the head change CID from the Ref's version target, then callsGetDocumentChangeand reads.message. Rendered as italic text below the document update line.Versions panel: reuses
ListDocumentChanges/GetDocumentChange, which now includemessage. No additional plumbing needed.Desktop publish UI (
publish-draft-button.tsx): a textarea labeled "Publish message (optional)" in the publish popover. Forwarded throughusePublishResource → publishDocument → daemon CreateDocumentChange(or the client-side path for new-home-document bootstrapping).CLI (
document.ts):--message/-mflag ondocument createanddocument update. Passed intocreateChangeOps, notcreateVersionRef.
Compatibility
The branch had not yet merged when this decision was made. There are no shipped Refs in the wild with Ref.Message, so we drop the field cleanly with no migration shim and no fallback read path.
Forward-compatible: clients running older code that don't send message simply produce changes without the field, and the indexer treats absence as "no message" (omitempty).
Non-goals
Editing message after publish. The message is signed into the Change blob; rewriting it would change the CID. Out of scope.
Rich text / markdown messages. Plain text only. A short annotation, not a document.
AI-generated messages. Orthogonal. Anyone can populate the same field later from a model — the storage doesn't care where the string came from.
Comment threads on versions. Out of scope. This is a single annotation, not a discussion system.
Diff summaries. Not the same feature. Could be layered on top — generate a summary, write it as the message — but that's separate work.
Verification checklist
End-to-end test path used during implementation:
direnv exec . plz run //proto/documents/v3alpha:go.gento regenerate proto bindings.direnv exec . go test ./backend/blob/... ./backend/api/documents/v3alpha/... -count=1— all pass, including the newTestChangeMessageRoundTrip.direnv exec . pnpm typecheck— clean across all 16 frontend workspaces.direnv exec . pnpm test— 414 frontend tests pass.Manually: open desktop app, create draft, type content, click Publish, type a message in the textarea, confirm publish.
Manually: open the Activity feed accessory and verify the italic message line under the publish event.
Manually: open the Versions panel and confirm the message appears next to the change.
Manually:
seed document create … -m "cli test"→seed document update … -m "cli edit"→ verify both messages appear via the API.Manually: tombstone or redirect via
CreateRef— confirm nomessagefield is present (it's gone from the proto).
Do you like what you are reading?. Subscribe to receive updates.
Unsubscribe anytime