Files
PaperClipAI/packages/shared/src/validators/decision.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

150 lines
5.2 KiB
TypeScript

import { z } from "zod";
import { ISSUE_STATUSES } from "../constants.js";
export const decisionEffectStalenessSchema = z.enum(["strict", "lenient"]);
export const decisionOptionStyleSchema = z.enum(["default", "primary", "destructive"]);
const decisionEffectBaseShape = {
targetIssueId: z.string().uuid(),
staleness: decisionEffectStalenessSchema,
};
export const commentOnIssueDecisionEffectSchema = z.object({
type: z.literal("comment_on_issue"),
...decisionEffectBaseShape,
bodyMarkdown: z.string().trim().min(1).max(20_000),
});
export const createIssueDecisionEffectSchema = z.object({
type: z.literal("create_issue"),
...decisionEffectBaseShape,
draft: z.object({
title: z.string().trim().min(1).max(500),
description: z.string().max(100_000).nullable().optional(),
parentId: z.string().uuid().nullable().optional(),
assigneeAgentId: z.string().uuid().nullable().optional(),
assigneeUserId: z.string().trim().min(1).nullable().optional(),
projectId: z.string().uuid().nullable().optional(),
goalId: z.string().uuid().nullable().optional(),
blockedByIssueIds: z.array(z.string().uuid()).max(100).optional(),
}),
});
export const updateIssueStatusDecisionEffectSchema = z.object({
type: z.literal("update_issue_status"),
...decisionEffectBaseShape,
status: z.enum(ISSUE_STATUSES),
comment: z.string().trim().min(1).max(20_000).nullable().optional(),
});
export const assignIssueDecisionEffectSchema = z.object({
type: z.literal("assign_issue"),
...decisionEffectBaseShape,
assigneeAgentId: z.string().uuid().nullable().optional(),
assigneeUserId: z.string().trim().min(1).nullable().optional(),
comment: z.string().trim().min(1).max(20_000).nullable().optional(),
});
export const cancelIssueTreeDecisionEffectSchema = z.object({
type: z.literal("cancel_issue_tree"),
targetIssueId: z.string().uuid(),
staleness: z.literal("strict"),
reasonComment: z.string().trim().min(1).max(20_000),
});
export const resolveBlockerDecisionEffectSchema = z.object({
type: z.literal("resolve_blocker"),
...decisionEffectBaseShape,
removeBlockedByIssueIds: z.array(z.string().uuid()).min(1).max(100),
});
export const decisionEffectSchema = z.discriminatedUnion("type", [
commentOnIssueDecisionEffectSchema,
createIssueDecisionEffectSchema,
updateIssueStatusDecisionEffectSchema,
assignIssueDecisionEffectSchema,
cancelIssueTreeDecisionEffectSchema,
resolveBlockerDecisionEffectSchema,
]).superRefine((effect, ctx) => {
if (effect.type === "create_issue" && effect.draft.assigneeAgentId && effect.draft.assigneeUserId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Only one assignee may be set",
path: ["draft", "assigneeUserId"],
});
}
if (effect.type === "assign_issue") {
const assigneeCount = Number(Boolean(effect.assigneeAgentId)) + Number(Boolean(effect.assigneeUserId));
if (assigneeCount !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Exactly one assignee must be set",
path: ["assigneeAgentId"],
});
}
}
});
export const decisionInputSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(240),
placeholder: z.string().max(500).nullable().optional(),
required: z.boolean().optional(),
maxLength: z.number().int().positive().max(20_000).optional(),
});
export const decisionOptionSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(240),
description: z.string().max(2_000).nullable().optional(),
style: decisionOptionStyleSchema.optional(),
effects: z.array(decisionEffectSchema).max(10),
}).superRefine((option, ctx) => {
if (option.effects.some((effect) => effect.type === "cancel_issue_tree") && option.style !== "destructive") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Options that cancel an issue tree must use destructive style",
path: ["style"],
});
}
});
export const decisionOptionsSchema = z.array(decisionOptionSchema).min(1).max(8).superRefine((options, ctx) => {
const seenIds = new Set<string>();
for (const [index, option] of options.entries()) {
if (seenIds.has(option.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Decision option ids must be unique",
path: [index, "id"],
});
}
seenIds.add(option.id);
}
});
export const decisionInputsSchema = z.array(decisionInputSchema).max(4).superRefine((inputs, ctx) => {
const seenIds = new Set<string>();
for (const [index, input] of inputs.entries()) {
if (seenIds.has(input.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Decision input ids must be unique",
path: [index, "id"],
});
}
seenIds.add(input.id);
}
});
export const decisionSpecSchema = z.object({
options: decisionOptionsSchema,
inputs: decisionInputsSchema.nullable().optional(),
});
export type DecisionEffectInput = z.input<typeof decisionEffectSchema>;
export type DecisionOptionInput = z.input<typeof decisionOptionSchema>;
export type DecisionInputInput = z.input<typeof decisionInputSchema>;
export type DecisionSpecInput = z.input<typeof decisionSpecSchema>;