Files
PaperClipAI/server/src/services/issue-thread-interaction-resolution.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

336 lines
12 KiB
TypeScript

import type {
IssueThreadInteractionCanonicalResolverPolicy,
IssueThreadInteractionResolverPolicy,
} from "@paperclipai/shared";
import { normalizeIssueThreadInteractionResolverPolicy } from "@paperclipai/shared";
import { HttpError } from "../errors.js";
export const ISSUE_THREAD_INTERACTION_RESOLUTION_DENIAL_CODES = [
"interaction_not_found",
"interaction_run_attribution_required",
"interaction_scope_denied",
"interaction_human_only",
"interaction_creator_excluded",
"interaction_addressee_mismatch",
"interaction_stale_target",
"interaction_superseded",
"interaction_already_resolved",
"interaction_issue_closed",
"interaction_governed_action_denied",
"review_policy_denied",
] as const;
export type IssueThreadInteractionResolutionDenialCode =
(typeof ISSUE_THREAD_INTERACTION_RESOLUTION_DENIAL_CODES)[number];
export type IssueThreadInteractionResolverActor =
| { type: "user"; userId: string }
| { type: "agent"; agentId: string | null | undefined; runId: string | null | undefined }
| { type: "system"; systemId: string };
export type IssueThreadInteractionResolverRestriction = {
policy: IssueThreadInteractionCanonicalResolverPolicy;
/**
* A server-resolved identity excluded by the binding flow in addition to the
* interaction creator. `null` means the binding requires an identity but it
* could not be resolved, so the evaluator must fail closed.
*/
excludedActor?: { type: "agent" | "user"; id: string } | null;
source?: "issue_review";
};
export type IssueThreadInteractionResolverAudienceInput = {
actor: IssueThreadInteractionResolverActor;
interaction: {
createdByAgentId?: string | null;
createdByUserId?: string | null;
sourceRunId?: string | null;
addresseeAgentId?: string | null;
effectiveResolverPolicy: IssueThreadInteractionResolverPolicy | string;
resolverPolicyProvenance?: string | null;
};
/**
* A server-derived restriction owned by a binding flow such as issue review.
* It may only narrow the interaction's persisted effective audience.
*/
additionalRestriction?:
| IssueThreadInteractionCanonicalResolverPolicy
| IssueThreadInteractionResolverRestriction
| null;
governedAction?: boolean;
};
export type IssueThreadInteractionResolverAudienceDecision =
| {
allowed: true;
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
reason: "allow_anyone" | "allow_addressee" | "allow_human" | "allow_system" | "allow_human_override";
}
| {
allowed: false;
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
status: 403 | 422;
code: Extract<
IssueThreadInteractionResolutionDenialCode,
| "interaction_run_attribution_required"
| "interaction_human_only"
| "interaction_creator_excluded"
| "interaction_addressee_mismatch"
| "interaction_governed_action_denied"
| "review_policy_denied"
>;
message: string;
details?: Record<string, unknown>;
};
/**
* Canonicalize one stored resolver-policy value.
*
* Before provenance existed, `board_or_agents` excluded both the creator agent
* and the creating run, so a pre-migration row must stay `not_creator` rather
* than widening to canonical `anyone`. Every reader of the stored columns —
* hydration, the audience evaluator, and the attention feed — goes through here
* so none of them can disagree about a legacy row.
*/
export function canonicalizeStoredResolverPolicy(
policy: IssueThreadInteractionResolverPolicy | string,
resolverPolicyProvenance: string | null | undefined,
): IssueThreadInteractionCanonicalResolverPolicy {
if (resolverPolicyProvenance === "legacy_inherited_restriction" && policy === "board_or_agents") {
return "not_creator";
}
return normalizeIssueThreadInteractionResolverPolicy(policy as IssueThreadInteractionResolverPolicy);
}
function canonicalStoredPolicy(input: IssueThreadInteractionResolverAudienceInput["interaction"]) {
return canonicalizeStoredResolverPolicy(
input.effectiveResolverPolicy,
input.resolverPolicyProvenance,
);
}
function reviewRestrictionExcludesActor(
restriction: IssueThreadInteractionResolverRestriction | null | undefined,
actor: { type: "agent" | "user"; id: string },
) {
return restriction?.policy === "not_creator"
&& restriction.source === "issue_review"
&& (
restriction.excludedActor === null
|| (
restriction.excludedActor?.type === actor.type
&& restriction.excludedActor.id === actor.id
)
);
}
function reviewPolicyDeniedDecision(
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy,
requesterKnown: boolean,
): Extract<IssueThreadInteractionResolverAudienceDecision, { allowed: false }> {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "review_policy_denied",
message: requesterKnown
? "Review policy `not_creator` requires someone other than the writer who moved the issue into `in_review` to approve or reject it."
: "Review policy `not_creator` requires a different writer, but the review requester could not be determined.",
details: {
policy: "not_creator",
allowedActor: "writer_other_than_review_requester",
remediation: requesterKnown
? "Have another writer with issue write access submit the verdict, or change reviewPolicy to `anyone`."
: "Change reviewPolicy to `anyone`, or move the issue out of and back into `in_review` to record a requester before another writer submits the verdict.",
},
};
}
/**
* The single pure audience evaluator for issue-thread interaction resolution.
* Resource access, run validation, containment, and current-target checks must
* happen before this evaluator. Mutation routes must re-run it at use time.
*/
export function evaluateIssueThreadInteractionResolverAudience(
input: IssueThreadInteractionResolverAudienceInput,
): IssueThreadInteractionResolverAudienceDecision {
const persistedPolicy = canonicalStoredPolicy(input.interaction);
const additionalRestriction = typeof input.additionalRestriction === "string"
? { policy: input.additionalRestriction }
: input.additionalRestriction;
// human_only and not_creator are independent constraints, not a linear
// severity scale: their intersection means "a human other than the
// creator." Keep both predicates even though the persisted public policy
// enum can only name one of them.
const creatorExcluded =
persistedPolicy === "not_creator"
|| additionalRestriction?.policy === "not_creator";
const humanOnly =
Boolean(input.governedAction)
|| persistedPolicy === "human_only"
|| additionalRestriction?.policy === "human_only";
const effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy = humanOnly
? "human_only"
: creatorExcluded
? "not_creator"
: "anyone";
if (input.actor.type === "system") {
return { allowed: true, effectiveResolverPolicy, reason: "allow_system" };
}
if (input.actor.type === "user") {
if (
creatorExcluded
&& input.interaction.createdByUserId === input.actor.userId
) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_creator_excluded",
message: "This issue-thread interaction requires a resolver other than its creator",
};
}
if (reviewRestrictionExcludesActor(additionalRestriction, {
type: "user",
id: input.actor.userId,
})) {
return reviewPolicyDeniedDecision(
effectiveResolverPolicy,
additionalRestriction?.excludedActor !== null,
);
}
return {
allowed: true,
effectiveResolverPolicy,
reason: input.interaction.addresseeAgentId ? "allow_human_override" : "allow_human",
};
}
if (!input.actor.agentId || !input.actor.runId?.trim()) {
return {
allowed: false,
effectiveResolverPolicy,
status: 422,
code: "interaction_run_attribution_required",
message: "A valid authenticated agent run is required to resolve this issue-thread interaction",
};
}
if (input.governedAction) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_governed_action_denied",
message: "This interaction is bound to a governed action that requires independent authorization",
};
}
if (effectiveResolverPolicy === "human_only") {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_human_only",
message: "This issue-thread interaction is human-only",
};
}
if (
input.interaction.addresseeAgentId
&& input.interaction.addresseeAgentId !== input.actor.agentId
) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_addressee_mismatch",
message: "Only the addressed agent or an authorized human may resolve this issue-thread interaction",
};
}
if (
creatorExcluded
&& (
input.interaction.createdByAgentId === input.actor.agentId
|| (
Boolean(input.interaction.sourceRunId)
&& input.interaction.sourceRunId === input.actor.runId
)
)
) {
return {
allowed: false,
effectiveResolverPolicy,
status: 403,
code: "interaction_creator_excluded",
message: "This issue-thread interaction requires a resolver other than its creator or creating run",
};
}
if (reviewRestrictionExcludesActor(additionalRestriction, {
type: "agent",
id: input.actor.agentId,
})) {
return reviewPolicyDeniedDecision(
effectiveResolverPolicy,
additionalRestriction?.excludedActor !== null,
);
}
return {
allowed: true,
effectiveResolverPolicy,
reason: input.interaction.addresseeAgentId ? "allow_addressee" : "allow_anyone",
};
}
export function issueThreadInteractionResolutionError(
status: number,
code: IssueThreadInteractionResolutionDenialCode,
message: string,
details: Record<string, unknown> = {},
) {
return new HttpError(status, message, { code, ...details });
}
export function assertIssueThreadInteractionResolverAudience(
input: IssueThreadInteractionResolverAudienceInput,
) {
const decision = evaluateIssueThreadInteractionResolverAudience(input);
if (!decision.allowed) {
throw issueThreadInteractionResolutionError(
decision.status,
decision.code,
decision.message,
{ effectiveResolverPolicy: decision.effectiveResolverPolicy, ...(decision.details ?? {}) },
);
}
return decision;
}
/**
* Resolves agent-owned attention with the same audience rules used by mutation
* routes. Run attribution is deliberately represented by the interaction's
* source run for the creator and by an opaque non-source run for other agents;
* the real run is still authenticated at mutation time.
*/
export function issueThreadInteractionAttentionAgentAllowed(input: {
agentId: string;
interaction: IssueThreadInteractionResolverAudienceInput["interaction"];
additionalRestriction?: IssueThreadInteractionResolverAudienceInput["additionalRestriction"];
governedAction?: boolean;
}) {
const attentionRunId = input.interaction.createdByAgentId === input.agentId
? input.interaction.sourceRunId ?? "attention-owner-creator-run"
: "attention-owner-non-source-run";
return evaluateIssueThreadInteractionResolverAudience({
actor: { type: "agent", agentId: input.agentId, runId: attentionRunId },
interaction: input.interaction,
additionalRestriction: input.additionalRestriction,
governedAction: input.governedAction,
}).allowed;
}