Files
PaperClipAI/ui/src/components/InteractionGovernancePanel.tsx
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

307 lines
12 KiB
TypeScript

import { Fragment } from "react";
import {
ISSUE_THREAD_INTERACTION_KINDS,
normalizeIssueThreadInteractionResolverPolicy,
type InteractionResolverGovernance,
type IssueThreadInteractionCanonicalResolverPolicy,
type IssueThreadInteractionKind,
type IssueThreadInteractionResolverPolicy,
} from "@paperclipai/shared";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { resolverPolicyLabel } from "../lib/interaction-audience";
const INTERACTION_KIND_LABELS: Record<IssueThreadInteractionKind, string> = {
suggest_tasks: "Suggested tasks",
ask_user_questions: "Ask user questions",
request_confirmation: "Confirmations",
request_checkbox_confirmation: "Checkbox confirmations",
request_item_verdicts: "Item verdicts",
};
/**
* Sentinel for "no override" — Radix Select disallows empty-string item values.
* Under the open-default contract (PAP-17280) an absent override *is* the open
* audience, so this sentinel is what a company sees selected until it
* deliberately narrows a kind.
*/
export const GOVERNANCE_UNSET = "default";
export type GovernanceSelectValue = typeof GOVERNANCE_UNSET | IssueThreadInteractionResolverPolicy;
export type GovernanceField = "defaultPolicy" | "cap";
/**
* Only *narrowing* policies are offered. `anyone` is the product default, so
* requesting it as a default override is a no-op, and capping at `anyone` cannot
* narrow anything — both collapse into the unset sentinel, which is presented as
* the visible default.
*/
const NARROWING_POLICIES: readonly IssueThreadInteractionCanonicalResolverPolicy[] = [
"not_creator",
"human_only",
];
const UNSET_LABELS: Record<GovernanceField, string> = {
defaultPolicy: "Anyone (default)",
cap: "No cap",
};
const UNSET_EFFECTS: Record<GovernanceField, string> = {
defaultPolicy: "New cards are open — the board or any agent can respond, including the one that asked.",
cap: "A request keeps whatever audience it asks for.",
};
const DEFAULT_POLICY_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
anyone: UNSET_EFFECTS.defaultPolicy,
not_creator: "New cards exclude the agent that created them, so the answer comes from someone else.",
human_only: "New cards wait for a person on the board. Agents are turned away.",
};
const CAP_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
anyone: UNSET_EFFECTS.cap,
not_creator: "Even a card that asks for Anyone is narrowed to exclude its creator.",
human_only: "Every card of this kind waits for a person, whatever it asked for.",
};
function governanceOptions(field: GovernanceField): {
value: GovernanceSelectValue;
label: string;
effect: string;
}[] {
const effects = field === "cap" ? CAP_EFFECTS : DEFAULT_POLICY_EFFECTS;
return [
{ value: GOVERNANCE_UNSET, label: UNSET_LABELS[field], effect: UNSET_EFFECTS[field] },
...NARROWING_POLICIES.map((policy) => ({
value: policy as GovernanceSelectValue,
label: resolverPolicyLabel(policy),
effect: effects[policy],
})),
];
}
/**
* The label a *closed* trigger must show. Derived from the value rather than
* looked up in the option list so an out-of-list value (a raw `anyone`, say)
* still renders a complete, truthful label instead of falling back to a lie.
*/
export function governanceValueLabel(field: GovernanceField, value: GovernanceSelectValue): string {
return value === GOVERNANCE_UNSET ? UNSET_LABELS[field] : resolverPolicyLabel(value);
}
/**
* Map a persisted override onto a select value. A stored `anyone` — including
* the deprecated `board_or_agents` alias — is the open default, so it shows as
* the unset sentinel rather than as a narrowing override.
*/
export function toGovernanceSelectValue(
policy: IssueThreadInteractionResolverPolicy | undefined,
): GovernanceSelectValue {
if (!policy) return GOVERNANCE_UNSET;
const canonical = normalizeIssueThreadInteractionResolverPolicy(policy);
return canonical === "anyone" ? GOVERNANCE_UNSET : canonical;
}
/**
* Apply a single (kind, field) change to a governance map immutably, pruning
* empty entries so the persisted object stays sparse (only real overrides).
*/
export function applyGovernanceChange(
current: InteractionResolverGovernance,
kind: IssueThreadInteractionKind,
field: GovernanceField,
value: GovernanceSelectValue,
): InteractionResolverGovernance {
const next: InteractionResolverGovernance = { ...current };
const entry = { ...(next[kind] ?? {}) };
if (value === GOVERNANCE_UNSET) {
delete entry[field];
} else {
entry[field] = value;
}
if (entry.defaultPolicy === undefined && entry.cap === undefined) {
delete next[kind];
} else {
next[kind] = entry;
}
return next;
}
function GovernanceSelect({
field,
value,
onChange,
disabled,
testId,
ariaLabel,
mobileLabel,
}: {
field: GovernanceField;
value: GovernanceSelectValue;
onChange: (value: GovernanceSelectValue) => void;
disabled?: boolean;
testId?: string;
ariaLabel: string;
mobileLabel: string;
}) {
const options = governanceOptions(field);
return (
<div className="min-w-0">
{/*
* Below `sm` the governance grid collapses to a single column (see the
* grid classes on the panel), detaching each select from its column
* header. Surface a mobile-only inline label so the control stays
* self-describing for sighted users, and always carry `aria-label` for
* screen-reader pairing. WCAG 2.1 SC 1.4.10 (Reflow) — design review R2.
*/}
<span className="mb-1 block text-xs font-medium text-muted-foreground uppercase tracking-wide sm:hidden">
{mobileLabel}
</span>
<Select value={value} onValueChange={(v) => onChange(v as GovernanceSelectValue)} disabled={disabled}>
<SelectTrigger
size="sm"
aria-label={ariaLabel}
// 208px is sized for the longest label the control can hold —
// `Anyone except creator` needs ~150px of text room, and 170px only
// left 122px after padding, gap and chevron, so it clipped even once
// the effect sentence was gone (PAP-17297).
className="w-full min-w-0 text-xs sm:w-(--sz-208px)"
data-testid={testId}
>
{/*
* Explicit children, not the default `<SelectValue />`. Radix portals
* the selected item's *whole* subtree into an empty value node, which
* dragged each option's effect sentence into the closed trigger and
* clipped the selected label (desktop truncated the prose, mobile cut
* `Anyone (default)` mid-label — PAP-17293/PAP-17297). Passing children
* sets `valueNodeHasChildren`, which suppresses that portal, so the
* trigger shows exactly the label and nothing else.
*/}
<SelectValue>{governanceValueLabel(field, value)}</SelectValue>
</SelectTrigger>
{/*
* Cap the option list so the effect sentences wrap instead of stretching
* the popover past a ~390px viewport (WCAG 2.1 SC 1.4.10 Reflow).
*/}
<SelectContent className="max-w-(--sz-280px) sm:max-w-(--sz-360px)">
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
// Keyboard typeahead matches on `textValue` when given; without it
// Radix would match against the effect prose too.
textValue={option.label}
className="text-xs"
>
{/*
* Effect preview lives inside the option so the consequence of a
* narrowing choice is legible at the moment of choosing, not only
* after saving (PAP-17280).
*/}
<span className="flex min-w-0 flex-col gap-0.5">
<span>{option.label}</span>
<span className="text-(length:--text-micro) text-muted-foreground">
{option.effect}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
/**
* Company-level interaction governance: the per-kind default audience and cap.
*
* The open default is the headline — interactions are resolvable by anyone in
* the company unless a row here narrows them — so the panel only offers
* narrowing choices and never presents an unrestricted card as board-required
* (PAP-17280, contract in `doc/SPEC-implementation.md` §9.8.1).
*/
export function InteractionGovernancePanel({
governance,
onChange,
isPending,
errorMessage,
}: {
governance: InteractionResolverGovernance;
onChange: (kind: IssueThreadInteractionKind, field: GovernanceField, value: GovernanceSelectValue) => void;
isPending?: boolean;
errorMessage?: string | null;
}) {
return (
<div className="space-y-4" data-testid="company-settings-interaction-governance-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Interaction governance
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<p className="text-sm text-muted-foreground">
Thread interactions are open by default:{" "}
<span className="font-medium text-foreground">Anyone</span> in the company — the
board or any agent, including the one that asked — can respond. Narrow a kind
only when you need to.{" "}
<span className="font-medium text-foreground">Default policy</span> is the
audience new cards get when the requester does not ask for one;{" "}
<span className="font-medium text-foreground">Cap</span> narrows every request of
that kind and can never widen one. Tool-approval confirmations always stay{" "}
<span className="font-medium text-foreground">Human only</span>.
</p>
{/*
* Responsive: below `sm` the row collapses to a single column so the
* two 170px selects never force horizontal overflow on a ~390px
* viewport (WCAG 2.1 SC 1.4.10 Reflow — design review R2). Each kind
* then stacks as: label → Default policy → Cap, each full-width with
* its own inline label. At `sm`+ it restores the aligned 3-col grid.
*/}
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-[1fr_auto_auto] sm:items-center sm:gap-x-4 sm:gap-y-2.5">
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
Kind
</div>
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
Default policy
</div>
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
Cap
</div>
{ISSUE_THREAD_INTERACTION_KINDS.map((kind) => {
const entry = governance[kind] ?? {};
const kindLabel = INTERACTION_KIND_LABELS[kind];
return (
<Fragment key={kind}>
<div className="text-sm font-medium sm:font-normal">{kindLabel}</div>
<GovernanceSelect
field="defaultPolicy"
testId={`governance-${kind}-default`}
ariaLabel={`Default resolver audience for ${kindLabel}`}
mobileLabel="Default policy"
value={toGovernanceSelectValue(entry.defaultPolicy)}
disabled={isPending}
onChange={(v) => onChange(kind, "defaultPolicy", v)}
/>
<GovernanceSelect
field="cap"
testId={`governance-${kind}-cap`}
ariaLabel={`Resolver cap for ${kindLabel}`}
mobileLabel="Cap"
value={toGovernanceSelectValue(entry.cap)}
disabled={isPending}
onChange={(v) => onChange(kind, "cap", v)}
/>
</Fragment>
);
})}
</div>
{errorMessage ? (
<span className="text-xs text-destructive">{errorMessage}</span>
) : null}
</div>
</div>
);
}