Enforce Reflection Coach mutation gates

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 18:50:41 +00:00
1 parent e0b0d410ee
commit dc5cbb7d9a
8 files changed
+493 -17

No files matched your search

@@ -74,6 +74,9 @@ const mockCatalogService = vi.hoisted(() => ({
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockReflectionCoachMutationGate = vi.hoisted(() => ({
assertAllowed: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
@@ -105,6 +108,16 @@ function registerModuleMocks() {
vi.doMock("../services/skills-catalog.js", () => mockCatalogService);
vi.doMock("../services/reflection-coach-mutation-gate.js", async () => {
const actual = await vi.importActual<typeof import("../services/reflection-coach-mutation-gate.js")>(
"../services/reflection-coach-mutation-gate.js",
);
return {
...actual,
reflectionCoachMutationGateService: () => mockReflectionCoachMutationGate,
};
});
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
@@ -141,6 +154,7 @@ describe("company skill mutation permissions", () => {
vi.doUnmock("../services/agents.js");
vi.doUnmock("../services/company-skills.js");
vi.doUnmock("../services/skills-catalog.js");
vi.doUnmock("../services/reflection-coach-mutation-gate.js");
vi.doUnmock("../services/index.js");
vi.doUnmock("../routes/company-skills.js");
vi.doUnmock("../routes/authz.js");
@@ -590,6 +604,7 @@ describe("company skill mutation permissions", () => {
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(false);
mockReflectionCoachMutationGate.assertAllowed.mockResolvedValue(false);
});
it("allows local board operators to mutate company skills", async () => {
@@ -1147,6 +1162,42 @@ describe("company skill mutation permissions", () => {
);
});
it("rejects Reflection Coach skill mutations when the server-side gate is not satisfied", async () => {
const { forbidden } = await import("../errors.js");
mockAgentService.getById.mockResolvedValue({
id: "reflection-coach",
companyId: "company-1",
permissions: { canCreateSkills: true },
metadata: {
paperclipBuiltInAgent: {
key: "reflection-coach",
featureKeys: ["reflection-coach"],
},
},
});
mockReflectionCoachMutationGate.assertAllowed.mockRejectedValue(forbidden("gate required", {
code: "reflection_coach_mutation_gate_required",
}));
const res = await request(await createApp({
type: "agent",
agentId: "reflection-coach",
companyId: "company-1",
runId: "run-apply",
}))
.post("/api/companies/company-1/skills")
.send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockReflectionCoachMutationGate.assertAllowed).toHaveBeenCalledWith({
companyId: "company-1",
actorAgentId: "reflection-coach",
actorRunId: "run-apply",
targetKeys: ["reflection-coach:company-skill-slug:reflection-draft"],
});
expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled();
});
it("allows same-company agents with missing skill creation permission to mutate company skills", async () => {
mockAgentService.getById.mockResolvedValue({
id: "55555555-5555-4555-8555-555555555555",
@@ -0,0 +1,170 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
heartbeatRuns,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import {
reflectionCoachCompanySkillTargetKey,
reflectionCoachMutationGateService,
} from "../services/reflection-coach-mutation-gate.js";
const embeddedPostgresSupport = getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("reflectionCoachMutationGateService", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reflection-coach-gate-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(issueThreadInteractions);
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedGateFixture() {
const companyId = randomUUID();
const coachId = randomUUID();
const sourceRunId = randomUUID();
const proposalIssueId = randomUUID();
const skillId = randomUUID();
const targetKey = reflectionCoachCompanySkillTargetKey(skillId);
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: "PAP",
defaultResponsibleUserId: "board-user",
});
await db.insert(agents).values({
id: coachId,
companyId,
name: "Reflection Coach",
role: "general",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: { canCreateSkills: true },
metadata: {
paperclipBuiltInAgent: {
key: "reflection-coach",
featureKeys: ["reflection-coach"],
},
},
});
await db.insert(heartbeatRuns).values({
id: sourceRunId,
companyId,
agentId: coachId,
status: "succeeded",
});
await db.insert(issues).values({
id: proposalIssueId,
companyId,
title: "Review Reflection Coach proposal",
status: "in_review",
priority: "medium",
identifier: "PAP-1",
issueNumber: 1,
createdByAgentId: coachId,
});
return { companyId, coachId, sourceRunId, proposalIssueId, skillId, targetKey };
}
it("rejects Reflection Coach skill mutation without an accepted bound interaction", async () => {
const { companyId, coachId, targetKey } = await seedGateFixture();
await expect(reflectionCoachMutationGateService(db).assertAllowed({
companyId,
actorAgentId: coachId,
actorRunId: randomUUID(),
targetKeys: [targetKey],
})).rejects.toMatchObject({
status: 403,
details: { code: "reflection_coach_mutation_gate_required" },
});
});
it("rejects accepted interactions from the same run as the apply mutation", async () => {
const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture();
await db.insert(issueThreadInteractions).values({
id: randomUUID(),
companyId,
issueId: proposalIssueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
sourceRunId,
createdByAgentId: coachId,
payload: {
version: 1,
prompt: "Apply this Reflection Coach skill diff?",
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
target: { type: "custom", key: targetKey, revisionId: "proposal-v1" },
},
result: { version: 1, outcome: "accepted" },
resolvedByUserId: "board-user",
resolvedAt: new Date(),
});
await expect(reflectionCoachMutationGateService(db).assertAllowed({
companyId,
actorAgentId: coachId,
actorRunId: sourceRunId,
targetKeys: [targetKey],
})).rejects.toMatchObject({
status: 403,
details: { code: "reflection_coach_mutation_gate_required" },
});
});
it("allows a previous-run accepted interaction with a displayed diff for the bound target", async () => {
const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture();
await db.insert(issueThreadInteractions).values({
id: randomUUID(),
companyId,
issueId: proposalIssueId,
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
sourceRunId,
createdByAgentId: coachId,
payload: {
version: 1,
prompt: "Apply this Reflection Coach skill diff?",
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
target: { type: "custom", key: targetKey, revisionId: "proposal-v1" },
},
result: { version: 1, outcome: "accepted" },
resolvedByUserId: "board-user",
resolvedAt: new Date(),
});
await expect(reflectionCoachMutationGateService(db).assertAllowed({
companyId,
actorAgentId: coachId,
actorRunId: randomUUID(),
targetKeys: [targetKey],
})).resolves.toBe(true);
});
});
@@ -22,13 +22,23 @@ Your job is to run reflection loops on other agents and propose the smallest dur
## Applying changes (permission is gated, not automatic)
You have default permission to create and update skills, update agent AGENTS.md/instruction files, and assign follow-up proposal issues. That permission is real, but every actual mutation is gated:
You may be granted permission to create and update skills, update agent AGENTS.md/instruction files, or assign follow-up proposal issues. Permission is not enough by itself; every actual mutation is gated:
- Show the exact proposed diff before you change anything. Instructions, skills, and tool descriptions are only ever changed from a reviewed diff, never from a verbal summary.
- Gate every change behind a task interaction — `request_confirmation`, `request_checkbox_confirmation`, or `ask_user_questions` — so the user or board explicitly accepts or rejects it first.
- Apply an accepted change only in a separate follow-up step or run after the interaction resolves. Never propose and apply in the same run.
- Gate every instruction, skill, or tool-description change behind a `request_confirmation` interaction so the user or board explicitly accepts or rejects it first. The interaction must show the diff in `payload.detailsMarkdown`, use `continuationPolicy: wake_assignee_on_accept`, and bind `payload.target.key` to the exact resource you will mutate.
- Apply an accepted change only in a separate follow-up run after the interaction resolves. Never propose and apply in the same run.
- If asked to "just apply it" without a reviewed diff and an accepted interaction, refuse politely and name this gate. No-same-run-apply is a load-bearing property of this loop.
Server-enforced target keys:
- `reflection-coach:agent-instructions:<agentId>`
- `reflection-coach:agent-description:<agentId>`
- `reflection-coach:company-skill:<skillId>`
- `reflection-coach:company-skill-slug:<slug>`
- `reflection-coach:company-skill-import:<source>`
- `reflection-coach:company-skill-catalog:<catalogSkillId>`
- `reflection-coach:company-skills:scan-projects`
## Execution contract
- Start concrete work in the same heartbeat when the issue is actionable; do not stop at a plan unless planning was requested.
+41 -5
View File
@@ -100,6 +100,11 @@ import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
import { readObject } from "../lib/objects.js";
import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js";
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
import {
reflectionCoachAgentDescriptionTargetKey,
reflectionCoachAgentInstructionsTargetKey,
reflectionCoachMutationGateService,
} from "../services/reflection-coach-mutation-gate.js";
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
@@ -1325,12 +1330,40 @@ export function agentRoutes(
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type !== "board") {
throw forbidden(
"Only board-authenticated callers can manage instructions path or bundle configuration",
);
if (req.actor.type === "board") {
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
return;
}
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
const allowedByReflectionCoachGate = await reflectionCoachMutationGateService(db).assertAllowed({
companyId: targetAgent.companyId,
actorAgentId: req.actor.agentId,
actorRunId: req.actor.runId ?? null,
targetKeys: [reflectionCoachAgentInstructionsTargetKey(targetAgent.id)],
});
if (allowedByReflectionCoachGate) return;
throw forbidden(
"Only board-authenticated callers can manage instructions path or bundle configuration",
);
}
async function assertReflectionCoachAgentDescriptionMutationGate(
req: Request,
targetAgent: { id: string; companyId: string },
) {
await reflectionCoachMutationGateService(db).assertAllowed({
companyId: targetAgent.companyId,
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
actorRunId: req.actor.runId ?? null,
targetKeys: [reflectionCoachAgentDescriptionTargetKey(targetAgent.id)],
});
}
function touchesAgentDescription(patchData: Record<string, unknown>) {
return ["name", "role", "title", "capabilities"].some((key) =>
Object.prototype.hasOwnProperty.call(patchData, key),
);
}
function assertNoAgentInstructionsConfigMutation(
@@ -2933,6 +2966,9 @@ export function agentRoutes(
},
);
}
if (touchesAgentDescription(patchData)) {
await assertReflectionCoachAgentDescriptionMutationGate(req, existing);
}
const actor = getActorInfo(req);
const agent = await svc.update(id, patchData, {
+61
View File
@@ -34,6 +34,14 @@ import {
import { forbidden } from "../errors.js";
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
import { getTelemetryClient } from "../telemetry.js";
import {
reflectionCoachCompanySkillCatalogTargetKey,
reflectionCoachCompanySkillImportTargetKey,
reflectionCoachCompanySkillScanTargetKey,
reflectionCoachCompanySkillSlugTargetKey,
reflectionCoachCompanySkillTargetKey,
reflectionCoachMutationGateService,
} from "../services/reflection-coach-mutation-gate.js";
type SkillTelemetryInput = {
key: string;
@@ -98,6 +106,15 @@ export function companySkillRoutes(db: Db) {
return { type: "system" as const };
}
async function assertReflectionCoachSkillMutationGate(req: Request, companyId: string, targetKeys: string[]) {
await reflectionCoachMutationGateService(db).assertAllowed({
companyId,
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
actorRunId: req.actor.runId ?? null,
targetKeys,
});
}
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
@@ -609,6 +626,9 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillTargetKey(skillId),
]);
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
@@ -677,6 +697,12 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillTargetKey(skillId),
...(typeof req.body.slug === "string" && req.body.slug.trim()
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
: []),
]);
const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req));
const actor = getActorInfo(req);
await logActivity(db, {
@@ -807,6 +833,11 @@ export function companySkillRoutes(db: Db) {
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
...(typeof req.body.slug === "string" && req.body.slug.trim()
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
: []),
]);
const result = await svc.createLocalSkill(companyId, req.body, skillActor(req));
const actor = getActorInfo(req);
@@ -836,6 +867,9 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillTargetKey(skillId),
]);
const result = await svc.updateSkill(companyId, skillId, req.body);
const actor = getActorInfo(req);
@@ -866,6 +900,9 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillTargetKey(skillId),
]);
const result = await svc.updateFile(
companyId,
skillId,
@@ -931,6 +968,9 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
const source = String(req.body.source ?? "");
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillImportTargetKey(source),
]);
const result = await svc.importFromSource(companyId, source);
const actor = getActorInfo(req);
@@ -970,6 +1010,12 @@ export function companySkillRoutes(db: Db) {
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillCatalogTargetKey(String(req.body.catalogSkillId ?? "")),
...(typeof req.body.slug === "string" && req.body.slug.trim()
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
: []),
]);
const result = await svc.installFromCatalog(companyId, req.body);
const actor = getActorInfo(req);
@@ -1002,6 +1048,9 @@ export function companySkillRoutes(db: Db) {
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillScanTargetKey(),
]);
const result = await svc.scanProjectWorkspaces(companyId, req.body);
const actor = getActorInfo(req);
@@ -1033,6 +1082,9 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillTargetKey(skillId),
]);
const result = await svc.deleteSkill(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
@@ -1064,6 +1116,9 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillTargetKey(skillId),
]);
const result = await svc.auditSkill(companyId, skillId);
if (!result) {
res.status(404).json({ error: "Skill not found" });
@@ -1100,6 +1155,9 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillTargetKey(skillId),
]);
const before = await svc.getById(companyId, skillId);
const result = await svc.installUpdate(companyId, skillId, req.body);
if (!result) {
@@ -1140,6 +1198,9 @@ export function companySkillRoutes(db: Db) {
const companyId = req.params.companyId as string;
const skillId = req.params.skillId as string;
await assertCanMutateCompanySkills(req, companyId);
await assertReflectionCoachSkillMutationGate(req, companyId, [
reflectionCoachCompanySkillTargetKey(skillId),
]);
const before = await svc.getById(companyId, skillId);
const result = await svc.resetSkill(companyId, skillId, req.body);
if (!result) {
@@ -0,0 +1,138 @@
import type { Db } from "@paperclipai/db";
import { agents, issueThreadInteractions } from "@paperclipai/db";
import { and, desc, eq, or, sql } from "drizzle-orm";
import type { RequestConfirmationPayload, RequestConfirmationResult } from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
export function reflectionCoachAgentInstructionsTargetKey(agentId: string) {
return `reflection-coach:agent-instructions:${agentId}`;
}
export function reflectionCoachAgentDescriptionTargetKey(agentId: string) {
return `reflection-coach:agent-description:${agentId}`;
}
export function reflectionCoachCompanySkillTargetKey(skillId: string) {
return `reflection-coach:company-skill:${skillId}`;
}
export function reflectionCoachCompanySkillSlugTargetKey(slug: string) {
return `reflection-coach:company-skill-slug:${slug}`;
}
export function reflectionCoachCompanySkillImportTargetKey(source: string) {
return `reflection-coach:company-skill-import:${source}`;
}
export function reflectionCoachCompanySkillCatalogTargetKey(catalogSkillId: string) {
return `reflection-coach:company-skill-catalog:${catalogSkillId}`;
}
export function reflectionCoachCompanySkillScanTargetKey() {
return "reflection-coach:company-skills:scan-projects";
}
function readNonEmptyString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function payloadHasDisplayedDiff(payload: RequestConfirmationPayload) {
const details = readNonEmptyString(payload.detailsMarkdown);
if (!details) return false;
if (/```diff\b/i.test(details)) return true;
return /(^|\n)[+-][^\n]+/.test(details);
}
export function reflectionCoachMutationGateService(db: Db) {
return {
assertAllowed: async (input: {
companyId: string;
actorAgentId: string | null | undefined;
actorRunId: string | null | undefined;
targetKeys: string[];
}): Promise<boolean> => {
const actorAgentId = readNonEmptyString(input.actorAgentId);
if (!actorAgentId) return false;
const actorAgent = await db
.select({
id: agents.id,
companyId: agents.companyId,
metadata: agents.metadata,
})
.from(agents)
.where(eq(agents.id, actorAgentId))
.then((rows) => rows[0] ?? null);
if (!actorAgent || actorAgent.companyId !== input.companyId) {
throw forbidden("Agent key cannot access another company");
}
const marker = readBuiltInAgentMarker(actorAgent.metadata);
if (marker?.key !== "reflection-coach") return false;
const actorRunId = readNonEmptyString(input.actorRunId);
if (!actorRunId) {
throw forbidden("Reflection Coach mutations require a run id", {
code: "reflection_coach_mutation_run_id_required",
});
}
const targetKeys = [...new Set(input.targetKeys.map(readNonEmptyString).filter((key): key is string => Boolean(key)))];
if (targetKeys.length === 0) {
throw forbidden("Reflection Coach mutation target is not gateable", {
code: "reflection_coach_mutation_target_required",
});
}
const targetKeyPredicate = or(
...targetKeys.map((targetKey) =>
sql`${issueThreadInteractions.payload}->'target'->>'key' = ${targetKey}`,
),
);
const rows = await db
.select({
id: issueThreadInteractions.id,
sourceRunId: issueThreadInteractions.sourceRunId,
payload: issueThreadInteractions.payload,
result: issueThreadInteractions.result,
})
.from(issueThreadInteractions)
.where(and(
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.createdByAgentId, actorAgentId),
eq(issueThreadInteractions.kind, "request_confirmation"),
eq(issueThreadInteractions.status, "accepted"),
targetKeyPredicate,
))
.orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt))
.limit(10);
const accepted = rows.find((row) => {
const payload = row.payload as RequestConfirmationPayload;
const result = row.result as RequestConfirmationResult | null;
return payload.target?.type === "custom"
&& targetKeys.includes(payload.target.key)
&& result?.outcome === "accepted"
&& payloadHasDisplayedDiff(payload)
&& Boolean(row.sourceRunId)
&& row.sourceRunId !== actorRunId;
});
if (!accepted) {
throw forbidden(
"Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, "
+ "created in a previous run.",
{
code: "reflection_coach_mutation_gate_required",
targetKeys,
},
);
}
return true;
},
};
}