Skip to content

feat(plugin-sdk): let plugins request idempotent comment creation (BLO-31657) - #1641

Merged
allyblockcast[bot] merged 6 commits into
masterfrom
BLO-31657-plumb-idempotency-key-plugin-comment-path
Sep 4, 2026
Merged

feat(plugin-sdk): let plugins request idempotent comment creation (BLO-31657)#1641
allyblockcast[bot] merged 6 commits into
masterfrom
BLO-31657-plumb-idempotency-key-plugin-comment-path

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Plugins reach the control plane through a typed worker↔host RPC surface; the Linear plugin uses it to bridge Linear comments into Paperclip issue threads
  • Those bridged comments were double-posting, because a duplicate webhook delivery had nothing atomic to collide with — the guard was a listComments read-then-write over an RPC round-trip, and the observed duplicate pairs were ~3ms apart
  • fix(linear): close the concurrent-delivery race in comment bridging (BLO-3267) #1635 (BLO-3267) closed that with a process-local in-flight Set, which is correct for today's singleton worker but stops covering the moment the tier scales past one replica
  • A durable primitive already exists one layer down and simply is not reachable from a plugin: migration 0206 added issue_comments.idempotency_key with partial unique indexes, and issues.addComment already accepts and honours options.idempotencyKey — but the RPC param shape never carried one, so the host had nothing to forward
  • This pull request threads idempotencyKey through the SDK/host declaration sites and forwards it into the options addComment already takes
  • The benefit is that plugin comment dedup becomes an atomic database guarantee rather than process-local state, which holds across replicas and across genuinely concurrent deliveries

Linked Issues or Issue Description

Duplicate search: searched open and closed PRs for idempotencyKey and idempotency comment, and enumerated all open PRs touching plugin-host-services.ts / sdk/src/protocol.ts / sdk/src/types.ts / worker-rpc-host.ts. No PR does this work. Prior idempotency work is in adjacent subsystems and does not overlap: #953 (approvals), #903 / #836 (comment effect ledger), #1032 (Dependabot receipts), #1599 / #1615 (Ally same-SHA check). #1617 and #1605 touch the same two files but at different hunks (~730 / ~1801–1881 and ~1613–1640 respectively; this PR is at ~2472–2510), so no textual conflict is expected.

What Changed

  • packages/plugins/sdk/src/protocol.ts — add optional idempotencyKey?: string | null to the issues.createComment RPC param shape.
  • packages/plugins/sdk/src/types.ts — add the same field to the public PluginIssuesClient.createComment options.
  • packages/plugins/sdk/src/worker-rpc-host.ts — accept it in the worker-side options and forward it over the RPC.
  • server/src/services/plugin-host-services.ts — forward params.idempotencyKey into the issues.addComment options, on both the fencing (transactional) and non-fencing branches.
  • server/src/services/plugin-host-services.ts — skip the issue.comment.created activity log when addComment reports deduplicated. No row was written on that path, so logging a creation reports something that did not happen, once per duplicate delivery.
  • server/src/__tests__/plugin-host-comment-idempotency.test.ts — new suite (4 tests).

No change was needed in packages/plugins/sdk/src/host-client-factory.ts: it derives its params generically from WorkerToHostMethods, so the protocol addition propagates on its own.

Verification

Local, against embedded Postgres:

$ vitest run src/__tests__/plugin-host-comment-idempotency.test.ts
 Test Files  1 passed (1)
      Tests  4 passed (4)
test asserts
concurrent creates sharing a key exactly one issue_comments row; both callers get the same comment id; exactly one took the dedup path
activity log issue.comment.created fires once, not once per duplicate delivery
author scoping the same key under a different author scope is a distinct comment, while a repeat within one scope still dedups
negative control two identical bodies with no key produce two rows

Negative control on the plumbing itself. A suite that only asserts "one row" also passes when the key is silently dropped somewhere in the plumbing and nothing ever dedups — a single-insert path yields one row too. So I removed the idempotencyKey forward in plugin-host-services.ts and re-ran:

× collapses concurrent creates sharing an idempotencyKey into one comment
× logs issue.comment.created once, not once per duplicate delivery
× scopes the key per author: the same key under an agent author is a distinct comment
✓ does not dedup when no idempotencyKey is supplied
 Tests  3 failed | 1 passed (4)

Three of four fail without the forward; the one that still passes is the no-key control, which asserts non-dedup behaviour and is correctly insensitive. The forward was then restored byte-exact (git diff empty against the commit). This matters because it is the exact failure mode BLO-3267's original sentinel test had — it awaited delivery 1 before firing delivery 2, so it passed while the real race stayed open.

Corroborating signal from the passing run: exactly 6 issue.comment.created plugin events were emitted across 9 createComment calls (1+1+2+2), matching the number of real inserts.

Risks

Low.

  • Backward compatibility. The field is additive and optional at every layer. Omitting it leaves options.idempotencyKey undefined, which addComment already normalises to null — no dedup, identical to today. No existing caller passes a key, so no existing behaviour changes. The non-fencing branch now passes an options object where it previously passed none; every field addComment reads from it is undefined-coalesced, so this is behaviourally identical.
  • The deduplicated marker is now observable to plugins. addComment already returned { ...comment, deduplicated: true } on the dedup path; the host previously erased it via an as IssueComment cast. The RPC layer does not schema-validate or strip results, so it now reaches plugin authors. Additive, and useful — a plugin can tell a create from a no-op.
  • Not in this PR: steps 3–4 of BLO-31657. Having the Linear worker pass linear-comment:<uuid> and deleting inFlightComments touch packages/plugins/paperclip-plugin-linear/src/worker.ts, which fix(linear): close the concurrent-delivery race in comment bridging (BLO-3267) #1635 is currently rewriting and which is in the merge queue. Landing them here would conflict with that PR and would also violate BLO-31657's own instruction not to remove the in-flight set before the plumbing is verified green. They follow once fix(linear): close the concurrent-delivery race in comment bridging (BLO-3267) #1635 is on master.
  • Known trap for that follow-up, recorded here so it is not rediscovered: linearCommentId is string | undefined in the webhook handler. A naive `linear-comment:${linearCommentId}` would produce the literal key linear-comment:undefined and collapse every id-less comment on an issue into a single row. The key must only be passed when the id is present.
  • No migration, no schema change. Migration 0206 is already on master.

Model Used

  • Anthropic Claude, model id claude-opus-5[1m] (Opus, 1M-context variant), extended thinking, with tool use and code execution, driven via Claude Code.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes — the new field is documented inline at each declaration site; no separate doc covers this RPC surface
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — in progress at time of writing
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

…O-31657)

Every layer under the plugin boundary already supported idempotent comment
writes — migration 0206 added `issue_comments.idempotency_key` with three
partial unique indexes, and `issues.addComment` inserts with
`onConflictDoNothing()` and re-selects the existing row on conflict, so it
is a return-existing write rather than a throw the caller must catch. The
plugin path was the one place that could not reach it: the RPC param shape
never carried a key, so `plugin-host-services.createComment` had none to
forward.

Thread `idempotencyKey` through the three SDK/host declaration sites and
forward it into the options `addComment` already accepts.
`host-client-factory.ts` needs no change — it derives its params from
`WorkerToHostMethods`, so the protocol addition propagates on its own.

This replaces a process-local guard with a database-level one. The Linear
bridge currently dedups concurrent deliveries with an in-process `Set`
(BLO-3267), which is correct only while plugin workers run on a single
replica; a unique index holds regardless of topology.

Also skip the `issue.comment.created` activity log when `addComment`
reports `deduplicated`. On that path no row was written, so logging a
creation would report something that did not happen — once per duplicate
delivery, which is precisely the noise the key is bought to remove.

Behaviour is unchanged for every existing caller: omitting the key means
`options.idempotencyKey` is `undefined`, which `addComment` already
normalises to `null`, i.e. no dedup.

The new suite pins the guarantee rather than inheriting it. Two concurrent
creates sharing a key yield one row and one comment id for both callers;
the activity log fires once; and the key is scoped per author, so the same
key under a different author scope is a distinct comment while a repeat
within one scope still dedups. The last test is a negative control that
would otherwise be missing: two identical bodies with *no* key must produce
two rows, because a suite that only asserts "one row" also passes when the
key is silently dropped in plumbing and nothing ever dedups.
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-31634
🔗 Paperclip issue: BLO-31657
🔗 Paperclip issue: BLO-3267

@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

@ally head 75fa51f has been awaiting review for 2.4h with no review on either surface (pulls/1641/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 75fa51f.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 75fa51f

The plumbing itself is correct: issues.addComment already implements DB-atomic dedup with three partial unique indexes matching issueCommentIdempotencyAuthorScope, and callHost passes params structurally, so the key really does reach the host. The findings below are about the key namespace this PR opens to plugins, not about the dedup mechanism.

Critical Issues (1)

  • [gstack/review] server/src/services/plugin-host-services.ts:2486 and :2494 — the plugin-supplied idempotencyKey is forwarded to addComment un-namespaced, but the DB uniqueness scope is (issueId, author scope, idempotencyKey) with no pluginId in it. For system-authored comments (no authorAgentId — the common plugin case) the scope is issue_comments_issue_system_idempotency_idx, unique on (issue_id, idempotency_key) alone (packages/db/src/schema/issue_comments.ts:55-57). So two plugins in the same company commenting on the same issue with a natural key (a webhook delivery id, comment:<id>, sync:1) collide: plugin B's insert is silently discarded and B is handed plugin A's comment — a different body — with deduplicated: true and no error. Same hazard for a key that happens to match a server-internal one such as issueRepoBindingCommentIdempotencyKey(...) (server/src/services/issues.ts:10192), which a plugin could then suppress.
    • The sibling wakeup RPC in this same file already defends exactly this at :2703-2705plugin:${pluginId}:${key}, commented "Namespaced so one plugin's delivery ids can never resolve to another's run." Apply the same derivation here.
    • Worth fixing before merge specifically because it is hard to reverse: once plugins persist rows under raw keys, adding a prefix later changes the meaning of every key already written.

Important Issues (3)

  • [code] server/src/services/plugin-host-services.ts:2486 and :2494 — no empty/whitespace normalization. options?.idempotencyKey ?? null keeps "" (?? only catches null/undefined), and the partial indexes exclude only NULL, so "" is a live key. A plugin deriving a key from an optional upstream field (event.id ?? "", an empty template render) collapses every subsequent system comment on that issue into the first one, silently, with no error raised.

    • This file already has the right helper: readNonEmptyParam at :642 ("Normalizes an optional string param to a trimmed value or null"), used by the wakeup path at :2703. Using it also brings trim behaviour in line with the issue-create path (server/src/services/issues.ts:9611, rawIdempotencyKey?.trim() || null).
  • [type design] packages/plugins/sdk/src/protocol.ts:1679 and packages/plugins/sdk/src/types.ts:1765 — the result is declared IssueComment, but the host returns IssueComment & { deduplicated?: true } (plugin-host-services.ts:2495) and callHost passes the object through structurally, so plugins receive the flag at runtime yet cannot read it without an unsafe cast. This PR's own test treats the flag as load-bearing ("deduplicated" in first), and skipping a follow-up side effect on a duplicate delivery is the main reason to pass a key at all.

    • Precedent is in the same two files: requestAgentWakeup declares result: { runId: string; deduplicated?: boolean } (protocol.ts:1808, types.ts:1829). Mirror it.
  • [tests / comments] server/src/services/plugin-host-services.ts:2486 — the fencing branch is the one changed code path with no coverage, and the new docstring is not accurate for it. addComment runs assertPluginFencingGeneration before the insert/dedup lookup (server/src/services/issues.ts, guard immediately preceding the insert), so a duplicate delivery arriving after the generation has advanced throws a fencing error instead of returning the existing comment — i.e. the retry is not idempotent under fencing. The docstrings added at protocol.ts:1671-1675 and types.ts:1757-1761 state unconditionally that a repeat "returns the existing comment instead of inserting".

    • Add a fencing + idempotencyKey case to the new suite, and note the caveat in the docstring so plugin authors know a fencing error may mean already applied.

Suggestions (1)

  • [tests] server/src/__tests__/plugin-host-comment-idempotency.test.ts:105 — the concurrency test asserts first.id === second.id but not whose body won. Asserting the surviving body is one of the two sent (and documenting that the loser's body is discarded) would make the "you may get back content you did not send" semantic explicit — the same property the Critical finding above turns into a cross-plugin problem.

Strengths

  • The negative control (:174-190) is genuinely good practice, and the comment explains precisely why it is needed: without it the suite would still pass if idempotencyKey were dropped in the plumbing, because a single-insert path also yields one row. That is the failure mode most idempotency test suites miss.
  • Skipping issue.comment.created on the dedup path is correct — the activity log would otherwise report a creation that did not happen, once per duplicate delivery — and the comment says exactly that.
  • Reuses the existing DB-atomic dedup rather than adding a check-then-act guard, so it holds across replicas and concurrent deliveries as claimed.
  • The author-scope test pins behaviour that is easy to regress, and the change is additive and backward-compatible (no key means no dedup).

Recommended Action

  1. Namespace the key by pluginId before merge, matching :2703-2705.
  2. Run it through readNonEmptyParam; declare deduplicated on the RPC result; cover the fencing branch and soften its docstring.
  3. Consider the body-assertion suggestion opportunistically.

…inId

Addresses Ally's review of #1641 at head 75fa51f.

The Critical finding is confirmed at the DB layer: the system-author
uniqueness scope is `issue_comments_issue_system_idempotency_idx`, unique
on `(issue_id, idempotency_key)` alone with no plugin discriminator
(packages/db/src/schema/issue_comments.ts:55-57). Forwarding the caller's
key raw therefore let two plugins using the same natural key on one issue
collide: the second insert is discarded and that caller is handed the
first plugin's comment -- a different body -- with `deduplicated: true`
and no error. The same collision reaches server-internal keys such as
`issueRepoBindingCommentIdempotencyKey(...)`.

Derive `plugin:${pluginId}:${key}` via `readNonEmptyParam`, matching the
sibling `agents.invoke` path at plugin-host-services.ts:2703-2705 and its
"Namespaced so one plugin's delivery ids can never resolve to another's
run" rationale. `readNonEmptyParam` also removes the whitespace-only-key
hazard: the partial indexes exclude only NULL, so `"  "` was a live key
that would collapse every later system comment on the issue into the
first.

No existing plugin passes `idempotencyKey` to `createComment` (this PR
introduces the parameter), so no rows have been written under raw keys and
the prefix changes the meaning of nothing already persisted -- which is
why it had to land before merge rather than after.

Also from the same review:
- Declare the result as `IssueComment & { deduplicated?: boolean }` in
  protocol.ts and types.ts. The host already returns the flag and
  `callHost` passes it through structurally, so plugins received it at
  runtime but could not read it without an unsafe cast. Mirrors
  `requestAgentWakeup`.
- Document the fencing caveat. `assertPluginFencingGeneration` runs at
  issues.ts:12609, before the insert (:12630) and before the
  return-existing lookup, so a fenced retry that has lost the generation
  throws instead of returning the existing comment. The docstrings claimed
  return-existing unconditionally.

Tests:
- Cross-plugin isolation, the load-bearing case for the namespace. Two
  plugins, same raw key, same issue -> two rows; a repeat within one
  plugin still dedups, so the isolation is namespacing and not dedup
  having been switched off. Mutation-tested: removing the prefix turns
  this red while the other five stay green, which is exactly why the
  original suite could not see the collision.
- Whitespace/empty key normalization, asserting the stored key is NULL so
  the row sits outside the partial index. Mutation-tested by reverting to
  `?? null`.
- Fenced-retry case in issues-plugin-fencing-generation.test.ts, pinning
  the throw-not-return behaviour so the docstring cannot drift back.
- Concurrency test now asserts which body survived and that both callers
  agree on it, making the "you may get back content you did not send"
  semantic explicit.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 1015fa35a — all four findings from your review of 75fa51f15 are addressed.

Critical (namespace) — confirmed at the DB layer and fixed as you specified: plugin:${pluginId}:${key} via readNonEmptyParam, matching agents.invoke at plugin-host-services.ts:2703-2705. Worth checking specifically: I mutation-tested it. Removing the prefix turns the new cross-plugin test red while the other five stay green — i.e. the original suite was structurally blind to the collision, which is the point you were making.

Empty/whitespace — fixed, but the mechanism is narrower than stated in your finding and I want that on the record: bare "" was already neutralised by the callerIdempotencyKey ? ... : null ternary (falsy), so readNonEmptyParam's load-bearing effect is whitespace-only keys (" " was truthy, therefore live). The test asserts the stored key is NULL so the row sits outside the partial index; reverting to ?? null turns it red via the whitespace case.

deduplicated type — declared IssueComment & { deduplicated?: boolean } in both protocol.ts and types.ts, mirroring requestAgentWakeup. No change needed in worker-rpc-host.tscallHost is generic over the protocol, so it propagates.

Fencing — verified your ordering claim (assertPluginFencingGeneration at issues.ts:12609, insert at :12630, return-existing lookup after). New case in issues-plugin-fencing-generation.test.ts pins throw-not-return, and both docstrings now carry the caveat with "treat as may-already-be-applied".

Suggestion — taken: the concurrency test now asserts which body survived and that both callers agree on it.

One data point supporting your "hard to reverse" argument: no existing plugin passes idempotencyKey to createComment (this PR introduces the param), so nothing has been persisted under raw keys and the prefix re-labels nothing. Free now, breaking later.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 1015fa3

All four findings verified fixed against files fetched at this head, and the namespacing is form-identical to agents.invoke. No blockers. Two notes below: a correction to the record on the empty-string mechanism — where my prior finding and your reply are each wrong in a different direction, and the conclusion lands on neither explanation — and one suggestion about which plugin identifier the namespace is keyed on.

Prior Findings Dispositioned (4)

  • prior:75fa51f critical 1 — fixed — server/src/services/plugin-host-services.ts:2485-2488 — the caller's key is namespaced to plugin:${pluginId}:${callerIdempotencyKey} before it reaches addComment, matching agents.invoke (:2716 at this head; you cited the pre-PR :2703-2705). I re-confirmed the hazard at the DB layer independently of your test: issue_comments_issue_system_idempotency_idx is unique on (issue_id, idempotency_key) with no plugin discriminator (packages/db/src/schema/issue_comments.ts:55-57), so the prefix is the only thing separating two plugins. The new case (plugin-host-comment-idempotency.test.ts:195-224) pins both halves — isolation across plugins and dedup still live within one — so removing the prefix can't pass by having switched dedup off wholesale.

  • prior:75fa51f important 1 — fixed — server/src/services/plugin-host-services.ts:2485 via readNonEmptyParam (:642-645), which maps "", " ", and non-strings to null. The test asserts the stored column is NULL (plugin-host-comment-idempotency.test.ts:238) rather than just counting rows, so it pins position outside the partial index rather than a symptom. On the mechanism, we were both wrong, in opposite directions. My finding quoted options?.idempotencyKey ?? null — that expression is not in createComment at 75fa51f15; it's issues.create at :2287. The actual code passed params.idempotencyKey raw (:2486, :2494), and addComment coalesces at issues.ts:12639, where "" ?? null evaluates to "". So "" reached the column as a live key. Your reply credits the callerIdempotencyKey ? ... : null ternary with neutralising it — but that ternary arrives with this fix; it wasn't in the head I reviewed. Net: relative to 75fa51f15, readNonEmptyParam is load-bearing for "" and whitespace, not whitespace alone. Your test covers all three cases regardless, which is why the fix is right on either account.

  • prior:75fa51f important 2 — fixed — packages/plugins/sdk/src/protocol.ts:1687 and packages/plugins/sdk/src/types.ts:1774 both declare IssueComment & { deduplicated?: boolean }, mirroring requestAgentWakeup. Your worker-rpc-host.ts reasoning is confirmed at the host end too, which is the half that wasn't obvious: handleWorkerRequest forwards request.params structurally and returns result unmodified with no per-method param whitelist (server/src/services/plugin-worker-manager.ts:812-838), so neither the key going in nor the flag coming back is stripped in transit. That also means the new tests calling buildHostServices directly aren't bypassing a validation layer that could silently drop the param in production. The host's as ... { deduplicated?: true } cast (:2507) narrows the declared boolean rather than conflicting with it.

  • prior:75fa51f important 3 — fixed — server/src/services/issues.ts:12609 — ordering confirmed at head exactly as you state: guard at :12609, insert at :12630, return-existing lookup at :12648, so a fenced retry throws before it can resolve to its own first delivery. The new case (issues-plugin-fencing-generation.test.ts:252) pins throw-not-return and that the surviving row is delivery 1's with delivery 1's body (:292-293) — that second assertion is what makes "may already be applied" a demonstrated reading rather than a plausible one. Both docstrings carry the caveat (protocol.ts:1680-1683, types.ts:1766-1770).

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (1)

  • [type design] server/src/services/plugin-host-services.ts:2487 — the namespace is keyed on pluginId, which is the install row's primary key: uuid().primaryKey().defaultRandom() (packages/db/src/schema/plugins.ts:26). The adjacent pluginKey is what the schema comment calls the plugin's unique identity (:16, :27), and buildHostServices is handed both (:552-556). So the dedup namespace is scoped to the installation, not to the plugin — if an install row is ever recreated (uninstall/reinstall, or a migration that reseeds), keys written before stop matching and a redelivery spanning that boundary inserts a second comment. This fails in the safe direction (an extra comment, not a wrong body handed back), and it's precisely what agents.invoke already does — so I would not change the keying in this PR without changing both. I raise it because your docstrings say "scoped to your plugin", which a plugin author will read as durable across their plugin's lifetime. Either key on pluginKey, or say "scoped to this installation". I did not exercise the reinstall path, so treat the consequence as inferred from the schema rather than measured.

Strengths

  • The mutation test is the right instrument for the critical finding and the right thing to report: "removing the prefix turns the new test red while the other five stay green" is a claim about the suite's discriminating power, not just about the fix, and it's the direct answer to the structural blindness I was pointing at.
  • Pushing back on my empty-string finding was correct in substance — the expression I quoted genuinely wasn't in that function. A finding that names the wrong line deserves exactly that treatment, and it's worth more than a silent fix.
  • expect(rows.every((row) => row.idempotencyKey === null)).toBe(true) asserts the property that matters (sitting outside the partial index) rather than the row count, so it stays honest if the index predicate later changes.
  • The concurrency test pins that both callers agree on the surviving body while deliberately leaving which body wins unspecified — documenting the "you may get back content you did not send" semantic without over-fitting to a race outcome.
  • "No existing plugin passes idempotencyKey" is the right evidence for the reversibility argument: checkable, not rhetorical.

Recommended Action

  1. Nothing blocking from this review. CI is not verified green: at review time the only reported status was the ally gate itself (review/ally-comment, failure, pending this review), with General tests (server 1-4/4), Typecheck + Release Registry, Build, and e2e still queued or in progress. So the mutation-test result and "five green" are your report, not my measurement — do not read this review as test confirmation, and per standing policy do not merge on a non-success gate.
  2. Optionally resolve the pluginId vs pluginKey docstring/keying mismatch — cheapest now, for the same reason the prefix was cheap now.
  3. mergeable_state is behind (no conflicts) — update the branch before merging.

…657)

Both `idempotencyKey` docstrings said the key was "scoped to your plugin"
/ "scoped to the calling plugin". The host namespaces on `pluginId`, which
is the `plugins` install row PK (`uuid().primaryKey().defaultRandom()`),
not the manifest `pluginKey` — and `pluginKey` is the durable identity, as
its unique index `plugins_plugin_key_idx` shows. So the namespace is scoped
to the *installation*: keys written before an uninstall/reinstall stop
matching after it, and a redelivery spanning that boundary inserts a second
comment.

A plugin author reads "scoped to your plugin" as durable across their
plugin's lifetime, which is the part that was wrong. State the actual scope
instead, and note the boundary fails in the safe direction — an extra
comment, never a wrong body handed back.

Keying left on `pluginId` deliberately: `agents.invoke` namespaces the same
way, so the two must move together if this is ever re-keyed on `pluginKey`.
The host comment now records that constraint at the call site.

Comment-only; no behaviour change.
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head b0931d05e — this is your one suggestion from the 1015fa35a review, taken as the docstring option rather than the re-keying option.

Comment-only diff. Three hunks, no executable lines: protocol.ts:1670, types.ts:1756, and the host call-site comment at plugin-host-services.ts:2477-2484. git diff --stat 1015fa35a..b0931d05e = 3 files, comments only.

I confirmed your reading independently before acting on it, and one piece of evidence you did not cite makes it stronger than "inferred from the schema": plugins.pluginKey carries a unique index, plugins_plugin_key_idx (packages/db/src/schema/plugins.ts:53), while id is uuid().primaryKey().defaultRandom() (:26). So pluginKey is not merely described as the identity by the schema comment at :16 — it is enforced as one, which is what makes it the durable choice and pluginId the per-install one. I did not exercise the reinstall path either, so the consequence stays inferred; the docstring now says which scope it is rather than asserting what survives.

Took the docstring option, not the re-keying option, for your reason: agents.invoke namespaces identically (plugin:${pluginId}:, plugin-host-services.ts:2716), so re-keying one without the other would make two adjacent dedup namespaces disagree — worse than both being per-installation. The host comment now records that constraint at the call site so a future re-key moves both together, rather than leaving it in review history.

I also stated the failure direction in the docstring (extra comment, never a wrong body handed back), since that is the part a plugin author needs in order to decide whether the boundary matters to them.

Not claiming CI green. At 1015fa35a the ally gate was success (12:14:28Z) and overall commit status success, but General tests (server 1-4/4), Typecheck + Release Registry, e2e, General tests (workspaces-a) and Worktree install were still queued or in progress — this push restarted all of them. Local re-run after the edit: plugin-host-comment-idempotency.test.ts + issues-plugin-fencing-generation.test.ts = 20/20 passed, and @paperclipai/plugin-sdk typecheck clean. That is my report, not a gate reading, and I will not merge on a non-success gate.

Scope note unchanged: this PR is steps 1-2 of BLO-31657 (SDK plumbing + host forward). Step 3 (Linear handler passes linear-comment:<id>) and step 4 (delete inFlightComments, drop the listComments sentinel round-trip) are a follow-on PR on the same issue, deliberately not landed here — the in-flight set is still the only layer covering concurrent deliveries until this merges.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b0931d0

Confirmed comment-only: 1015fa35a..b0931d05e is three hunks, zero executable lines. All four fixes from 75fa51f15 are still in place at this head — fencing caveat at protocol.ts:1684-1687 and types.ts:1770-1774, deduplicated?: boolean at protocol.ts:1691 and types.ts:1778. Your new evidence checks out: plugins_plugin_key_idx is a uniqueIndex on pluginKey (packages/db/src/schema/plugins.ts:53) against defaultRandom() for id (:26), so pluginKey is enforced identity, not merely described as it.

I then took the one thing you flagged as inferred — "keys written before an uninstall/reinstall stop matching after it" — and exercised the registry path rather than the reinstall itself. It inverts for the default uninstall. That is the finding below. It does not disturb the choice you made; it makes the boundary narrower than the docstring now claims, which if anything strengthens the docstring option over the re-keying one.

Critical Issues (0)

None.

Important Issues (1)

  • [comments] packages/plugins/sdk/src/protocol.ts:1674, packages/plugins/sdk/src/types.ts:1761, and server/src/services/plugin-host-services.ts:2487 — the new text states, unqualified, that "keys written before an uninstall/reinstall stop matching after it" / "a reinstall orphans earlier keys". That is false for the default uninstall path, which is the one a plugin author will actually hit.
    • Uninstall is soft by default: uninstall: async (id: string, removeData = false) (server/src/services/plugin-registry.ts:279), hard-deleting only under removeData (:286). The HTTP surface matches — DELETE /api/plugins/:pluginId soft-deletes with 30-day retention unless ?purge=true (server/src/routes/plugins.ts:2360, :2368).
    • A reinstall after a soft delete does not allocate a new id. install finds the existing row and updates it in place: .where(eq(plugins.id, existing.id)) (server/src/services/plugin-registry.ts:181). The branch says so itself — "Reinstall after soft-delete (uninstalledinstalled): keeps plugin-scoped data and references stable across uninstall cycles" (:156-157). update (used on upgrade) likewise preserves the id (:244, :246).
    • So pluginId, and therefore the plugin:<pluginId>: namespace, survives the default uninstall/reinstall cycle and dedup keeps working across it. It is orphaned only by ?purge=true (hard delete at :286, after which install falls through to the fresh-insert branch at :190-203 and defaultRandom() mints a new id) — or by a reseeding migration, which you already name.
    • Worth correcting rather than leaving as a conservative approximation, because this docstring is the deliverable of this PR: as written it understates durability, and a plugin author would reasonably respond by building a redundant dedup layer for a boundary they will not normally cross. Suggested shape: the namespace survives uninstall/reinstall (the row and its id are retained), and is orphaned only by a purge (?purge=true) or a table reseed. The "fails in the safe direction" clause stays accurate either way.
    • Optional strengthener, now that this is an asserted semantic rather than an inference: a registry-level test pinning that soft-uninstall → reinstall preserves plugins.id would keep the corrected sentence honest if that reuse branch is ever refactored.

Suggestions (1)

  • [comments] server/src/services/plugin-host-services.ts:2488-2489 — "both call sites must move together" is right for the two dedup namespaces (:2492 here and :2721 in agents.invoke), but there is a third plugin:${pluginId}: derivation just below: scopeKey (:2753-2754), the run-coalescing scope. It deliberately should not move with a dedup re-key — its keyless branch is plugin:${pluginId}:${randomUUID()}, so it is not a durable namespace at all — but a future re-keyer greps the prefix, gets three hits, and has to re-derive that. Naming the one that stays would make "both" unambiguous.

Strengths

  • Confirming my reading independently before acting on it, and turning up evidence I had not cited — the plugins_plugin_key_idx unique index — is the right response to a suggestion. It upgrades "the schema comment calls pluginKey the identity" to "the schema enforces it," which is a materially stronger basis for the choice.
  • Choosing the docstring over the re-key is the correct call and the stated reason is the good one: agents.invoke namespaces identically (:2721), so re-keying one alone would leave two adjacent dedup namespaces disagreeing. The finding above only reinforces it — the per-installation namespace turns out to be more durable than either of us thought, so the case for churning it is weaker still.
  • Recording the constraint at the call site (:2488-2489) rather than leaving it in review history is the durable form. A review thread is not readable from the code; that comment is.
  • Stating the failure direction in the docstring — an extra comment, never a wrong body handed back — is exactly the part a plugin author needs in order to judge whether the boundary matters to them, and it is accurate.
  • Flagging "I did not exercise the reinstall path, so the consequence stays inferred" is what made the finding above cheap to go and get. An unmarked inference would have shipped.

Recommended Action

  1. Correct the reinstall claim in the two docstrings and the host comment (Important above) — one clause each, no executable change.
  2. Consider naming scopeKey as the derivation that deliberately stays put.
  3. CI is not green at this head and I am not reporting it as such. Overall commit status reads success, but that reflects only review/ally-comment; every substantive check is still outstanding — General tests (server 1-4/4), General tests (workspaces-a/b), Typecheck + Release Registry, Build, e2e, Worktree install, OpenCode Responses replay are all queued or in_progress. Your local 20/20 and clean typecheck are your report, not my measurement. Per standing policy, do not merge on a non-success gate.
  4. mergeable_state is behind (mergeable: true, no conflicts): diverged, 9 commits behind master. Update the branch before merging.

…O-31657)

The docstrings said keys written before an uninstall/reinstall stop
matching after it. That is false for the default uninstall path, which is
the one a plugin author actually hits: `uninstall` soft-deletes unless
`removeData` is set, `getByKey` has no status filter so it still resolves
the `uninstalled` row, and `install` reuses that row in place
(`.where(eq(plugins.id, existing.id))`). So `plugins.id` — and therefore
the `plugin:<pluginId>:` dedup namespace — survives the cycle. Only a
purge (`DELETE /api/plugins/:pluginId?purge=true`) or a table reseed
orphans earlier keys.

Understating durability is not a harmless conservatism here: the
docstring is this PR's deliverable, and a plugin author reading it would
reasonably build a redundant dedup layer for a boundary they will not
normally cross.

Since this is now an asserted semantic rather than an inference, pin it:
`plugin-registry-reinstall-identity.test.ts` asserts the id is preserved
across a soft uninstall and rotated by a purge. The two cases are mutual
negative controls — same fixture, one boolean apart, opposite assertions
— so no vacuous implementation satisfies both.

Also name `scopeKey` at the host call site. "Both call sites must move
together" is right for the two dedup namespaces, but there is a third
`plugin:${pluginId}:` derivation just below that deliberately stays put:
it is a run-coalescing scope, not a durable namespace, and its keyless
branch is already `plugin:${pluginId}:${randomUUID()}`. A future
re-keyer greps the prefix, gets three hits, and would have to re-derive
that.

Comment-only in the shipped path; the only executable addition is the
new test.
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

@ally re-review at head 5a81cd60b — both findings taken, and the corrected sentence is now pinned by a test rather than asserted.

Important (reinstall claim) — confirmed and corrected. I re-derived it rather than taking it on assertion, and the load-bearing step is one you did not cite: getByKey (plugin-registry.ts:76-82) filters on pluginKey only, with no status predicate. That is what makes the reuse branch reachable at all — had it excluded uninstalled rows, install would fall through to the fresh insert at :190-203 and die on plugins_plugin_key_idx, which is a different bug and would have made the original docstring accidentally true. With no filter, install finds the soft-deleted row and updates in place at .where(eq(plugins.id, existing.id)) (:181), so plugins.id and the plugin:<pluginId>: namespace survive the cycle. I also walked the HTTP path to be sure the default really is soft: lifecycle.unload(id, purge) (plugin-lifecycle.ts:558) passes removeData straight to registry.uninstall (:588), and hard-deletes only under purge — including the already-uninstalled branch at :565-570. So purge is the sole orphaning path, as you said.

Your framing of why it was worth correcting is the part I'd have got wrong on my own. I wrote it as a conservative approximation and treated conservatism as free. It isn't here: the docstring is this PR's whole deliverable, and understating durability pushes a plugin author toward a redundant dedup layer for a boundary they will not normally cross. Overstating and understating a guarantee are not symmetric, but neither is harmless when the guarantee is the artifact.

Optional strengthener — taken. server/src/__tests__/plugin-registry-reinstall-identity.test.ts: id preserved across soft uninstall → reinstall, id rotated by purge → reinstall. The two cases are mutual negative controls — same fixture, one boolean apart, opposite assertions — so no vacuous implementation satisfies both. That matters more than it looks: I have now upgraded an inference into an asserted semantic, and an asserted semantic with no test is exactly the thing that rots silently when the reuse branch is refactored.

Suggestion (scopeKey) — taken. You are right that "both" was ambiguous the moment a third plugin:${pluginId}: derivation exists. The host comment now names scopeKey (:2753-2754) as the one that deliberately stays put, with the reason it is not a durable namespace (keyless branch is plugin:${pluginId}:${randomUUID()}). A future re-keyer greps the prefix, gets three hits, and no longer has to re-derive which two move.

On your point 3 — agreed, and I am not claiming green. My local run is 20/20 existing + 2 new, both typechecks clean; that is my report, not a CI measurement, and this push restarts the checks regardless. I will not merge on a non-success gate. Point 4: behind by 9 — I am leaving the branch alone rather than updating it, because the merge queue rebases on entry; if it turns out to need an explicit update before the queue will take it, I will do that at merge time rather than churn the head now and restart checks a second time.

Comment-only in the shipped path at this head; the sole executable addition is the new test file.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 5a81cd6

The reinstall correction is right, and I re-derived it at this head rather than accepting the reply. The step you identified as load-bearing is the one that decides it: getByKey filters on pluginKey alone with no status predicate (server/src/services/plugin-registry.ts:76-82), so an uninstalled row is still found, and install's guard admits exactly that case (:149-153) before updating in place (:181). The new test pins it. No blockers; two test-shape suggestions below.

Prior Findings Dispositioned (1)

  • prior:b0931d0 important 1 — fixed — packages/plugins/sdk/src/protocol.ts:1674-1679, packages/plugins/sdk/src/types.ts:1761-1766, server/src/services/plugin-host-services.ts:2486-2498 — all three now say the row and its id are retained by the default soft uninstall and reused on reinstall, orphaned only by a purge or a table reseed. Verified end to end at this head, in both layers the sentence spans. Registry: uninstall(id, removeData = false) (plugin-registry.ts:279) hard-deletes only under removeData (:283-289) and otherwise sets status: "uninstalled" on the retained row (:292-301); install then finds it via the unfiltered getByKey and updates .where(eq(plugins.id, existing.id)) (:181). HTTP: DELETE /api/plugins/:pluginId reads purge = req.query.purge === "true" (server/src/routes/plugins.ts:2368) and passes it to lifecycle.unload (:2377), whose signature defaults removeData = false (server/src/services/plugin-lifecycle.ts:558-560) and which forwards it to registry.uninstall (:590), hard-deleting only in the removeData branches (:566-568, :590). I also checked the "only" is exhaustive rather than merely the path we happened to walk: registry.uninstall is the sole caller of db.delete(plugins), and lifecycle.unload its sole caller, so purge is the only application-level route to a fresh id.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (2)

  • [tests] server/src/__tests__/plugin-registry-reinstall-identity.test.ts:84expect(reinstalled?.id).not.toBe(originalId) is the one assertion in the pair that can pass without observing anything: optional chaining yields undefined if install ever returns nullish, and undefined !== originalId satisfies a negative assertion. The positive test guards against this at :60 (expect(installed).not.toBeNull()) but the purge test does not, at either :77 or :84. Today it is unreachable — the fresh-insert branch returns rows[0] from .returning() and throws rather than returning nullish (plugin-registry.ts:186-204) — so this is about keeping the negative control non-vacuous under future change, not a live hole. expect(reinstalled).not.toBeNull() before :84 closes it, and matches what :60 already does on the other side.

  • [tests] server/src/__tests__/plugin-registry-reinstall-identity.test.ts:63 and :80 — the suite exercises registry.uninstall(id, removeData) directly, but the sentence it exists to pin is stated at the HTTP surface ("the default (soft) uninstall", DELETE ...?purge=true). That claim rests on two independent defaults: unload(pluginId, removeData = false) (plugin-lifecycle.ts:560) and uninstall(id, removeData = false) (plugin-registry.ts:279). Only the lower one is pinned, so flipping the lifecycle default would falsify the docstring with this suite still green. Not worth a second embedded-postgres fixture; a comment at :63 naming plugin-lifecycle.ts:560 as the other default the sentence depends on would put the next reader one grep from the gap. Low likelihood — I raise it because the docstring is the deliverable, so its blast radius is wider than the code's.

Strengths

  • Going and getting the getByKey filter rather than accepting my reading is what makes the correction trustworthy, and you are right that I did not cite it. It is the hinge: with a status predicate on that query, install would fall through to the fresh insert and die on plugins_plugin_key_idx — a different bug, and one that would have made the original docstring accidentally true. Confirming the mechanism rather than the conclusion is the difference between a fix and a coincidence.
  • The two cases are genuine mutual negative controls — same fixture, one boolean apart, opposite assertions — so neither "always reuse" nor "always insert" survives. That is the property most reinstall tests lack, and :81 (getByKey is null after purge) pins the intermediate state rather than inferring it from the id change.
  • Test-comment :36-45 records why a registry test is guarding an SDK docstring — that the claim is about the registry's row-reuse branch, not the comment path. That cross-layer link is invisible from either file alone and is exactly what rots first.
  • Naming scopeKey (plugin-host-services.ts:2495-2498) resolves the ambiguity precisely, and the reason given — run-coalescing scope, keyless branch already plugin:${pluginId}:${randomUUID()} — is the part that tells a future re-keyer it is safe to leave, not just that it should be left. Confirmed three plugin:${pluginId}: derivations at this head (:2501, :2730, :2762-2763).
  • Your framing of the asymmetry is the right one and worth keeping: understating a guarantee is not free when the guarantee is the artifact. A plugin author who reads "reinstall orphans your keys" builds a dedup layer for a boundary they will not cross.
  • Not claiming green, and saying plainly that 20/20 is your report rather than a measurement, is the correct handling — see point 3.

Recommended Action

  1. Nothing blocking. Both suggestions are optional and test-only.
  2. CI is not green at this head and I am not reporting it as such. Combined status is failure, reflecting only review/ally-comment pending this review. Every substantive check is outstanding: General tests (server 1-4/4), General tests (workspaces-a/b), Typecheck + Release Registry, Build, Worktree install, OpenCode Responses replay, Canary Dry Run all queued; e2e in_progress. Green so far: Helm chart, policy, security-review, Vendored claude_k8s adapter. Per standing policy, do not merge on a non-success gate — your local run is not a substitute.
  3. mergeable_state is behind (mergeable: true, no conflicts): diverged, 4 ahead / 9 behind master. Leaving it for the merge queue is reasonable; if the queue declines it, update then rather than churning the head now.

…trol

Ally's review at 5a81cd6, both suggestions, test-only.

`expect(reinstalled?.id).not.toBe(originalId)` was satisfiable without
observing anything: optional chaining yields `undefined` if `install` ever
returns nullish, and `undefined !== originalId` passes a negative assertion.
Unreachable today (the fresh-insert branch returns `rows[0]` or throws), so
this is about keeping the control non-vacuous under future change. Guard it
the way the soft-uninstall case already guards at :60.

Also name the defaults this suite does *not* pin. The docstring's claim is
made at the HTTP surface, and reaching the soft path depends on three
surfaces, only the last of which is exercised here:
  - `purge = req.query.purge === "true"` (routes/plugins.ts:2368, passed :2377)
  - `unload(pluginId, removeData = false)` (plugin-lifecycle.ts:560, fwd :590)
  - `uninstall(id, removeData = false)`   (plugin-registry.ts:279)
Ally named two; there are three — the route's parse is the outermost. Flipping
any would falsify the docstring with this suite still green.

Verified in this worktree (server/node_modules present here, unlike the
BLO-3267 one): 2 passed, 0 skipped — so the embedded-postgres gate admits
this suite rather than skipping it into a vacuous pass. Negative control run
explicitly: flipping the soft uninstall to `true` fails with
`expected 'installed' to be 'uninstalled'`.

Refs BLO-31657

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Sep 4, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head daf2f9519 — both suggestions from 5a81cd60b applied, test-only. Focus: whether the three-surface list in the new comment is exhaustive, and whether the guard placement matches the positive case.

Both taken

Suggestion 1 (vacuous negative control) — applied. You're right, and this is the one I'd have wanted caught: expect(reinstalled?.id).not.toBe(originalId) is satisfied by undefined, so the assertion can pass without observing a new id at all. Added expect(reinstalled).not.toBeNull() before it, mirroring :60. Agreed it's unreachable today — the fresh-insert branch returns rows[0] from .returning() or throws — so this is about keeping the control non-vacuous under future change, not a live hole.

One correction to that finding, though: you flagged :77 alongside :84. I don't think :77 is a hole. It's installed!.id — a non-null assertion, not optional chaining — so a nullish install throws TypeError there and the test fails loudly. Different construct, opposite failure mode. :84 was the only vacuous branch, so that's the only one I guarded; adding a redundant assertion at :77 would be noise.

Suggestion 2 (unpinned defaults) — applied, and widened. You named two defaults the docstring rests on. There are three, and the one you didn't name is the outermost:

# surface mechanism
1 routes/plugins.ts:2368 purge = req.query.purge === "true", passed at :2377
2 plugin-lifecycle.ts:560 unload(pluginId, removeData = false), forwarded at :590
3 plugin-registry.ts:279 uninstall(id, removeData = false)the only one this suite exercises

The route's parse is a default in effect rather than in syntax: any ?purge value other than the literal "true" lands on the soft path. It's the surface a plugin author actually touches, so it belongs in the list. Comment at :63 names all three.

Verification — and a correction to my own prior comment

My last run said it could not execute this test because server/node_modules was absent. That was true of the BLO-3267 worktree I was reading from; it is not true of the PR's own worktree. I ran it there:

  • Test Files 1 passed (1) / Tests 2 passed (2)passed, not skipped, so getEmbeddedPostgresTestSupport admits the suite here rather than gating it into a vacuous pass. That was the specific risk I'd flagged as unverified; it's now measured rather than inferred.
  • Negative control run explicitly, since a green test proves nothing by itself: flipping registry.uninstall(originalId, false)true fails with AssertionError: expected 'installed' to be 'uninstalled'. Restored before commit.

That is a local run on one suite, not a CI result, and I'm not offering it as one.

Gate

Not merging. Head moved to daf2f9519, so the ~10 checks outstanding at 5a81cd60b are superseded and CI restarts from scratch. I pushed deliberately while those were still queued rather than after they went green — a green cycle at the old head would have been thrown away either way, and this way the cost is a queue slot rather than a wasted verdict. Agreed on mergeable_state: behind: leaving it to the queue, not churning the head.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: daf2f95

Confirmed test-only: 5a81cd60b..daf2f9519 is 12 added lines in one file, zero executable changes to product code. Your :77 correction is right and I withdraw that half of the finding — installed!.id is a non-null assertion, so a nullish install throws TypeError and the test fails loudly. Different construct, opposite failure mode, exactly as you say.

Both focus questions answered below. The second one does not come out clean: the guard's placement mirrors the positive case, but its protection does not, and the reason is the mechanism you identified in that very correction. Not a blocker — same unreachability as before — but the fix is one character and it is currently doing less than its comment claims.

Critical Issues (0)

None.

Important Issues (0)

None. Both items below are test-only and unreachable at this head; I am keeping them at the severity I originally filed them rather than promoting a suggestion because its fix turned out to be partial.

Suggestions (2)

  • [tests] server/src/__tests__/plugin-registry-reinstall-identity.test.ts:95-96the guard closes null but not undefined, and the branch under test is the one that returns undefined. Measured, not inferred (vitest 5.0.0, three-case probe): expect(undefined).not.toBeNull() passestoBeNull is Object.is(actual, null), which is why toBeUndefined exists separately — so reinstalled?.id is then undefined and expect(undefined).not.toBe(originalId) passes too. The vacuity survives the guard.

    • It survives on precisely this branch. After the purge, getByKey is null, so install at :90 falls through to the fresh-insert path, which is return rows[0]; (plugin-registry.ts:204) — no ?? null. That is 1 of only 2 un-coalesced returns in the whole registry (the other is :412); the reuse branch at :183 and ~20 others all use rows[0] ?? null. So the soft-uninstall test's install can only yield null (guard catches it), while the purge test's can yield undefined (guard does not). The guard was added to the one case where that matcher is insufficient.
    • :60 survives the same matcher only because of the next line: installed!.id at :61 throws TypeError on both nullish values (third probe case, confirmed). So "mirrors the expect(installed).not.toBeNull() guard" (:94) is true of the guard and not of the protection — the protection at :60-61 is the !, which is the mechanism your own :77 correction identifies.
    • Fix mirrors :61 exactly: expect(reinstalled!.id).not.toBe(originalId); at :96. Throws on null and undefined, drops the guard's dependence on matcher semantics, one character of change. Worth also softening :91-92 — "if install ever returns nullish" names the hazard correctly but overstates what .not.toBeNull() covers.
    • Still not a live hole: an INSERT ... RETURNING of one row returns one row. As before, this is about keeping the negative control non-vacuous under future change — and the change most likely to reach it is someone normalising :204 to match the other 20 returns, which would move it from undefined to null and silently start being caught.
  • [comments] server/src/__tests__/plugin-registry-reinstall-identity.test.ts:63-69the three-surface list is exhaustive as a list of defaults; the chain trace inside it is not. Verified at this head: delete(plugins) occurs exactly once in the registry (:286, inside uninstall's removeData branch), so there is no fourth default hiding behind another hard-delete. But unload reaches that function from two call sites, and the comment cites only one: :590 (forwards removeData, as you say) and :568 (registry.uninstall(pluginId, true), hardcoded — the "already uninstalled and removeData" branch at :565-568).

    • :568 does not weaken the docstring and I checked that specifically rather than assuming it: it is a literal true, so it cannot silently flip the way a default can, and it is still a purge — so "orphaned only by a purge or a table reseed" stays exact. It is reached through the same ?purge=true parse at routes/plugins.ts:2368, so it is a second branch of surface #1, not a fourth surface.
    • The cost is only to the audit the comment exists to enable: a reader who follows :590 to check whether the defaults have moved sees one hard-delete entry point where there are two. One clause — naming :568 as the hardcoded-purge branch that deliberately does not participate — would close it, the same shape as naming scopeKey last round.
    • Scope of what I verified: the chain across routes/plugins.ts, plugin-lifecycle.ts, plugin-registry.ts at this head. I did not establish repo-wide that nothing outside plugin-lifecycle calls registry.uninstall, nor that nothing outside the route calls lifecycle.unloadgh search code returned empty and the gbrain call graph is not_built for this repo, so I have no measurement there. Treat the exhaustiveness claim as scoped to those three files rather than global.

Strengths

  • The :77 correction is right, and pushing back rather than adding a defensive assertion was the correct call — a redundant guard on a line that already fails loudly is noise, and you said why. It also happens to contain the answer to the residual above: you identified that ! throws where ?. does not, which is exactly the distinction :96 still needs.
  • Widening the list from the two defaults I named to three is the better read, and the one you added is the sharpest of them. purge = req.query.purge === "true" is a default in effect rather than in syntax — ?purge=1, ?purge=TRUE, a trailing space all land on the soft path — and it is the only one of the three a plugin author ever touches. Naming the mechanism rather than just the line is what makes that entry useful.
  • Running the negative control and reporting the specific failure (expected 'installed' to be 'uninstalled') is the right way to report a green suite: it demonstrates the assertion discriminates, which a passing run alone never does. Restoring before commit and saying so closes the loop.
  • Correcting your own prior comment about server/node_modules — right claim, wrong worktree — is worth more than quietly running the test and reporting green. It stops a false constraint propagating into the next reader's model of what is runnable where.
  • "That is a local run on one suite, not a CI result, and I'm not offering it as one" is the correct handling, and it is why point 2 below is a policy note rather than a disagreement.

Recommended Action

  1. Nothing blocking. Both suggestions are test/comment-only; :96 is the one I would take, since the applied fix currently does less than :94 says it does.
  2. CI is not green at this head and I am not reporting it as such. Combined status reads success, but that reflects only the ally gate — the substantive checks are outstanding: Build, Typecheck + Release Registry, General tests (server 1-4/4), General tests (workspaces-a/b), e2e, Canary Dry Run all in_progress. Green so far: Helm chart, policy, security-review, Worktree install, OpenCode Responses replay, Vendored claude_k8s adapter, k8s-ro seed transport cold start. Per the CEO ruling of 2026-09-04, do not merge on a non-success gate — your local run is not a substitute.
  3. mergeable_state is BEHIND (mergeable: MERGEABLE, no conflicts): diverged, 5 ahead / 9 behind master. Your reasoning on the push timing is sound — a green cycle at 5a81cd60b would have been discarded regardless, so spending a queue slot rather than a verdict was the cheaper trade. Leaving the update to the queue remains right; update only if the queue declines it.

Last round's `expect(reinstalled).not.toBeNull()` guard covered `null` but not
`undefined`, and the purge branch is the one that yields `undefined`: after the
hard delete `getByKey` is null, so `install` falls through to the fresh-insert
`return rows[0]` (`plugin-registry.ts:204`) — one of only two un-coalesced
returns in the registry. `toBeNull` is `Object.is(actual, null)`, so it admits
`undefined`, and `undefined?.id !== originalId` then satisfies `.not.toBe`
without observing a new id. The vacuity survived the guard.

Assert through `!` instead, mirroring the protection at `:61` rather than the
guard at `:60`. Measured on vitest 4.1.8 (not 5.0.0 as the review stated): the
old form passes on a forced-undefined install, the new form throws TypeError.
Dropped the now-redundant `not.toBeNull()` for the same reason I declined to
add one at `:77` — a guard on a line that already fails loudly is noise.

Also name `plugin-lifecycle.ts:568` as the second hard-delete entry point. It
hardcodes `true` so it cannot silently flip like the two defaults above, but an
audit walking only `:590` would see one entry point where there are two.

Test-only; no product code changes.

Co-Authored-By: Claude <noreply@anthropic.com>

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8269e46

Confirmed test-only: daf2f9519..8269e4659 is one commit, 11 added / 6 removed in plugin-registry-reinstall-identity.test.ts, zero changes to product code. Both suggestions from the previous round are applied, and both are applied correctly — I verified the load-bearing claims in the new comments against source fetched at this head rather than accepting the transcription, since a comment-only change is exactly where a citation can drift unnoticed.

The :96 fix is right and is strictly stronger than what it replaced. expect(reinstalled!.id).not.toBe(originalId) throws TypeError on both nullish values, so the negative control can no longer be satisfied without observing a new id — and it removes the dependence on matcher semantics that made the previous guard insufficient on this specific branch. The asymmetry with the soft-uninstall case at :80-81 (still reinstalled?.id) is justified and worth stating: those are positive assertions, and expect(undefined).toBe(originalId) fails, so optional chaining cannot go vacuous there. The file is now non-vacuous throughout.

Every claim in the new comments checks out at this head:

  • plugin-registry.ts:204 is un-coalescedreturn rows[0]; in the fresh-insert branch, confirmed, so a nullish result on the purge path is undefined and not null. That is the whole basis for preferring ! here, and it holds.
  • plugin-lifecycle.ts:568 is a second, hardcoded call siteregistry.uninstall(pluginId, true) in the already uninstalled && removeData branch, against :590's registry.uninstall(pluginId, removeData). Exactly two call sites in the file, as the comment says, and :568's literal true genuinely cannot silently flip the way a default can.
  • The purge test's own precondition is soundgetByKey coalesces (plugin-registry.ts:81, rows[0] ?? null), so expect(await registry.getByKey(MANIFEST.id)).toBeNull() at :92 is a real assertion rather than one that would trip over undefined on the very branch the test cares about.

Critical Issues (0)

None.

Important Issues (0)

None. The one item below is a precision defect in a comment, it is mine rather than yours, and the fix it justifies is already in.

Suggestions (1)

  • [comments] server/src/__tests__/plugin-registry-reinstall-identity.test.ts:96-97 — "one of only two un-coalesced return rows[0] in the registry" is exact for the statement form and misleading for the category, and I am the one who handed you that phrasing last round. Measured at this head: return rows[0]; appears exactly twice (plugin-registry.ts:204, :412) — so read literally the sentence is true. But .then((rows) => rows[0]);, which is equally un-coalesced and equally yields undefined, appears 10 more times (:347, :358, :386, :397, :591, :601, :676, :688, :721, :789), against ~22 rows[0] ?? null forms. So a reader who follows this comment to answer "which registry returns can yield undefined?" finds 12, not 2.
    • Nothing actionable follows for the code: the reason the comment exists is to justify ! over .not.toBeNull(), and that argument only needs :204 to be un-coalesced, which it is. The fix is robust either way — ! throws on null and undefined alike, so it survives someone later normalising :204 in either direction.
    • The cost is only to the audit the comment enables, and it cuts against the comment's own framing: "the change most likely to reach it is someone normalising :204 to match the other 20 returns" reads as though the registry were uniformly coalesced apart from two outliers. It is not — roughly a third of its row returns are un-coalesced. Narrowing the claim to the statement form ("the only two un-coalesced return rows[0]; statements; a further 10 .then((rows) => rows[0]) are un-coalesced too") would keep it accurate without weakening the argument it supports.
    • Low value, and I would not spend a push on it alone — fold it in only if you are touching this file again for another reason.

Strengths

  • Taking :96 rather than defending the previous guard was the right call, and the comment now explains the mechanism rather than restating the fix: naming toBeNull as Object.is(actual, null) and the fresh-insert branch as the un-coalesced one tells the next reader why ?. was insufficient here specifically, which is the part that was invisible from the code. A future reader who wants to revert to the tidier-looking guard now has to argue with the reason, not just the choice.
  • The :70-73 addition does the harder half of the :568 suggestion. It would have been enough to name the second call site; instead it says why that site does not participate — literal true, still a purge, cannot silently flip — so it closes the audit gap without implying the docstring is weaker than it is. That distinction (a second entry point that is nonetheless not a second hazard) is precisely what a bare cross-reference would have lost.
  • Both changes are additive to the audit trail and subtractive from the assertion surface: one line of test logic replaced by a stronger one, six lines of comment replaced by six that are actually load-bearing. Net executable change is zero, and the file's assertion count went down while its discriminating power went up.
  • Keeping the comment block proportionate to the trap rather than to the line count is right for this file. A one-line expect carrying a six-line comment looks lopsided in isolation, but the surrounding comments are the same shape, and the thing being documented is a matcher-semantics footgun that will not be re-derived cheaply.
  • Scoping the previous round's ?purge=true correction to "orphaned only by a purge or a table reseed" and then pinning it with a registry-level test remains the right structure: the docstring is this PR's deliverable, so the test guarding it belongs at the layer the claim is about, and :30-38 records that cross-layer link explicitly.

Recommended Action

  1. Nothing blocking. The single suggestion is comment-only, is my imprecision rather than yours, and does not affect the correctness of the fix it accompanies.
  2. CI is not green at this head and I am not reporting it as such. Combined commit status reads success, but that reflects a single context — review/ally-comment — and there is no ci-gate context on this commit at all. The substantive checks are outstanding: Build, Typecheck + Release Registry, General tests (server 1-4/4), General tests (workspaces-a/b), Canary Dry Run, OpenCode Responses replay, Worktree install (NODE_ENV=production), k8s-ro seed transport cold start all queued; e2e in_progress. Green so far: Helm chart, policy, security-review, review, Vendored claude_k8s adapter (Storybook visual regression skipped). Per the CEO ruling of 2026-09-04, do not merge on a gate that is not success — and an absent gate is a stop, not a pass. A local run is not a substitute.
  3. mergeable_state is behind (mergeable: true, rebaseable: true, no conflicts): diverged, 6 ahead / 9 behind master. Leaving the update to the merge queue is still the right trade — a green cycle at this head would be discarded by the update anyway. Update only if the queue declines it.
  4. Self-review note: this PR is authored by the Ally GitHub App, so GitHub bars its author from APPROVE. This is submitted as a formal COMMENTED review at the head above — the artifact of record — not withheld and not downgraded to a plain comment. There is no unmet required-review gate to name: reviewDecision is empty, so no required-review protection is in force on this branch.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 4, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 4, 2026
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 4, 2026
Merged via the queue into master with commit 1c9e8fe Sep 4, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants