1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
|
#+TITLE: pearl — Delete Current Comment Spec
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-25
#+STARTUP: showall
* Status
*Ready — reviews incorporated through round 3, live API verified (Codex + Craig, 2026-05-25).* The one gate (the =commentDelete= contract) is resolved by a live check — see Resolved API Facts. The dirty-local-delete policy is final (allow + discard-local-edits wording). No open questions remain; implementation can start. Modified recommendations are in Review Dispositions.
Companion to [[file:issue-comment-editing-spec.org][issue-comment-editing-spec.org]] (the comment surface, viewer identity, and per-comment drawer this builds on).
* Problem
Pearl can render, create, and edit comments, but it can't delete one. The comment surface is =pearl-add-comment= (create), =pearl-edit-current-comment= (update your own), and the rendered headings — there is no delete. Removing a comment means leaving Emacs for the Linear web app. The Stage 2 keymap even leaves a natural empty slot for it (=C-; L d c=, "delete comment", beside =d t= "delete ticket").
* Current State
- *Comments render* as headings under the =**** Comments= subtree, each carrying a drawer: =LINEAR-COMMENT-ID=, =LINEAR-COMMENT-AUTHOR-ID=, =LINEAR-COMMENT-SHA256=.
- *Ownership* is decided by =pearl--comment-editable-p= (author-id = viewer-id; a nil/empty author-id, meaning a bot or external comment, is never editable). The viewer is resolved by =pearl--viewer-async= and cached in =pearl--cache-viewer=.
- *Edit* (=pearl-edit-current-comment=) reads =LINEAR-COMMENT-ID= via =pearl--goto-heading-or-error=, refuses a non-own comment with no delete/update mutation, and pushes via =pearl--update-comment-async= (=commentUpdate=). It also computes a local-dirty check: =hash(pearl--org-to-md body) /= LINEAR-COMMENT-SHA256=.
- *Issue delete* (=pearl-delete-current-issue=) is the shape to follow: capture a marker, confirm with =yes-or-no-p=, fire =pearl--delete-issue-async=, and on success remove the subtree (=org-back-to-heading= -> =org-end-of-subtree= -> =delete-region=) and surface the buffer.
- *No comment delete exists* — no command, no =commentDelete= mutation, no binding. The keymap test currently asserts =C-; L d c= is intentionally unbound.
* Resolved API Facts (live check, 2026-05-25)
Verified against the live workspace by creating a throwaway issue + comment, running the raw mutation, and querying the comment back:
- *Mutation:* =commentDelete(id: String!)= returns a payload with =success=. The live call returned ={"data":{"commentDelete":{"success":true}}}=. So =pearl--delete-comment-async= mirrors =pearl--delete-issue-async= exactly: callback with =(:success BOOL)=, error branch -> =(:success nil)=.
- *Recoverability:* querying =comment(id){ ... }= immediately after the delete returned ="Entity not found: Comment"= (=INPUT_ERROR=, 400) — *not* the soft-archive the published-schema =Comment.archivedAt= field had suggested. There is *no API restore path*: once deleted, the comment is gone from the API and Pearl cannot bring it back. (Linear's web UI may offer a momentary undo toast; that is not reachable from Pearl.)
- *Consequence:* undo/restore from Pearl is *impossible*, not merely deferred (it moves from "vNext if recoverable" to permanently out of scope). The prompt does not promise recoverability.
- *Permission / not-found behavior* was not exercised destructively beyond the own-comment success path; the permission gate (own-only, below) prevents a non-own =commentDelete= call in the first place, and a not-found id returns the same error shape, handled as =(:success nil)=.
* Proposed Design
** Command: =pearl-delete-current-comment=
Runs from the comment heading or its body text before any child heading (the reach of =pearl--goto-heading-or-error=, matching =pearl-edit-current-comment=; it does not climb out of a nested Org heading authored inside the comment body — see Review Dispositions MP2). The flow:
1. *Locate the comment.* =pearl--goto-heading-or-error=, then read =LINEAR-COMMENT-ID=. If absent, =user-error= "Not on a Linear comment" with no mutation.
2. *Permission gate (no delete mutation for non-own).* Resolve the viewer (=pearl--viewer-async=, reusing the cache; a cold cache costs one read-only lookup, not a mutation). If viewer resolution fails, refuse with no =commentDelete= call and the message "Could not determine your Linear identity; not deleting". If =LINEAR-COMMENT-AUTHOR-ID= is not the viewer (=pearl--comment-editable-p= nil), refuse with no =commentDelete= call ("You can only delete your own comments"). This matches edit's permission model.
3. *Local-dirty check, then confirm.* Compute =hash(pearl--org-to-md body)= against =LINEAR-COMMENT-SHA256=. Match (clean) -> standard wording. Differ (the user edited the body and is deleting instead of saving) -> the stronger discard-local-edits wording. *Missing* =LINEAR-COMMENT-SHA256= (a hand-edited or partially generated comment with no stored hash) -> treat as unknown provenance and take the stronger wording too, since the local state can't be confirmed clean. Delete proceeds on confirmation in every case (decision 2). =yes-or-no-p=, mirroring =pearl-delete-current-issue=.
4. *Delete.* Fire =pearl--delete-comment-async= (=commentDelete=). On =:success=, remove the comment's Org subtree (=org-back-to-heading= -> =org-end-of-subtree t t= -> =delete-region=) and surface the buffer. On failure, message and leave the subtree intact.
No three-way conflict gate. A delete is unconditional against the remote — it doesn't merge, so remote drift since fetch doesn't change the outcome. The permission gate, the local-dirty wording, and the confirmation are the guards.
*Prompt wording* (final, given the resolved recoverability) always names both the remote effect and the local removal:
- clean comment: "Delete this comment from Linear and remove it here?"
- dirty or unknown-provenance comment: "Delete this comment from Linear and discard your local edits here?"
Plain "Delete" rather than "Permanently delete": Pearl has no restore path (Resolved API Facts), but we don't claim permanence Linear's own UI may not honor. "remove it here" + the no-undo reality is the honest threshold.
Both name Linear and the local effect, so neither the remote delete nor a discarded local edit can be missed.
** Async mutation: =pearl--delete-comment-async=
Mirrors =pearl--delete-issue-async= and =pearl--update-comment-async=: =commentDelete(id: String!) { success }= (verified — see Resolved API Facts), callback with =(:success BOOL)=, error branch -> =(:success nil)=.
** Callback safety
The local removal operates on a marker captured *before* the async dispatch. The success callback checks the marker's buffer is still live and does nothing (no signal) if it was killed before the callback returned. At most one viewer lookup and one delete mutation per invocation.
** Immediate, not part of the save model
Delete is an immediate mutation, like the field setters and =pearl-delete-current-issue=. It does not fold into =pearl-save-issue= / =pearl-save-all=. It surfaces under the delete prefix and the transient for discoverability only.
** Surfaces
- *Keymap (Stage 2 slot):* =C-; L d c= -> =pearl-delete-current-comment=, label "delete comment", beside =d t= "delete ticket". The keymap test that asserts =d c= is unbound is inverted when this ships.
- *Transient:* =K= "delete comment" under the Delete group (=k= is "delete ticket"; =K= pairs with it).
* Agreed Decisions
1. Own-only, mirroring =pearl-edit-current-comment='s permission model; a non-own / bot / external comment is refused with no =commentDelete= mutation.
2. Dirty local comment (HP2 — *final*, Craig 2026-05-25): *allow* the delete, switching the confirmation to discard-local-edits wording when =hash(org->md body) /= LINEAR-COMMENT-SHA256= or the hash is missing (unknown provenance). This matches the immediate-delete model rather than refusing. (The rejected alternative was to refuse dirty comments and tell the user to save/revert/refresh first.)
3. "No delete mutation for non-own" is the precise guarantee, not "no network" — a cold viewer cache costs one read-only lookup (MP1).
4. Point reach is the comment heading or its pre-child body, matching current helpers; the ancestor-climbing helper is vNext (MP2).
5. Transient key =K=; keymap slot =C-; L d c= (MP3).
* Open Questions (Craig)
None — all resolved. The last gate (the live =commentDelete= contract) was verified 2026-05-25; the dirty-delete policy (decision 2) and the transient key =K= were already settled.
* Files Touched
- =pearl.el=: =pearl-delete-current-comment= (command), =pearl--delete-comment-async= (mutation), the transient =K= entry, the =C-; L d c= keymap binding.
- =docs/=: this spec.
- =README.org=: the comment section gains "delete your own comments".
- =tests/=: =tests/test-pearl-comment-deletion.el= (or additions to =test-pearl-delete.el=).
* Test Plan
- =pearl--delete-comment-async= success parses the =commentDelete= success payload; a false / GraphQL-error body returns =(:success nil)=.
- Not on a comment / missing =LINEAR-COMMENT-ID= -> =user-error=, no delete mutation.
- Own comment + confirmation accepted + success -> removes only that comment's subtree; sibling comments and the issue body intact.
- Confirmation declined -> no delete mutation, subtree intact.
- Non-own and bot/external comments -> no delete mutation (permission gate).
- Delete mutation failure (=:success nil=) -> subtree intact, failure reported.
- Dirty local comment -> the discard-wording confirmation path (decision 2); a clean comment -> the standard wording. The dirty prompt names both "Linear" and the local-edit discard.
- Missing =LINEAR-COMMENT-SHA256= -> takes the stronger (unknown-provenance) prompt, not the clean path.
- Viewer resolution failure -> the exact refusal message "Could not determine your Linear identity; not deleting", no delete mutation.
- Killed-buffer / lost-marker safety -> if the marker's buffer is gone when the callback fires, no signal and no attempt to surface a dead buffer.
- Keymap: =C-; L d c= binds =pearl-delete-current-comment= (inverts the current "no delete comment yet" assertion); transient contains the =K= delete-comment entry.
- Live-verified 2026-05-25: =commentDelete(id){success}= returned =success: true=, and the comment was not-found immediately afterward (no restore). The only remaining manual check is that a real own-comment delete removes the *right* comment subtree in a live buffer.
* Review Dispositions
*Round 1 (Codex, 2026-05-25).* Rubric =Needs research=. Accepted as written: HP1 (verify =commentDelete= before coding — recast as a blocking Implementation Prerequisite, with the partial published-schema finding recorded), MP1 ("no delete mutation for non-own", reworded throughout), MP3 (finalize =K= + the keymap slot), and the architecture / robustness / test-strategy / UX observations (callback-safety rule, prompt naming both effects, the expanded test plan). Two were modified:
- *HP2 (local dirty comment) — modified to a choice.* The review offered two coherent options (allow-with-discard-wording, or refuse-dirty). Chose allow-with-discard-wording as the v1 decision because it matches the immediate-delete model — an explicit delete is already destructive, so discarding an unsaved body edit with clear wording is consistent, where refusing would make delete the one comment command that blocks on dirtiness. Flagged for Craig's confirmation since it's a data-loss call.
- *MP2 (point location) — modified to scope.* Adopted the narrow promise (reach = the comment heading or its pre-child body, matching =pearl--goto-heading-or-error=) rather than building the ancestor-climbing helper now. The shared climbing helper is recorded as a vNext candidate; building it here would widen the change beyond a thin delete command for a UX (nested headings inside a comment body) that isn't in use.
Everything else accepted as written.
*Round 2 (Codex, 2026-05-25).* Rubric still =Needs research= — HP1 (the live =commentDelete= check) is the one hard gate and stays a named prerequisite, unchanged. HP2 (the dirty-delete decided-and-also-open contradiction the response introduced) is resolved: Craig finalized allow-with-discard-wording, so it's now a settled decision and out of Open Questions. MP1 (prompt names both Linear and the local discard), MP2 (missing =LINEAR-COMMENT-SHA256= = unknown provenance -> stronger prompt + test), and MP3 (restore the exact viewer-failure message + test) accepted as written. No modifications or rejects this round.
*Round 3 (Codex, 2026-05-25).* Rubric =Needs research=, sole blocker HP1 (the live =commentDelete= check), no other edits requested. Resolved by running the live check: created a throwaway issue + comment, ran =commentDelete= (=success: true=), confirmed the comment is not-found afterward (no API restore), and removed the test issue. Folded the verified contract into Resolved API Facts, finalized the prompt verb (plain "Delete"), and moved undo/restore from conditional-vNext to permanently out of scope. Rubric -> =Ready=.
* Out of Scope
- Bulk comment deletion.
- Undo / restore of a deleted comment from Emacs — *impossible*, not deferred: the live check found no API restore path (the comment is not-found immediately after =commentDelete=).
- A shared "find enclosing comment heading" helper that works from nested Org headings inside a comment body (vNext; would be shared with =pearl-edit-current-comment=).
- Deleting comments authored by others (Linear permissions forbid it; the gate enforces own-only).
|