Files
PaperClipAI/ui/src/lib/interaction-audience.ts
T
DottaandClaude Opus 5.5 9b21dcbdda refactor: remove SPEC docs and repoint spec citations (#20565)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors and agents read the docs under `doc/` before they
change code
> - `doc/SPEC.md` and `doc/SPEC-implementation.md` were out of date.
`doc/SPEC-implementation.md` became an append-only log: in 60 days it
got about 1,645 new lines and lost 181, across 140 commits
> - #20557 changed `AGENTS.md` so the code and its tests are the source
of truth, and the specs are only background
> - This pull request removes the two spec docs. It moves only the
sections that live code comments cite into focused docs, and it repoints
all other references
> - The benefit is one less stale document that agents read and append
to

## Linked Issues or Issue Description

Refs #20557 (merged). This PR is the follow-up that removes the docs. It
is based on `master`, which already contains #20557.

**Issue type**
Outdated content. The docs are out of date and conflict with the code.

**Where is the issue?**
`doc/SPEC.md`, `doc/SPEC-implementation.md`, and the code comments and
docs that cite them.

**What's wrong?**
`doc/SPEC.md` and `doc/SPEC-implementation.md` are out of date, but
agents still read them and append a paragraph with each fix.

**Suggested fix**
Delete both files. Move the sections that code cites into focused docs.
Repoint the references.

## What Changed

- Deleted `doc/SPEC.md` and `doc/SPEC-implementation.md`.
- Moved these spec sections, because live code comments cite them:
- §9.3.1 "Shared default-open issue writes" → new section in
`doc/agent-permission-defaults.md`.
- §9.8.1 "Issue-thread interaction resolver contract" →
`doc/execution-semantics.md`, "Issue-thread interaction resolution".
That section already had most of the contract. I added only the missing
parts: the `supersedeOnUserComment` default, the canonical policy list,
the addressee and cap rules, the rule that surfaces show the server's
effective audience, and the cross-issue budget charge. The short policy
summary at the top of the section now points to the list, so the
definitions occur one time only.
- §9.9 "Task Watchdog Authority Contract" → new "Authority contract"
section in `doc/TASK-WATCHDOG.md`. It holds the allowed and disallowed
mutation lists and the deferred capabilities. The atomic recovery batch,
restoration verification, and interaction rules were already in
`doc/execution-semantics.md` §11, so the new section links there and
does not repeat them. I did not move the "Downstream acceptance
criteria" checklist or the sentence about the CTO and Security sign-off.
These were implementation-time notes; the tests now hold that coverage.
- §9.10 "Company Skill Policy Contract" → new
`doc/company-skill-policy.md`. No focused doc had this contract. I did
not move the "Required regression tests" phase checklist.
- Did not move any other part of the specs.
- Repointed code comments that cited spec sections:
- `packages/shared/src/issue-write-denial.ts` (§9.3) →
`doc/agent-permission-defaults.md`
- `server/src/routes/issues.ts` (§9.9) → `doc/TASK-WATCHDOG.md`,
"Authority contract"
- `server/src/routes/issues.ts` (§9.3, §9.8.1 cross-issue budget) →
`doc/agent-permission-defaults.md`
- `ui/src/lib/interaction-audience.ts` and
`ui/src/components/InteractionGovernancePanel.tsx` (§9.8.1) →
`doc/execution-semantics.md`
- `ui/src/lib/skill-policy-denial.ts`, `ui/src/pages/CompanySkills.tsx`,
`ui/src/pages/CompanySkills.production.tsx`,
`ui/src/pages/SkillStudio.tsx` (§9.10) → `doc/company-skill-policy.md`
- `server/src/services/recovery/ownership-invariant-report.ts` (monitor
deadline wait path) → `doc/execution-semantics.md` §8. That section
already lists the one-shot monitor as a valid wait path.
- Resynced the git blob pin for
`packages/shared/src/issue-write-denial.ts` in
`tests/runner-e2e/source-sdk-public-inputs.json`. The comment edit
changed the file's hash, and the Runner support suite checks that pin. I
used `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD>
--write`; only that one pin changed.
- Updated `AGENTS.md` §1 so it does not name the deleted files.
- Removed the links to the deleted files from
`doc/plugins/PLUGIN_SPEC.md`.
- Updated the `doc-maintenance` agent skill
(`.agents/skills/doc-maintenance/`) so it no longer audits or stages
`doc/SPEC.md`.
- Removed or replaced references in `maps/README.md`,
`maps/feature-map/README.md`, `maps/storybook-map/README.md`,
`doc/PRODUCT.md`, `doc/CLIPHUB.md`, `doc/DEPLOYMENT-MODES.md`,
`doc/DEVELOPING.md`, `doc/evals.md`, `doc/spec/agent-runs.md`,
`doc/AGENTCOMPANIES_SPEC_INVENTORY.md`,
`doc/connections/CONNECTOR-PLAYBOOK.md`,
`doc/plugins/ideas-from-opencode.md`, `doc/execution-semantics.md`,
`doc/TASK-WATCHDOG.md`, and `.devin/wiki.json`.
- In `doc/execution-semantics.md`, the "admission is independent of
cleanup and accounting" reference now states the rule inline: only a
hard stop, an explicit pause, or an inactive company refuses admission.
- Kept these references as they are: dated plans in `doc/plans/` and
`docs/plans/`, the dated log
`doc/logs/2026-05-24-cli-api-parity-e2e-log.md`, `releases/`, the
transcript fixture `ui/src/fixtures/runTranscriptFixtures.ts` (it uses
the filename only as sample text), and
`docs/specs/external-task-protocol.md` (it refers to the Symphony
project's own `SPEC.md`).

## Verification

- `git grep -n "SPEC-implementation\|SPEC\.md"` (without `releases/`,
and ignoring `PLUGIN_SPEC.md` citations) finds only the kept references
listed above.
- `grep -rn "§9\.\(3\|8\|9\|10\)" ui/src server/src packages/shared/src`
finds no results.
- `node scripts/check-maps.mjs`: "Maps aligned: 35 areas, 192 capability
IDs".
- `pnpm check:token-gates`: "All gates clean".
- `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/source-sdk-public-type.test.ts
tests/runner-e2e/source-sdk-evidence.test.ts`: 45 passed. These failed
in CI before the pin resync.
- `pnpm verify`: exit 2 ("Broader verification required") because
`AGENTS.md` is a global CI input. Required CI supplies the broad
coverage for this revision.
- All code changes are comments only. No runtime behavior changes. The
title uses `refactor:` because comments in source files changed.

## Risks

- Low risk. The change removes docs and edits comments only.
- A reader can lose context from spec sections that I did not move. The
text stays in git history. The code and its tests are the source of
truth.
- External links to the two deleted files will return 404.

> 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

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), in Claude Code with
tool use (shell, file edits). Standard context window.

## 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 (focused checks above; CI
supplies broad coverage)
- [x] I have added or updated tests where applicable (not applicable:
docs and comments only)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:07:59 -06:00

284 lines
12 KiB
TypeScript

/**
* Plain-language presentation of *who may resolve* an issue-thread interaction
* (PAP-17280, Phase 3 of the open-default resolver contract in
* `doc/execution-semantics.md`, "Issue-thread interaction resolution").
*
* The product default is open: an interaction created without an explicit
* `resolverPolicy` is resolvable by `anyone` in the company — the board or any
* agent, including the agent that created it. `not_creator`, `human_only`, and a
* named addressee are *narrowing* controls that a requester asks for on purpose.
*
* This module is presentation only. It never decides authority: the server
* evaluator owns that, and every downstream effect re-runs its own
* authorization (see the Phase 1S security verdict on PAP-17278). Everything
* here is derived from the server-provided snapshot fields
* (`requestedResolverPolicy`, `effectiveResolverPolicy`,
* `effectiveResolverPolicySource`, `resolverPolicyProvenance`) so the copy a
* reader sees agrees with the policy the server will apply.
*/
import {
ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES,
normalizeIssueThreadInteractionResolverPolicy,
type AttentionItem,
type AttentionResolverAudience,
type IssueThreadInteractionCanonicalResolverPolicy,
type IssueThreadInteractionEffectiveResolverPolicySource,
type IssueThreadInteractionResolverPolicy,
type IssueThreadInteractionResolverPolicyProvenance,
} from "@paperclipai/shared";
import type { IssueThreadInteraction } from "./issue-thread-interactions";
/**
* The open default. Creation surfaces present this first and requesters omit
* `resolverPolicy` to get it.
*/
export const DEFAULT_RESOLVER_POLICY: IssueThreadInteractionCanonicalResolverPolicy = "anyone";
/** Short label for a resolver audience — badges, select options, table cells. */
const RESOLVER_POLICY_LABELS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
anyone: "Anyone",
not_creator: "Anyone except creator",
human_only: "Human only",
};
/**
* Plain-language preview of what a policy *does*, phrased for a surface that is
* choosing it for future cards (company defaults and caps). Card-specific copy
* that names the real creator/addressee comes from
* {@link describeInteractionAudience}.
*/
const RESOLVER_POLICY_EFFECTS: Record<IssueThreadInteractionCanonicalResolverPolicy, string> = {
anyone:
"Anyone in the organization can respond — the board or any agent, including the one that asked.",
not_creator:
"Anyone in the organization except the agent that created the card, and its run. Use this when the answer has to come from someone else.",
human_only: "Only a person on the board can respond. Agents are turned away.",
};
/** Accepts canonical values and the deprecated `board_*` compatibility aliases. */
export function resolverPolicyLabel(policy: IssueThreadInteractionResolverPolicy): string {
return RESOLVER_POLICY_LABELS[normalizeIssueThreadInteractionResolverPolicy(policy)];
}
/** Accepts canonical values and the deprecated `board_*` compatibility aliases. */
export function resolverPolicyEffect(policy: IssueThreadInteractionResolverPolicy): string {
return RESOLVER_POLICY_EFFECTS[normalizeIssueThreadInteractionResolverPolicy(policy)];
}
/**
* Ordered choice list for every surface that picks a resolver audience. `anyone`
* is first and marked as the default so the open option reads as the normal
* path rather than a permission grant.
*/
export const RESOLVER_POLICY_CHOICES: readonly {
value: IssueThreadInteractionCanonicalResolverPolicy;
label: string;
effect: string;
isDefault: boolean;
}[] = ISSUE_THREAD_INTERACTION_CANONICAL_RESOLVER_POLICIES.map((value) => ({
value,
label: RESOLVER_POLICY_LABELS[value],
effect: RESOLVER_POLICY_EFFECTS[value],
isDefault: value === DEFAULT_RESOLVER_POLICY,
}));
/** Why an effective audience ended up narrower than the requested one. */
export type InteractionAudienceNarrowing =
| "requested"
| "company_cap"
| "governed_action"
| "addressee"
| "legacy_restriction";
export interface InteractionAudienceDescription {
/** Effective canonical policy the server will enforce. */
policy: IssueThreadInteractionCanonicalResolverPolicy;
/** Canonical policy the creator asked for (before caps/clamps). */
requestedPolicy: IssueThreadInteractionCanonicalResolverPolicy;
/** Short badge label for the effective audience. */
label: string;
/** One sentence naming exactly who can respond to *this* card. */
summary: string;
/**
* The same fact in a glanceable clause, for a dense surface that shows the
* audience beside compact decision buttons (collapsed attention rows).
*/
shortSummary: string;
/** True when the card is open to the whole company with no addressee. */
isOpen: boolean;
/** Set when something narrows the audience below the open default. */
narrowedBy: InteractionAudienceNarrowing | null;
/** Extra sentence explaining a narrowing the requester did not ask for. */
narrowedNote: string | null;
}
/**
* The server-evaluated facts an audience description is derived from. Both the
* full interaction snapshot (issue thread) and the attention feed's
* `resolverAudience` (collapsed queue rows) reduce to this shape, so one copy
* table serves both surfaces and they cannot drift.
*/
export interface InteractionAudienceFacts {
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
requestedResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
effectiveResolverPolicySource: IssueThreadInteractionEffectiveResolverPolicySource;
resolverPolicyProvenance: IssueThreadInteractionResolverPolicyProvenance;
hasAddressee: boolean;
isUserAddressee?: boolean;
}
/**
* Describe the effective audience of one interaction.
*
* Precedence mirrors the server evaluator's *narrowing* order so the copy can
* never promise a wider audience than the API allows: `human_only` (including a
* governed-action clamp) beats a named addressee, which beats `not_creator`,
* which beats the open default.
*/
export function describeInteractionAudience({
interaction,
creatorLabel,
addresseeLabel,
}: {
interaction: IssueThreadInteraction;
/** Display label of the creating actor, when known. */
creatorLabel?: string | null;
/** Display label of the named addressee, when the card has one. */
addresseeLabel?: string | null;
}): InteractionAudienceDescription {
return describeResolverAudience({
facts: {
effectiveResolverPolicy: interaction.effectiveResolverPolicy,
requestedResolverPolicy: interaction.requestedResolverPolicy,
effectiveResolverPolicySource: interaction.effectiveResolverPolicySource,
resolverPolicyProvenance: interaction.resolverPolicyProvenance,
hasAddressee: Boolean(interaction.addresseeAgentId || interaction.addresseeUserId),
isUserAddressee: Boolean(interaction.addresseeUserId),
},
creatorLabel,
addresseeLabel,
});
}
/**
* Describe an effective audience from the server-evaluated facts alone — used
* where the full interaction is not loaded, such as a collapsed attention row
* (PAP-17287).
*/
export function describeResolverAudience({
facts,
creatorLabel,
addresseeLabel,
}: {
facts: InteractionAudienceFacts;
creatorLabel?: string | null;
addresseeLabel?: string | null;
}): InteractionAudienceDescription {
const policy = facts.effectiveResolverPolicy;
const requestedPolicy = facts.requestedResolverPolicy;
const hasAddressee = facts.hasAddressee;
const isUserAddressee = facts.isUserAddressee === true;
// `formatAssigneeUserLabel` returns the display-cased "You" for the signed-in
// reader, which is right for a badge and wrong mid-sentence ("Only You can
// respond."). Every use below is inside a sentence.
const midSentence = (label: string) => (label === "You" ? "you" : label);
const addressee = midSentence(
addresseeLabel?.trim() || (isUserAddressee ? "the addressed user" : "the addressed agent"),
);
const creator = midSentence(creatorLabel?.trim() || "the agent that created it");
const summary = isUserAddressee
? `Only ${addressee} can respond.`
: policy === "human_only"
? `${hasAddressee ? `Assigned to ${addressee}. ` : ""}Only a person on the board can respond — agents cannot resolve this card.`
: hasAddressee
? `Only ${addressee} or a person on the board can respond.`
: policy === "not_creator"
? `Anyone in the organization except ${creator} can respond.`
: "Anyone in the organization can respond — the board or any agent, including the one that asked.";
// Same fact, fewer words: a collapsed row has to answer "is this mine to
// decide?" in one glance, next to the buttons that act on the answer.
const shortSummary = isUserAddressee
? `Only ${addressee} can respond`
: policy === "human_only"
? hasAddressee
? `Assigned to ${addressee} · board only`
: "Only the board can respond"
: hasAddressee
? `Only ${addressee} or the board can respond`
: policy === "not_creator"
? `Anyone except ${creator} can respond`
: "Anyone can respond";
const source = facts.effectiveResolverPolicySource;
const provenance = facts.resolverPolicyProvenance;
// Narrowing the requester did *not* ask for is the only thing worth an extra
// sentence: a governed-action clamp, a company cap, or a card created before
// open resolution existed. An explicitly requested restriction is already
// fully described by `summary`.
const narrowedNote = source === "governed_action"
? "This card runs a governed action, so it stays human-only whatever audience was requested."
: source === "company_cap"
? `Organization interaction governance narrowed this from ${RESOLVER_POLICY_LABELS[requestedPolicy]} to ${RESOLVER_POLICY_LABELS[policy]}.`
: provenance === "legacy_inherited_restriction"
? "Created before Anyone became the default, so it stays restricted. A new card would be open."
: null;
const narrowedBy: InteractionAudienceNarrowing | null = source === "governed_action"
? "governed_action"
: source === "company_cap"
? "company_cap"
: provenance === "legacy_inherited_restriction"
? "legacy_restriction"
: policy !== "anyone"
? "requested"
: hasAddressee
? "addressee"
: null;
return {
policy,
requestedPolicy,
// A named addressee owns the response, so the label must not read "Anyone"
// while the sentence next to it names one actor. `human_only` wins for an
// agent addressee, while a user addressee is the narrower human audience.
label: (policy !== "human_only" || isUserAddressee) && hasAddressee
? "Addressed"
: RESOLVER_POLICY_LABELS[policy],
summary,
shortSummary,
isOpen: policy === "anyone" && !hasAddressee,
narrowedBy,
narrowedNote,
};
}
/**
* Audience of an `issue_thread_interaction` attention row, from the server
* metadata the feed ships with the item (PAP-17287). Returns `null` for every
* other source kind and for a feed built before the metadata existed, so a
* caller renders nothing rather than guessing a policy client-side.
*/
export function describeAttentionResolverAudience(
item: Pick<AttentionItem, "sourceKind" | "resolverAudience">,
): InteractionAudienceDescription | null {
if (item.sourceKind !== "issue_thread_interaction") return null;
const audience: AttentionResolverAudience | null | undefined = item.resolverAudience;
if (!audience) return null;
return describeResolverAudience({
facts: {
effectiveResolverPolicy: audience.effectiveResolverPolicy,
requestedResolverPolicy: audience.requestedResolverPolicy,
effectiveResolverPolicySource: audience.effectiveResolverPolicySource,
resolverPolicyProvenance: audience.resolverPolicyProvenance,
hasAddressee: Boolean(audience.addresseeAgentId || audience.addresseeUserId),
isUserAddressee: Boolean(audience.addresseeUserId),
},
creatorLabel: audience.createdByAgentName,
addresseeLabel: audience.addresseeName,
});
}