Attribute the element history to the editor who made each change (pseudonymous authorship, merge publish, host mailmap) #295
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#295
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Every change an editor makes is committed in that editor's name, as a pseudonym a host supplies in a trusted request header; a branch-mode publish becomes a merge that keeps those commits in main's history; and the History sheet names, per element, who edited it in each publish and who has unpublished edits on it, with display names resolved at read time through git's mailmap. Standalone deckle is unchanged.
Current behavior
internal/gitrepohas one identity function that reads the repository'suser.name/user.emailand falls back to a fixeddeckle <deckle@localhost>. It is used as both author and committer by every commit-creating path: draft commits (CommitDraft), WIP snapshots (SnapshotWIP), both publish cores, and every policy rebase (publish,Reconcile, sync).<site> editors <noreply@…>), so every row of the History sheet names the same author.publishBranch) creates a single-parent commit on the remote main whose tree is main plus the publish's paths, pushes it, then rebases the draft branch onto it (rebaseDraftOnto) and collapses the draft to main when the trees are equal (collapse). The per-save commits are rewritten by that rebase, so only main's first-parent chain is durable (ADR 0023).GET /api/element/history(internal/server/history.go) walks main's first-parent chain withgitrepo.NeedleHistory(pickaxe onuid="<uid>"), locates the element on both sides of each commit (locateElement) and askselemops.DiffElementwhether it changed. Each entry carriesauthoras%an. The pending row (pendingRow) is the working tree against main and names nobody; the client shows "working tree".harness servehas no notion of a request's identity and trusts no header.server/editor.go,proxyUnix) already overridesAccept-Languageper user on every forwarded request; it sets no identity header. Users there have an id and an email, no display name.Desired behavior
Identity of a request. The server owns one rule, "who is the editor behind this request", evaluated once per mutating request and once per publish request. Input: the trusted request header
X-Deckle-Editorcarrying a pseudonymous git identity in theName <email>form, for exampleu123 <u123@noreply.example>. The value is validated as a git identity (no control characters, no</>inside the name, exactly one address) and a malformed value is refused as a request error with a coded message (ADR 0020). Without the header, or when the harness does not trust headers, the identity is the repository's default identity (see Decisions). The header carries no display name.Trust is a serve flag.
harness serve --trust-editor-header(boolean, off by default) turns header trust on. Without it the header is ignored, never an error. It is a host decision, not site content, so it is not aharness.yamlkey.Author and committer. The request identity is the author of every commit the harness creates for an editor: draft commits in branch mode, WIP snapshots, the catch-up commit a publish or sync makes of uncommitted work, and the publish commit (authored by the publisher). The committer is always the repository's default identity. Publish runs as a background job (ADR 0018): the identity is captured at request time and carried into the job.
Publish is a merge (branch mode). The publish commit has two parents: the reconciled main tip first, the draft tip second. Its tree is built exactly as today (main plus the publish's paths from the draft tip). After the push:
The post-publish rebase of the draft branch is removed. Reconcile (a developer pushed to main while drafts exist) is unchanged: it rebases draft commits, which preserves their authors. WIP-mode publish stays a single-parent commit. Main's first-parent chain stays one commit per publish, so ADR 0023's reading model holds; the existing history walk already diffs a merge against its first parent.
Per-element authors in the history. For each history entry that is a merge, the authors are the authors of the commits in
entry^1..entry^2in which this element changed: each such commit that touched the element's file is located on both sides (parent blob, commit blob) and compared with the same comparison the entries use (elemops.DiffElementvia the server'slocateElement). A commit that touched the file without changing the element contributes no author. Authors are ordered newest first and de-duplicated. A single-parent entry (a hand commit, a WIP-mode publish, a publish from before this change) lists its own author. The pending row lists, the same way, the authors of the draft commits not yet on main (main..draft) in which the element changed; when the working tree differs from the draft tip, or in WIP mode, the current request's identity stands in for the uncommitted work. Author names are read mailmap-aware (%aN), so a repository configured withmailmap.fileshows display names and one without shows the raw pseudonym.Wire shape. A history entry keeps
author(the commit's author: the publisher for a publish) and gainsauthors, a non-empty ordered list of mailmap-resolved names.pendinggainsauthorswith the same meaning. The client renders both and derives nothing: the publisher where the author stands today, the editors beside it, several names formatted as a list in the interface language (the existingformatList). New interface strings go into both catalogs (en.json,de.json).Display names are a mailmap, owned by the host. deckle writes only pseudonyms into commits. A host keeps the display names and points every worktree's git config at a mailmap file (
Display Name <u123@noreply.example>per user) viamailmap.file. A rename is a rewrite of that file; an erasure removes the user's line, after which git shows the pseudonym. deckle does not implement, receive or parse the map; the host contract document says how a host provides it.Standalone deckle (one developer, HEAD on main, no flag) looks exactly as today: author and committer are the repository's git identity.
Decisions
Settled during triage with the maintainer; implement, do not reopen.
X-Deckle-Editor, valueName <email>; the flag isharness serve --trust-editor-header— both are contract surface the hosted issues quote, so they are pinned here. The header carries no display name because the harness never needs one. A host that proxies the editor sets the header on every forwarded request and strips any copy coming from the browser; the harness documents that requirement.harness reconcileand sync run, so moving it would create a second caller. The server's rule is "trusted header if present and valid, else the repository default".PublishPathshas no production caller, so a path-scoped editor publish is not a case to design for; the carry-over handles it anyway.elemops.DiffElementthrough the server'slocateElement); applying it to the draft commits of a publish is the same rule over another range. Cost is two blobs per draft commit that touched the file, per listed entry, bounded by the window; read them in as fewcat-file --batchcalls as practical.elemops.DiffElementvia the server's existing locate, the same call the entries make. "Who has unpublished edits" — the same history code overmain..draft, not a second comparison in elemops. The client derives nothing.docs/architecture.mdGit backbone, Publish and Branch mode paragraphs link it.docs/api.md: the history route's twoauthorsfields, and the header's effect on mutating routes andPOST /api/publish-allin the place the document already describes request conventions.docs/harness.md: the flag in the usage block and under "Serving".docs/host-navigation.md: a new section for the identity header, the stripping requirement, the mailmap and the flag.scaffold/site/AGENTS.md"Previewing locally": one line naming the flag as host-only. README: at most one pointer line.docs/agents/docs-map.mdif a new area needs a row.preview-sitekeeps startingharness servewithout the flag. No skill changes.git log mainwithout--first-parentbecomes noisier for developers and in the forge's commit list; the ADR states this as the price of per-save authorship.Key interfaces
Name,Email) with a parse/validate function for theName <email>form, and aRepomethod returning the repository's default identity (today's config-then-fixed chain). The commit-creating functionsCommitDraft,SnapshotWIP,PublishAll/PublishPathsand the catch-up commit take the author identity as input instead of reading config;identityEnvbecomes "author from the argument, committer from the default". Policy rebases keep using the default for the committer; rebase preserves the replayed commits' authors.publishBranch: two-parentcommit-tree, then fast-forward or carry-over as decided;rebaseDraftOntois no longer called after a publish.PublishResultmay report what happened to the draft if a test needs it.gitrepo.HistoryVersiongains the per-commit material the server needs for the second-parent range (the commits inentry^1..entry^2that touched the element's files, each with author%aN, and the blobs on both sides), or a sibling call that answers it for a given entry; the server decides "changed" withlocateElement+elemops.DiffElementexactly as for entries.touchingCommits'%anbecomes%aN.main..draftwith the same shape (branch mode); in WIP mode the server uses the request identity.server.Optionsfield for trust, the identity threaded through the mutation hook (snapshotWIP) and intorunPublishJob. Newapimsgcode for a malformed header (and its entry inservermessages.json/ the client catalogs if the client shows it).historyEntryandhistoryPendinggainAuthors []string. Client:web/src/api/history.tsparsesauthors;HistorySheet.tsxrenders publisher and editors.cmd/harness: the--trust-editor-headerflag onserve, inparseServeArgsandusage().internal/gitrepo/{gitrepo.go,publish.go,branch.go,snapshot.go,history.go},internal/server/{git.go,publish.go,history.go,mutation.go},cmd/harness/main.go,web/src/components/HistorySheet.tsx,web/src/api/history.ts.Acceptance criteria
--trust-editor-header, a mutating request carryingX-Deckle-Editor: u1 <u1@noreply.test>produces a draft commit (branch mode) or WIP snapshot (WIP mode) whose author is exactly that identity and whose committer is the repository's configured identity.deckle <deckle@localhost>when none is configured.docs/api.md.content/andassets/media/, the draft ref is one commit above the publish commit, its tree equals the old draft tip's tree, and the working tree still holds that change.--forcestill holds); the post-publish draft push is a fast-forward.GET /api/element/history: every entry carriesauthors(non-empty). For a publish whose draft commits were made by two identities on two elements of the same file, each element's entry lists only the identity that changed it; a commit by a third identity that touched the file without changing either element contributes no author. Authors are newest first and de-duplicated. A single-parent entry'sauthorsis[author].pending.authorslists the identities of the draft commits not yet on main in which the element changed; with uncommitted working-tree changes, or in WIP mode, it contains the current request's identity (the fallback identity when no header is trusted).mailmap.fileconfigured to a file mappingu1@noreply.testtoAlice,authorsandauthorreadAlice; after the line is removed they readu1. Nothing in git is rewritten.en.jsonandde.json.authorsfields, each[author].harness serve --trust-editor-headeris accepted; it appears inusage()and indocs/harness.md;harness serve -hlists it.docs/architecture.md; ADRs 0011 and 0023 carry a superseding note;docs/api.md,docs/host-navigation.mdandscaffold/site/AGENTS.mdare updated as decided; the README gains at most one pointer line.cmd/harnessusage ⇔docs/harness.md,internal/serverroutes ⇔docs/api.md,docs/docs_test.go(README budget, links),scaffold/skills_test.go,internal/element/agentsdoc_test.go.How to verify
go test ./...andmaketargets the repo already uses;cd web && npm test(vitest) for the client.TestBranchPublishThenCollapse,TestBranchModePublishUndoAndDeveloperMain,TestCommitDraftCommitsWholeTreeAndPushes,TestSyncRebasesOntoMovedRemoteWithPolicy): assert parents withgit rev-list --parents -n1, authors withgit log --format=%an/%cn, the draft ref and the working tree after publish.TestNeedleHistory,TestNeedleHistoryBranchModeEqualsWIPMode): a merge publish with saves by two identities; mailmap on and off viagit config mailmap.file.ServeHTTPagainst a temporary site and repository (prior art:internal/server/branch_test.go,history_test.go): header trusted / untrusted / malformed / absent;authorson entries and pending; ordering and de-duplication.HistorySheetrendering one and several authors beside the publisher (prior art:web/src/test/HistorySheet.test.tsx).cmd/harnessflag parsing test beside the existingparseServeArgstests.harness serve --site <branch-mode site> --trust-editor-header, two saves with differentX-Deckle-Editorvalues via curl, Publish, open the History sheet of an edited element and of an untouched element on the same page.Assumptions to check
%aNappliesmailmap.filefrom the repository config with no further flag ongit log; confirm with the git version the harness supports.git commit-treewith two-pflags andGIT_AUTHOR_*/GIT_COMMITTER_*set separately produces the expected author/committer split;git rebasekeeps the replayed commits' authors while taking the committer from the environment.--first-parent --diff-merges=first-parent) needs no change for a merge publish — verify with the new fixture rather than assuming.--no-first-parentoverentry^1..entry^2lists the second-parent side only once a merge has been reached; check that commits reachable from both parents are excluded by the range as expected.Out of scope
Blocked by
None - can start immediately.
Original report
Problem Statement
A site on deckle-hosted is edited by several people, but its element history names none of them. Every save and every publish is committed as the site's fixed identity (" editors"), so the History sheet's author column says the same thing on every row. An editor cannot see who changed a text, who disabled an element or who has unpublished edits on it, and an organization cannot answer "who published this" after the fact.
Attributing the publish commit to the person who clicked Publish would not fix it: one Publish ships every pending change, other editors' included (ADR 0013), so the publisher would be credited for everyone's work.
A naive fix also creates a GDPR liability. A name or email written into a commit object cannot be removed later without rewriting every later commit of the site, which changes every commit id, breaks developer clones and the pipeline's references.
Solution
Every change an editor makes is committed in that editor's name, as a pseudonym; a publish keeps those commits as part of main's history instead of squashing them away; and the History sheet names, per element and per publish, the people who actually changed it, with their display names resolved at read time.
Concretely, from the editor's point of view:
User Stories
git log --first-parentand the pipeline's "build the tip" keep working.Implementation Decisions
The identity of a request. The server owns one rule, "who is the editor behind this request", evaluated once per mutating request and per publish request. Its input is one trusted request header carrying a pseudonymous git identity in the
Name <email>form, for exampleu123 <u123@noreply.example>. The value is validated as a git identity (no control characters, no angle brackets inside the name, one address) and refused as a request error when malformed. Without the header, or when the harness does not trust headers, the identity is the repository's configureduser.name/user.email, else the existing fixed fallback; this fallback chain moves out of gitrepo's env builder into the same rule, so there is exactly one owner. The header carries no display name: the harness never needs one.Trust is a serve flag.
harness servegains a flag that turns header trust on; it is off by default. The flag is a host decision, not site content, so it is not aharness.yamlkey. A host that proxies the editor sets the header on every request it forwards and strips any such header coming from the browser; the harness documents that requirement in the host contract document.Author and committer. The identity becomes the author of every commit the harness creates for an editor: draft commits in branch mode, WIP snapshots in WIP mode, and the publish commit (whose author is the publisher). The committer stays the repository's configured identity (on hosted: the site identity). The gitrepo functions that create commits take an identity value as input instead of reading git config themselves; the identity type lives in gitrepo because gitrepo consumes it. Publish runs as a background job (ADR 0018), so the identity is captured at request time and carried into the job.
Publish becomes a merge. In branch mode, a publish commit has two parents: the reconciled main tip first, the draft tip second. The tree is built as today. Because the draft tip is then an ancestor of main, the rebase of the draft branch after a publish is removed and the collapse is a fast-forward of the draft ref to the publish commit. Reconcile (a developer pushed to main while drafts exist) is unchanged: it rebases draft commits, which preserves their authors. WIP mode's publish stays a single-parent commit: the snapshot chain is not on main and is a single user's. A path-scoped publish from the CLI makes a merge whose tree is neither parent's; the unpublished remainder of the draft becomes reachable from main's history, which is acceptable because the draft branch is already on the same remote, and the ADR says so. The first-parent chain of main stays one commit per publish, so ADR 0023's reading model holds.
Per-element authors in the history. For each history entry that is a merge, the history walks the commits between the entry's first and second parent with the same pickaxe it already uses (the uid spelling, fixed string, renames off) and collects, newest first and de-duplicated, the authors of the commits that changed the element's file. A single-parent entry (a hand commit, a WIP-mode publish) lists its own author. The pending row collects the authors of the draft commits not yet on main the same way; in WIP mode, or when the working tree differs from the draft tip, the current request's identity stands in for uncommitted work. Author names are read mailmap-aware (
%aN), so git applies any mailmap the repository is configured with.Wire shape. A history entry keeps
author(the commit's author, the publisher for a publish) and gainsauthors, a non-empty ordered list of mailmap-resolved names. The pending object gainsauthorswith the same meaning. The client renders both and derives nothing; it formats several names as a comma-separated list.Display names are a mailmap, owned by the host. deckle writes only pseudonyms into commits. deckle-hosted keeps the display names and maintains one mailmap file (
Display Name <u123@noreply.example>per user) that every site worktree's git config points at throughmailmap.file, written on the same cold-start config pass that sets the site identity today, at the container's path. A rename is a rewrite of that file and shows on the next request; an erasure removes the user's line, after which git shows the raw pseudonym. deckle does not implement, receive or parse this map; it is the host contract document's job to say how a host provides it.deckle-hosted changes (a separate issue on that repository, written from this PRD): a display name on users with an Account-page field; a per-user pseudonymous address derived from the user id under the host's noreply domain; the identity header set by the editor proxy and stripped from inbound requests; the mailmap file and the
mailmap.fileconfig; starting the harness with the trust flag; erasure removes the mailmap line.Rule owners. "Who is this request's editor": the server's identity function. "What is the fallback identity": the same function (moved from gitrepo). "Which commits of a publish touched this element": gitrepo history, reusing the existing pickaxe needle, which stays the one spelling of the uid param. "Who has unpublished edits": the same history code over the draft range, not a second comparison in elemops. The client derives nothing.
Documents. A new ADR ("publish is a merge; authorship is pseudonymous and resolved by mailmap") linked from the architecture narrative's Git backbone, Publish, Branch mode and history paragraphs. The API document: the history route's
authorsfields and the identity header's effect on mutating routes. The harness document and AGENTS.md'sharness servesection: the new flag. The host contract document: a new section for the identity header, the stripping requirement, the mailmap and the flag, versioned as the rest of that document. The README gains at most one pointer line. No scaffolded skill walks through authorship;preview-sitestartsharness servewithout the flag and stays as it is.Testing Decisions
A good test exercises external behaviour through the package's public interface and asserts on what a caller or a user would observe: the commit graph git reports, the JSON the route answers, the text the sheet renders. It does not assert on internal helpers, environment strings or intermediate refs.
ServeHTTPagainst a temporary site and repository.mailmap.fileresolves names and its removal shows the pseudonym again. Prior art: the existing history tests.authorspresent on entries and pending, ordering, de-duplication, the single-author case. Prior art: the existing history route tests.Out of Scope
Further Notes
git log mainwithout--first-parentbecomes noisier for developers and in the forge's commit list; this is the price of per-save authorship and the ADR should state it.