mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## 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>
173 lines
5.5 KiB
TypeScript
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);
|
|
});
|