fix(inbox): keep automatic recovery cleanup out of personal participation (#16063)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The personal inbox uses issue activity to identify a person's
participation.
> - Issue reads can cancel stale recovery actions and record that
cleanup as the reader's action.
> - This adds unrelated tasks to the inbox and refreshes their activity
timestamps.
> - A workspace owner hold can also alternate between creation and
cancellation after a task finishes.
> - This pull request corrects cleanup attribution, excludes historical
read cleanup, and retains one exact owner hold.
> - Agents can read authorized issue activity to diagnose the same
problem themselves.

## Linked Issues or Issue Description

Refs #15742 excludes logs by the execution-recovery actor from activity
timestamps. This PR covers reader-attributed stale recovery cleanup,
personal inbox participation, exact workspace owner holds, and sandbox
issue activity access. I searched open issues and PRs for recovery inbox
and activity bridge fixes. I found no duplicate for this defect.

**What happened?**

Loading issue lists cancels stale recovery actions under the reader's
identity. The personal inbox treats those rows as user participation.
One completed issue had hundreds of such rows and no edits or comments
by the affected reader. A retained native workspace owner repeatedly
creates a verification action that read projections then cancel. Sandbox
agents cannot inspect the issue activity because the reverse bridge
rejects the route.

**Expected behavior**

Automatic cleanup belongs to the system and does not add an issue to a
reader's inbox. Historical cleanup rows remain auditable but do not
count as participation or recent activity. An unresolved physical
workspace owner keeps one stable verification action. Agents can inspect
activity only for issues their identity may read.

**Steps to reproduce**

1. Finish a task whose native run retains an unverifiable workspace
owner receipt.
2. Repeat workspace recovery checks and GET requests for issue detail,
compact lists, and recovery actions.
3. Observe new recovery action IDs and reader-attributed cancellations
in the activity history.
4. Query the personal inbox for that reader and observe the unrelated
task.
5. From a sandbox agent, request GET /api/issues/:id/activity and
observe Route not allowed.

**Paperclip version or commit**

Observed at f5346c05b9. Regression tests
use current master behavior.

**Deployment mode**

Authenticated hosted instance with native and legacy sandbox agents. The
inbox defect is independent of the adapter.

## What Changed

- Attribute automatic stale recovery cleanup to the recovery system
actor.
- Exclude exact historical read cleanup rows from personal inbox
membership, unread counts, and both activity timestamp queries. Keep all
audit rows and explicit user recovery decisions.
- Retain owner verification holds only while the exact company, run,
issue, and owner fingerprint still match. Apply the same rule to reads
and background recovery.
- Make repeated checks for the same owner hold leave its identity,
attempts, and timestamps unchanged. Keep physical takeover blocked.
- Allow GET issue activity through the sandbox bridge. Keep the existing
company and issue-read authorization and reject broader audit routes.
- Add regressions and update the runtime API and recovery documentation.
Refresh generated source references for the documentation edits. Update
the paired activity, inbox, and recovery maps.

## Verification

- Focused Vitest verification: 315 tests pass across five files. The
updated terminal owner hold regression also passes. The files cover
issue service, recovery routes, activity authorization, sandbox callback
bridge, and native workspace finalization.
- `pnpm -r typecheck` and `pnpm build` pass locally. The full general
and serialized unit test matrix passes in CI on the latest commit. The
duplicate local `pnpm test:run` was stopped after about 30 minutes once
that full CI coverage passed; it did not report a local full-suite pass.
- `node scripts/check-maps.mjs` passes for all paired areas and
capability IDs.
- Apex is 5/5 on commit 44c3787888. Its
access map finding is fixed and resolved. All CI checks are green on
this commit.
- The new regression repeats reads and background sweeps for done and
cancelled tasks. It checks one unchanged hold, zero physical copyback,
zero personal inbox participation, and system cleanup after the exact
receipt is cleared.
- Historical cleanup tests preserve explicit recovery decisions,
nullable metadata, unread counts, and the original audit rows.
- Authorization tests permit an authorized agent and reject
cross-company or issue-boundary reads. Bridge tests reject write
methods, extra path segments, and company-wide audit routes.

## Risks

Existing inbox items can disappear if their only participation evidence
was automatic read cleanup. No data migration or audit deletion is
needed. An unresolved workspace owner remains visible after task
completion; this preserves the physical writer fence. A stale or cleared
owner does not retain the hold. Issue activity access expands only the
sandbox transport allowlist and still uses existing controller
authorization.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
editing, shell execution, and test tools. The session identifies the
model family as GPT-6. A more specific runtime model ID and context
window size are not exposed.

## 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: Amy (Opus) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 19:25:15 -07:00
1 parent f329b24ee1
commit 83e13db8fa
24 files changed
+913 -666

No files matched your search

@@ -9,6 +9,15 @@ A completed workspace barrier is reread
under ownership before export, and a committed coordinator cannot be overwritten
by a late failure receipt.
An unverified workspace owner keeps one board recovery action for its exact run
and owner fingerprint. Repeated recovery checks do not consume attempts or
rewrite that action. Task completion or cancellation does not prove the old
physical writer stopped, so reads and recovery sweeps retain this action while
the matching run still carries that owner receipt. After the receipt is cleared
or replaced, ordinary stale-action cleanup applies. Cleanup is attributed to
the system; historical cleanup from page reads does not add tasks to a person's
inbox or refresh issue activity timestamps.
The lock transaction holds no row locks. Ordinary progress and finalization
receipts remain visible through the normal database pool. A dedicated connection outside the application pool is
reserved for the duration of copyback and closed when it settles; even a one-connection application pool remains available for progress writes. Its run profile carries
+21 -11
View File
@@ -40,22 +40,32 @@ Review: `baseline`.
API: `present` — representative operation IDs:
- `GET /api/companies/{companyId}/activity`.
- `GET /api/issues/{id}/activity` (UUID or issue identifier).
Agent access: `available` — REST / native call_api / CLI activity.
Agent access: `partial` — REST / native call_api / CLI activity for company
activity. Sandbox agents can use GET issue activity through the default
[callback bridge](../../packages/adapter-utils/src/sandbox-callback-bridge.ts).
The bridge still rejects company activity and company audit routes.
Policy: Same company plus visibility/audit/cost/run permissions where the route requires
them.
Policy: Company activity requires same-company access and `company_scope:read`,
with issue visibility filtering. Issue activity requires same-company resource
access and `issue:read` for the resolved issue. The sandbox transport does not
replace those controller checks or permit activity writes.
Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at
the reviewed commit above.
Evidence: `source-only` — [activity routes](../../server/src/routes/activity.ts),
[agent authorization tests](../../server/src/__tests__/activity-routes.test.ts),
and [bridge tests](../../packages/adapter-utils/src/sandbox-callback-bridge.test.ts).
Issue activity transport reviewed at `89770ba707fb1ebd4cabd184eec2e7fee226e0c3`;
other operations retain the baseline source review above.
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
Verification: `not-run` — no live agent-credential proof. Local route and bridge
regressions pass: permitted agent issue read, denied company/resource scope,
denied writes, extra path segments, and company audit routes. With a real
sandbox agent, read an authorized issue's activity, then verify denial for an
inaccessible issue, another company, and company-wide audit.
Gaps: Audit export and raw activity creation have extra restrictions; do not equate them
with activity reading.
Gaps: Audit export and raw activity creation have extra restrictions; do not
equate them with issue activity reading. Live sandbox access remains unverified.
## `runs`
+12 -3
View File
@@ -22,15 +22,24 @@ API: `present` — representative operation IDs:
Agent access: `partial` — REST / native call_api; MCP inbox/task search.
Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for
cross-user archive; decision policy separately.
cross-user archive; decision policy separately. Automatic read cleanup does not
count as user participation, including historical reader-attributed rows.
Explicit user recovery decisions still count.
Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at
the reviewed commit above.
the reviewed commit above. The cleanup exclusion in
[issue queries](../../server/src/services/issues.ts) was reviewed at
`89770ba707fb1ebd4cabd184eec2e7fee226e0c3`.
[Issue service regressions](../../server/src/__tests__/issues-service.test.ts)
cover membership, unread counts, timestamps, and retained audit rows.
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
remaining operations before qualifying the whole capability. Seed a completed
issue with only automatic read cleanup under a user identity: Mine and Unread
must exclude it while activity history retains the row. An explicit recovery
decision must still establish participation. No live user-inbox proof is claimed.
Gaps: Agent inbox and Mine support exist; board attention/unread/dismissal aggregation
is a separate user-only surface.
+13 -3
View File
@@ -64,20 +64,30 @@ Review: `baseline`.
API: `present` — representative operation IDs:
- `GET /api/issues/{id}/recovery-actions`.
- `POST /api/issues/{id}/recovery-actions/resolve`.
Agent access: `partial` — Eligible agent REST / CLI; source-bound recovery state.
Policy: Task/run visibility, run ownership and source/action policy; workspace export
retry and some operator controls require board.
retry and some operator controls require board. Completion or cancellation of a
task does not clear its exact unresolved native workspace owner hold. Physical
owner release still requires the [operator stop-verification procedure](../../doc/native-workspace-finalization-recovery.md).
Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at
the reviewed commit above.
the reviewed commit above. Exact owner hold retention in the
[recovery state helper](../../server/src/services/native-runtime/native-workspace-finalization-recovery-state.ts)
was reviewed at `89770ba707fb1ebd4cabd184eec2e7fee226e0c3`.
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
Assert the relevant denied actor, owner, company or mode from Policy. Review the
remaining operations before qualifying the whole capability.
remaining operations before qualifying the whole capability. Local
[recovery route regressions](../../server/src/__tests__/issue-recovery-actions.test.ts)
verify one unchanged hold across reads and sweeps on done/cancelled tasks, no
physical copyback, and cleanup after the receipt is cleared. Exercise this
sequence in a disposable instance; automatic cleanup must use the system actor
and must not create reader participation. No live recovery proof is claimed.
Gaps: Need operation-level eligibility and a real native binding for permitted recovery
actions; do not route around lifecycle authority.
+6
View File
@@ -40,6 +40,12 @@ Automated: [activity service](../../server/src/__tests__/activity-service.test.t
Manual: Find a known mutation by actor/resource and a known run by status. Change filters, load more, and follow the source links. Reload the chosen view and verify no company leakage or duplicate events. Inspect a routine run from its audit entry.
Issue activity is also available through `GET /api/issues/{id}/activity`.
[Agent authorization](../../server/src/__tests__/activity-routes.test.ts) and
[sandbox bridge](../../packages/adapter-utils/src/sandbox-callback-bridge.test.ts)
regressions cover this route separately from company-wide audit. Live sandbox
agent access remains unverified; use the companion recipe.
Audit-event Load more and run listings differ: Runs currently loads at most 200
entries without pagination. The tested RoutineAuditActivity component has no
registered activity-route host; inspect execution links actually offered by
+11
View File
@@ -35,8 +35,19 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Seed
Automated: [archive routes](../../server/src/__tests__/inbox-archive-routes.test.ts) cover server mutations. [Task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, and presentation constants; they do not render the combined Inbox/Tasks page. Reading, archiving, and navigating that page remain manual checks.
Automated: [Issue service regressions](../../server/src/__tests__/issues-service.test.ts)
cover exclusion of historical automatic read cleanup from user participation,
unread counts, and activity timestamps while retaining explicit user decisions
and audit history.
Manual: Read an unread task, return to Unread, archive a handled item, and reload. Verify personal triage state changes while task status stays correct. Switch users and companies to check isolation.
Seed a completed task whose only activity under the viewing user is automatic
read cleanup. Refresh Mine and Unread: the task must not appear through that
participation, while the cleanup remains in activity history. Record an explicit
user recovery decision on a separate task and verify it still counts. This
browser journey remains manual.
### `decisions`
Automated: [decision queues](../../server/src/__tests__/decision-queues-routes.test.ts) covers route behavior. End-to-end queue-to-source navigation is manual.
+8
View File
@@ -90,6 +90,14 @@ the supported recovery until the source task produces a useful result or a
specific escalation. Reload and verify that resolved notices retire and attempt
history remains. A fixture pass does not qualify every live harness/provider.
For a native workspace owner hold, repeat issue reads and recovery checks after
the task becomes done or cancelled. Verify one unchanged action ID, attempt
count, and timestamps; task completion must not authorize copyback. Clear only
the exact receipt after operator stop verification, then confirm stale action
cleanup. The [route regressions](../../server/src/__tests__/issue-recovery-actions.test.ts)
and [heartbeat regression](../../server/src/__tests__/heartbeat-process-recovery.test.ts)
cover this sequence without a live operator recovery claim.
### `run-detail`
Automated: [run workspace recovery](../../ui/src/components/RunWorkspaceRecoverySurface.test.tsx)
@@ -1500,6 +1500,7 @@ describe("sandbox callback bridge", () => {
{ method: "GET", path: "/api/goals/goal-1" },
{ method: "GET", path: "/api/issues/issue-1" },
{ method: "GET", path: "/api/issues/issue-1/heartbeat-context" },
{ method: "GET", path: "/api/issues/issue-1/activity" },
{ method: "GET", path: "/api/issues/issue-1/comments" },
{ method: "GET", path: "/api/issues/issue-1/comments/c-1" },
{ method: "POST", path: "/api/issues/issue-1/comments" },
@@ -1546,6 +1547,10 @@ describe("sandbox callback bridge", () => {
}
const denied: Array<{ method: string; path: string }> = [
{ method: "POST", path: "/api/issues/issue-1/activity" },
{ method: "GET", path: "/api/issues/issue-1/activity/extra" },
{ method: "GET", path: "/api/companies/co-1/activity" },
{ method: "GET", path: "/api/companies/co-1/audit/agent-actions" },
{ method: "GET", path: "/api/companies/co-1/agent-commentary" },
{ method: "POST", path: "/api/companies/co-1/agent-commentary/other" },
{ method: "POST", path: "/api/companies/co-1/email/inboxes" },
@@ -162,6 +162,8 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa
// Issue lifecycle: read context, checkout, update, comment, document, release
{ method: "GET", path: /^\/api\/issues\/[^/]+$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ },
// The controller still checks company membership and issue read authority.
{ method: "GET", path: /^\/api\/issues\/[^/]+\/activity$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ },
{ method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ },
{ method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ },
@@ -47,104 +47,104 @@ The skill/reference inventory and eval cases are the only normative behavior sou
| skill:skills/paperclip/SKILL.md:conversation-tasks:32 | optional_agent_tool | skills/paperclip/SKILL.md:32 |
| skill:skills/paperclip/SKILL.md:server-verified-external-chat-turns:49 | control_plane_owned | skills/paperclip/SKILL.md:49 |
| skill:skills/paperclip/SKILL.md:the-heartbeat-procedure:89 | optional_agent_tool | skills/paperclip/SKILL.md:89 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:198 | always_agent_tool | skills/paperclip/SKILL.md:198 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:246 | control_plane_owned | skills/paperclip/SKILL.md:246 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:256 | optional_agent_tool | skills/paperclip/SKILL.md:256 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:269 | always_agent_tool | skills/paperclip/SKILL.md:269 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:280 | control_plane_owned | skills/paperclip/SKILL.md:280 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:288 | control_plane_owned | skills/paperclip/SKILL.md:288 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:313 | optional_agent_tool | skills/paperclip/SKILL.md:313 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:334 | optional_agent_tool | skills/paperclip/SKILL.md:334 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:364 | optional_agent_tool | skills/paperclip/SKILL.md:364 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:468 | optional_agent_tool | skills/paperclip/SKILL.md:468 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:510 | optional_agent_tool | skills/paperclip/SKILL.md:510 |
| skill:skills/paperclip/SKILL.md:cases:520 | optional_agent_tool | skills/paperclip/SKILL.md:520 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:525 | optional_agent_tool | skills/paperclip/SKILL.md:525 |
| skill:skills/paperclip/SKILL.md:routines:536 | optional_agent_tool | skills/paperclip/SKILL.md:536 |
| skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:547 | optional_agent_tool | skills/paperclip/SKILL.md:547 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:553 | optional_agent_tool | skills/paperclip/SKILL.md:553 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:560 | optional_agent_tool | skills/paperclip/SKILL.md:560 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:567 | optional_agent_tool | skills/paperclip/SKILL.md:567 |
| skill:skills/paperclip/SKILL.md:critical-rules:593 | optional_agent_tool | skills/paperclip/SKILL.md:593 |
| skill:skills/paperclip/SKILL.md:comment-style-required:614 | always_agent_tool | skills/paperclip/SKILL.md:614 |
| skill:skills/paperclip/SKILL.md:update:646 | optional_agent_tool | skills/paperclip/SKILL.md:646 |
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:656 | always_agent_tool | skills/paperclip/SKILL.md:656 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:670 | optional_agent_tool | skills/paperclip/SKILL.md:670 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:701 | optional_agent_tool | skills/paperclip/SKILL.md:701 |
| skill:skills/paperclip/SKILL.md:searching-issues:730 | optional_agent_tool | skills/paperclip/SKILL.md:730 |
| skill:skills/paperclip/SKILL.md:full-reference:740 | optional_agent_tool | skills/paperclip/SKILL.md:740 |
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:744 | always_agent_tool | skills/paperclip/SKILL.md:744 |
| skill:skills/paperclip/SKILL.md:incidental-feedback:761 | optional_agent_tool | skills/paperclip/SKILL.md:761 |
| skill:skills/paperclip/SKILL.md:generated-artifacts-and-work-products:200 | always_agent_tool | skills/paperclip/SKILL.md:200 |
| skill:skills/paperclip/SKILL.md:status-quick-guide:248 | control_plane_owned | skills/paperclip/SKILL.md:248 |
| skill:skills/paperclip/SKILL.md:monitors-and-watchers-say-only-what-you-actually-scheduled:258 | optional_agent_tool | skills/paperclip/SKILL.md:258 |
| skill:skills/paperclip/SKILL.md:delegating-review-tasks:271 | always_agent_tool | skills/paperclip/SKILL.md:271 |
| skill:skills/paperclip/SKILL.md:managing-a-user-s-inbox:282 | control_plane_owned | skills/paperclip/SKILL.md:282 |
| skill:skills/paperclip/SKILL.md:issue-dependencies-blockers:290 | control_plane_owned | skills/paperclip/SKILL.md:290 |
| skill:skills/paperclip/SKILL.md:requesting-board-approval:315 | optional_agent_tool | skills/paperclip/SKILL.md:315 |
| skill:skills/paperclip/SKILL.md:issue-thread-interactions:336 | optional_agent_tool | skills/paperclip/SKILL.md:336 |
| skill:skills/paperclip/SKILL.md:standalone-decisions:366 | optional_agent_tool | skills/paperclip/SKILL.md:366 |
| skill:skills/paperclip/SKILL.md:mcp-tool-approval-gates:470 | optional_agent_tool | skills/paperclip/SKILL.md:470 |
| skill:skills/paperclip/SKILL.md:niche-workflow-pointers:512 | optional_agent_tool | skills/paperclip/SKILL.md:512 |
| skill:skills/paperclip/SKILL.md:cases:522 | optional_agent_tool | skills/paperclip/SKILL.md:522 |
| skill:skills/paperclip/SKILL.md:company-skills-workflow:527 | optional_agent_tool | skills/paperclip/SKILL.md:527 |
| skill:skills/paperclip/SKILL.md:routines:538 | optional_agent_tool | skills/paperclip/SKILL.md:538 |
| skill:skills/paperclip/SKILL.md:task-files-and-workspace-choices:549 | optional_agent_tool | skills/paperclip/SKILL.md:549 |
| skill:skills/paperclip/SKILL.md:issue-workspace-runtime-controls:555 | optional_agent_tool | skills/paperclip/SKILL.md:555 |
| skill:skills/paperclip/SKILL.md:proposing-credentials-safely:562 | optional_agent_tool | skills/paperclip/SKILL.md:562 |
| skill:skills/paperclip/SKILL.md:reading-granted-secrets:569 | optional_agent_tool | skills/paperclip/SKILL.md:569 |
| skill:skills/paperclip/SKILL.md:critical-rules:595 | optional_agent_tool | skills/paperclip/SKILL.md:595 |
| skill:skills/paperclip/SKILL.md:comment-style-required:616 | always_agent_tool | skills/paperclip/SKILL.md:616 |
| skill:skills/paperclip/SKILL.md:update:648 | optional_agent_tool | skills/paperclip/SKILL.md:648 |
| skill:skills/paperclip/SKILL.md:task-documents-and-deliverables:658 | always_agent_tool | skills/paperclip/SKILL.md:658 |
| skill:skills/paperclip/SKILL.md:planning-required-when-planning-requested:672 | optional_agent_tool | skills/paperclip/SKILL.md:672 |
| skill:skills/paperclip/SKILL.md:key-endpoints-hot-routes:703 | optional_agent_tool | skills/paperclip/SKILL.md:703 |
| skill:skills/paperclip/SKILL.md:searching-issues:733 | optional_agent_tool | skills/paperclip/SKILL.md:733 |
| skill:skills/paperclip/SKILL.md:full-reference:743 | optional_agent_tool | skills/paperclip/SKILL.md:743 |
| skill:skills/paperclip/SKILL.md:conversational-confirmation-answers:747 | always_agent_tool | skills/paperclip/SKILL.md:747 |
| skill:skills/paperclip/SKILL.md:incidental-feedback:764 | optional_agent_tool | skills/paperclip/SKILL.md:764 |
| skill:skills/paperclip/references/api-reference.md:paperclip-api-reference:1 | optional_agent_tool | skills/paperclip/references/api-reference.md:1 |
| skill:skills/paperclip/references/api-reference.md:response-schemas:9 | optional_agent_tool | skills/paperclip/references/api-reference.md:9 |
| skill:skills/paperclip/references/api-reference.md:agent-record-get-api-agents-me-or-get-api-agents-agentid:11 | optional_agent_tool | skills/paperclip/references/api-reference.md:11 |
| skill:skills/paperclip/references/api-reference.md:company-portability:44 | optional_agent_tool | skills/paperclip/references/api-reference.md:44 |
| skill:skills/paperclip/references/api-reference.md:issue-with-ancestors-get-api-issues-issueid:110 | optional_agent_tool | skills/paperclip/references/api-reference.md:110 |
| skill:skills/paperclip/references/api-reference.md:issue-update-response-patch-api-issues-issueid:196 | optional_agent_tool | skills/paperclip/references/api-reference.md:196 |
| skill:skills/paperclip/references/api-reference.md:blocker-diagnostics-get-api-issues-issueid-diagnostics-blockers:238 | control_plane_owned | skills/paperclip/references/api-reference.md:238 |
| skill:skills/paperclip/references/api-reference.md:wake-diagnostics-get-api-issues-issueid-diagnostics-wakes:277 | control_plane_owned | skills/paperclip/references/api-reference.md:277 |
| skill:skills/paperclip/references/api-reference.md:subtree-diagnostics-get-api-issues-issueid-diagnostics-subtree:321 | optional_agent_tool | skills/paperclip/references/api-reference.md:321 |
| skill:skills/paperclip/references/api-reference.md:execution-policy-fields-on-an-issue:369 | optional_agent_tool | skills/paperclip/references/api-reference.md:369 |
| skill:skills/paperclip/references/api-reference.md:cross-agent-review-gates:421 | always_agent_tool | skills/paperclip/references/api-reference.md:421 |
| skill:skills/paperclip/references/api-reference.md:worked-example-ic-heartbeat:454 | optional_agent_tool | skills/paperclip/references/api-reference.md:454 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:459 | control_plane_owned | skills/paperclip/references/api-reference.md:459 |
| skill:skills/paperclip/references/api-reference.md:2-check-inbox:463 | control_plane_owned | skills/paperclip/references/api-reference.md:463 |
| skill:skills/paperclip/references/api-reference.md:3-already-have-issue-101-inprogress-highest-priority-continue-it:470 | optional_agent_tool | skills/paperclip/references/api-reference.md:470 |
| skill:skills/paperclip/references/api-reference.md:4-do-the-actual-work-write-code-run-tests:477 | optional_agent_tool | skills/paperclip/references/api-reference.md:477 |
| skill:skills/paperclip/references/api-reference.md:5-work-is-done-update-status-and-comment-in-one-call:479 | always_agent_tool | skills/paperclip/references/api-reference.md:479 |
| skill:skills/paperclip/references/api-reference.md:6-still-have-time-checkout-the-next-task:483 | control_plane_owned | skills/paperclip/references/api-reference.md:483 |
| skill:skills/paperclip/references/api-reference.md:7-made-partial-progress-not-done-yet-comment-and-exit:490 | always_agent_tool | skills/paperclip/references/api-reference.md:490 |
| skill:skills/paperclip/references/api-reference.md:worked-example-report-a-board-user-s-mine-inbox:495 | control_plane_owned | skills/paperclip/references/api-reference.md:495 |
| skill:skills/paperclip/references/api-reference.md:board-user-created-the-requesting-issue:500 | optional_agent_tool | skills/paperclip/references/api-reference.md:500 |
| skill:skills/paperclip/references/api-reference.md:fetch-the-board-user-s-mine-inbox-issues:504 | control_plane_owned | skills/paperclip/references/api-reference.md:504 |
| skill:skills/paperclip/references/api-reference.md:summarize-it-back-to-the-board-in-a-comment-or-document:518 | always_agent_tool | skills/paperclip/references/api-reference.md:518 |
| skill:skills/paperclip/references/api-reference.md:worked-example-archive-a-resolved-inbox-item:523 | control_plane_owned | skills/paperclip/references/api-reference.md:523 |
| skill:skills/paperclip/references/api-reference.md:the-responsible-user-s-id-is-resolved-from-the-authenticated-agent-run:528 | optional_agent_tool | skills/paperclip/references/api-reference.md:528 |
| skill:skills/paperclip/references/api-reference.md:reverse-the-archive-if-it-was-premature-or-no-longer-desired:537 | optional_agent_tool | skills/paperclip/references/api-reference.md:537 |
| skill:skills/paperclip/references/api-reference.md:worked-example-reviewer-approver-heartbeat:547 | always_agent_tool | skills/paperclip/references/api-reference.md:547 |
| skill:skills/paperclip/references/api-reference.md:worked-example-manager-heartbeat:586 | optional_agent_tool | skills/paperclip/references/api-reference.md:586 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:589 | control_plane_owned | skills/paperclip/references/api-reference.md:589 |
| skill:skills/paperclip/references/api-reference.md:2-check-team-status:593 | optional_agent_tool | skills/paperclip/references/api-reference.md:593 |
| skill:skills/paperclip/references/api-reference.md:3-agent-42-is-blocked-read-comments:600 | control_plane_owned | skills/paperclip/references/api-reference.md:600 |
| skill:skills/paperclip/references/api-reference.md:4-unblock-reassign-and-comment:604 | control_plane_owned | skills/paperclip/references/api-reference.md:604 |
| skill:skills/paperclip/references/api-reference.md:5-check-own-assignments:608 | optional_agent_tool | skills/paperclip/references/api-reference.md:608 |
| skill:skills/paperclip/references/api-reference.md:6-create-subtasks-and-delegate:615 | optional_agent_tool | skills/paperclip/references/api-reference.md:615 |
| skill:skills/paperclip/references/api-reference.md:load-tests-depend-on-caching-layer-being-done-first-paperclip-will-auto-wake-agent-55-when-the-blocker-resolves:621 | control_plane_owned | skills/paperclip/references/api-reference.md:621 |
| skill:skills/paperclip/references/api-reference.md:7-dashboard-for-health-check:626 | optional_agent_tool | skills/paperclip/references/api-reference.md:626 |
| skill:skills/paperclip/references/api-reference.md:comments-and-mentions:632 | always_agent_tool | skills/paperclip/references/api-reference.md:632 |
| skill:skills/paperclip/references/api-reference.md:update:639 | optional_agent_tool | skills/paperclip/references/api-reference.md:639 |
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:661 | optional_agent_tool | skills/paperclip/references/api-reference.md:661 |
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:665 | optional_agent_tool | skills/paperclip/references/api-reference.md:665 |
| skill:skills/paperclip/references/api-reference.md:questions-and-dependencies:675 | always_agent_tool | skills/paperclip/references/api-reference.md:675 |
| skill:skills/paperclip/references/api-reference.md:company-context:687 | optional_agent_tool | skills/paperclip/references/api-reference.md:687 |
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:699 | optional_agent_tool | skills/paperclip/references/api-reference.md:699 |
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:719 | optional_agent_tool | skills/paperclip/references/api-reference.md:719 |
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:738 | optional_agent_tool | skills/paperclip/references/api-reference.md:738 |
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:771 | optional_agent_tool | skills/paperclip/references/api-reference.md:771 |
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:795 | optional_agent_tool | skills/paperclip/references/api-reference.md:795 |
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:814 | optional_agent_tool | skills/paperclip/references/api-reference.md:814 |
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:843 | optional_agent_tool | skills/paperclip/references/api-reference.md:843 |
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:847 | optional_agent_tool | skills/paperclip/references/api-reference.md:847 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:912 | optional_agent_tool | skills/paperclip/references/api-reference.md:912 |
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:921 | always_agent_tool | skills/paperclip/references/api-reference.md:921 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1025 | always_agent_tool | skills/paperclip/references/api-reference.md:1025 |
| skill:skills/paperclip/references/api-reference.md:conversational-confirmation-answers:1083 | always_agent_tool | skills/paperclip/references/api-reference.md:1083 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1089 | always_agent_tool | skills/paperclip/references/api-reference.md:1089 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1204 | optional_agent_tool | skills/paperclip/references/api-reference.md:1204 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1314 | optional_agent_tool | skills/paperclip/references/api-reference.md:1314 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1320 | always_agent_tool | skills/paperclip/references/api-reference.md:1320 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1338 | always_agent_tool | skills/paperclip/references/api-reference.md:1338 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1368 | control_plane_owned | skills/paperclip/references/api-reference.md:1368 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1382 | optional_agent_tool | skills/paperclip/references/api-reference.md:1382 |
| skill:skills/paperclip/references/api-reference.md:agents:1384 | optional_agent_tool | skills/paperclip/references/api-reference.md:1384 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1405 | optional_agent_tool | skills/paperclip/references/api-reference.md:1405 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1446 | optional_agent_tool | skills/paperclip/references/api-reference.md:1446 |
| skill:skills/paperclip/references/api-reference.md:routines:1470 | optional_agent_tool | skills/paperclip/references/api-reference.md:1470 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1486 | optional_agent_tool | skills/paperclip/references/api-reference.md:1486 |
| skill:skills/paperclip/references/api-reference.md:secrets:1508 | optional_agent_tool | skills/paperclip/references/api-reference.md:1508 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1521 | optional_agent_tool | skills/paperclip/references/api-reference.md:1521 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1621 | optional_agent_tool | skills/paperclip/references/api-reference.md:1621 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1661 | optional_agent_tool | skills/paperclip/references/api-reference.md:1661 |
| skill:skills/paperclip/references/api-reference.md:issue-with-ancestors-get-api-issues-issueid:116 | optional_agent_tool | skills/paperclip/references/api-reference.md:116 |
| skill:skills/paperclip/references/api-reference.md:issue-update-response-patch-api-issues-issueid:202 | optional_agent_tool | skills/paperclip/references/api-reference.md:202 |
| skill:skills/paperclip/references/api-reference.md:blocker-diagnostics-get-api-issues-issueid-diagnostics-blockers:244 | control_plane_owned | skills/paperclip/references/api-reference.md:244 |
| skill:skills/paperclip/references/api-reference.md:wake-diagnostics-get-api-issues-issueid-diagnostics-wakes:283 | control_plane_owned | skills/paperclip/references/api-reference.md:283 |
| skill:skills/paperclip/references/api-reference.md:subtree-diagnostics-get-api-issues-issueid-diagnostics-subtree:327 | optional_agent_tool | skills/paperclip/references/api-reference.md:327 |
| skill:skills/paperclip/references/api-reference.md:execution-policy-fields-on-an-issue:375 | optional_agent_tool | skills/paperclip/references/api-reference.md:375 |
| skill:skills/paperclip/references/api-reference.md:cross-agent-review-gates:427 | always_agent_tool | skills/paperclip/references/api-reference.md:427 |
| skill:skills/paperclip/references/api-reference.md:worked-example-ic-heartbeat:460 | optional_agent_tool | skills/paperclip/references/api-reference.md:460 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:465 | control_plane_owned | skills/paperclip/references/api-reference.md:465 |
| skill:skills/paperclip/references/api-reference.md:2-check-inbox:469 | control_plane_owned | skills/paperclip/references/api-reference.md:469 |
| skill:skills/paperclip/references/api-reference.md:3-already-have-issue-101-inprogress-highest-priority-continue-it:476 | optional_agent_tool | skills/paperclip/references/api-reference.md:476 |
| skill:skills/paperclip/references/api-reference.md:4-do-the-actual-work-write-code-run-tests:483 | optional_agent_tool | skills/paperclip/references/api-reference.md:483 |
| skill:skills/paperclip/references/api-reference.md:5-work-is-done-update-status-and-comment-in-one-call:485 | always_agent_tool | skills/paperclip/references/api-reference.md:485 |
| skill:skills/paperclip/references/api-reference.md:6-still-have-time-checkout-the-next-task:489 | control_plane_owned | skills/paperclip/references/api-reference.md:489 |
| skill:skills/paperclip/references/api-reference.md:7-made-partial-progress-not-done-yet-comment-and-exit:496 | always_agent_tool | skills/paperclip/references/api-reference.md:496 |
| skill:skills/paperclip/references/api-reference.md:worked-example-report-a-board-user-s-mine-inbox:501 | control_plane_owned | skills/paperclip/references/api-reference.md:501 |
| skill:skills/paperclip/references/api-reference.md:board-user-created-the-requesting-issue:506 | optional_agent_tool | skills/paperclip/references/api-reference.md:506 |
| skill:skills/paperclip/references/api-reference.md:fetch-the-board-user-s-mine-inbox-issues:510 | control_plane_owned | skills/paperclip/references/api-reference.md:510 |
| skill:skills/paperclip/references/api-reference.md:summarize-it-back-to-the-board-in-a-comment-or-document:524 | always_agent_tool | skills/paperclip/references/api-reference.md:524 |
| skill:skills/paperclip/references/api-reference.md:worked-example-archive-a-resolved-inbox-item:529 | control_plane_owned | skills/paperclip/references/api-reference.md:529 |
| skill:skills/paperclip/references/api-reference.md:the-responsible-user-s-id-is-resolved-from-the-authenticated-agent-run:534 | optional_agent_tool | skills/paperclip/references/api-reference.md:534 |
| skill:skills/paperclip/references/api-reference.md:reverse-the-archive-if-it-was-premature-or-no-longer-desired:543 | optional_agent_tool | skills/paperclip/references/api-reference.md:543 |
| skill:skills/paperclip/references/api-reference.md:worked-example-reviewer-approver-heartbeat:553 | always_agent_tool | skills/paperclip/references/api-reference.md:553 |
| skill:skills/paperclip/references/api-reference.md:worked-example-manager-heartbeat:592 | optional_agent_tool | skills/paperclip/references/api-reference.md:592 |
| skill:skills/paperclip/references/api-reference.md:1-identity-skip-if-already-in-context:595 | control_plane_owned | skills/paperclip/references/api-reference.md:595 |
| skill:skills/paperclip/references/api-reference.md:2-check-team-status:599 | optional_agent_tool | skills/paperclip/references/api-reference.md:599 |
| skill:skills/paperclip/references/api-reference.md:3-agent-42-is-blocked-read-comments:606 | control_plane_owned | skills/paperclip/references/api-reference.md:606 |
| skill:skills/paperclip/references/api-reference.md:4-unblock-reassign-and-comment:610 | control_plane_owned | skills/paperclip/references/api-reference.md:610 |
| skill:skills/paperclip/references/api-reference.md:5-check-own-assignments:614 | optional_agent_tool | skills/paperclip/references/api-reference.md:614 |
| skill:skills/paperclip/references/api-reference.md:6-create-subtasks-and-delegate:621 | optional_agent_tool | skills/paperclip/references/api-reference.md:621 |
| skill:skills/paperclip/references/api-reference.md:load-tests-depend-on-caching-layer-being-done-first-paperclip-will-auto-wake-agent-55-when-the-blocker-resolves:627 | control_plane_owned | skills/paperclip/references/api-reference.md:627 |
| skill:skills/paperclip/references/api-reference.md:7-dashboard-for-health-check:632 | optional_agent_tool | skills/paperclip/references/api-reference.md:632 |
| skill:skills/paperclip/references/api-reference.md:comments-and-mentions:638 | always_agent_tool | skills/paperclip/references/api-reference.md:638 |
| skill:skills/paperclip/references/api-reference.md:update:645 | optional_agent_tool | skills/paperclip/references/api-reference.md:645 |
| skill:skills/paperclip/references/api-reference.md:cross-team-work-and-delegation:667 | optional_agent_tool | skills/paperclip/references/api-reference.md:667 |
| skill:skills/paperclip/references/api-reference.md:receiving-cross-team-work:671 | optional_agent_tool | skills/paperclip/references/api-reference.md:671 |
| skill:skills/paperclip/references/api-reference.md:questions-and-dependencies:681 | always_agent_tool | skills/paperclip/references/api-reference.md:681 |
| skill:skills/paperclip/references/api-reference.md:company-context:693 | optional_agent_tool | skills/paperclip/references/api-reference.md:693 |
| skill:skills/paperclip/references/api-reference.md:company-branding-ceo-board:705 | optional_agent_tool | skills/paperclip/references/api-reference.md:705 |
| skill:skills/paperclip/references/api-reference.md:openclaw-invite-prompt-ceo:725 | optional_agent_tool | skills/paperclip/references/api-reference.md:725 |
| skill:skills/paperclip/references/api-reference.md:setting-agent-instructions-path:744 | optional_agent_tool | skills/paperclip/references/api-reference.md:744 |
| skill:skills/paperclip/references/api-reference.md:project-setup-create-workspace:777 | optional_agent_tool | skills/paperclip/references/api-reference.md:777 |
| skill:skills/paperclip/references/api-reference.md:option-a-one-call-create-with-workspace:801 | optional_agent_tool | skills/paperclip/references/api-reference.md:801 |
| skill:skills/paperclip/references/api-reference.md:option-b-two-calls-project-first-then-workspace:820 | optional_agent_tool | skills/paperclip/references/api-reference.md:820 |
| skill:skills/paperclip/references/api-reference.md:governance-and-approvals:849 | optional_agent_tool | skills/paperclip/references/api-reference.md:849 |
| skill:skills/paperclip/references/api-reference.md:requesting-a-hire-management-only:853 | optional_agent_tool | skills/paperclip/references/api-reference.md:853 |
| skill:skills/paperclip/references/api-reference.md:ceo-strategy-approval:918 | optional_agent_tool | skills/paperclip/references/api-reference.md:918 |
| skill:skills/paperclip/references/api-reference.md:questions-and-waiting-for-human-input:927 | always_agent_tool | skills/paperclip/references/api-reference.md:927 |
| skill:skills/paperclip/references/api-reference.md:issue-thread-confirmations:1031 | always_agent_tool | skills/paperclip/references/api-reference.md:1031 |
| skill:skills/paperclip/references/api-reference.md:conversational-confirmation-answers:1089 | always_agent_tool | skills/paperclip/references/api-reference.md:1089 |
| skill:skills/paperclip/references/api-reference.md:checkbox-confirmations:1095 | always_agent_tool | skills/paperclip/references/api-reference.md:1095 |
| skill:skills/paperclip/references/api-reference.md:item-verdict-requests:1210 | optional_agent_tool | skills/paperclip/references/api-reference.md:1210 |
| skill:skills/paperclip/references/api-reference.md:checking-approval-status:1320 | optional_agent_tool | skills/paperclip/references/api-reference.md:1320 |
| skill:skills/paperclip/references/api-reference.md:approval-follow-up-requesting-agent:1326 | always_agent_tool | skills/paperclip/references/api-reference.md:1326 |
| skill:skills/paperclip/references/api-reference.md:issue-lifecycle:1344 | always_agent_tool | skills/paperclip/references/api-reference.md:1344 |
| skill:skills/paperclip/references/api-reference.md:error-handling:1374 | control_plane_owned | skills/paperclip/references/api-reference.md:1374 |
| skill:skills/paperclip/references/api-reference.md:full-api-reference:1388 | optional_agent_tool | skills/paperclip/references/api-reference.md:1388 |
| skill:skills/paperclip/references/api-reference.md:agents:1390 | optional_agent_tool | skills/paperclip/references/api-reference.md:1390 |
| skill:skills/paperclip/references/api-reference.md:issues-tasks:1411 | optional_agent_tool | skills/paperclip/references/api-reference.md:1411 |
| skill:skills/paperclip/references/api-reference.md:companies-projects-goals:1452 | optional_agent_tool | skills/paperclip/references/api-reference.md:1452 |
| skill:skills/paperclip/references/api-reference.md:routines:1476 | optional_agent_tool | skills/paperclip/references/api-reference.md:1476 |
| skill:skills/paperclip/references/api-reference.md:approvals-costs-activity-dashboard:1492 | optional_agent_tool | skills/paperclip/references/api-reference.md:1492 |
| skill:skills/paperclip/references/api-reference.md:secrets:1514 | optional_agent_tool | skills/paperclip/references/api-reference.md:1514 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-proposals:1527 | optional_agent_tool | skills/paperclip/references/api-reference.md:1527 |
| skill:skills/paperclip/references/api-reference.md:agent-secret-access:1627 | optional_agent_tool | skills/paperclip/references/api-reference.md:1627 |
| skill:skills/paperclip/references/api-reference.md:common-mistakes:1667 | optional_agent_tool | skills/paperclip/references/api-reference.md:1667 |
| skill:skills/paperclip/references/artifacts.md:generated-artifacts-and-work-products:1 | always_agent_tool | skills/paperclip/references/artifacts.md:1 |
| skill:skills/paperclip/references/artifacts.md:workspace-only-file-references:15 | optional_agent_tool | skills/paperclip/references/artifacts.md:15 |
| skill:skills/paperclip/references/cases.md:cases:1 | optional_agent_tool | skills/paperclip/references/cases.md:1 |
File diff suppressed because it is too large. Load diff
@@ -5,6 +5,6 @@ Generated by `scripts/generate-capability-contract.mjs`; do not edit generated f
- Skill/reference headings: 165
- Legacy MCP tools: 42
- Eval cases: 106 across 16 groups
- Deterministic content SHA-256: `83447a4f9463f9a3d73dd4bea78b580df0356545ba00d0da96763282653bdf0f`
- Deterministic content SHA-256: `74c5fcb9ac01e20178834438536f40edc8b630e15571b447cd783a77b8b69c7b`
Every row has exactly one primary disposition, a source anchor, a semantic operation, and a mock-state expectation.
File diff suppressed because it is too large. Load diff
@@ -126,6 +126,33 @@ describe("activity routes", () => {
mockAccessService.canUser.mockResolvedValue(false);
});
it("lets an agent read activity for an authorized issue", async () => {
mockIssueService.getByIdentifier.mockResolvedValue({
id: "issue-1", companyId: "company-1", projectId: null, parentId: null,
assigneeAgentId: "agent-1", assigneeUserId: null, status: "done",
});
mockActivityService.forIssue.mockResolvedValue([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/TEST-1/activity"));
expect(res.status).toBe(200);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "issue:read", resource: expect.objectContaining({ companyId: "company-1", issueId: "issue-1" }),
}));
expect(mockActivityService.forIssue).toHaveBeenCalledWith("issue-1");
expect(res.body).toEqual([{ action: "issue.recovery_action_resolved", actorType: "system" }]);
});
it.each(["boundary", "company"])("denies agent activity reads outside the %s scope", async (kind) => {
const issue = { id: "issue-1", companyId: kind === "company" ? "company-2" : "company-1" };
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getByIdentifier.mockResolvedValue(issue);
mockAccessService.decide.mockResolvedValue({ allowed: false });
const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" });
const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/issues/issue-1/activity"));
expect(res.status).toBe(kind === "company" ? 404 : 403);
expect(mockActivityService.forIssue).not.toHaveBeenCalled();
});
it("returns redacted all-actors rows to a basic company reader", async () => {
mockAgentActionAuditService.list.mockResolvedValue({
items: [{
@@ -2415,7 +2415,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.toMatchObject({ status: "active", resolvedAt: null });
});
it("keeps existing terminal-task disposition for a native finalization hold", async () => {
it("retains the exact native owner hold without changing a terminal task's disposition", async () => {
const f = await seedPendingNativeFinalization();
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeWorkspaceFinalizationOwner: { unknown: true } } })
.where(eq(heartbeatRuns.id, f.runId));
@@ -2423,6 +2423,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await heartbeat.recoverNativeRunsAfterRestart();
const [hold] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
for (let attempt = 0; attempt < 3; attempt += 1) {
await heartbeat.reconcileStrandedAssignedIssues();
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, hold.id)))[0])
.toEqual(hold);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("done");
}
await db.update(heartbeatRuns).set({ runnerProfileJson: {} }).where(eq(heartbeatRuns.id, f.runId));
await heartbeat.reconcileStrandedAssignedIssues();
expect((await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, hold.id)))[0])
.toMatchObject({ status: "resolved", resolutionNote: "source_terminal" });
@@ -38,6 +38,8 @@ import { deliverReconciledExecutions } from "../services/execution-recovery-reso
import { workspaceOperationService, isNativeWorkspaceFinalizationOperationActive } from "../services/workspace-operations.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { recoveryService } from "../services/recovery/service.js";
import { issueService } from "../services/issues.js";
import { withNativeWorkspaceFinalizationOwnership } from "../services/native-runtime/native-workspace-finalization-ownership.js";
import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/successful-run-handoff.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
@@ -2479,6 +2481,51 @@ describeEmbeddedPostgres("issue recovery actions", () => {
trigger: "read_projection",
recoveryActionId: action.id,
});
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")).toMatchObject({
actorType: "system", actorId: "recovery", agentId: null,
});
});
it.each(["done", "cancelled"])("keeps one workspace owner hold across reads and sweeps of a %s task", async (status) => {
const { companyId, coderId, sourceIssueId } = await seedCompany();
const runId = randomUUID();
const owner = { token: randomUUID(), hostname: "old-controller.invalid", pid: 42, processStartedAt: new Date(0).toISOString() };
await db.insert(heartbeatRuns).values({
id: runId, companyId, agentId: coderId, nativeIssueId: sourceIssueId,
runtimeMode: "native", status: "failed", finishedAt: new Date(),
runnerProfileJson: { nativeWorkspaceFinalizationOwner: owner },
});
await db.update(issues).set({ status }).where(eq(issues.id, sourceIssueId));
const userId = "board-reader";
const app = createApp({ type: "board", source: "local_implicit", userId });
const copyback = vi.fn();
const recovery = recoveryService(db, { enqueueWakeup: vi.fn(async () => null) });
let firstAction: typeof issueRecoveryActions.$inferSelect | undefined;
for (let tick = 0; tick < 3; tick += 1) {
expect(await withNativeWorkspaceFinalizationOwnership({ db, companyId, runId }, copyback)).toEqual({ acquired: false });
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toMatchObject({
cause: "native_workspace_finalization_owner_unverified", status: "active", attemptCount: 1,
});
await request(app).get(`/api/companies/${companyId}/issues?view=compact`).expect(200);
await request(app).get(`/api/issues/${sourceIssueId}/recovery-actions`).expect(200);
await recovery.reconcileStrandedAssignedIssues();
const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, sourceIssueId));
expect(actions).toHaveLength(1);
firstAction ??= actions[0]!;
expect(actions[0]).toEqual(firstAction);
}
expect(copyback).not.toHaveBeenCalled();
expect(await issueService(db).list(companyId, { touchedByUserId: userId })).toEqual([]);
expect(await db.select().from(activityLog).where(eq(activityLog.entityId, sourceIssueId))).toHaveLength(0);
// Only clearing the exact physical owner receipt makes this hold stale.
await db.update(heartbeatRuns).set({ runnerProfileJson: {} }).where(eq(heartbeatRuns.id, runId));
const cleared = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(cleared.body.activeRecoveryAction).toBeNull();
const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, sourceIssueId));
expect(rows).toEqual([expect.objectContaining({ actorType: "system", actorId: "recovery", action: "issue.recovery_action_resolved" })]);
expect(await issueService(db).list(companyId, { touchedByUserId: userId })).toEqual([]);
});
it("keeps active recovery visible when a plain comment does not create a live path", async () => {
@@ -668,6 +668,39 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => {
]);
});
it("ignores historical reader-attributed recovery cleanup without hiding explicit recovery decisions", async () => {
const companyId = await seedAssignableAgentCompany();
const userId = "board-user";
const old = new Date("2026-03-26T10:00:00Z");
const recent = new Date("2026-03-26T15:00:00Z");
const variants = [
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "read_projection" }, counts: false },
{ id: randomUUID(), details: null, counts: true },
{ id: randomUUID(), details: { outcome: "restored" }, counts: true },
{ id: randomUUID(), details: { source: "source_revalidation", trigger: "issue_update" }, counts: true },
];
await db.insert(issues).values(variants.map(({ id }) => ({
id, companyId, title: "Recovery audit", status: "done", updatedAt: old,
})));
await db.insert(activityLog).values(variants.map(({ id, details }) => ({
companyId, actorType: "user", actorId: userId, action: "issue.recovery_action_resolved",
entityType: "issue", entityId: id, details, createdAt: recent,
})));
await db.insert(issueComments).values(variants.map(({ id }) => ({
companyId, issueId: id, authorUserId: "other-user", body: "An older update", createdAt: old,
})));
const expectedIds = variants.filter(v => v.counts).map(v => v.id).sort();
expect((await svc.list(companyId, { touchedByUserId: userId })).map(v => v.id).sort()).toEqual(expectedIds);
expect(await svc.countUnreadTouchedByUser(companyId, userId)).toBe(expectedIds.length);
const rows = await svc.list(companyId, {});
for (const variant of variants) {
expect(rows.find(v => v.id === variant.id)?.lastActivityAt).toEqual(variant.counts ? recent : old);
}
// Audit evidence is preserved, including the wrongly attributed old row.
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId))).toHaveLength(variants.length);
});
function agentRow(companyId: string, input: {
id: string;
name: string;
+9 -5
View File
@@ -1,6 +1,7 @@
import { parseObject } from "../adapters/utils.js";
import { enqueueFastResponse } from "../services/fast-responses.js";
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js";
import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../services/native-runtime/native-workspace-finalization-recovery-state.js";
import { monitorPoliciesEqual, applyActorMonitorScheduledBy, assertCanManageIssueMonitor, summarizeIssueMonitor } from "../services/issue-monitors.js";
import type { IssuePrivacyConstraints } from "@paperclipai/shared";
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
@@ -4344,6 +4345,10 @@ export function issueRoutes(
: input.activeRecoveryAction;
if (!activeRecoveryAction) return null;
if (await hasUnverifiedNativeWorkspaceFinalizationOwner(db, activeRecoveryAction)) {
return activeRecoveryAction;
}
const resolutionNote = await classifySourceRecoveryRevalidation(input);
if (!resolutionNote) return activeRecoveryAction;
@@ -4357,13 +4362,12 @@ export function issueRoutes(
});
if (!resolved) return activeRecoveryAction;
const actor = input.actor;
await logActivity(db, {
companyId: input.issue.companyId,
actorType: actor?.actorType ?? "system",
actorId: actor?.actorId ?? "system",
agentId: actor?.agentId ?? null,
runId: actor?.runId ?? null,
actorType: "system",
actorId: "recovery",
agentId: null,
runId: input.actor?.runId ?? null,
action: "issue.recovery_action_resolved",
entityType: "issue",
entityId: input.issue.id,
+13
View File
@@ -2842,6 +2842,16 @@ const ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS = [
"issue.work_product_updated",
] as const;
// Old read projections attributed this automatic cleanup to the reader. Keep
// the audit rows, but do not count them as participation or issue activity.
function automaticReadRecoveryCleanupCondition() {
return sql<boolean>`(
${activityLog.action} = 'issue.recovery_action_resolved'
AND COALESCE(${activityLog.details}->>'source', '') = 'source_revalidation'
AND COALESCE(${activityLog.details}->>'trigger', '') = 'read_projection'
)`;
}
function touchedByUserCondition(companyId: string, userId: string) {
return sql<boolean>`
(
@@ -2855,6 +2865,7 @@ function touchedByUserCondition(companyId: string, userId: string) {
AND ${activityLog.companyId} = ${companyId}
AND ${activityLog.actorType} = 'user'
AND ${activityLog.actorId} = ${userId}
AND NOT ${automaticReadRecoveryCleanupCondition()}
AND ${activityLog.action} IN (${sql.join(
ISSUE_USER_PARTICIPATION_ACTIVITY_ACTIONS.map(
(action) => sql`${action}`,
@@ -2960,6 +2971,7 @@ function issueLatestLogAtExpr(companyId: string) {
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} = ${issues.id}::text
AND NOT ${automaticReadRecoveryCleanupCondition()}
AND ${activityLog.action} NOT IN (${sql.join(
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map((action) => sql`${action}`),
sql`, `,
@@ -5138,6 +5150,7 @@ async function lastActivityStatsForIssues(
eq(activityLog.companyId, companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, issueIdChunk),
sql`NOT ${automaticReadRecoveryCleanupCondition()}`,
sql`${activityLog.action} NOT IN (${sql.join(
ISSUE_LOCAL_INBOX_ACTIVITY_ACTIONS.map(
(action) => sql`${action}`,
@@ -1,5 +1,6 @@
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { nativeSha256 } from "./canonical.js";
import { NATIVE_WORKSPACE_OWNER_UNVERIFIED } from "./native-workspace-finalization-recovery-state.js";
import { randomUUID } from "node:crypto";
import os from "node:os";
import { and, eq, sql } from "drizzle-orm";
@@ -74,11 +75,18 @@ export async function withNativeWorkspaceFinalizationOwnership<T>(
const prior = readOwner(rawPrior);
if (rawPrior != null) {
const requireStopVerification = async () => {
if (run.issueId) await issueRecoveryActionService(input.db).upsertSourceScoped({
const fingerprint = nativeSha256({ runId: input.runId, owner: rawPrior });
const recovery = issueRecoveryActionService(input.db);
const existing = run.issueId ? await recovery.getActiveForIssue(input.companyId, run.issueId) : null;
// Rechecking the same physical owner is not a new recovery attempt.
if (existing?.cause === NATIVE_WORKSPACE_OWNER_UNVERIFIED && existing.fingerprint === fingerprint) {
return { acquired: false } as const;
}
if (run.issueId) await recovery.upsertSourceScoped({
companyId: input.companyId, sourceIssueId: run.issueId,
kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: run.agentId,
cause: "native_workspace_finalization_owner_unverified",
fingerprint: nativeSha256({ runId: input.runId, owner: rawPrior }),
cause: NATIVE_WORKSPACE_OWNER_UNVERIFIED,
fingerprint,
evidence: { runId: input.runId, owner: rawPrior },
nextAction: "Verify the previous controller and its workspace-copyback processes have stopped, then release only this exact workspace owner receipt using doc/native-workspace-finalization-recovery.md. Resume workspace finalization without another provider turn.",
wakePolicy: null, maxAttempts: 1, supersedeOnIdentityChange: true,
@@ -0,0 +1,24 @@
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import { isUuidLike, type IssueRecoveryAction } from "@paperclipai/shared";
import { nativeSha256 } from "./canonical.js";
export const NATIVE_WORKSPACE_OWNER_UNVERIFIED = "native_workspace_finalization_owner_unverified";
/** A terminal task does not prove that an older controller's copyback stopped. */
export async function hasUnverifiedNativeWorkspaceFinalizationOwner(
db: Db,
action: Pick<IssueRecoveryAction, "companyId" | "sourceIssueId" | "cause" | "fingerprint" | "evidence">,
): Promise<boolean> {
if (action.cause !== NATIVE_WORKSPACE_OWNER_UNVERIFIED) return false;
const runId = action.evidence?.runId;
if (typeof runId !== "string" || !isUuidLike(runId)) return false;
const [run] = await db.select({ profile: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, action.companyId),
eq(heartbeatRuns.id, runId),
eq(heartbeatRuns.nativeIssueId, action.sourceIssueId),
eq(heartbeatRuns.runtimeMode, "native"),
)).limit(1);
const owner = run?.profile?.nativeWorkspaceFinalizationOwner;
return owner != null && action.fingerprint === nativeSha256({ runId, owner });
}
+5
View File
@@ -93,6 +93,7 @@ import { emitAgentTaskRun } from "../agent-task-run-telemetry.js";
import { budgetService } from "../budgets.js";
import { unadmittedChatWakeupCondition } from "../durable-chat-wakeup.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../native-runtime/native-workspace-finalization-recovery-state.js";
import {
legacyExecutionNeedsReconciliationWithEvidence,
terminalizeLegacyExecution,
@@ -3428,6 +3429,10 @@ export function recoveryService(
issueIds: [] as string[],
};
for (const { action, issue } of rows) {
if (await hasUnverifiedNativeWorkspaceFinalizationOwner(db, action)) {
result.skipped += 1;
continue;
}
const wakePolicy = parseObject(action.wakePolicy);
const wakePolicyType = readNonEmptyString(wakePolicy.type);
if (
+3
View File
@@ -131,6 +131,8 @@ If already checked out by you, returns normally. If owned by another agent: `409
**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.
For attribution questions, read `GET /api/issues/{issueId}/activity`. Sandbox agents can use this issue-scoped route subject to the same company and issue-read authorization. Automatic recovery cleanup is system activity, not evidence that the person viewing a task participated in it.
If the run prompt includes a Paperclip wake payload, inspect that section before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.
Use comments incrementally:
@@ -708,6 +710,7 @@ If `plan` already exists, first `GET /api/issues/{issueId}/documents/plan` and r
| Checkout task | `POST /api/issues/:issueId/checkout` |
| Get task + ancestors | `GET /api/issues/:issueId` |
| Compact heartbeat context | `GET /api/issues/:issueId/heartbeat-context` |
| Issue activity and attribution | `GET /api/issues/:issueId/activity` |
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
| Get comments / delta / single | `GET /api/issues/:issueId/comments[?after=:commentId&order=asc]` • `/comments/:commentId` |
| Add comment | `POST /api/issues/:issueId/comments` |
@@ -107,6 +107,12 @@ POST /api/companies/company-1/exports
}
```
**Issue Activity (`GET /api/issues/:issueId/activity`)**
Returns the issue's activity history, including actor, action, timestamp, and sanitized details. Sandbox agents can call this read-only route for issues their identity may read. Company boundaries and issue-read authorization still apply; this does not grant company-wide audit access.
An automatic stale-recovery cancellation has `action: "issue.recovery_action_resolved"` and `details.source: "source_revalidation"`. Page reads use `details.trigger: "read_projection"`. These cancellations belong to the system. Historical reader-attributed rows with that source and trigger do not count as inbox participation or recent issue activity.
### Issue with Ancestors (`GET /api/issues/:issueId`)
Includes the issue's `project` and `goal` (with descriptions), plus each ancestor's resolved `project` and `goal`. This gives agents full context about where the task sits in the project/goal hierarchy.