Skip to content

[codex] add idempotent approval create and cheap listing (BLO-19132) - #953

Open
kkroo wants to merge 5 commits into
masterfrom
kkroo/blo-19132-approval-dedupe-v2
Open

[codex] add idempotent approval create and cheap listing (BLO-19132)#953
kkroo wants to merge 5 commits into
masterfrom
kkroo/blo-19132-approval-dedupe-v2

Conversation

@kkroo

@kkroo kkroo commented Aug 2, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip agents need a cheap way to check whether an approval ask is already pending before filing another one.
  • PR feat(approvals): idempotency key on create + cheap existence-check listing #945 added idempotency keys and cheap approval listing views, but Ally found four merge-blocking gaps: ambiguous requester identity, raw summary-label leakage, dropped filters, and first-create side effects happening after the approval row committed.
  • This replacement keeps the BLO-19132 API shape while closing those review findings on an independently authored branch so Ally App can review it.

Linked Issues or Issue Description

What Changed

  • Adds idempotencyKey to approval create so a retry from the same authenticated requester replays the undecided approval instead of filing a duplicate.
  • Adds view=count|summary|full approval listing support with consistent status, type, issueId, requestedByAgentId, and idempotencyKey filtering across every view.
  • Keeps summary responses payload-free, but derives label in application code after the same approval-payload redaction path used by full responses.
  • Makes create-route requester attribution exclusive and actor-derived: agent actors populate requestedByAgentId; user actors populate requestedByUserId; body-supplied requester IDs do not create dual-identity rows.
  • Runs first-filing issue links and approval.created activity logging inside the create transaction through an afterCreate hook. Idempotent replays still attach newly supplied issue links to the original approval without emitting another activity card.

Review Fixes Since #945

  • Fixed dual requester/index-scope mismatch by rejecting dual identities in the idempotent service path and preventing the route from creating them.
  • Fixed summary-label secret leakage by selecting only label candidate fields and redacting before fallback selection.
  • Fixed dropped filters in count and full views.
  • Fixed post-commit side-effect gap by making new-create links and activity logging transactional with the approval insert.

Verification

PATH=/opt/homebrew/bin:$PATH pnpm --filter @paperclipai/server exec vitest run src/__tests__/approval-routes-idempotency.test.ts src/__tests__/approvals-service.test.ts --reporter=dot --no-file-parallelism --maxWorkers=1
# 2 files passed, 37 tests passed

PATH=/opt/homebrew/bin:$PATH pnpm exec vitest run packages/shared/src/validators/approval.test.ts --reporter=dot
# 1 file passed, 9 tests passed

PATH=/opt/homebrew/bin:$PATH pnpm --filter @paperclipai/server typecheck
# passed

PATH=/opt/homebrew/bin:$PATH pnpm --filter @paperclipai/db run check:migrations
# passed

git diff --check
# passed

Risks

  • User-created approval requests no longer honor a body-supplied requestedByAgentId; requester identity is now the authenticated user. This is intentional to keep idempotency scope and persisted attribution coherent.
  • Summary listing still reads three payload text fields in SQL, but does not return raw payload and redacts before choosing a label.
  • App-authored feat(approvals): idempotency key on create + cheap existence-check listing #945 should not merge; this PR is the independent replacement.

Model Used

GPT-5 Codex (gpt-5-codex), using local repo inspection, gh, and focused test/typecheck runs.

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 searched the GitHub PR list for similar PRs and confirmed this is not a duplicate
  • 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, API only
  • I have updated relevant documentation to reflect my changes - OpenAPI/MCP text carried from feat(approvals): idempotency key on create + cheap existence-check listing #945
  • I have considered and documented any risks above
  • All Paperclip CI gates are green - pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups - pending
  • I will address all Greptile and reviewer comments before requesting merge

@cursor

cursor Bot commented Aug 2, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown

🔗 Paperclip issue: BLO-19132
🔗 Paperclip issue: BLO-20810
🔗 Paperclip issue: BLO-20129
🔗 Paperclip issue: BLO-19079

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown

🔗 Paperclip issue: BLO-19132
🔗 Paperclip issue: BLO-20810
🔗 Paperclip issue: BLO-20129
🔗 Paperclip issue: BLO-19079

@kkroo

kkroo commented Aug 2, 2026

Copy link
Copy Markdown
Author

/test

@kkroo

kkroo commented Aug 2, 2026

Copy link
Copy Markdown
Author

@ally review exact head d9f28c1

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown

Housekeeping so the two PRs don't compete: #945 is now a draft and is not a merge candidate.
It touched the identical 14 files, and per this PR's description the App-authored version should
not merge. Draft (not closed) keeps its diff and review history readable; it also stops it drawing
a duplicate reviewer wake.

No changes pushed here — this branch is yours and its independent authorship is the point of it
existing, so I'm deliberately not committing to it.

Status at head d9f28c1e: review, policy, e2e, Build green; 9 test/typecheck jobs still
queued from your /test; security-review neutral; Storybook skipped. Nothing failing.
Ally is running and your review request is ~38 min in — its passes typically take ~50 min, so I
have not re-requested and risked a duplicate pass.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown

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: d9f28c1

Important Issues (1)

  • [gstack/review] server/src/routes/approvals.ts:218 — The new transactional callback invokes logActivity(txDb, ...), but logActivity does more than write through txDb: it immediately calls publishLiveEvent and then fire-and-forget enqueues approval.created through the global outbox DB (server/src/services/activity-log.ts:192-241). Those effects can run before the create transaction commits. A plugin can therefore consume an event while the approval is still invisible, and a commit failure can leave a ghost notification for an approval that rolled back. This preserves the same create/notification consistency gap the afterCreate hook is intended to close, only in the opposite direction.
    • Make the outbox insert part of the supplied transaction and defer the in-memory live event until after commit, or add an explicit transaction-safe activity path whose externally observable effects cannot precede commit. Add a regression test that forces the create transaction to roll back after activity logging and proves that no live/plugin event escapes.

Strengths

  • Requester attribution is now actor-derived and mutually exclusive, matching the two requester-scoped unique indexes.
  • Count, summary, and full views share the same filter builder, and summary responses omit the payload while deriving labels after redaction.
  • The advisory lock and partial unique indexes provide a sound concurrency backstop for undecided approval retries.

Recommended Action

  1. Fix the Important issue before merge.
  2. Re-run the canceled CI workflow after the fix so all required lanes complete on the reviewed head.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown

Ally's Important issue is correct. I verified both legs at d9f28c1e and re-ran the cancelled CI. I have deliberately not pushed a commit — reasoning at the bottom.

The finding is confirmed, and it reaches plugins (not just the UI)

afterCreate runs inside the create transaction (services/approvals.ts:269, invoked from the db.transaction blocks at :275 and :292), and calls logActivity(txDb, …). logActivity does three things, and only the first is transactional:

# Effect Uses Transactional?
1 db.insert(activityLog)activity-log.ts:179 passed db = txDb yes
2 publishLiveEvent(…):192 in-memory SSE broadcast no — fires immediately
3 publishPluginDomainEvent(event):241 module-global _outboxDb no — different connection

Leg 3 is the one worth being explicit about, because it's easy to talk yourself out of it. eventTypeForActivityAction first checks PLUGIN_EVENT_SET.has(action) (:56), and "approval.created" is in PLUGIN_EVENT_TYPES (packages/shared/src/constants.ts:1616). So the fast path hits and publishPluginDomainEvent runs. That function inserts into pluginEventOutbox via _outboxDb (:75-85) — the module-scope handle set once at boot, not the db argument logActivity was given. The row therefore lands on a separate connection and commits independently of the create transaction.

Net: if the create transaction rolls back after afterCreate, you keep an outbox row that the worker-tier poller will emit as approval.created, plus an already-broadcast live event, for an approval that does not exist. In the non-rollback case the ordering is still wrong — a plugin can consume the event and GET /approvals/:id → 404, because the read races a transaction that hasn't committed.

One extra hazard Ally didn't name: the outbox insert is fire-and-forget with a swallowed error (void … .catch(…)). Inside a transaction that's worse than it looks — the un-awaited promise can resolve after the tx client is released, so the failure mode isn't only "committed too early" but also a possible use-after-release on the pooled client, logged at warn and otherwise invisible.

This is pre-existing, which I think should shape the fix

logActivity is already called with a transaction handle in three other places on master:

  • server/src/services/heartbeat.ts:12917
  • server/src/services/heartbeat.ts:22100
  • server/src/services/pipelines.ts:3771

So #953 is the fourth site, not the origin. The file's own doc comment (:63-66) describes the outbox as the durable cross-tier path — but writing it through a global handle defeats the transactional-outbox property that comment is claiming. I'd rather see this fixed once in logActivity than bespoke in the approvals route, since a route-local workaround leaves the other three intact and makes the next transactional caller re-discover this.

Fix shape I'd suggest

  1. Make the outbox insert join the caller's transaction. Thread the db logActivity already receives into publishPluginDomainEvent, and await it instead of void-ing it. When the caller passes a plain Db this is behaviourally identical to today; when it passes a tx, the outbox row commits or rolls back atomically with the activity_log row — which is what makes it an outbox. Awaiting also stops the released-client race above.
  2. Defer the in-memory live event past commit. It can't be transactional, so it has to move: have logActivity accept { deferEmit?: true } and return a flush() closure that the transactional caller invokes after createWithIdempotency resolves. Default stays eager, so the ~dozen non-transactional callers are untouched.

That keeps the property #953 is actually after — a replay must not emit a second card, and a first filing must not be silently card-less — without letting either effect precede commit.

  1. Regression test, per Ally: force a rollback after afterCreate (e.g. have the afterCreate hook throw, or make the issue-link insert fail) and assert plugin_event_outbox has no row for that approvalId and no live event escaped. Worth asserting the happy path too: exactly one outbox row after a successful create, and zero additional rows on a deduplicated replay.

CI

Ally's Recommended Action #2 — I've done this part. The PR workflow run at this head (30738540290) was cancelled, not failed, and the verify job's failure is purely downstream of that: its log shows TYPECHECK_RELEASE_REGISTRY_RESULT: cancelled, GENERAL_TESTS_RESULT: cancelled, WORKTREE_INSTALL_RESULT: cancelled, BUILD_RESULT: success, and it's a gate asserting all four are success. So there is no known test failure at d9f28c1e — the lanes simply never finished. I've re-run that workflow; it's queued now. Note the PR is also BEHIND master, so a rebase may be wanted before the final green.

Why I'm not pushing this myself

#953 exists because #945 was authored by app/allyblockcast — the same identity that reviews it — so it could never receive an independent review. That worked: Ally has now reviewed #953, which #945 never managed. This PR is yours, on your branch, and the fix above touches activity-log.ts, a shared file with three other affected call sites — a wider blast radius than a review nit, and a scoping call I don't think I should make unilaterally on someone else's PR.

So: happy to implement exactly the above and hand you a patch or a branch you can cherry-pick, if you'd rather not write it — just say. I haven't re-requested Ally, since there's no new head and a re-request would burn another ~50-minute pass for nothing.

Refs: BLO-19132.

@allyblockcast

allyblockcast Bot commented Aug 3, 2026

Copy link
Copy Markdown

Not a review — a CI status correction, because the last word on this PR is now wrong and has been for ~15 hours.

The re-run kicked off at 12:01Z (30738540290) has since finished, and head d9f28c1e is not green:

job conclusion at
General tests (workspaces-a) failure 12:17:02Z
General tests (server 4/4) cancelled 12:26:16Z
Verify serialized server suites cancelled 12:26:16Z
verify (gate) failure 12:27:21Z

Everything else on that run passed — e2e, Build, Typecheck + Release Registry, Canary Dry Run, Worktree install, policy, review, and server shards 1/2/3.

So the "no known test failure at d9f28c1e / nothing failing" note above was correct when written at 12:01Z and was overtaken 15 minutes later. I have not read the workspaces-a log and am not calling it real or flaky — it just needs someone to look, and so far nobody has.

Three things are outstanding here, all yours to sequence:

  1. workspaces-a red (above) — unattributed.
  2. Ally's Important finding — the transactional logActivity/outbox gap. Confirmed analysis and a proposed fix shape are in the comment above, and the offer of a patch or a cherry-pickable branch is still open if you'd rather not write it.
  3. Still BEHIND master — rebase likely wanted before final green.

Flagging rather than acting: this branch is yours and its independent authorship is the whole reason it exists, so nothing has been pushed here.

CEO — refs BLO-19132.

kkroo pushed a commit that referenced this pull request Aug 3, 2026
…e's own style (BLO-20886)

Review follow-up on the owning-reference rule. The body tier anchored the
Fixes:/Closes:/Resolves:/Refs: keyword to the start of the line, but
.github/PULL_REQUEST_TEMPLATE.md renders "## Linked Issues or Issue
Description" as a bullet list, so the repo's house style for an owning
reference is `- Refs: BLO-1`. PR #953 -- the live misroute this rule exists
to fix -- writes exactly `- Refs: [BLO-19132](...)`.

Replaying #953's verbatim body against the resolver showed the body tier
matching nothing: it resolved correctly only because the PR title happened
to carry `(BLO-19132)`. Any PR whose title omits the ref and whose body uses
the template's bullet style would have failed closed to
`no_owning_reference`, silently dropping an author wake that should have
been delivered to its owner. The pre-existing test did not catch this
because its fixture synthesizes a bare `Refs:    BLO-19132` line rather
than the bulleted shape the live payload actually has.

Allow an optional leading list marker (-, *, +, or `1.`). `Related:` still
never resolves as owning, bulleted or not.

Tests: github-webhook.test.ts 112 passed (new bulleted-reference case,
including #953's verbatim body); heartbeat-context-summary.test.ts 83
passed; server tsc --noEmit clean.

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

allyblockcast Bot commented Aug 4, 2026

Copy link
Copy Markdown

@kkroo — two updates that should shrink your remaining work on this to a single action. Not repeating the checklist from my 03:24Z comment above; this supersedes part of it.

1. workspaces-a is not a real failure — please don't spend time on it. I flagged it red earlier and said explicitly that nobody had read the log yet. I have now. The tests passed; the job flipped to failure on a cancelled post-step, and the whole run was cancelled:

gh api repos/Blockcast/paperclip/actions/jobs/91494060595 --jq '.steps[] | "\(.conclusion) \(.name)"'
  success   Run grouped general test suites     <-- tests PASSED
  cancelled Post Set up GHA-backed sccache      <-- this is what reddened the job

gh api repos/Blockcast/paperclip/actions/runs/30738540290 --jq '.conclusion'
  cancelled

Same artifact class as the verify red. The honest statement is not "it's green" but CI has never completed a clean run at d9f28c1e — both attempts ended cancelled. That needs a fresh run, not a bug hunt.

2. Ally's Important finding is being fixed upstream, not here. I opened #1024 against master for it. The finding is pre-existing and not introduced by this PRlogActivity already took a tx handle at three other call sites and ignored it for the plugin-outbox enqueue; yours would just be the first caller where the leaked event is a real plugin domain event (approval.created is in PLUGIN_EVENT_TYPES; the other three actions are not). So the right home is the shared helper, where it also fixes those three, rather than a route-local patch here that would leave them broken.

I have deliberately not pushed to this branch. Its independent authorship is the whole reason it exists — a commit from the bot identity that reviews it would destroy exactly the property that got you a real review — so that call stays yours, and the fix went somewhere it doesn't cost you anything.

Your remaining action, once #1024 merges: rebase onto master. That single step clears three things at once — it resolves the current dirty/conflicting state, retires Ally's finding without you writing any code, and triggers the fresh CI run that point 1 actually needs.

If you'd rather not wait on #1024, say so and I'll hand you the patch as a branch you can cherry-pick under your own authorship. Happy either way — flag whichever you prefer.

CTO and others added 4 commits August 4, 2026 15:38
…sting

The board-approval queue had a create/read asymmetry: filing a duplicate ask
was cheaper than checking whether one already existed, and a pending approval
emitted nothing back to its requester, so retrying was the only way to learn
anything. Three asks for one PR review landed inside 73 minutes because of it.

- `createApprovalSchema` gains an optional `idempotencyKey`. A retry with the
  same key, from the same requester, while the original is still undecided,
  replays the original (HTTP 200, `deduplicated: true`) instead of filing a
  second card. Scope is (company, requester, key) over the undecided statuses
  only — once the board answers, the key is released, because re-asking after a
  decision is a legitimately new request.
- Race safety via `pg_advisory_xact_lock` before the lookup, matching the
  issue-create path; two partial unique indexes are the backstop.
- The replay response carries a `statusReadback` line, so a requester learns the
  original is still pending without re-filing to find out.
- `GET /companies/:id/approvals` gains `view=count` and `view=summary` plus
  `type` / `issueId` / `requestedByAgentId` / `idempotencyKey` filters. Summary
  omits the payload body and derives an always-populated `label`, so a
  pre-file existence check costs a fraction of a re-file. Measured on the live
  63-row pending queue: full 213.2 KB, summary 22.5 KB (9x), count 12 bytes.
- The derived label falls back through title -> summary -> description -> type
  plus short id, and treats a literal `***REDACTED***` as absent, so an approval
  whose title was blanked by the field-name redactor still renders something a
  human can triage. The redactor itself is BLO-20810, not this change.

Default `view=full` behaviour is unchanged.

Refs BLO-19132

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

Self-review follow-up on the dedupe change. The create-side lookup used a
locally-declared status set while the partial unique indexes hardcoded the same
set in SQL, in two places (the drizzle schema and migration 0208). If those ever
drift, an idempotent replay stops matching and the insert hits a raw unique
violation — a 500 where the whole point was to return the original.

- `APPROVAL_UNDECIDED_STATUSES` in shared constants is now the single source of
  truth, consumed by `approvalService`'s `canResolveStatuses`.
- The migration is frozen history and the drizzle schema must mirror it verbatim,
  so neither imports the constant. An eager cross-package import at schema module
  scope would also take the entire db schema down if it ever failed to resolve,
  which is a worse failure than the drift it prevents.
- Instead two tests read the migration SQL and the schema file and assert both
  agree with the constant. Mutation-checked: widening either site alone fails its
  own test and only its own test.

No behaviour change.

Refs BLO-19132

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
@kkroo
kkroo force-pushed the kkroo/blo-19132-approval-dedupe-v2 branch from d9f28c1 to 4b00481 Compare August 4, 2026 22:45
@kkroo

kkroo commented Aug 4, 2026

Copy link
Copy Markdown
Author

@ally please re-review exact head 4b00481223fc0d6b93bcfb6f67541d3e35e3a43d. I rebased onto current master, renumbered the approval idempotency migration to 0210, and fixed the Important finding by using logActivity(..., { deferPublish: true }) inside the create transaction and flushing the returned publisher only after createWithIdempotency returns. Added route regressions for post-commit flush and rollback/no-flush. Local verification: git diff --check, server approval route/service Vitest (39 passed), shared approval validator Vitest (9 passed), and pnpm --filter @paperclipai/server typecheck all passed.

@allyblockcast allyblockcast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved exact head 4b00481. Rebased onto current master, migration renumbered to 0210, and the prior transactional approval-created finding is addressed by deferring logActivity publication until after createWithIdempotency returns. Local focused route/service/shared tests and server typecheck passed; no unresolved review threads.

@kkroo
kkroo enabled auto-merge August 4, 2026 22:48

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown

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: 4b00481

Looks good. The prior transactional publication blocker is fixed, and no new Critical or Important issues remain.

Prior Findings Dispositioned (1)

  • prior:d9f28c1 important 1 — fixed — server/src/routes/approvals.ts:219logActivity now records with deferPublish: true inside the create transaction, and the returned publisher is flushed only after createWithIdempotency returns at line 252; rollback therefore cannot emit a phantom approval.created event.

Suggestions (1)

  • [pr-review-toolkit] server/src/services/approvals.ts:64 — Consider bounding the derived summary label length so an unusually large payload title cannot make the cheap summary response unexpectedly large.

Strengths

  • Requester-scoped advisory locking and partial unique indexes provide coherent retry behavior under concurrency.
  • Count, summary, and full views share one filter builder, while summary labels pass through approval-type redaction and omit raw payloads.
  • Route regressions cover post-commit publication and rollback without publication.

Recommended Action

  1. Merge after the required CI checks complete successfully.

@kkroo
kkroo added this pull request to the merge queue Aug 5, 2026
kkroo pushed a commit that referenced this pull request Aug 5, 2026
…, not an arbitrary Related: backlink (BLO-20886)

extractPaperclipIdentifiers treated every BLO-#### token in a PR's
branch/title/body as equally-weighted, so the author-directed wake loop
(prRole: "author") fired for the assignee of EVERY matched issue --
including ones named only under an informational `Related:` list. Live
incident: PR #953 carried `Refs: BLO-19132` (the true owner) plus
`Related: BLO-20810, BLO-20129, BLO-19079`; CTO, assignee of BLO-20129 (the
third Related: entry), got a wake asserting "a reviewer just posted
findings on YOUR pull request" and instructing a push -- to a human
contributor's PR with zero recorded reviews.

Fix:
- paperclip-identifiers.ts: resolveOwningPaperclipIdentifiers() resolves the
  PR's owning issue(s) via branch > title > labeled Fixes:/Closes:/Resolves:/
  Refs: body line (colon optional, matching this repo's existing PR-body
  convention). A bare/Related: mention is never owning.
- github-webhook.ts: the author-wake loop is now restricted to the owning
  issue(s) only. `matched` (the full identifier set) is untouched for the
  back-link comment and merged-PR forward-capture, which are informational
  and correctly link every mentioned issue. When no owning issue resolves,
  the wake is dropped with a logged suppressionReason (no_owning_reference)
  instead of falling through to a lower-priority or Related: mention.
- heartbeat.ts: the author directive text ("YOUR pull request" / "push a
  follow-up commit") now only renders for wakeReasons that structurally
  guarantee review content exists (github_pr_review_submitted,
  github_pr_review_feedback). github_pr_review_requested and plain PR
  lifecycle events get a neutral directive stating what's actually known,
  with an explicit instruction not to push on unconfirmed feedback.

Selection rule (per BLO-20886's acceptance criteria): branch ref outranks
title ref outranks a labeled Fixes:/Closes:/Resolves:/Refs: body line;
Related: and unlabeled mentions never count as owning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kkroo pushed a commit that referenced this pull request Aug 5, 2026
…e's own style (BLO-20886)

Review follow-up on the owning-reference rule. The body tier anchored the
Fixes:/Closes:/Resolves:/Refs: keyword to the start of the line, but
.github/PULL_REQUEST_TEMPLATE.md renders "## Linked Issues or Issue
Description" as a bullet list, so the repo's house style for an owning
reference is `- Refs: BLO-1`. PR #953 -- the live misroute this rule exists
to fix -- writes exactly `- Refs: [BLO-19132](...)`.

Replaying #953's verbatim body against the resolver showed the body tier
matching nothing: it resolved correctly only because the PR title happened
to carry `(BLO-19132)`. Any PR whose title omits the ref and whose body uses
the template's bullet style would have failed closed to
`no_owning_reference`, silently dropping an author wake that should have
been delivered to its owner. The pre-existing test did not catch this
because its fixture synthesizes a bare `Refs:    BLO-19132` line rather
than the bulleted shape the live payload actually has.

Allow an optional leading list marker (-, *, +, or `1.`). `Related:` still
never resolves as owning, bulleted or not.

Tests: github-webhook.test.ts 112 passed (new bulleted-reference case,
including #953's verbatim body); heartbeat-context-summary.test.ts 83
passed; server tsc --noEmit clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 5, 2026
# Conflicts:
#	packages/db/src/migrations/meta/_journal.json
#	packages/db/src/schema/approvals.ts
#	server/src/__tests__/approvals-service.test.ts
#	server/src/services/approvals.ts
@kkroo
kkroo enabled auto-merge August 5, 2026 23:22

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown

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: a1127d4

Looks good. The merge conflict resolution preserves the previously reviewed approval behavior, integrates the new linkedAgentId schema from master, and renumbers the idempotency migration and its drift test consistently to 0211.

Suggestions (1)

  • [native-codex] server/src/services/approvals.ts:354 — Consider detecting reuse of an idempotency key with a different approval type or normalized payload. The current contract deliberately replays the original and permits additive issue links; a request fingerprint plus 409 Conflict for mismatched immutable fields would make accidental key collisions easier to diagnose.

Strengths

  • Requester-scoped advisory locking and partial unique indexes provide coherent retry behavior under concurrency.
  • The prior transactional publication blocker remains fixed: activity publication is deferred until the create transaction succeeds, with rollback/no-flush regression coverage.
  • Count, summary, and full views share one filter builder, while summary labels pass through type-aware redaction and omit raw payloads.
  • The synchronized head resolves the migration conflict cleanly: master owns 0210_approval_linked_agent, and this change consistently uses 0211_approval_create_idempotency.

Recommended Action

  1. Merge after the required CI checks complete successfully.

@allyblockcast allyblockcast left a comment

Copy link
Copy Markdown
Collaborator

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: a1127d4

Looks good. The merge conflict resolution preserves the previously reviewed approval behavior, integrates the new linkedAgentId schema from master, and renumbers the idempotency migration and its drift test consistently to 0211.

Suggestions (1)

  • [native-codex] server/src/services/approvals.ts:354 — Consider detecting reuse of an idempotency key with a different approval type or normalized payload. The current contract deliberately replays the original and permits additive issue links; a request fingerprint plus 409 Conflict for mismatched immutable fields would make accidental key collisions easier to diagnose.

Strengths

  • Requester-scoped advisory locking and partial unique indexes provide coherent retry behavior under concurrency.
  • The prior transactional publication blocker remains fixed: activity publication is deferred until the create transaction succeeds, with rollback/no-flush regression coverage.
  • Count, summary, and full views share one filter builder, while summary labels pass through type-aware redaction and omit raw payloads.
  • The synchronized head resolves the migration conflict cleanly: master owns 0210_approval_linked_agent, and this change consistently uses 0211_approval_create_idempotency.

Recommended Action

  1. Merge after the required CI checks complete successfully.

kkroo pushed a commit that referenced this pull request Aug 6, 2026
…, not an arbitrary Related: backlink (BLO-20886)

extractPaperclipIdentifiers treated every BLO-#### token in a PR's
branch/title/body as equally-weighted, so the author-directed wake loop
(prRole: "author") fired for the assignee of EVERY matched issue --
including ones named only under an informational `Related:` list. Live
incident: PR #953 carried `Refs: BLO-19132` (the true owner) plus
`Related: BLO-20810, BLO-20129, BLO-19079`; CTO, assignee of BLO-20129 (the
third Related: entry), got a wake asserting "a reviewer just posted
findings on YOUR pull request" and instructing a push -- to a human
contributor's PR with zero recorded reviews.

Fix:
- paperclip-identifiers.ts: resolveOwningPaperclipIdentifiers() resolves the
  PR's owning issue(s) via branch > title > labeled Fixes:/Closes:/Resolves:/
  Refs: body line (colon optional, matching this repo's existing PR-body
  convention). A bare/Related: mention is never owning.
- github-webhook.ts: the author-wake loop is now restricted to the owning
  issue(s) only. `matched` (the full identifier set) is untouched for the
  back-link comment and merged-PR forward-capture, which are informational
  and correctly link every mentioned issue. When no owning issue resolves,
  the wake is dropped with a logged suppressionReason (no_owning_reference)
  instead of falling through to a lower-priority or Related: mention.
- heartbeat.ts: the author directive text ("YOUR pull request" / "push a
  follow-up commit") now only renders for wakeReasons that structurally
  guarantee review content exists (github_pr_review_submitted,
  github_pr_review_feedback). github_pr_review_requested and plain PR
  lifecycle events get a neutral directive stating what's actually known,
  with an explicit instruction not to push on unconfirmed feedback.

Selection rule (per BLO-20886's acceptance criteria): branch ref outranks
title ref outranks a labeled Fixes:/Closes:/Resolves:/Refs: body line;
Related: and unlabeled mentions never count as owning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kkroo pushed a commit that referenced this pull request Aug 6, 2026
…e's own style (BLO-20886)

Review follow-up on the owning-reference rule. The body tier anchored the
Fixes:/Closes:/Resolves:/Refs: keyword to the start of the line, but
.github/PULL_REQUEST_TEMPLATE.md renders "## Linked Issues or Issue
Description" as a bullet list, so the repo's house style for an owning
reference is `- Refs: BLO-1`. PR #953 -- the live misroute this rule exists
to fix -- writes exactly `- Refs: [BLO-19132](...)`.

Replaying #953's verbatim body against the resolver showed the body tier
matching nothing: it resolved correctly only because the PR title happened
to carry `(BLO-19132)`. Any PR whose title omits the ref and whose body uses
the template's bullet style would have failed closed to
`no_owning_reference`, silently dropping an author wake that should have
been delivered to its owner. The pre-existing test did not catch this
because its fixture synthesizes a bare `Refs:    BLO-19132` line rather
than the bulleted shape the live payload actually has.

Allow an optional leading list marker (-, *, +, or `1.`). `Related:` still
never resolves as owning, bulleted or not.

Tests: github-webhook.test.ts 112 passed (new bulleted-reference case,
including #953's verbatim body); heartbeat-context-summary.test.ts 83
passed; server tsc --noEmit clean.

Co-Authored-By: Claude <noreply@anthropic.com>
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.

2 participants