Files
PaperClipAI/scripts
DottaandClaude Fable 5.2 8ab955776d perf(github): check the requester provenance of an issue list's review waits in one batch (#20851)
<!-- Write all pull request text in Simplified Technical English
(ASD-STE100): short sentences, one instruction per sentence, simple
approved vocabulary, and the active voice. -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - An agent can review a GitHub pull request. After the agent publishes
the review, the task waits in review for the next GitHub reply. A later
run of the same task can inherit this wait.
> - Issue lists, issue summaries and inbox attention show these waits.
For each waiting task, the server first checks the requester provenance
of the waiting run (`chatRunSourceMessageId`). This check is an
authorization check: it walks the identity-context lineage of the run
and rejects a changed requester, another task, another company or
another provider.
> - #20673 batched the review reads and the publication reads of one
list request. It kept the provenance check as one call for each waiting
run, inside `Promise.all`.
> - Thus a request with N waits still built and sent at least 2N
statements at the same moment. The database is fast for each statement,
but the server builds each statement on its main thread.
> - On a hosted instance, a CPU profile of the server showed the main
thread at 96% of one core. SQL text building, per-statement timers,
socket writes and garbage collection had the largest shares. Connection
samples showed 235 to 292 pool connections whose last statement was the
source-message link read of this check, and 53 to 112 whose last
statement was its identity-context read.
> - This pull request checks the provenance of all the waiting runs of
one request in one batch. The runs that need the same kind of read share
one statement. Each run gets the same decision as a separate call, from
the same walk and the same reads.
> - The benefit is that a list request with 250 waits sends 17
statements, not 658, and uses about one quarter of the CPU time. The
statement count no longer follows the number of waits.

## Linked Issues or Issue Description

Refs #20673 (batched the review reads and the publication reads of the
same request; this change does the same for the provenance reads that
#20673 kept for each run).
Refs #20551 (authorizes the waits of one issue page with one read).

**What happened**

`readInheritedGitHubReviewWaits` called `chatRunSourceMessageId` one
time for each waiting run, inside `Promise.all`. Each call sent an
identity-context read and a source-message link read. A run that
continues an earlier run also sent a parent-context read and a
source-run read for each continuation in its lineage.
`listExternalReviewWaits` calls this function for the issue list, the
issue summaries and the inbox attention of a company, so each of those
requests repeated the same reads for every waiting task.

Evidence from a hosted instance (sanitized):

- CPU profile of the server process, 30 s, 5 ms samples: main thread at
96% of one core. Building SQL text for select statements: 17.7%
inclusive. The idle-timer restart that the database client does for each
statement: 7.6%. Socket writes: 6.2%. Garbage collection: 13%.
- Three samples of the pool connections (about 494 connections). 235 to
292 connections had the source-message link read of
`chatRunSourceMessageId` as their last statement. 53 to 112 connections
had its identity-context read as their last statement.
- The `DISTINCT ON` review read that #20673 added ran about 29 times
each minute. It runs one time for each list request, so each request
started hundreds of provenance reads.

**Expected behavior**

One list request sends a number of provenance statements that does not
follow the number of waiting tasks. The decision for each run does not
change.

**Steps to reproduce**

1. Put many tasks of one company in review, each with an inherited
GitHub review wait.
2. Open the issue list or the inbox.
3. Count the statements that the server sends. The identity-context read
and the source-message link read run one time for each waiting task.

The benchmark below does these steps on a local PostgreSQL.

## What Changed

- `server/src/services/chat-channels/run-origin.ts`:
- The provenance decision is now one walk (`sourceMessageWalk`). The
walk asks for one read at a time and decides on the returned rows only.
Its checks, their order and its error messages are the checks of the
previous `chatRunSourceMessageId`.
- The five reads of the walk (active identity context, source-message
link, board message, parent context, source run) are defined one time in
`provenanceReads`. Each definition takes its values either as literals
or as SQL columns.
- `chatRunSourceMessageId` (single run) drives the walk with one
statement for each read, as before. The joins, the company scope and the
filters are the same. The identity-context reads now select the seven
columns that the walk uses instead of every column.
- New `chatRunSourceMessageIds(db, runs, options)` (batch). It drives
one walk for each run. In each round, the runs that wait for the same
kind of read share one statement: `unnest(...)` of the request values,
`CROSS JOIN LATERAL` the same single-row read with the request columns
in place of the literals. Thus each run gets the row that its own single
read returns, with `LIMIT 1` and the company scope on each join. The
result for each run is `{ messageId }` or `{ forbidden }` (the 403 that
the single call throws). A database error rejects the whole batch, as
`Promise.all` did.
- `server/src/services/chat-channels/github/review-wait.ts`:
`readInheritedGitHubReviewWaits` checks the provenance of its runs with
the batch. A 403 still gives `revoked`. The single-run
`readInheritedGitHubReviewWait` does not change.
- `server/src/services/chat-channels/run-origin.test.ts`: two new tests
(see Verification).
- `server/src/__tests__/github-review-wait.test.ts`: one new regression
test for the statement count.
- `server/scripts/benchmark-review-wait-provenance.ts`: the reproduction
and the before/after measurement. It loads the pre-fix modules from
`--baseline-ref` and fails unless both versions return the same waits.
- `scripts/benchmark-github-review-wait.ts`: removed an assertion that
`run-origin.ts` is byte-identical to its baseline. Both sides of that
benchmark import the current helper, so the comparison stays fair.
Without this change, the documented command of that benchmark fails.
- `doc/DATABASE.md`: describes the batch provenance check and the new
benchmark command.

Statement count of one batch: the walk of one run does at most 3 + 4L
reads, where L is the number of continuations in its requester lineage.
The batch does the same number of rounds for its longest lineage. Each
round sends at most one statement for each kind of read (5 kinds), for
any number of runs. A request whose waiting runs accepted their GitHub
comment directly sends 2 provenance statements.

## Verification

Red/green regression test
(`server/src/__tests__/github-review-wait.test.ts`):

- `reads the waits of forty runs with the statements of four runs`.
Before the fix: `expected { few: 10, many: 82 } to deeply equal { few:
4, many: 4 }`. After the fix: passes.

Equivalence tests
(`server/src/services/chat-channels/run-origin.test.ts`), on real
PostgreSQL:

- `gives every run of a batch the decision of its own check, in every
provenance state`. 33 runs, each in a different provenance state, are
checked with the single function and with the batch for five option sets
(no options, legacy wake allowed, GitHub, GitHub with legacy wake,
Slack). The test compares the decision of each run (message, `null`, or
the 403 and its message) for: the full batch, the reversed batch, and a
batch of one run. The states include the negative cases: a run of
another company, a message that is linked only in another company, a
message that is linked to another task, a conversation that moved to
another task, a continuation on another task, a changed requester (run,
identity context, continuation, board message), a link of another
provider, a missing link, an outbound link only, a deleted message, an
agent message, a rejected or pending identity context, a missing parent,
another agent and a lineage cycle. The test also states the expected
GitHub decision for each state, so the comparison cannot pass on two
wrong answers.
- `checks the runs of a batch with statements that follow the lineage,
not the number of runs`. 4 runs and 40 runs each send 2 statements. 40
separate checks send 80. One continuation in each lineage gives 4
statements for the batch.
- A mutation check: when the batch gives a row to the wrong request,
both tests fail.

Commands:

- `pnpm vitest run server/src/services/chat-channels/run-origin.test.ts
server/src/__tests__/github-review-wait.test.ts`
- Focused run of 15 test files that import the changed modules, their
callers or the shared seed helper, including
`external-chat-wait.integration.test.ts`, `chat-github-tools.test.ts`,
`slack-authority.test.ts` and
`issue-list-review-wait-authorization.integration.test.ts`: 482 tests
passed. In `idle-completed-history.test.ts`, `afterEach` hooks (a
`TRUNCATE` of the test database) timed out at 30 s for 1 test in this
run and for 3 tests in a second run of the file, while other agents
loaded the machine. No assertion failed. The three tests of that file
that reach the changed code pass when they run alone. CI runs the full
file.
- `server/src/__tests__/chat-channels.integration.test.ts` also uses the
single check. It was not run locally (very large file). CI runs it.
- Typecheck of the changed files and of the callers of
`chatRunSourceMessageId` with a focused `tsc` program: no errors in
these files. The full server typecheck was not run locally (shared
machine). CI supplies it.
- The first CI run of this pull request failed `ci / policy`: the module
boundary check rejects an insert into the agents table outside the agent
lifecycle module, and the benchmark seeded its fixture agent that way.
The second commit seeds that agent with SQL. `pnpm
check:module-boundaries` passes locally, and the benchmark gives the
same statement counts.
- `pnpm verify` returns exit 2 (broader verification required) because
the change adds a file under `server/scripts/`. CI supplies the broad
coverage.

Before/after measurement:

```sh
pnpm --filter @paperclipai/server exec tsx scripts/benchmark-review-wait-provenance.ts --baseline-ref=e2cf9a670fc5d5f5058fd0972beb71650f77c554
```

One call of `listExternalReviewWaits` (the read of one issue list
request). Embedded PostgreSQL 18.1, Node v26.4.0, `VACUUM ANALYZE` after
seeding. One company, one agent, one GitHub repository. Every wait has a
published review. Every tenth waiting run continues three earlier runs.
3 warm-up calls, then 15 pairs in alternating order. Both versions
return the same waits and the same inherited wait for each run.

| Waits | Version | Statements | SQL text bytes | Wall ms, median
(min–max) | CPU ms, median (min–max) |
| --- | --- | --- | --- | --- | --- |
| 20 | before | 60 | 31,084 | 14.1 (12.8–19.6) | 13.7 (8.7–39.3) |
| 20 | after | 17 | 15,847 | 12.5 (9.4–22.7) | 8.8 (4.1–87.1) |
| 250 | before | 658 | 300,611 | 63.3 (58.1–89.6) | 78.1 (64.8–103.3) |
| 250 | after | 17 | 17,171 | 20.8 (18.9–29.2) | 18.7 (14.1–27.3) |
| 1000 | before | 2,608 | 1,179,710 | 256.0 (221.0–363.2) | 303.3
(262.2–507.8) |
| 1000 | after | 17 | 21,695 | 54.9 (46.7–81.7) | 51.0 (41.6–72.2) |

Of the 658 statements before the fix at 250 waits, 650 are provenance
reads: 325 identity-context reads, 250 source-message link reads and 75
source-run reads. After the fix, 9 statements do the same reads. The
other 8 statements of the request do not change. Sizes are set with
`WAITS=20` and `WAITS=1000`. Other agents used the same machine during
the measurement, so the maximum values have noise.

- The batch statements use index scans only
(`run_identity_contexts_pkey`, `run_identity_contexts_company_run_idx`,
`chat_message_links_company_comment_idx`, `chat_conversations_pkey`,
`chat_endpoints_company_id_uq`, `heartbeat_runs_pkey`). The 9 batch
statements of a request with 250 waits take about 1 ms of server
execution in total.
- Wall time and CPU time are almost equal before the fix. This shows
that the cost is in the server process, not in the database.

Not verified:

- The effect on the hosted instance. This pull request does not deploy.
The share of the main-thread CPU that this path uses there is not known;
the profile gives the share of all statement building.

## Risks

- The provenance check is an authorization check. The risk is a batch
decision that differs from the single decision. Mitigation: both paths
run the same walk and build each read from the same definition. The
batch read is the single read inside `CROSS JOIN LATERAL`, for each
request row. The equivalence test compares both paths in every state.
- A database error (for example an invalid UUID in a legacy task
binding) rejects the whole batch. The previous `Promise.all` also
rejected the whole request. The callers of the batch pass the native
task of each run.
- The statement count of the batch follows the longest lineage of its
runs. A batch with one very long lineage sends as many statements as
that run sent before, in sequence. It does not send more.
- The batch sends its statements in sequence on one connection. Before,
the reads of N runs used up to N pool connections at the same moment.
- The single-run callers (GitHub tools, review publication, response
comments, Slack authority, the finalizer, the status committer) keep one
statement for each read. Their identity-context reads select fewer
columns.
- No migration. No new index.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Fable 5.2 (`claude-fable-5-2`), Anthropic, used through Claude
Code with tool use (code reading, editing, shell, tests).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 10:19:29 -06:00
..
2026-03-09 10:03:45 -05:00
2026-03-03 14:00:10 -06:00
2026-04-09 06:14:12 -05:00