Files
PaperClipAI/ui/src/api/decisions.ts
T
9c1f8e7887 feat(decisions): add first-class propose mode (#10010)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents can currently perform many mutations directly, while humans
often need a durable review point before cross-issue or destructive
actions occur
> - Existing approvals and issue-thread interactions do not provide a
standalone, reusable object for presenting options, collecting typed
inputs, detecting stale targets, and auditing effect execution
> - The control plane therefore needs a first-class propose mode that
separates an agent's recommendation from the governed mutation it may
cause
> - This pull request adds Decisions v1 across the database, shared
contracts, server execution and telemetry, agent skill guidance, and
operator UI
> - The benefit is that agents can propose multi-option actions safely
while operators get explicit provenance, fail-closed execution,
per-effect results, and a focused attention workflow

## Linked Issues or Issue Description

### Subsystem affected

Cross-cutting: `packages/db`, `packages/shared`, `server`, and `ui`.

### Problem or motivation

Agents need a governed way to propose consequential work without
immediately mutating issues, especially when one choice can affect
several issue trees. Existing approvals and issue-thread interactions do
not provide a standalone object with typed options, target snapshots,
effect-level authorization, expiration, execution outcomes, and reusable
attention-feed presentation.

### Proposed solution

Add first-class Decisions that store options and typed inputs, surface
open proposals in the operator attention feed, validate target freshness
and the origin-agent/operator authorization intersection at decision
time, execute a bounded set of auditable effects, and retain terminal
outcomes. Decisions v1 supports comments, status and assignee changes,
follow-up issue creation, blocker resolution, and issue-tree
cancellation, plus bundle grouping, expiration/dismissal, rule-key
telemetry, and agent-facing API guidance.

### Alternatives considered

- Extend approvals with arbitrary effects: rejected because approvals
represent governed yes/no actions and would become an unsafe generic
mutation envelope.
- Model every proposal as an issue-thread interaction: rejected because
decisions can span several targets and need independent lifecycle,
telemetry, idempotency, and effect results.
- Let agents perform the mutation and ask for retrospective review:
rejected because it removes the pre-execution governance boundary this
feature is meant to provide.

### Roadmap alignment

Aligns with `ROADMAP.md` sections **Agent Reviews and Approvals**,
**Enforced Outcomes**, **MCP Tool Gateway & Apps (governed tool
access)**, and **Activity History** by making explicit decisions,
authorization gates, auditable execution, and terminal outcomes
first-class control-plane objects.

### Additional context

This does not replace existing approvals or issue-thread interactions,
and it does not add an unrestricted generic mutation effect.

## What Changed

- Added company-scoped decision, option, target, and effect-execution
schema plus migration and shared TypeScript/Zod contracts.
- Added decision routes and services for propose, list/get, decide,
dismiss, cancel, target freshness checks, authorization intersection,
idempotency, activity logging, and execution auditing.
- Added rule-key decision telemetry and attention-feed metadata so open
decisions are visible and measurable.
- Added agent skill documentation for proposing and resolving decisions
through the Paperclip API.
- Added the Decisions UI: API client, query keys, inline attention
resolver, bundle grouping, target-issue strip, terminal history,
destructive confirmation, and per-effect result rendering.
- Added server service coverage, DecisionCard state tests, and Storybook
stories for the supported visual states.

## Verification

- `pnpm -r typecheck` — passed.
- `pnpm test:run` — 2,876 passed, 1 skipped, with one unrelated
cross-suite cleanup-order failure in
`heartbeat-responsible-user-invariant.test.ts`; the failing file passes
in isolation (`6/6`).
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-responsible-user-invariant.test.ts` — passed.
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/DecisionCard.test.tsx` — passed (`9/9`).
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/authz-existence-oracle-guard.test.ts
src/__tests__/openapi-routes.test.ts` — passed (`5/5`).
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/decisions-service.test.ts` — passed (`16/16`).
- `pnpm --filter paperclipai exec vitest run
src/__tests__/company-import-export-e2e.test.ts` — passed (`1/1`).
- `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter
paperclipai typecheck` — passed.
- `pnpm build` — passed.
- Rebased-head focused suite — passed (`6` files, `88` tests): shared
decision contracts, Decisions service, OpenAPI routes, startup feedback
export, DecisionCard states, and attention helpers. The follow-up
stale-secondary-target regression passes in the DecisionCard suite
(`10/10`).
- Rebased-head scoped typechecks — passed for `@paperclipai/shared`,
`@paperclipai/db`, `@paperclipai/server`, and `@paperclipai/ui`.
- Rebased-head migration numbering and safety checks — passed after
renumbering the additive migration to `0193` and making it replay-safe
for environments that applied the earlier feature-branch number.
- `pnpm check:token-gates` — passed with all gates clean.
- GitHub PR workflow and Greptile review for
`1f9f7645882d05dfdd9c99377c03a1f53f20e8be` — running after the
stale-secondary-target fix and PR metadata refresh on July 27, 2026.
- `pnpm --filter @paperclipai/ui build-storybook` exposes an existing
Storybook version mismatch (`storybook` 10.4.6 vs
`@storybook/addon-docs` 10.5.0); Decisions stories were validated with
the docs addon temporarily disabled and the tracked config remains
unchanged.

## Risks

- **Migration:** Adds replay-safe migration `0193`; migration numbering
and safety checks pass. The new tables and indexes are additive.
- **Authorization:** Effect execution intersects the proposing agent's
permissions with the responsible user context and fails closed; mistakes
could reject a valid proposal rather than silently over-authorize it.
- **Concurrency:** Target snapshots and idempotency keys protect against
stale or duplicate execution, but reviewers should focus on mixed-effect
partial outcomes and retry behavior.
- **UI:** Decisions are integrated into the existing attention feed
rather than a separate navigation surface, reducing routing risk but
increasing the importance of attention-item metadata compatibility.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex CLI using `gpt-5.6-sol` for final PR preparation, review
fixes, and verification; repository tools and code execution were
enabled, and context-window size is not exposed in this runtime.
- Anthropic Claude Opus 4.8 with 1M context assisted with the Decisions
UI implementation, as recorded in the relevant commits.

## 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
- [x] All Paperclip CI gates are green
- [x] 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: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 19:17:02 -07:00

115 lines
4.0 KiB
TypeScript

import type { DecisionInput, DecisionOption } from "@paperclipai/shared";
import { api } from "./client";
/**
* Decisions v1 (PAP-14939 §4). Standalone decision objects proposed by agents
* and resolved by the board. Open decisions surface in the attention feed as a
* `decision` source; decided/expired history is fetched directly here. Response
* DTOs mirror the P3 service (`server/src/services/decisions.ts`) and are kept
* UI-local rather than in `@paperclipai/shared` on purpose — only the option /
* input / effect specs are shared (they round-trip on create).
*/
export type DecisionStatus = "open" | "decided" | "expired" | "cancelled";
export type DecisionExecutionStatus = "running" | "succeeded" | "partial" | "failed";
export type DecisionEffectExecutionStatus = "claimed" | "executed" | "failed" | "skipped";
export interface DecisionTargetSnapshot {
status: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
updatedAt: string;
descendantCount?: number;
descendantIds?: string[];
/** Legacy snapshots created before descendantCount was named explicitly. */
childCount?: number;
}
export interface Decision {
id: string;
companyId: string;
bundleId: string | null;
originAgentId: string;
originIssueId: string;
originRunId: string;
ruleKey: string | null;
title: string;
body: string;
options: DecisionOption[];
inputs: DecisionInput[] | null;
status: DecisionStatus;
executionStatus: DecisionExecutionStatus | null;
chosenOptionId: string | null;
inputValues: Record<string, string> | null;
decidedByUserId: string | null;
decidedAt: string | null;
expiresAt: string;
idempotencyKey: string | null;
targetSnapshots: Record<string, DecisionTargetSnapshot>;
continuationPolicy: "none" | "wake_origin_agent";
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
/** `list()` annotates each open decision with which targets drifted since snapshot. */
export interface DecisionListItem extends Decision {
targetChanged: Record<string, boolean>;
/** Included by terminal-state lists so history cards avoid detail-query fan-out. */
executions?: DecisionEffectExecution[];
}
export interface DecisionEffectExecution {
id: string;
decisionId: string;
effectIndex: number;
effectType: string;
targetIssueId: string;
status: DecisionEffectExecutionStatus;
result: Record<string, unknown> | null;
error: string | null;
activityLogId: string | null;
executedAt: string | null;
}
/** `get()` / `decide()` / `dismiss()` return the decision plus per-effect executions. */
export interface DecisionOutcome extends Decision {
executions: DecisionEffectExecution[];
}
export interface DecisionListFilter {
status?: DecisionStatus;
bundleId?: string;
targetIssueId?: string;
originAgentId?: string;
limit?: number;
}
export interface DecideInput {
optionId: string;
inputValues?: Record<string, string>;
idempotencyKey?: string | null;
}
function listQuery(filter: DecisionListFilter): string {
const params = new URLSearchParams();
if (filter.status) params.set("status", filter.status);
if (filter.bundleId) params.set("bundleId", filter.bundleId);
if (filter.targetIssueId) params.set("targetIssueId", filter.targetIssueId);
if (filter.originAgentId) params.set("originAgentId", filter.originAgentId);
if (filter.limit != null) params.set("limit", String(filter.limit));
const qs = params.toString();
return qs ? `?${qs}` : "";
}
export const decisionsApi = {
list: (companyId: string, filter: DecisionListFilter = {}) =>
api.get<DecisionListItem[]>(`/companies/${companyId}/decisions${listQuery(filter)}`),
get: (id: string) => api.get<DecisionOutcome>(`/decisions/${id}`),
decide: (id: string, input: DecideInput) =>
api.post<DecisionOutcome>(`/decisions/${id}/decide`, input),
dismiss: (id: string, reason?: string | null) =>
api.post<DecisionOutcome>(`/decisions/${id}/dismiss`, reason ? { reason } : {}),
cancel: (id: string) => api.post<Decision>(`/decisions/${id}/cancel`, {}),
};