Files
PaperClipAI/packages/shared/src/issue-write-denial.ts
DottaandClaude Opus 5 e8ae5286eb feat(issues): explain cross-task agent writes with attribution, audit receipts, and actionable denials (#10843)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents write to tasks they do not own. They comment, they change
fields, and the control plane now permits this by default for
standard-trust agents on any task they can read
> - This makes a task thread ambiguous. A reader sees a comment from an
agent that is not the assignee, but no surface says whose authority that
write rode
> - The same gap applies to field edits. The activity stream named the
verb, but it did not show the before value, the after value, or the
reason the write was permitted
> - The remaining refusals are also opaque. An agent that hits a wall
receives a 403 with no boundary name, no actor who can act, and no
sanctioned path. One real incident spent a full detour to find the
workaround
> - This pull request adds the three surfaces that make open cross-task
writes legible: an attribution chip, a field-level audit receipt, and an
actionable denial contract shared by the API and the UI
> - The benefit is that a reader can answer "who did this, on whose
authority, and was it allowed?" on the task itself, and a blocked writer
is told what to do next

## Linked Issues or Issue Description

No public issue exists for this work, so the enhancement is described
here.

**What existing behavior does this improve?**

Cross-task agent writes are permitted, but they are not explained. A
task thread can hold comments from agents that are not the assignee, and
the activity stream can hold field changes made by those agents. Neither
surface names the responsible user behind the write. When a write is
refused, the error text does not name the boundary or the way forward.

**Subsystem affected**

Issue detail UI (comment thread and activity stream), the issue write
authorization responses in the server, and the shared copy contract that
both consume.

**Current behavior**

- An agent comment on a task the agent does not own looks the same as an
assignee comment.
- An `issue.updated` activity row states the verb only. It does not show
the field-level before and after values, the responsible user, or the
authorization reason.
- A refused write returns a short message such as an ownership error.
The message does not state which rule fired, who is able to perform the
action, or which alternative path is sanctioned.

**Proposed behavior**

- An agent comment on a task the agent does not own carries a chip that
reads "for {user}". The chip names the responsible user. Its tooltip
states that the author is not the assignee and cannot exceed that user's
permissions.
- Each `issue.updated` row shows a receipt: the changed fields with
before and after values, the responsible user, and the authorization
reason. This applies to board edits as well as agent edits.
- Each refusal states three things: the boundary that fired, who is able
to act, and the sanctioned path. The API error body and the in-app
notice use the same words, because both read one shared contract.

Related pull requests, found by searching this repository:

- Refs #10837 — merged. It added the default-open cross-task write rule,
the comment attribution data, and the per-run containment cap that this
pull request makes visible.
- Refs #10114 — open. It proposes a narrower authorization change in the
same area.
- Refs #7998 — open. It proposes append-only cross-assignee comments as
an alternative to opening writes.

## What Changed

- Adds `packages/shared/src/issue-write-denial.ts`. This is one copy
contract for eight ways an issue write can be refused: not visible,
responsible-user ceiling, responsible user unavailable, excluded actor
class, assignee run lock, per-run cross-task cap, missing run context,
and rejected attribution. Each entry names the boundary, who can act,
and the sanctioned path.
- Maps server authorization decisions onto that contract in
`server/src/routes/issues.ts` and
`server/src/services/cross-issue-influence-limit.ts`. The flattened
`error` string carries all three obligations, and `details.code` lets
the UI render the same words. The two cap codes keep the names they
already ship under.
- Adds `CommentAttributionChip`. It renders "for {user}" beside the
author name on agent comments where the author is not the assignee. It
renders nothing when no responsible user is recorded, so older rows stay
clean. It is wired into both `IssueChatThread` and the flagged
`TaskChatThread` redesign.
- Adds `IssueFieldChangeReceipt`. It renders the change receipt under
`issue.updated` rows in the activity stream. Ids resolve to agent and
user names where the directory is loaded. Server-truncated text is
labelled as a preview, so the receipt never implies that it shows a
whole value.
- Adds `IssueWriteDenialNotice`. It renders the shared copy in the app,
keyed off the denial events the server logs on a task.
- Adds a public `/ux-lab/cross-issue-collaboration` page. It renders all
three surfaces and their edge cases for review without a seeded thread.
This follows the existing `ux-lab` pages.

## Verification

Automated, all green:

```
pnpm --filter @paperclipai/shared exec vitest run src/issue-write-denial.test.ts        # 17 tests
pnpm --filter @paperclipai/ui exec vitest run src/components/IssueWriteDenialNotice.test.tsx \
  src/components/IssueFieldChangeReceipt.test.tsx src/components/CommentAttributionChip.test.tsx \
  src/lib/issue-change-receipt.test.ts src/lib/comment-attribution.test.ts                # 46 tests
pnpm --filter @paperclipai/server exec vitest run src/__tests__/cross-issue-influence-limit.test.ts \
  src/__tests__/issue-comment-attribution-audit-routes.test.ts \
  src/__tests__/issue-agent-mutation-ownership-routes.test.ts \
  src/__tests__/low-trust-red-team-routes.test.ts                                        # 98 tests
```

`tsc --noEmit` passes for the shared, ui, and server packages.

Manual, in a browser:

1. Start the UI only: `pnpm --filter @paperclipai/ui exec vite`.
2. Open `/ux-lab/cross-issue-collaboration`. No session is needed,
because `ux-lab` routes are public.
3. All three surfaces were captured at 1440x900 in light mode and dark
mode, and at 390x844. The page reported no errors.
4. The chip tooltip was opened by a hover and by a keyboard focus.

Rendering the page found defects that the tests had missed. Three copy
and contrast defects were fixed, and two of them are now pinned by a
test. A design review then found three layout defects, which are also
fixed: the denial notice orphaned its label when a value wrapped, the
receipt icon wrapped onto its own line at narrow widths, and the chip
tooltip was reachable by hover only.

## Risks

Low risk, and additive.

- Every new surface renders nothing when its data is absent. Comments
without a recorded responsible user show no chip, and activity events
without a receipt show no receipt, so existing rows do not change.
- No migration is included. The data these surfaces read already ships.
- The wire values of the two per-run cap denial codes are unchanged.
Only the human-readable text changes, plus six codes that had no
`details.code` before.
- The denial copy is read by agents as well as people. If wording must
change later, one shared module is the only place to change it.
- Roadmap check: this extends the completed "Activity log & action
attribution" area rather than duplicating planned core work.

## Model Used

Claude Opus 5 (Anthropic), model id `claude-opus-5[1m]`, 1M context
window, extended thinking, with tool use and code execution. It ran as
an agent in Claude Code and drove a real browser to capture the review
screenshots.

## 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 (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 23:02:51 -05:00

334 lines
13 KiB
TypeScript

/**
* Copy contract for denied issue writes (open cross-task writes: failure UX).
*
* Cross-issue issue writes are default-open for standard-trust agents on issues
* they can already read (see SPEC-implementation §9.3). The remaining walls are
* rare — but a real incident burned a full detour discovering a workaround
* behind an opaque 403, so every one of them must say three things:
*
* 1. which boundary fired,
* 2. who *can* act,
* 3. the sanctioned path forward.
*
* This module is the single source of truth for that copy, so the API error
* body an agent reads and the notice a human sees in the UI are the same words.
* It is the issue-write sibling of `responsible-user-denial.ts`: the two
* responsible-user ceiling codes delegate to that module's copy so terminology
* ("on behalf of {user}", never "impersonate") stays consistent.
*/
import {
describeResponsibleUserDenial,
responsibleUserLabel,
type ResponsibleUserDenialCode,
} from "./responsible-user-denial.js";
export const ISSUE_WRITE_DENIAL_CODES = [
"issue_write_not_visible",
"issue_write_actor_class_excluded",
"issue_write_responsible_user_ceiling",
"issue_write_responsible_user_unavailable",
"issue_write_assignee_run_lock",
"cross_issue_influence_cap_exceeded",
"cross_issue_influence_run_context_required",
"issue_write_attribution_spoof_rejected",
] as const;
export type IssueWriteDenialCode = (typeof ISSUE_WRITE_DENIAL_CODES)[number];
/**
* Why the write stopped, which drives icon + colour. `boundary` is an
* authorization wall, `lock` is run-lifecycle machinery that will clear on its
* own, `cap` is a rate backstop, and `attribution` is a rejected spoof.
*/
export type IssueWriteDenialTone = "boundary" | "lock" | "cap" | "attribution";
export interface IssueWriteDenialCopy {
code: IssueWriteDenialCode;
/** HTTP status the server pairs with this code. */
status: 403 | 409 | 422 | 429;
tone: IssueWriteDenialTone;
/** The boundary that fired, as a short noun phrase for a banner title. */
boundary: string;
/** Short heading. */
title: string;
/** What happened and why, in one or two sentences. */
description: string;
/** Who is able to perform this write instead. */
whoCanAct: string;
/** The supported way to get the work moving. */
sanctionedPath: string;
}
export interface IssueWriteDenialContext {
/** Display name of the agent or user that attempted the write. */
actorLabel?: string | null;
/** Display name of the responsible ("on behalf of") user for the attempt. */
responsibleUserName?: string | null;
/** Display name of the target issue's current assignee. */
assigneeLabel?: string | null;
/** Target issue identifier, e.g. `TASK-482`. */
issueIdentifier?: string | null;
/** Per-run cross-issue influence cap. */
cap?: number | null;
/** Attempt count that tripped the cap. */
count?: number | null;
/** ISO timestamp at which log-only rollout becomes enforcement. */
enforceAt?: string | null;
}
export function isIssueWriteDenialCode(
code: string | null | undefined,
): code is IssueWriteDenialCode {
return ISSUE_WRITE_DENIAL_CODES.includes(code as IssueWriteDenialCode);
}
/**
* Bridge the two responsible-user ceiling codes emitted by the authorization
* layer into this module's code space, so a single UI notice covers every way
* an issue write can be refused.
*/
export function issueWriteDenialCodeForResponsibleUserDenial(
code: ResponsibleUserDenialCode,
): IssueWriteDenialCode {
return code === "RESPONSIBLE_USER_UNAVAILABLE"
? "issue_write_responsible_user_unavailable"
: "issue_write_responsible_user_ceiling";
}
/** "this task" when the identifier is unknown, so copy never shows a raw id. */
function issueLabel(identifier: string | null | undefined): string {
const trimmed = identifier?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "this task";
}
/** "the assignee" when the name is unknown. */
function assigneeLabel(name: string | null | undefined): string {
const trimmed = name?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "the current assignee";
}
/** "this agent" when the name is unknown. */
function actorLabel(name: string | null | undefined): string {
const trimmed = name?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "this agent";
}
/**
* The escape hatch an earlier incident had to discover by trial and error. Naming it in
* every boundary denial is the point of plan §6 — an agent that reads the error
* should not need a detour to find the supported path.
*/
const CHILD_ISSUE_PATH =
"create a child issue with the request in its description (issue creation is a " +
"separate, open write path) and let its assignee act";
export function describeIssueWriteDenial(
code: IssueWriteDenialCode,
context: IssueWriteDenialContext = {},
): IssueWriteDenialCopy {
const issue = issueLabel(context.issueIdentifier);
const actor = actorLabel(context.actorLabel);
const assignee = assigneeLabel(context.assigneeLabel);
const responsible = responsibleUserLabel(context.responsibleUserName);
switch (code) {
case "issue_write_not_visible":
return {
code,
status: 403,
tone: "boundary",
boundary: "Issue visibility",
title: "Task is outside this actor's visibility",
description:
`Issue writes are open by default, but only for tasks the actor can already ` +
`read. ${issue} is not visible to ${actor}, so its comment, update, child, and ` +
`assignment channels are all closed — the wall is visibility, not the write itself.`,
whoCanAct:
`${assignee}, and any agent or board member the task is visible to.`,
sanctionedPath:
`Ask the board to widen visibility for ${actor}, or ${CHILD_ISSUE_PATH}.`,
};
case "issue_write_actor_class_excluded":
return {
code,
status: 403,
tone: "boundary",
boundary: "Actor-class boundary",
title: "This actor class cannot write to tasks",
description:
`Default-open issue writes are a standard-trust privilege. Low-trust, ` +
`skill-test, and task-bridge scopes keep their existing tight walls, so ` +
`${actor} cannot write to ${issue} no matter who it acts for.`,
whoCanAct:
`A standard-trust agent in this company, or a board member.`,
sanctionedPath:
`Report the request upward and let a standard-trust agent make the write — ` +
`actor-class scope cannot be widened per task.`,
};
case "issue_write_responsible_user_ceiling": {
const ceiling = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAUTHORIZED", {
userName: context.responsibleUserName,
});
return {
code,
status: 403,
tone: "boundary",
boundary: "Responsible-user ceiling",
title: ceiling.title,
description: `${ceiling.description} The write to ${issue} was refused for that reason.`,
whoCanAct:
`${responsible} once authorized, or anyone already permitted to write to ${issue}.`,
sanctionedPath: ceiling.recommendedAction,
};
}
case "issue_write_responsible_user_unavailable": {
const unavailable = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAVAILABLE", {
userName: context.responsibleUserName,
});
return {
code,
status: 403,
tone: "boundary",
// Distinct from the title, which already says "unavailable" — the
// boundary names the *mechanism*, so the two do not read as a stutter.
boundary: "Responsible-user availability",
title: unavailable.title,
description: `${unavailable.description} The write to ${issue} was refused for that reason.`,
whoCanAct: `A board member, or ${actor} once it has an active responsible user.`,
sanctionedPath: unavailable.recommendedAction,
};
}
case "issue_write_assignee_run_lock":
return {
code,
status: 409,
tone: "lock",
boundary: "Run checkout lock",
title: "Another agent's run owns this task",
description:
`${assignee} has ${issue} checked out and a run is live. Checkout and run ` +
`ownership stay assignee-scoped even though writes are open, so field edits ` +
`belong to the run that holds the lock until it finishes.`,
whoCanAct:
`${assignee}'s live run, or an agent holding the manage-active-checkouts permission.`,
sanctionedPath:
`Comment instead of patching — comments stay open and wake ${assignee} — or ` +
`wait for the run to release the lock and retry.`,
};
case "cross_issue_influence_cap_exceeded": {
const cap = context.cap ?? 20;
const attempt = context.count ?? null;
return {
code,
status: 429,
tone: "cap",
// No parentheses: surfaces render the boundary inside their own parens.
boundary: `Per-run cross-issue cap of ${cap} writes`,
title: "This run has spent its cross-issue write budget",
description:
`A single heartbeat run may make at most ${cap} cross-issue comments or task ` +
`updates combined${attempt !== null ? `; this was attempt ${attempt}` : ""}. The cap ` +
`bounds runaway comment sprays and loops — it is a rate backstop, not a ` +
`permission decision, so ${actor} is still allowed to write to ${issue}.`,
whoCanAct:
`${actor} on its next heartbeat run, or ${assignee} on ${issue} directly.`,
sanctionedPath:
`Consolidate what is left into one comment on your own task, or end the run and ` +
`continue on the next heartbeat — the budget resets per run.`,
};
}
case "cross_issue_influence_run_context_required":
return {
code,
status: 403,
tone: "boundary",
boundary: "Heartbeat run context",
title: "Cross-issue writes need a run to attribute them to",
description:
`Every agent comment and task update is attributed to a heartbeat run so the ` +
`cross-issue cap can be counted and the audit trail can name who acted for whom. ` +
`This request arrived without a valid run, so it could not be contained.`,
whoCanAct: `${actor}, once the request carries its own run id.`,
sanctionedPath:
`Send the \`X-Paperclip-Run-Id\` header with your current run (\`$PAPERCLIP_RUN_ID\`) ` +
`and retry.`,
};
case "issue_write_attribution_spoof_rejected":
return {
code,
status: 422,
tone: "attribution",
boundary: "Server-derived attribution",
title: "Responsible user cannot be chosen by the caller",
description:
`\`onBehalfOfUserId\` is derived from the authenticated actor, never from the ` +
`request body — an agent cannot pick the human whose authority it rides. The ` +
`attempt was recorded in the audit log.`,
whoCanAct:
`${actor} itself: the write is allowed, only the chosen attribution is not.`,
sanctionedPath:
`Remove \`onBehalfOfUserId\` from the request and retry; the server fills in ` +
`${responsible} from your run.`,
};
}
}
/**
* Flatten a denial into the single `error` string an API client sees.
*
* Agents typically surface only `error`, so all three §6 obligations — boundary,
* who can act, sanctioned path — have to survive the flattening.
*/
export function issueWriteDenialApiMessage(copy: IssueWriteDenialCopy): string {
return [
`${copy.title} (${copy.boundary}).`,
copy.description,
`Who can act: ${copy.whoCanAct}`,
`Try this: ${copy.sanctionedPath}`,
].join(" ");
}
/**
* Build the full `{ error, details }` body for a denied issue write. Keeping the
* machine-readable `code` next to the prose lets the board UI render the same
* copy without parsing sentences.
*/
export function issueWriteDenialResponse(
code: IssueWriteDenialCode,
context: IssueWriteDenialContext = {},
): {
status: IssueWriteDenialCopy["status"];
body: {
error: string;
details: {
code: IssueWriteDenialCode;
boundary: string;
whoCanAct: string;
sanctionedPath: string;
} & Record<string, unknown>;
};
} {
const copy = describeIssueWriteDenial(code, context);
return {
status: copy.status,
body: {
error: issueWriteDenialApiMessage(copy),
details: {
code: copy.code,
boundary: copy.boundary,
whoCanAct: copy.whoCanAct,
sanctionedPath: copy.sanctionedPath,
},
},
};
}