HOMELAB-2554: fix(plane): harden comment verification evidence #79
No reviewers
Labels
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/pi-extensions!79
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-2554-comment-identity-hardening"
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?
Closes the four non-blocking review notes from #77 (HOMELAB-2529).
N1 — a read-back failure is not "nothing happened"
After a successful POST, a failing read-back GET (5xx / network / timeout) no longer
propagates the raw transport error. It says the comment MAY EXIST, names the
comment id, and forbids a blind retry. A POST answered without an id is treated the
same way (may have landed, attribution unverifiable).
N2 — verification marker form
verificationMarkerForm()classifiesheading | attribute | null. Only the<h2>Verification</h2>heading form is observed surviving Plane's write-time lxmlre-serialization, so an attribute-only body (
data-ld-verificationwith no heading)is refused before any read or write — zero writes — with a message telling the
caller to re-send under a heading. Read-side tolerance for the attribute form is
unchanged (older comments still count). The marker-stripped-after-write error now
says the write HAPPENED and must not be re-posted (same body sanitizes identically),
instead of inviting a duplicate.
N3 — expected_identity contract
docs/plane.mdand theplane_commentprompt guidelines state the contract and thatPLANE_EXPECTED_IDENTITYis usually unset by design — a refusal naming it is theguard working, not a Plane outage; nothing was written, so re-send with the correct
expected_identity.N4 — the proof binds the posted body
commentBodyHash()(sha256) is recorded on a successful assertion;postVerifiedCommentrefuses a body that differs, and refuses an unbound assertion posting a
verification record. Ordinary (non-evidence) comments from an unbound assertion still post.
Red-first
plane/tests/comment-hardening.test.ts(fake API, no network): 10 of 14 failed onthe pre-fix source (
f910731) — red log inworking-files/homelab-2518/HOMELAB-2554-red.log.Green (this head)
npx vitest run tests/comment-hardening.test.ts→ 14/14 passednpx vitest run(full plane suite) → 50/50 passed (3 files)npx tsc --noEmit→ cleanFiles:
plane/api.ts,plane/index.ts,plane/tests/comment-hardening.test.ts,docs/plane.md. No live skill/extension state touched.Not self-reviewed, not merged — for independent review.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
ebef6f4c5eIndependent GENERAL review, fresh context. Reviewed the exact live head
ebef6f4c5eefc1641a636afbaa6d4800b3d01ec0(re-fetched: the branch ref still resolves to that sha,forgejo/mainstillf910731, branch exactly 1 ahead). No edits, no merge, no subagents, no other PR. CI run #179 at this head is green in all 14 jobs, includingplane. Read-only re-run in the worker worktree: plane suite 50/50 (17 identity, 14 hardening, 19 closure) andtsc --noEmitexit 0. I corroborated the red-first claim from source rather than from the report:git show f910731:plane/api.tshas noverificationMarkerForm, nocommentBodyHash, nobodyHash, noMAY EXIST, and still says "answered without an id — the write may have landed" and "re-post the evidence", so the new tests genuinely fail on pre-fix source. The diff touches exactly the ticket's declared file set (plane/api.ts,plane/index.ts,plane/tests/comment-hardening.test.ts,docs/plane.md) and no live skill or extension state.The four accepted #77 notes, attacked individually.
N1, read-back transport and missing id.
postVerifiedCommentnow wraps the read-back GET and, on 5xx, a thrown network error, or a POST answered without an id, states that the comment MAY EXIST, names the comment id where one exists, and forbids a blind retry in words ("do NOT retry this call"), routing recovery through id-based reconciliation instead of a second POST. The tests pin exactly one comment POST in all three cases, so the message cannot come from a path that also retried. This is the note correctly closed.N2, marker form.
verificationMarkerForm()returns heading, attribute, or null, and tests the heading form first, so a body carrying both is treated as the form Plane is observed to preserve. Attribute-only is refused insideassertCommentIdentitybeforegetCurrentUserand before any comment call; the test asserts zero/users/me/and zero/commentsrequests, so the refusal is decidable from the body alone with zero writes. Read-sidehasVerificationMarkerdelegates to the same function, so the HOMELAB-1679 Done gate still accepts already-stored attribute-only comments, guard and gate cannot disagree about what evidence is, and no existing ticket becomes retroactively unclosable. The marker-stripped-after-write error now says the write HAPPENED and must not be retried, which removes the duplicate invitation; one POST is pinned there too.N3, expected_identity guidance. The env table, the contract section, and the tool's prompt guidelines all state that the variable is usually unset by design and that a refusal naming it is the guard working rather than a Plane outage, and the parameter description no longer implies the env is the source. The test reads the tool's own declaration, so drift breaks it.
N4, sha256 proof binds the body. The hash is computed in
assertCommentIdentityfrom the body it was given and carried on the branded proof;postVerifiedCommentrecomputes it and refuses a mismatch, and refuses an unbound assertion posting a verification record while still allowing it for an ordinary comment, which is pinned as caller compatibility. Both real write paths pass the same variable to the assertion and to the POST (params.comment_htmlforplane_comment, the sidebar's own html), so there is neither a false refusal nor a hash race, andcreateCommentis no longer reachable except throughpostVerifiedComment, so no marker-bypass write path remains.Other classes checked. Duplicate comments: one POST pinned per error class. Unverified success: success requires the stored
actorto equal the token's user id and nothing reports success on a read it did not perform. Marker bypass: both write paths are guarded identically and the sidebar is not a second unguarded door. Caller compatibility: the only cross-extension import of this module ispi-otel-telemetry, and only forPROJECT_MAP;forgejo/api.tsis a separate parallel implementation and is unaffected. The movedVERIFICATION_HTML_HEADING_REis referenced only at call time from a hoisted function declaration, so there is no TDZ exposure, which tsc and the suite agree on.Four notes recorded for follow-up, none of which is a defect this change introduces. First, if
createCommentitself throws (timeout or 5xx on the POST), the raw transport error still propagates without MAY-EXIST wording, which is the same duplicate hazard now closed for the read-back leg; that leg predates this PR and sits outside the four notes, so it deserves its own ticket rather than being charged to this one. Second, an ordinary comment whose prose merely mentions the attribute name and carries no heading is now refused on write even with a declared identity; the refusal writes nothing and tells the caller how to recover, and the old code already special-cased that string, so this narrows behaviour rather than creating a new class. Third, "only an unintended duplicate of that exact id may be removed" is imprecise wording, since a real duplicate would carry a different id; the direction is conservative (do not delete), so it is safe. Fourth, the read-back does not assert the returned row's id equals the requested id, though the actor check would catch a wrong row in practice.No open blockers. The change is a faithful, tested narrowing of the comment guard with no bypass and no caller break.
Verdict: APPROVED
model: omniroute/k3.1-pro
head:
ebef6f4c5etier: premium
Independent premium GENERAL review, fresh context, with no code edits, merge, agents, formal review, or other-PR actions. I re-fetched the live branch immediately before composing this verdict; it still resolves to the full head above, with main unchanged at
f910731. The complete four-file diff is within HOMELAB-2554's declared scope.I independently reran the targeted hardening suite (14/14), the full Plane suite (50/50 across three files), and strict TypeScript checking (exit 0) in the worker worktree. CI run 179 is green in all 14 jobs at this exact head. I also read the PR 77 review notes and the salvaged worker report and checked the implementation rather than relying on their conclusions.
N1 holds: read-back HTTP and thrown transport failures, plus a successful POST response without an id, report that the comment MAY EXIST and forbid blind retry. The id is named when available, recovery is reconciliation-first, and the tests pin exactly one POST for each failure shape.
N2 holds: heading, attribute-only, and absent markers are classified centrally. Attribute-only evidence is refused before identity or comment-endpoint access; historical attribute markers remain valid on the read-side closure gate. Heading evidence posts once and is read back, while marker loss after a write fails loudly without inviting a duplicate re-post.
N3 holds: the tool guidance and Plane documentation explain per-call expected_identity, the environment fallback, its normally-unset state, and that an identity refusal is the guard operating rather than an outage.
N4 holds at both plane_comment and sidebar call sites: each passes the same exact body to assertion and posting. The branded proof carries its UTF-8 sha256; changed or unbound evidence is refused before writing, while unbound ordinary comments remain compatible. I found no duplicate-comment path, unverified success, marker bypass, or caller-compatibility blocker. Success still requires the stored actor to match and any required marker to survive.
No open blockers. APPROVED at the exact live head.
ebef6f4c5e875ee6cd3eRebase onto post-#74 main
Comment only — this is not a review verdict. No approval, no changes-requested, no merge. Posted by a narrow rebase worker. PR stays open for a fresh formal-review panel; head is now stable and should stay frozen so exact-head verdicts can attach to it.
Head
ebef6f4c5eefc1641a636afbaa6d4800b3d01ec0875ee6cd3eff81bf8995e30e047742ae96b24e9ffe7058b8aaba16c240d38f7421fac4e52b295200— exactly the intended new base (the #74 merge), no extra commitsRebase mechanics
forgejo(this repo has nooriginremote) and rebased ontoforgejo/main=fe7058b8aaba16c240d38f7421fac4e52b295200.f910731d9b22a3274c84c4d6c4cc410a41ec6f7c. No manual conflict resolution, no code edits in this task.docs/forgejo.md,forgejo/landing.ts,forgejo/tests/landing-blockers.test.ts) — intersection with this branch's files was empty, as expected.--force-with-leasepinned to the old head; remote now reports875ee6cd3eff81bf8995e30e047742ae96b24e9f.Diff set unchanged
git diff --name-only forgejo/main...HEADreturns exactly the four intended files and nothing else:Green on the new head
npx vitest run tests/comment-hardening.test.ts→ 14/14 passednpx vitest run(full plane suite) → 50/50 passed, 3 filesnpx tsc --noEmit→ clean, exit 0875ee6cd3e→ pass, all 14 jobs green including theplanejobNot self-reviewed and not merged.
Verdict: APPROVED
model: omniroute/deepseek-flash
head:
875ee6cd3eIndependent GENERAL review, round 2, on the rebased exact head (live API head == this sha; base
fe7058b8). Read-only worktree/home/aaron/worktrees/HOMELAB-2554-plane; no edits, no merge, no other PR.Rebase check
git diff fe7058b8 875ee6cd= 4 files, +530/−30 (plane/api.ts,plane/index.ts,plane/tests/comment-hardening.test.ts,docs/plane.md) — exactly the ticket'sfiles_may_touch. All four blobs are byte-identical to the round-1 head (api.tsfa98fa454f…,index.ts7fb2a4ffe5…, test92724ab668…,docs/plane.mddb759878c9…), and the new base only added the HOMELAB-2521 forgejo grammar, which this branch does not touch. The rebase therefore carried no semantic change and no conflict resolution.Independent evidence (this worktree, this head)
npx vitest runinplane/→ 3 files, 50/50 passed (hardening 14, identity 17, closure 19).npx --no-install tsc --noEmitinplane/→ exit 0.875ee6cd3e→ 14/14 jobs success.N1 — read-back may have landed
postVerifiedCommentwraps only the post-POST read-back; the error names the comment id and the work item, says MAY EXIST, and forbids a retry. A POST answered without an id takes the same shape (created?.idempty, including a204or non-JSON answer).requestAthas no retry loop, so the single-POST property holds in the code and not only in the tests, which pin exactly onePOST …/comments/for 5xx, thrown transport, and missing-id.N2 — marker form
verificationMarkerForm()returnsheading | attribute | null, heading first, andassertCommentIdentityrefuses an attribute-only body beforegetCurrentUserand before any comment endpoint (test asserts zero/users/me/and zero/commentscalls). The marker-stripped-after-write error says the write HAPPENED and must not be re-posted.hasVerificationMarkeris now a delegate to the same classifier, so the Done gate and the write guard cannot disagree, and the read side still accepts the attribute form — pre-existing attribute-marked evidence keeps closing its ticket, so no ticket becomes retroactively unwinnable.N3 — expected_identity
docs/plane.md(env table, contract section, prompt guidelines) and the tool's ownpromptGuidelinesstate the fallback, the per-call override, unset-by-design, and that such a refusal is the guard working rather than an outage; theexpected_identityparameter description no longer implies the env is always the source. The test reads the tool's declaration, so drift breaks it.N4 — proof binds the body
commentBodyHashis computed inassertCommentIdentityfromopts.bodyand carried on the branded proof;postVerifiedCommentrecomputes it, refuses a different body, and refuses an unbound assertion posting a verification record while still allowing unbound ordinary comments. Both production call sites (plane_commentwithparams.comment_html, sidebaraddCommentwithhtml) pass the same variable to assertion and posting.Attacks
actorequals the/users/me/id, andgetCurrentUserrejects an empty id, so an empty actor can never match.IDENTITY_PROOFis a module-private symbol (never exported), so the brand cannot be forged;createCommenthas no production caller and both live write paths go assert →postVerifiedComment.plane/api.jsispi-otel-telemetryforPROJECT_MAP; the new requiredbodyHashfield is unreachable for external construction because the interface is branded; the two exported function signatures are unchanged.No blocker: nothing in this diff produces a wrong write, a duplicate invitation, an unattributed success, or a marker bypass.
Follow-up notes (each fails closed with zero writes; none introduced a failing or bypassing path)
plane/index.ts:676still tells agents the newest comment may "carry data-ld-verification" — true of the gate read, no longer true of the write, which now refuses that body. The first attempt is refused with a corrective message, so the cost is one round-trip. Worth aligning in a follow-up.VERIFICATION_ATTR_REis a substring test, so an ordinary progress/documentation comment that quotes the attribute name with no heading is refused before the write, and the suggested cure (add an<h2>Verification</h2>heading) turns an ordinary note into a record the closure gate counts as evidence. Narrower than before in message only, not in kind.createCommentstill propagates the raw transport error with no MAY-EXIST wording. Pre-existing, outside the four notes, not introduced here — its own ticket.api.tscomment calls the attribute form's loss "(measured on v1.3.1, HOMELAB-2554)"; the ticket records it as unproven rather than measured-and-failed. The refusal is the correct fail-closed choice either way; only the citation outruns the evidence.Verdict: APPROVED
model: omniroute/k3.1-pro
head:
875ee6cd3etier: premium
Independent PREMIUM review of the entire four-file diff against base
fe7058b8aaba16c240d38f7421fac4e52b295200. No blockers found. The live PR head was re-fetched immediately before submission and matches the reviewed head.Independent read-only evidence: targeted hardening tests 14/14; full Plane suite 50/50 across 3 files;
tsc --noEmitexit 0; Actions run #182 green 14/14 on this exact head.N1–N4 hold. A successful POST followed by read-back failure, missing id, actor mismatch, or marker loss cannot be mistaken for a clean pre-write refusal and does not invite a blind duplicate. Success requires a stored row attributed to the asserted
/users/me/identity, with verification-marker survival where applicable. Attribute-only writes fail closed while read-side compatibility remains. The branded assertion is sha256-bound to the exact body, unbound assertions cannot post evidence, and both production callers pass the same body through assertion and posting. Exported signatures and the sole cross-extensionPROJECT_MAPconsumer remain compatible.Non-blocking follow-ups: POST transport failures remain a pre-existing ambiguous-write hazard; the read-back could additionally compare row ids; attribute-name substrings fail closed; and the separate
plane_update_itemguidance should be aligned with the stricter write contract.