Files
PaperClipAI/ui/src/lib/interaction-resolution-error.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

166 lines
6.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { ApiError } from "../api/client";
import {
describeInteractionResolutionFailure,
interactionResolutionErrorCode,
interactionResolutionErrorMessage,
isInteractionAudienceDenial,
} from "./interaction-resolution-error";
const addressedAudience = { shortSummary: "Only CodexCoder or the board can respond", isOpen: false };
const humanOnlyAudience = { shortSummary: "Only the board can respond", isOpen: false };
const openAudience = { shortSummary: "Anyone can respond", isOpen: true };
/** Shapes an `ApiError` the way `errorHandler` serializes an `HttpError`. */
function denial(status: number, code: string, message: string) {
return new ApiError(message, status, { error: message, code, details: { code } });
}
describe("interactionResolutionErrorCode", () => {
it("reads the top-level code the API returns", () => {
expect(interactionResolutionErrorCode(denial(403, "interaction_human_only", "human-only"))).toBe(
"interaction_human_only",
);
});
it("falls back to the code nested in details", () => {
const error = new ApiError("nope", 403, { error: "nope", details: { code: "interaction_addressee_mismatch" } });
expect(interactionResolutionErrorCode(error)).toBe("interaction_addressee_mismatch");
});
it("returns null for an error with no structured body", () => {
expect(interactionResolutionErrorCode(new Error("network down"))).toBeNull();
});
});
describe("isInteractionAudienceDenial", () => {
it("recognizes every audience denial code the evaluator can return", () => {
for (const code of [
"interaction_human_only",
"interaction_creator_excluded",
"interaction_addressee_mismatch",
"interaction_governed_action_denied",
"interaction_run_attribution_required",
"interaction_scope_denied",
]) {
expect(isInteractionAudienceDenial(denial(403, code, "denied"))).toBe(true);
}
});
it("treats an uncoded 403 as an authorization refusal", () => {
expect(isInteractionAudienceDenial(new ApiError("Forbidden", 403, { error: "Forbidden" }))).toBe(true);
});
it("does not treat a server fault or a settled card as an audience denial", () => {
expect(isInteractionAudienceDenial(new ApiError("boom", 500, { error: "boom" }))).toBe(false);
expect(isInteractionAudienceDenial(denial(409, "interaction_already_resolved", "already resolved"))).toBe(false);
});
});
describe("describeInteractionResolutionFailure", () => {
it("keeps the server denial reason and names who can respond", () => {
const failure = describeInteractionResolutionFailure(
denial(403, "interaction_human_only", "This issue-thread interaction is human-only"),
humanOnlyAudience,
);
expect(failure.kind).toBe("audience_denied");
expect(failure.code).toBe("interaction_human_only");
expect(failure.message).toBe(
"This issue-thread interaction is human-only. Only the board can respond.",
);
// A policy denial is permanent: never invite a retry that will fail again.
expect(failure.message).not.toMatch(/try again/i);
});
it("names the addressed responder for an addressee mismatch", () => {
expect(
interactionResolutionErrorMessage(
denial(
403,
"interaction_addressee_mismatch",
"Only the addressed agent or an authorized human may resolve this issue-thread interaction",
),
addressedAudience,
),
).toContain("Only CodexCoder or the board can respond.");
});
it("still explains a denial when no audience is known", () => {
const failure = describeInteractionResolutionFailure(
denial(403, "interaction_creator_excluded", "This issue-thread interaction requires a resolver other than its creator"),
null,
);
expect(failure.message).toBe(
"This issue-thread interaction requires a resolver other than its creator.",
);
expect(failure.message).not.toMatch(/try again/i);
});
// PAP-17289: the responder clause explains a denial, so it must never
// contradict it. An open card has no narrower audience to point at.
it("does not append the open-default clause to a refusal it would refute", () => {
const failure = describeInteractionResolutionFailure(
denial(
403,
"interaction_addressee_mismatch",
"Only the addressed agent can resolve this interaction",
),
openAudience,
);
expect(failure.kind).toBe("audience_denied");
expect(failure.message).toBe("Only the addressed agent can resolve this interaction.");
expect(failure.message).not.toMatch(/anyone can respond/i);
});
it("keeps the server's own text for an uncoded 403 instead of inventing a cause", () => {
const failure = describeInteractionResolutionFailure(
new ApiError("Forbidden", 403, { error: "Forbidden" }),
openAudience,
);
expect(failure).toMatchObject({ kind: "audience_denied", code: null, message: "Forbidden." });
expect(failure.message).not.toMatch(/resolver audience/i);
expect(failure.message).not.toMatch(/try again/i);
});
it("restates the refusal without claiming an audience cause when a 403 says nothing", () => {
const failure = describeInteractionResolutionFailure(new ApiError("", 403, null), null);
expect(failure.message).toBe("You do not have permission to respond to this card.");
expect(failure.message).not.toMatch(/resolver audience/i);
});
it("does not ask for a retry when the card has already moved on", () => {
const failure = describeInteractionResolutionFailure(
denial(409, "interaction_superseded", "This confirmation was superseded by a newer request"),
humanOnlyAudience,
);
expect(failure.kind).toBe("settled");
expect(failure.message).toBe("This confirmation was superseded by a newer request.");
expect(failure.message).not.toMatch(/try again/i);
});
it("keeps the retry prompt for a genuinely transient failure", () => {
expect(
describeInteractionResolutionFailure(new ApiError("Request failed: 503", 503, null), humanOnlyAudience),
).toMatchObject({ kind: "transient", message: "Request failed: 503. Try again." });
});
it("falls back to generic copy when nothing explains the failure", () => {
expect(describeInteractionResolutionFailure(undefined, null)).toMatchObject({
kind: "transient",
message: "Couldn't submit. Try again.",
});
});
it("leaves punctuation the server already wrote alone", () => {
expect(
interactionResolutionErrorMessage(denial(403, "interaction_human_only", "Agents cannot resolve this."), null),
).toBe("Agents cannot resolve this.");
});
});