Attribute the element history to the editor who made each change (pseudonymous authorship, merge publish, host mailmap) #295

Closed
opened 2026-10-14 22:22:41 +02:00 by dominik.polakovics · 0 comments

This was generated by AI during triage.

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/gitrepo has one identity function that reads the repository's user.name/user.email and falls back to a fixed deckle <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).
  • On deckle-hosted every worktree's local config carries one site identity (<site> editors <noreply@…>), so every row of the History sheet names the same author.
  • A branch-mode publish (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 with gitrepo.NeedleHistory (pickaxe on uid="<uid>"), locates the element on both sides of each commit (locateElement) and asks elemops.DiffElement whether it changed. Each entry carries author as %an. The pending row (pendingRow) is the working tree against main and names nobody; the client shows "working tree".
  • harness serve has no notion of a request's identity and trusts no header.
  • deckle-hosted's editor proxy (server/editor.go, proxyUnix) already overrides Accept-Language per 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-Editor carrying a pseudonymous git identity in the Name <email> form, for example u123 <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 a harness.yaml key.

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:

  • when the publish tree equals the draft tip's tree, the draft ref fast-forwards to the publish commit (this is the collapse; a fully published site carries no draft history);
  • otherwise one single-parent commit goes on top of the publish commit, carrying the draft tip's tree, authored and committed by the repository's default identity with a fixed message, and the draft ref moves to it. Nothing leaves the checkout, and the draft stays a descendant of main.

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^2 in 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.DiffElement via the server's locateElement). 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 with mailmap.file shows 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 gains authors, a non-empty ordered list of mailmap-resolved names. pending gains authors with 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 existing formatList). 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) via mailmap.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.

  • Identity header is X-Deckle-Editor, value Name <email>; the flag is harness 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.
  • The fallback identity stays in gitrepo as the repository's default identity (the PRD proposed moving it into the server) — gitrepo needs it anyway for the committer of every commit and for the rebases harness reconcile and 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".
  • Author is the request identity; committer is the repository default — the committer stays the site identity on hosted so nothing a host keys on changes, and the author is the fact the history reads.
  • Publish is a merge; the draft fast-forwards when trees are equal and otherwise gets one carry-over commit — a plain fast-forward would hard-reset the checkout to the publish commit and lose any draft change outside the publish roots (theme or config files on the editor branch). The carry-over commit keeps the checkout whole and keeps the draft a descendant of main. PublishPaths has no production caller, so a path-scoped editor publish is not a case to design for; the carry-over handles it anyway.
  • Attribution is element-level, not file-level — on a page where Alice edits element A and Bob edits element B in one publish, A's history must name Alice only. "Did this element change in this commit" already has one owner (elemops.DiffElement through the server's locateElement); 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 few cat-file --batch calls as practical.
  • One issue — the identity work alone would make the publish row name the publisher for everyone's edits, which the PRD rejects as misleading; the client change is small. The deckle-hosted half is two issues on that repository, filed by the maintainer (display name; pseudonymous authorship), the second blocked by a deckle release carrying this issue.
  • Rule owners (CLAUDE.md): "who is this request's editor" — the server's identity function, called from the mutation middleware and the publish handler, nowhere else. "What is the repository's default identity" and "is this string a valid git identity" — gitrepo (the identity type lives there because gitrepo consumes it). "Which commits of a publish touched this element's file" — gitrepo's history code, reusing the one pickaxe needle spelling. "Did the element change in that commit" — elemops.DiffElement via the server's existing locate, the same call the entries make. "Who has unpublished edits" — the same history code over main..draft, not a second comparison in elemops. The client derives nothing.
  • Documents (CLAUDE.md): a new ADR 0024 "publish is a merge; authorship is pseudonymous and resolved by mailmap", which must also revise ADR 0011's "no draft push is ever a fast-forward" and ADR 0023's "a publish rebases and collapses the draft branch, so per-save commits are rewritten" (add a superseding note to each, do not rewrite their decisions); docs/architecture.md Git backbone, Publish and Branch mode paragraphs link it. docs/api.md: the history route's two authors fields, and the header's effect on mutating routes and POST /api/publish-all in 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.md if a new area needs a row.
  • Skills (CLAUDE.md): revisited; no scaffolded skill walks through authorship, and preview-site keeps starting harness serve without the flag. No skill changes.
  • Storage is essentially unchanged: branch mode already pushes every draft commit; the merge keeps them reachable instead of garbage-collectable. git log main without --first-parent becomes noisier for developers and in the forge's commit list; the ADR states this as the price of per-save authorship.

Key interfaces

  • A gitrepo identity type (Name, Email) with a parse/validate function for the Name <email> form, and a Repo method returning the repository's default identity (today's config-then-fixed chain). The commit-creating functions CommitDraft, SnapshotWIP, PublishAll/PublishPaths and the catch-up commit take the author identity as input instead of reading config; identityEnv becomes "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-parent commit-tree, then fast-forward or carry-over as decided; rebaseDraftOnto is no longer called after a publish. PublishResult may report what happened to the draft if a test needs it.
  • gitrepo.HistoryVersion gains the per-commit material the server needs for the second-parent range (the commits in entry^1..entry^2 that 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" with locateElement + elemops.DiffElement exactly as for entries. touchingCommits' %an becomes %aN.
  • A gitrepo call for the pending range main..draft with the same shape (branch mode); in WIP mode the server uses the request identity.
  • Server: one identity function (request → identity, honouring the trust option), a server.Options field for trust, the identity threaded through the mutation hook (snapshotWIP) and into runPublishJob. New apimsg code for a malformed header (and its entry in servermessages.json / the client catalogs if the client shows it).
  • historyEntry and historyPending gain Authors []string. Client: web/src/api/history.ts parses authors; HistorySheet.tsx renders publisher and editors.
  • cmd/harness: the --trust-editor-header flag on serve, in parseServeArgs and usage().
  • Starting hints: 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

  • With --trust-editor-header, a mutating request carrying X-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.
  • Without the flag the same header is ignored and the author is the repository identity; without the header (flag on or off) the author is the repository identity, else deckle <deckle@localhost> when none is configured.
  • A malformed header value with the flag on is a 400 with a coded message and no commit is made; the response shape follows the existing error convention in docs/api.md.
  • A branch-mode publish produces a commit with two parents, first the previous main tip and second the draft tip, whose tree equals today's publish tree; a WIP-mode publish stays single-parent. The publish commit's author is the publishing request's identity; its committer is the repository identity.
  • After a branch-mode publish whose tree equals the draft tip's tree, the draft ref equals the publish commit and the checkout is clean; a following save continues from it. After one where the draft held a change outside content/ and assets/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.
  • The publish result's draft push is a lease push (the existing trace assertion that no draft push is a plain --force still holds); the post-publish draft push is a fast-forward.
  • Reconcile after a developer pushes to main while drafts exist keeps the draft commits' authors.
  • GET /api/element/history: every entry carries authors (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's authors is [author].
  • pending.authors lists 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).
  • With mailmap.file configured to a file mapping u1@noreply.test to Alice, authors and author read Alice; after the line is removed they read u1. Nothing in git is rewritten.
  • The History sheet shows, on a publish row, the publisher and beside it the editors ("one name" and "several names" both rendered, the list formatted in the interface language); the pending row shows who has unpublished edits. Strings exist in en.json and de.json.
  • Standalone WIP mode without the flag: byte-identical behaviour to today for commits; the history answer differs only by the added authors fields, each [author].
  • harness serve --trust-editor-header is accepted; it appears in usage() and in docs/harness.md; harness serve -h lists it.
  • ADR 0024 exists and is linked from docs/architecture.md; ADRs 0011 and 0023 carry a superseding note; docs/api.md, docs/host-navigation.md and scaffold/site/AGENTS.md are updated as decided; the README gains at most one pointer line.
  • All drift tests stay green: cmd/harness usage ⇔ docs/harness.md, internal/server routes ⇔ docs/api.md, docs/docs_test.go (README budget, links), scaffold/skills_test.go, internal/element/agentsdoc_test.go.

How to verify

  • go test ./... and make targets the repo already uses; cd web && npm test (vitest) for the client.
  • New gitrepo tests on the branch fixture (prior art: TestBranchPublishThenCollapse, TestBranchModePublishUndoAndDeveloperMain, TestCommitDraftCommitsWholeTreeAndPushes, TestSyncRebasesOntoMovedRemoteWithPolicy): assert parents with git rev-list --parents -n1, authors with git log --format=%an/%cn, the draft ref and the working tree after publish.
  • New gitrepo history tests (prior art: TestNeedleHistory, TestNeedleHistoryBranchModeEqualsWIPMode): a merge publish with saves by two identities; mailmap on and off via git config mailmap.file.
  • New server tests driving routes through ServeHTTP against a temporary site and repository (prior art: internal/server/branch_test.go, history_test.go): header trusted / untrusted / malformed / absent; authors on entries and pending; ordering and de-duplication.
  • A vitest for HistorySheet rendering one and several authors beside the publisher (prior art: web/src/test/HistorySheet.test.tsx).
  • cmd/harness flag parsing test beside the existing parseServeArgs tests.
  • Manual: harness serve --site <branch-mode site> --trust-editor-header, two saves with different X-Deckle-Editor values via curl, Publish, open the History sheet of an edited element and of an untouched element on the same page.

Assumptions to check

  • %aN applies mailmap.file from the repository config with no further flag on git log; confirm with the git version the harness supports.
  • git commit-tree with two -p flags and GIT_AUTHOR_* / GIT_COMMITTER_* set separately produces the expected author/committer split; git rebase keeps the replayed commits' authors while taking the committer from the environment.
  • The existing history walk (--first-parent --diff-merges=first-parent) needs no change for a merge publish — verify with the new fixture rather than assuming.
  • deckle-hosted's bare-repository push path accepts a merge commit on main (it is a fast-forward of main); nothing there inspects commit shape. This cannot be verified from this repository; note it in the ADR as a host assumption.
  • Rename detection off plus --no-first-parent over entry^1..entry^2 lists 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

  • Any change to deckle-hosted (display names, the pseudonymous address, setting and stripping the header, writing the mailmap, starting the harness with the flag, erasure); two issues on that repository.
  • Restoring an earlier version from the history (a later increment, ADR 0023).
  • Per-element attribution in WIP mode beyond the single request identity.
  • Attribution of hand commits made outside the editor; they show their own git author as today.
  • Authenticating or authorizing editors in the harness; the host does that.
  • Showing display names to developers in their clones; they configure the mailmap themselves if they want them.
  • Historical backfill: publishes made before this change keep their single author.
  • Performance work on the history route beyond keeping the new reads batched (#289 is separate).

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:

  • A publish row in the History sheet shows who published and, next to it, who edited this element in that publish (one or several names).
  • The pending row at the top shows who has unpublished edits on this element.
  • Standalone deckle (one developer, HEAD on main) looks exactly as today: the author is the repository's git identity.
  • When a user is deleted from deckle-hosted, their rows keep working and show a neutral pseudonym; nothing in git is rewritten.

User Stories

  1. As an editor on a shared site, I want each history entry of an element to name the person who made that change, so that I know whom to ask about it.
  2. As an editor, I want the pending row of an element's history to name who has unpublished edits on it, so that I do not publish or discard someone else's half-done work unknowingly.
  3. As an editor, I want to see who published, separately from who edited, so that a publish that ships my colleague's change is not credited to me.
  4. As a deckle-hosted operator, I want to remove a former user's name from every site's history with a single data change, so that an erasure request is served without rewriting any repository.
  5. As a site developer who clones the site repository, I want main's publishes to remain one commit per publish on the first-parent chain, so that my existing habit of reading git log --first-parent and the pipeline's "build the tip" keep working.
  6. As a host operator, I want the harness to trust identity headers only when I explicitly tell it to, so that a standalone or misconfigured harness cannot be fed a forged identity.
  7. As a standalone deckle user, I want nothing to change for me, so that my commits keep carrying my own git identity.

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 example u123 <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 configured user.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 serve gains 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 a harness.yaml key. 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 gains authors, a non-empty ordered list of mailmap-resolved names. The pending object gains authors with 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 through mailmap.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.file config; 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 authors fields and the identity header's effect on mutating routes. The harness document and AGENTS.md's harness serve section: 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-site starts harness serve without 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.

  • Server identity rule: a trusted header yields that author on the resulting commit; an untrusted harness ignores the header; a malformed header is a request error with the coded message; no header falls back to the repository identity, then to the fixed fallback. Prior art: the handler tests that drive routes through ServeHTTP against a temporary site and repository.
  • gitrepo publish: a branch-mode publish produces a two-parent commit whose first parent is the old main and second the draft tip, with the expected tree; the draft ref equals the publish commit afterwards; a following save continues from it; reconcile after a developer push keeps draft authors; WIP-mode publish stays single-parent. Prior art: the branch, publish and reconcile tests on the branch fixture.
  • gitrepo history: per-element authors over a merge publish with saves by two identities, only those who touched the element's file are listed; a single-parent entry lists its own author; the pending row lists the draft authors; a mailmap configured via mailmap.file resolves names and its removal shows the pseudonym again. Prior art: the existing history tests.
  • Server history route: authors present on entries and pending, ordering, de-duplication, the single-author case. Prior art: the existing history route tests.
  • Client: a vitest for the history sheet rendering one and several authors beside the publisher. Prior art: the component tests under the web test directory.
  • Drift tests already in place must stay green: the harness usage text against the harness document, every route against the API document, the README budget and link checks.

Out of Scope

  • Any change to deckle-hosted itself; it gets its own issue written from this PRD.
  • Restoring an earlier version from the history (a later increment, per ADR 0023).
  • Per-element attribution in WIP mode beyond the single configured identity.
  • Attribution of hand commits made outside the editor; they show their own git author as today.
  • Authenticating or authorizing editors in the harness; the host does that, the harness only trusts a header it was told to trust.
  • Showing display names to developers in their clones; they see pseudonyms unless they configure the mailmap themselves.
  • Historical backfill: publishes made before this change keep the site identity as their only author.

Further Notes

  • Storage is essentially unchanged: branch mode already pushes every draft commit; the merge only keeps them reachable instead of garbage-collectable.
  • git log main without --first-parent becomes noisier for developers and in the forge's commit list; this is the price of per-save authorship and the ADR should state it.
  • The identity header name is part of the host contract and must be chosen so a browser cannot set it through the proxy; the proxy strips it on the way in.
  • Whether a pseudonymous id counts as personal data after the mailmap line and the user row are gone is a legal question for the operator; the design makes the link removable with one change, which is the most the engine can do.
> *This was generated by AI during triage.* ## 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/gitrepo` has one identity function that reads the repository's `user.name`/`user.email` and falls back to a fixed `deckle <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). - On deckle-hosted every worktree's local config carries one site identity (`<site> editors <noreply@…>`), so every row of the History sheet names the same author. - A branch-mode publish (`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](docs/adr/0023-the-element-history-is-derived-from-main.md)). - `GET /api/element/history` (`internal/server/history.go`) walks main's first-parent chain with `gitrepo.NeedleHistory` (pickaxe on `uid="<uid>"`), locates the element on both sides of each commit (`locateElement`) and asks `elemops.DiffElement` whether it changed. Each entry carries `author` as `%an`. The pending row (`pendingRow`) is the working tree against main and names nobody; the client shows "working tree". - `harness serve` has no notion of a request's identity and trusts no header. - deckle-hosted's editor proxy (`server/editor.go`, `proxyUnix`) already overrides `Accept-Language` per 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-Editor` carrying a pseudonymous git identity in the `Name <email>` form, for example `u123 <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](docs/adr/0020-coded-messages-the-server-never-translates.md)). 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 a `harness.yaml` key. **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](docs/adr/0018-publish-is-a-background-job.md)): 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: - when the publish tree equals the draft tip's tree, the draft ref fast-forwards to the publish commit (this is the collapse; a fully published site carries no draft history); - otherwise one single-parent commit goes on top of the publish commit, carrying the draft tip's tree, authored and committed by the repository's default identity with a fixed message, and the draft ref moves to it. Nothing leaves the checkout, and the draft stays a descendant of main. 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^2` **in 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.DiffElement` via the server's `locateElement`). 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 with `mailmap.file` shows 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 gains `authors`, a non-empty ordered list of mailmap-resolved names. `pending` gains `authors` with 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 existing `formatList`). 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) via `mailmap.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. - **Identity header is `X-Deckle-Editor`, value `Name <email>`; the flag is `harness 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. - **The fallback identity stays in gitrepo as the repository's default identity** (the PRD proposed moving it into the server) — gitrepo needs it anyway for the committer of every commit and for the rebases `harness reconcile` and 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". - **Author is the request identity; committer is the repository default** — the committer stays the site identity on hosted so nothing a host keys on changes, and the author is the fact the history reads. - **Publish is a merge; the draft fast-forwards when trees are equal and otherwise gets one carry-over commit** — a plain fast-forward would hard-reset the checkout to the publish commit and lose any draft change outside the publish roots (theme or config files on the editor branch). The carry-over commit keeps the checkout whole and keeps the draft a descendant of main. `PublishPaths` has no production caller, so a path-scoped editor publish is not a case to design for; the carry-over handles it anyway. - **Attribution is element-level, not file-level** — on a page where Alice edits element A and Bob edits element B in one publish, A's history must name Alice only. "Did this element change in this commit" already has one owner (`elemops.DiffElement` through the server's `locateElement`); 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 few `cat-file --batch` calls as practical. - **One issue** — the identity work alone would make the publish row name the publisher for everyone's edits, which the PRD rejects as misleading; the client change is small. The deckle-hosted half is two issues on that repository, filed by the maintainer (display name; pseudonymous authorship), the second blocked by a deckle release carrying this issue. - **Rule owners** (CLAUDE.md): "who is this request's editor" — the server's identity function, called from the mutation middleware and the publish handler, nowhere else. "What is the repository's default identity" and "is this string a valid git identity" — gitrepo (the identity type lives there because gitrepo consumes it). "Which commits of a publish touched this element's file" — gitrepo's history code, reusing the one pickaxe needle spelling. "Did the element change in that commit" — `elemops.DiffElement` via the server's existing locate, the same call the entries make. "Who has unpublished edits" — the same history code over `main..draft`, not a second comparison in elemops. The client derives nothing. - **Documents** (CLAUDE.md): a new ADR 0024 "publish is a merge; authorship is pseudonymous and resolved by mailmap", which must also revise ADR 0011's "no draft push is ever a fast-forward" and ADR 0023's "a publish rebases and collapses the draft branch, so per-save commits are rewritten" (add a superseding note to each, do not rewrite their decisions); `docs/architecture.md` Git backbone, Publish and Branch mode paragraphs link it. `docs/api.md`: the history route's two `authors` fields, and the header's effect on mutating routes and `POST /api/publish-all` in 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.md` if a new area needs a row. - **Skills** (CLAUDE.md): revisited; no scaffolded skill walks through authorship, and `preview-site` keeps starting `harness serve` without the flag. No skill changes. - **Storage** is essentially unchanged: branch mode already pushes every draft commit; the merge keeps them reachable instead of garbage-collectable. `git log main` without `--first-parent` becomes noisier for developers and in the forge's commit list; the ADR states this as the price of per-save authorship. ## Key interfaces - A gitrepo identity type (`Name`, `Email`) with a parse/validate function for the `Name <email>` form, and a `Repo` method returning the repository's default identity (today's config-then-fixed chain). The commit-creating functions `CommitDraft`, `SnapshotWIP`, `PublishAll`/`PublishPaths` and the catch-up commit take the author identity as input instead of reading config; `identityEnv` becomes "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-parent `commit-tree`, then fast-forward or carry-over as decided; `rebaseDraftOnto` is no longer called after a publish. `PublishResult` may report what happened to the draft if a test needs it. - `gitrepo.HistoryVersion` gains the per-commit material the server needs for the second-parent range (the commits in `entry^1..entry^2` that 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" with `locateElement` + `elemops.DiffElement` exactly as for entries. `touchingCommits`' `%an` becomes `%aN`. - A gitrepo call for the pending range `main..draft` with the same shape (branch mode); in WIP mode the server uses the request identity. - Server: one identity function (request → identity, honouring the trust option), a `server.Options` field for trust, the identity threaded through the mutation hook (`snapshotWIP`) and into `runPublishJob`. New `apimsg` code for a malformed header (and its entry in `servermessages.json` / the client catalogs if the client shows it). - `historyEntry` and `historyPending` gain `Authors []string`. Client: `web/src/api/history.ts` parses `authors`; `HistorySheet.tsx` renders publisher and editors. - `cmd/harness`: the `--trust-editor-header` flag on `serve`, in `parseServeArgs` and `usage()`. - Starting hints: `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 - [ ] With `--trust-editor-header`, a mutating request carrying `X-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. - [ ] Without the flag the same header is ignored and the author is the repository identity; without the header (flag on or off) the author is the repository identity, else `deckle <deckle@localhost>` when none is configured. - [ ] A malformed header value with the flag on is a 400 with a coded message and no commit is made; the response shape follows the existing error convention in `docs/api.md`. - [ ] A branch-mode publish produces a commit with two parents, first the previous main tip and second the draft tip, whose tree equals today's publish tree; a WIP-mode publish stays single-parent. The publish commit's author is the publishing request's identity; its committer is the repository identity. - [ ] After a branch-mode publish whose tree equals the draft tip's tree, the draft ref equals the publish commit and the checkout is clean; a following save continues from it. After one where the draft held a change outside `content/` and `assets/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. - [ ] The publish result's draft push is a lease push (the existing trace assertion that no draft push is a plain `--force` still holds); the post-publish draft push is a fast-forward. - [ ] Reconcile after a developer pushes to main while drafts exist keeps the draft commits' authors. - [ ] `GET /api/element/history`: every entry carries `authors` (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's `authors` is `[author]`. - [ ] `pending.authors` lists 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). - [ ] With `mailmap.file` configured to a file mapping `u1@noreply.test` to `Alice`, `authors` and `author` read `Alice`; after the line is removed they read `u1`. Nothing in git is rewritten. - [ ] The History sheet shows, on a publish row, the publisher and beside it the editors ("one name" and "several names" both rendered, the list formatted in the interface language); the pending row shows who has unpublished edits. Strings exist in `en.json` and `de.json`. - [ ] Standalone WIP mode without the flag: byte-identical behaviour to today for commits; the history answer differs only by the added `authors` fields, each `[author]`. - [ ] `harness serve --trust-editor-header` is accepted; it appears in `usage()` and in `docs/harness.md`; `harness serve -h` lists it. - [ ] ADR 0024 exists and is linked from `docs/architecture.md`; ADRs 0011 and 0023 carry a superseding note; `docs/api.md`, `docs/host-navigation.md` and `scaffold/site/AGENTS.md` are updated as decided; the README gains at most one pointer line. - [ ] All drift tests stay green: `cmd/harness` usage ⇔ `docs/harness.md`, `internal/server` routes ⇔ `docs/api.md`, `docs/docs_test.go` (README budget, links), `scaffold/skills_test.go`, `internal/element/agentsdoc_test.go`. ## How to verify - `go test ./...` and `make` targets the repo already uses; `cd web && npm test` (vitest) for the client. - New gitrepo tests on the branch fixture (prior art: `TestBranchPublishThenCollapse`, `TestBranchModePublishUndoAndDeveloperMain`, `TestCommitDraftCommitsWholeTreeAndPushes`, `TestSyncRebasesOntoMovedRemoteWithPolicy`): assert parents with `git rev-list --parents -n1`, authors with `git log --format=%an/%cn`, the draft ref and the working tree after publish. - New gitrepo history tests (prior art: `TestNeedleHistory`, `TestNeedleHistoryBranchModeEqualsWIPMode`): a merge publish with saves by two identities; mailmap on and off via `git config mailmap.file`. - New server tests driving routes through `ServeHTTP` against a temporary site and repository (prior art: `internal/server/branch_test.go`, `history_test.go`): header trusted / untrusted / malformed / absent; `authors` on entries and pending; ordering and de-duplication. - A vitest for `HistorySheet` rendering one and several authors beside the publisher (prior art: `web/src/test/HistorySheet.test.tsx`). - `cmd/harness` flag parsing test beside the existing `parseServeArgs` tests. - Manual: `harness serve --site <branch-mode site> --trust-editor-header`, two saves with different `X-Deckle-Editor` values via curl, Publish, open the History sheet of an edited element and of an untouched element on the same page. ## Assumptions to check - `%aN` applies `mailmap.file` from the repository config with no further flag on `git log`; confirm with the git version the harness supports. - `git commit-tree` with two `-p` flags and `GIT_AUTHOR_*` / `GIT_COMMITTER_*` set separately produces the expected author/committer split; `git rebase` keeps the replayed commits' authors while taking the committer from the environment. - The existing history walk (`--first-parent --diff-merges=first-parent`) needs no change for a merge publish — verify with the new fixture rather than assuming. - deckle-hosted's bare-repository push path accepts a merge commit on main (it is a fast-forward of main); nothing there inspects commit shape. This cannot be verified from this repository; note it in the ADR as a host assumption. - Rename detection off plus `--no-first-parent` over `entry^1..entry^2` lists 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 - Any change to deckle-hosted (display names, the pseudonymous address, setting and stripping the header, writing the mailmap, starting the harness with the flag, erasure); two issues on that repository. - Restoring an earlier version from the history (a later increment, ADR 0023). - Per-element attribution in WIP mode beyond the single request identity. - Attribution of hand commits made outside the editor; they show their own git author as today. - Authenticating or authorizing editors in the harness; the host does that. - Showing display names to developers in their clones; they configure the mailmap themselves if they want them. - Historical backfill: publishes made before this change keep their single author. - Performance work on the history route beyond keeping the new reads batched (#289 is separate). ## 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 ("<site> 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: - A publish row in the History sheet shows who published and, next to it, who edited this element in that publish (one or several names). - The pending row at the top shows who has unpublished edits on this element. - Standalone deckle (one developer, HEAD on main) looks exactly as today: the author is the repository's git identity. - When a user is deleted from deckle-hosted, their rows keep working and show a neutral pseudonym; nothing in git is rewritten. ## User Stories 1. As an editor on a shared site, I want each history entry of an element to name the person who made that change, so that I know whom to ask about it. 2. As an editor, I want the pending row of an element's history to name who has unpublished edits on it, so that I do not publish or discard someone else's half-done work unknowingly. 3. As an editor, I want to see who published, separately from who edited, so that a publish that ships my colleague's change is not credited to me. 4. As a deckle-hosted operator, I want to remove a former user's name from every site's history with a single data change, so that an erasure request is served without rewriting any repository. 5. As a site developer who clones the site repository, I want main's publishes to remain one commit per publish on the first-parent chain, so that my existing habit of reading `git log --first-parent` and the pipeline's "build the tip" keep working. 6. As a host operator, I want the harness to trust identity headers only when I explicitly tell it to, so that a standalone or misconfigured harness cannot be fed a forged identity. 7. As a standalone deckle user, I want nothing to change for me, so that my commits keep carrying my own git identity. ## 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 example `u123 <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 configured `user.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 serve` gains 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 a `harness.yaml` key. 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 gains `authors`, a non-empty ordered list of mailmap-resolved names. The pending object gains `authors` with 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 through `mailmap.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.file` config; 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 `authors` fields and the identity header's effect on mutating routes. The harness document and AGENTS.md's `harness serve` section: 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-site` starts `harness serve` without 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. - **Server identity rule**: a trusted header yields that author on the resulting commit; an untrusted harness ignores the header; a malformed header is a request error with the coded message; no header falls back to the repository identity, then to the fixed fallback. Prior art: the handler tests that drive routes through `ServeHTTP` against a temporary site and repository. - **gitrepo publish**: a branch-mode publish produces a two-parent commit whose first parent is the old main and second the draft tip, with the expected tree; the draft ref equals the publish commit afterwards; a following save continues from it; reconcile after a developer push keeps draft authors; WIP-mode publish stays single-parent. Prior art: the branch, publish and reconcile tests on the branch fixture. - **gitrepo history**: per-element authors over a merge publish with saves by two identities, only those who touched the element's file are listed; a single-parent entry lists its own author; the pending row lists the draft authors; a mailmap configured via `mailmap.file` resolves names and its removal shows the pseudonym again. Prior art: the existing history tests. - **Server history route**: `authors` present on entries and pending, ordering, de-duplication, the single-author case. Prior art: the existing history route tests. - **Client**: a vitest for the history sheet rendering one and several authors beside the publisher. Prior art: the component tests under the web test directory. - **Drift tests** already in place must stay green: the harness usage text against the harness document, every route against the API document, the README budget and link checks. ## Out of Scope - Any change to deckle-hosted itself; it gets its own issue written from this PRD. - Restoring an earlier version from the history (a later increment, per ADR 0023). - Per-element attribution in WIP mode beyond the single configured identity. - Attribution of hand commits made outside the editor; they show their own git author as today. - Authenticating or authorizing editors in the harness; the host does that, the harness only trusts a header it was told to trust. - Showing display names to developers in their clones; they see pseudonyms unless they configure the mailmap themselves. - Historical backfill: publishes made before this change keep the site identity as their only author. ## Further Notes - Storage is essentially unchanged: branch mode already pushes every draft commit; the merge only keeps them reachable instead of garbage-collectable. - `git log main` without `--first-parent` becomes noisier for developers and in the forge's commit list; this is the price of per-save authorship and the ADR should state it. - The identity header name is part of the host contract and must be chosen so a browser cannot set it through the proxy; the proxy strips it on the way in. - Whether a pseudonymous id counts as personal data after the mailmap line and the user row are gone is a legal question for the operator; the design makes the link removable with one change, which is the most the engine can do.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#295
No description provided.