Files
PaperClipAI/packages/db/src/issue-thread-interaction-resolver-policy-migration.test.ts
T
DottaandPaperclip 10d0555189 fix(interactions): authorize resolvers consistently (#11376)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Issue interactions give agents and people a structured decision
record.
> - Resolver routes used different authorization rules.
> - Some routes blocked valid agents, including task watchdogs with
normal issue access.
> - The API did not show who could resolve a pending interaction.
> - This pull request gives every interaction kind one resolver policy
evaluator.
> - The benefit is a clear decision path with consistent governance and
company isolation.

## Linked Issues or Issue Description

Fixes: #8087

Refs: #7403

Related PR: #11082 proposes board-only confirmation rules. This change
keeps human-only review as an explicit policy.

**What happened?**

Agents could create issue interactions. Some resolver routes still
required board access.

This left valid agent confirmations pending. Task watchdogs could see
the same problem without board identity.

**Expected behavior**

Every interaction kind must use one resolver policy contract.

The contract must support `anyone`, `not_creator`, and `human_only`. It
must also apply all normal governance controls.

**Steps to reproduce**

1. Create a `request_confirmation` interaction as an agent.
2. Resolve it with another authorized agent.
3. Observe the board-only denial.

**Paperclip version or commit**

The problem exists on `master` before this change.

**Deployment mode**

Local development with `pnpm dev`.

## What Changed

- Add canonical policies for `anyone`, `not_creator`, and `human_only`.
- Use one server evaluator for every interaction kind.
- Apply named addressees, company limits, review rules, and task
watchdog scope.
- Charge cross-issue resolutions to the existing per-run action limit.
- Return the effective resolver audience in attention and interaction
data.
- Show the audience, governance choices, and denial reasons in the board
UI.
- Add telemetry, API documents, product documents, and regression
fixtures.
- Add migration provenance for safe legacy behavior.
- Make migration `0218` safe for complete replays and partial prior
runs.

## Product Rules

- An interaction records a response. It does not grant authority for the
next action.
- `anyone` lets any authorized issue participant respond.
- `not_creator` requires a responder other than the interaction creator.
- `human_only` requires an authorized person.
- A named addressee, company policy, or governed action can narrow the
audience.
- These controls cannot widen the audience.
- A task watchdog uses the same rules as an ordinary agent.
- A task watchdog does not receive board authority.
- An agent resolution on another issue uses the shared cross-issue
action limit.
- Legacy pending interactions keep their earlier restrictions.
- The UI shows the effective audience and a permanent denial reason.

## Verification

- `pnpm --filter @paperclipai/db check:migrations`
- `pnpm --filter @paperclipai/db typecheck`
- `pnpm exec vitest run
packages/db/src/issue-thread-interaction-resolver-policy-migration.test.ts`
- The focused PostgreSQL test applies migration `0218` twice.
- The test also completes a partial prior run and preserves existing
provenance.
- The latest GitHub head has 29 successful checks.
- The opt-in Storybook visual check skipped as expected.
- Greptile reports 5/5 with no open comments.

## Risks

- New interaction writes use `anyone` by default.
- Callers must select `not_creator` or `human_only` when they need
stricter review.
- Legacy pending interactions keep the old creator and human
restrictions.
- Migration `0218` fills only missing provenance fields during recovery.
- Cross-issue resolutions can reach the existing action limit.
- The shared evaluator affects every interaction kind.
- Route, service, database, shared contract, and UI tests cover these
rules.

> This work matches the Agent Reviews and Approvals direction in
`ROADMAP.md`. It does not duplicate a planned item.

## Model Used

OpenAI Codex, GPT-5. The runtime does not expose the exact deployment ID
or context window.

The agent used reasoning, repository tools, shell commands, and test
execution.

## 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 linked public issues or described the issue with the
required labels
- [x] I have not referenced internal Paperclip issues or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented the risks
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open comments
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-16 13:46:50 -05:00

173 lines
5.5 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import postgres from "postgres";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const cleanups: Array<() => Promise<void>> = [];
const support = await getEmbeddedPostgresTestSupport();
const d = support.supported ? describe : describe.skip;
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()?.();
});
async function resolverPolicyMigrationStatements(): Promise<string[]> {
const migrationSql = await readFile(
fileURLToPath(new URL("./migrations/0218_mushy_jack_murdock.sql", import.meta.url)),
"utf8",
);
return migrationSql
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
d("issue interaction resolver policy migration", () => {
it("replays safely and completes a partial prior run", async () => {
const dbh = await startEmbeddedPostgresTestDatabase("resolver-policy-migration-");
cleanups.push(() => dbh.cleanup());
const sql = postgres(dbh.connectionString, { max: 1, onnotice: () => {} });
cleanups.push(async () => {
await sql.end();
});
const companyId = randomUUID();
const issueId = randomUUID();
const legacyInteractionId = randomUUID();
const partialInteractionId = randomUUID();
await sql`
INSERT INTO "companies" ("id", "name", "issue_prefix")
VALUES (${companyId}, 'Resolver migration test', 'RMT')
`;
await sql`
INSERT INTO "issues" ("id", "company_id", "title", "identifier")
VALUES (${issueId}, ${companyId}, 'Test migration replay', 'RMT-1')
`;
await sql.unsafe(
'ALTER TABLE "issue_thread_interactions" ALTER COLUMN "resolver_policy_provenance" DROP NOT NULL',
);
await sql.unsafe(
'ALTER TABLE "issue_thread_interactions" ALTER COLUMN "effective_resolver_policy_source" DROP NOT NULL',
);
await sql`
INSERT INTO "issue_thread_interactions" (
"id",
"company_id",
"issue_id",
"kind",
"requested_resolver_policy",
"effective_resolver_policy",
"resolver_policy_provenance",
"effective_resolver_policy_source",
"payload"
)
VALUES
(
${legacyInteractionId},
${companyId},
${issueId},
'request_confirmation',
'board_or_agents',
'board_only',
NULL,
NULL,
'{}'::jsonb
),
(
${partialInteractionId},
${companyId},
${issueId},
'request_confirmation',
'human_only',
'human_only',
'explicit',
NULL,
'{}'::jsonb
)
`;
const statements = await resolverPolicyMigrationStatements();
expect(statements.length).toBeGreaterThan(0);
for (const statement of statements) await sql.unsafe(statement);
const rowsAfterFirstRun = await sql<{
id: string;
requested_resolver_policy: string;
effective_resolver_policy: string;
resolver_policy_provenance: string;
effective_resolver_policy_source: string;
}[]>`
SELECT
"id",
"requested_resolver_policy",
"effective_resolver_policy",
"resolver_policy_provenance",
"effective_resolver_policy_source"
FROM "issue_thread_interactions"
WHERE "id" IN (${legacyInteractionId}, ${partialInteractionId})
ORDER BY "id"
`;
expect(rowsAfterFirstRun).toEqual(
[
{
id: legacyInteractionId,
requested_resolver_policy: "not_creator",
effective_resolver_policy: "human_only",
resolver_policy_provenance: "legacy_inherited_restriction",
effective_resolver_policy_source: "company_cap",
},
{
id: partialInteractionId,
requested_resolver_policy: "human_only",
effective_resolver_policy: "human_only",
resolver_policy_provenance: "explicit",
effective_resolver_policy_source: "requested",
},
].sort((left, right) => left.id.localeCompare(right.id)),
);
for (const statement of statements) await sql.unsafe(statement);
const rowsAfterReplay = await sql`
SELECT
"id",
"requested_resolver_policy",
"effective_resolver_policy",
"resolver_policy_provenance",
"effective_resolver_policy_source"
FROM "issue_thread_interactions"
WHERE "id" IN (${legacyInteractionId}, ${partialInteractionId})
ORDER BY "id"
`;
expect(rowsAfterReplay).toEqual(rowsAfterFirstRun);
const columns = await sql<
{ column_name: string; is_nullable: string; column_default: string }[]
>`
SELECT "column_name", "is_nullable", "column_default"
FROM "information_schema"."columns"
WHERE "table_schema" = 'public'
AND "table_name" = 'issue_thread_interactions'
AND "column_name" IN ('resolver_policy_provenance', 'effective_resolver_policy_source')
ORDER BY "column_name"
`;
expect(columns).toEqual([
{
column_name: "effective_resolver_policy_source",
is_nullable: "NO",
column_default: "'requested'::text",
},
{
column_name: "resolver_policy_provenance",
is_nullable: "NO",
column_default: "'inherited'::text",
},
]);
}, 240_000);
});