From 73fbdf36db15f7e1ef5eea0c9834804b0b54fd43 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 24 Apr 2026 19:25:23 -0500
Subject: [PATCH] Gate stale-run watchdog decisions by board access (#4446)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip orchestrates AI agents for zero-human companies
> - The run ledger surfaces stale-run watchdog evaluation issues and
recovery actions
> - Viewer-level board users should be able to inspect status without
getting controls that the server will reject
> - The UI also needs enough board-access context to know when to hide
those decision actions
> - This pull request exposes board memberships in the current board
access snapshot and gates watchdog action controls for known viewer
contexts
> - The benefit is clearer least-privilege UI behavior around recovery
controls
## What Changed
- Included memberships in `/api/cli-auth/me` so the board UI can
distinguish active viewer memberships from operator/admin access.
- Added the stale-run evaluation issue assignee to output silence
summaries.
- Hid stale-run watchdog decision buttons for known non-owner viewer
contexts.
- Surfaced watchdog decision failures through toast and inline error
text.
- Threaded `companyId` through the issue activity run ledger so access
checks are company-scoped.
- Added IssueRunLedger coverage for non-owner viewers.
## Verification
- `pnpm exec vitest run --project @paperclipai/ui
ui/src/components/IssueRunLedger.test.tsx`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
## Risks
- Medium-low risk. This is a UI gating change backed by existing server
authorization.
- Local implicit and instance-admin board contexts continue to show
watchdog decision controls.
- No migrations.
> 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, GPT-5 coding agent, tool-enabled with
shell/GitHub/Paperclip API access. Context window was not reported by
the runtime.
## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
packages/shared/src/types/heartbeat.ts | 1 +
server/src/routes/access.ts | 1 +
server/src/services/recovery/service.ts | 2 +
ui/src/api/access.ts | 5 ++
ui/src/components/IssueRunLedger.test.tsx | 21 ++++++++
ui/src/components/IssueRunLedger.tsx | 65 ++++++++++++++++++++++-
ui/src/pages/IssueDetail.tsx | 4 ++
7 files changed, 97 insertions(+), 2 deletions(-)
diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts
index 5aee87af..973d5d6c 100644
--- a/packages/shared/src/types/heartbeat.ts
+++ b/packages/shared/src/types/heartbeat.ts
@@ -77,6 +77,7 @@ export interface HeartbeatRunOutputSilence {
snoozedUntil: Date | string | null;
evaluationIssueId: string | null;
evaluationIssueIdentifier: string | null;
+ evaluationIssueAssigneeAgentId: string | null;
}
export interface AgentWakeupSkipped {
diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts
index 3df49fd6..44acab90 100644
--- a/server/src/routes/access.ts
+++ b/server/src/routes/access.ts
@@ -2617,6 +2617,7 @@ export function accessRoutes(
userId: req.actor.userId,
isInstanceAdmin: accessSnapshot.isInstanceAdmin,
companyIds: accessSnapshot.companyIds,
+ memberships: accessSnapshot.memberships,
source: req.actor.source ?? "none",
keyId: req.actor.source === "board_key" ? req.actor.keyId ?? null : null,
});
diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts
index f2c2f58d..f20dd059 100644
--- a/server/src/services/recovery/service.ts
+++ b/server/src/services/recovery/service.ts
@@ -82,6 +82,7 @@ export type RunOutputSilenceSummary = {
snoozedUntil: Date | null;
evaluationIssueId: string | null;
evaluationIssueIdentifier: string | null;
+ evaluationIssueAssigneeAgentId: string | null;
};
function readNonEmptyString(value: unknown): string | null {
@@ -590,6 +591,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
snoozedUntil: quietUntilDecision?.snoozedUntil ?? null,
evaluationIssueId: evaluation?.id ?? null,
evaluationIssueIdentifier: evaluation?.identifier ?? null,
+ evaluationIssueAssigneeAgentId: evaluation?.assigneeAgentId ?? null,
};
}
diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts
index c8f83134..b5b68946 100644
--- a/ui/src/api/access.ts
+++ b/ui/src/api/access.ts
@@ -238,6 +238,11 @@ export type CurrentBoardAccess = {
userId: string;
isInstanceAdmin: boolean;
companyIds: string[];
+ memberships?: Array<{
+ companyId: string;
+ membershipRole: HumanCompanyRole | "member" | null;
+ status: "pending" | "active" | "suspended" | "archived";
+ }>;
source: string;
keyId: string | null;
};
diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx
index adefcf4f..7ab4bf1c 100644
--- a/ui/src/components/IssueRunLedger.test.tsx
+++ b/ui/src/components/IssueRunLedger.test.tsx
@@ -124,6 +124,7 @@ function createActiveRun(overrides: Partial = {}): ActiveRunF
snoozedUntil: null,
evaluationIssueId: "issue-eval-1",
evaluationIssueIdentifier: "PAP-404",
+ evaluationIssueAssigneeAgentId: "agent-owner",
},
...overrides,
};
@@ -139,6 +140,8 @@ function renderLedger(props: Partial,
);
@@ -366,4 +369,22 @@ describe("IssueRunLedger", () => {
evaluationIssueId: "issue-eval-1",
});
});
+
+ it("hides watchdog decision actions for known non-owner viewers", () => {
+ const onWatchdogDecision = vi.fn();
+ renderLedger({
+ runs: [createRun({ runId: "run-live-1", status: "running", finishedAt: null })],
+ activeRun: createActiveRun(),
+ canRecordWatchdogDecisions: false,
+ onWatchdogDecision,
+ });
+
+ expect(container.textContent).toContain("Stale-run watchdog alert");
+ expect(container.textContent).toContain("PAP-404");
+ expect(container.textContent).not.toContain("Continue monitoring");
+ expect(container.textContent).not.toContain("Snooze 1h");
+ expect(container.textContent).not.toContain("Mark false positive");
+ expect(container.querySelectorAll("button")).toHaveLength(0);
+ expect(onWatchdogDecision).not.toHaveBeenCalled();
+ });
});
diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx
index 54ceaf0a..45f9bc26 100644
--- a/ui/src/components/IssueRunLedger.tsx
+++ b/ui/src/components/IssueRunLedger.tsx
@@ -1,14 +1,17 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import type { Issue, Agent } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@/lib/router";
+import { accessApi, type CurrentBoardAccess } from "../api/access";
import { activityApi, type RunForIssue, type RunLivenessState } from "../api/activity";
+import { ApiError } from "../api/client";
import {
heartbeatsApi,
type ActiveRunForIssue,
type LiveRunForIssue,
type WatchdogDecisionInput,
} from "../api/heartbeats";
+import { useToastActions } from "../context/ToastContext";
import { cn, relativeTime } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
@@ -16,6 +19,7 @@ import { describeRunRetryState } from "../lib/runRetryState";
type IssueRunLedgerProps = {
issueId: string;
+ companyId: string;
issueStatus: Issue["status"];
childIssues: Issue[];
agentMap: ReadonlyMap;
@@ -30,6 +34,8 @@ type IssueRunLedgerContentProps = {
childIssues: Issue[];
agentMap: ReadonlyMap>;
pendingWatchdogDecision?: WatchdogDecisionInput["decision"] | null;
+ canRecordWatchdogDecisions?: boolean;
+ watchdogDecisionError?: string | null;
onWatchdogDecision?: (input: WatchdogDecisionInput) => void;
};
@@ -309,14 +315,45 @@ function formatSilenceAge(ms: number | null | undefined) {
return `${hours}h ${minutes}m`;
}
+function canBoardRecordWatchdogDecision(
+ companyId: string,
+ boardAccess: CurrentBoardAccess | undefined,
+) {
+ if (!boardAccess) return false;
+ if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
+
+ const membership = boardAccess.memberships?.find(
+ (item) => item.companyId === companyId && item.status === "active",
+ );
+ if (!membership) return boardAccess.companyIds.includes(companyId) && !boardAccess.memberships;
+ return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
+}
+
+function watchdogDecisionErrorMessage(error: unknown) {
+ if (error instanceof ApiError && error.status === 403) {
+ return "Only the board or the assigned recovery owner can record watchdog decisions";
+ }
+ return error instanceof Error && error.message.trim().length > 0
+ ? error.message
+ : "Paperclip could not record the watchdog decision.";
+}
+
export function IssueRunLedger({
issueId,
+ companyId,
issueStatus,
childIssues,
agentMap,
hasLiveRuns,
}: IssueRunLedgerProps) {
const queryClient = useQueryClient();
+ const { pushToast } = useToastActions();
+ const [watchdogDecisionError, setWatchdogDecisionError] = useState(null);
+ const { data: boardAccess } = useQuery({
+ queryKey: queryKeys.access.currentBoardAccess,
+ queryFn: () => accessApi.getCurrentBoardAccess(),
+ retry: false,
+ });
const { data: runs } = useQuery({
queryKey: queryKeys.issues.runs(issueId),
queryFn: () => activityApi.runsForIssue(issueId),
@@ -339,10 +376,25 @@ export function IssueRunLedger({
});
const watchdogDecision = useMutation({
mutationFn: (input: WatchdogDecisionInput) => heartbeatsApi.recordWatchdogDecision(input),
+ onMutate: () => {
+ setWatchdogDecisionError(null);
+ },
onSuccess: () => {
+ setWatchdogDecisionError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId) });
},
+ onError: (error) => {
+ const message = watchdogDecisionErrorMessage(error);
+ const dedupeSuffix = error instanceof ApiError ? String(error.status) : "error";
+ setWatchdogDecisionError(message);
+ pushToast({
+ title: "Watchdog decision not recorded",
+ body: message,
+ tone: "error",
+ dedupeKey: `watchdog-decision:${issueId}:${dedupeSuffix}`,
+ });
+ },
});
return (
@@ -354,6 +406,8 @@ export function IssueRunLedger({
childIssues={childIssues}
agentMap={agentMap}
pendingWatchdogDecision={watchdogDecision.variables?.decision ?? null}
+ canRecordWatchdogDecisions={canBoardRecordWatchdogDecision(companyId, boardAccess)}
+ watchdogDecisionError={watchdogDecisionError}
onWatchdogDecision={(input) => watchdogDecision.mutate(input)}
/>
);
@@ -367,6 +421,8 @@ export function IssueRunLedgerContent({
childIssues,
agentMap,
pendingWatchdogDecision,
+ canRecordWatchdogDecisions = true,
+ watchdogDecisionError,
onWatchdogDecision,
}: IssueRunLedgerContentProps) {
const ledgerRuns = useMemo(() => mergeRuns(runs, liveRuns, activeRun), [activeRun, liveRuns, runs]);
@@ -468,7 +524,7 @@ export function IssueRunLedgerContent({
>
) : null}
- {onWatchdogDecision ? (
+ {onWatchdogDecision && canRecordWatchdogDecisions ? (
) : null}
+ {watchdogDecisionError ? (
+
+ {watchdogDecisionError}
+
+ ) : null}
) : null}
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index f35fa22b..319cb7b7 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -837,6 +837,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
type IssueDetailActivityTabProps = {
issueId: string;
+ companyId: string;
issueStatus: Issue["status"];
childIssues: Issue[];
agentMap: Map;
@@ -850,6 +851,7 @@ type IssueDetailActivityTabProps = {
function IssueDetailActivityTab({
issueId,
+ companyId,
issueStatus,
childIssues,
agentMap,
@@ -941,6 +943,7 @@ function IssueDetailActivityTab({