Q107: a task approval covers the content it was given; edits need a fresh one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 17:34:40 -04:00
1 parent c695fdb0f6
commit 4765683b86
12 files changed
+114 -52

No files matched your search

@@ -0,0 +1,12 @@
// Existing task approvals are taken to cover the task as it stands now; from here on an edit needs a fresh one.
export const approvalSubjectSnapshotSchema = `
alter table approvals add column subject_snapshot text;
update approvals set subject_snapshot = (
select json_object('title', t.title, 'description', t.description, 'assignee', t.assignee_agent_id, 'risk', t.risk, 'workspace', t.workspace_id, 'filesystem', t.requires_filesystem)
from tasks t where t.id = approvals.source_entity_id
)
where source_entity_type = 'task' and kind in ('task_risk', 'task_execution');
insert into schema_migrations(version, name, applied_at)
values (85, 'approval_subject_snapshot', datetime('now'));
`;
+2
View File
@@ -83,6 +83,7 @@ import { connectorExecutionsSchema } from "./migrations/081-connector-executions
import { connectorPendingAccountSchema } from "./migrations/082-connector-pending-account";
import { connectorAccountLabelsSchema } from "./migrations/083-connector-account-labels";
import { connectorGrantLimitsSchema } from "./migrations/084-connector-grant-limits";
import { approvalSubjectSnapshotSchema } from "./migrations/085-approval-subject-snapshot";
interface Migration {
version: number;
@@ -175,6 +176,7 @@ const MIGRATIONS: Migration[] = [
{ version: 82, name: "connector_pending_account", sql: connectorPendingAccountSchema },
{ version: 83, name: "connector_account_labels", sql: connectorAccountLabelsSchema },
{ version: 84, name: "connector_grant_limits", sql: connectorGrantLimitsSchema },
{ version: 85, name: "approval_subject_snapshot", sql: approvalSubjectSnapshotSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+35 -31
View File
@@ -1,5 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE } from "../../shared/domain";
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain";
import { createId } from "../../shared/ids";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
@@ -12,6 +12,7 @@ import { TaskMutations } from "./task-mutations";
import { normalizeAvatarColor, normalizeAvatarImage } from "./avatar-values";
import { approvalDecision, autonomy } from "./org-approvals";
import { admitAgent } from "./agent-admission";
import { TASK_APPROVAL_KINDS_SQL, TASK_SNAPSHOT_SQL } from "./task-approval";
export class OrganizationService {
private readonly audit: AuditService;
@@ -23,7 +24,7 @@ export class OrganizationService {
this.audit = new AuditService(db);
this.policy = new PolicyService(db);
this.governance = new OrgGovernanceService(db);
this.tasks = new TaskMutations(db, (input) => this.createTask(input));
this.tasks = new TaskMutations(db, (input) => this.createTask(input), (orgId, taskId) => this.requestTaskApproval(orgId, taskId));
}
createOrganization(input: CreateOrganizationInput): string {
@@ -262,35 +263,7 @@ export class OrganizationService {
now,
);
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
const approvalRequired = policy.approvalRequired || input.requiresFilesystem;
if (approvalRequired) {
const approvalId = createId("app");
const approvalKind = policy.approvalRequired ? "task_risk" : "task_execution";
const autoApprove = autonomy(this.db, input.orgId).executionMode === "ai_ceo";
const decision = approvalDecision(this.db, input.orgId, autoApprove, now);
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type,
requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
approvalId,
input.orgId,
approvalKind,
input.title.trim(),
input.description.trim(),
RISK_LEVEL.HIGH,
"system",
null,
"task",
id,
decision.state,
decision.decidedBy,
now,
decision.decidedAt,
);
this.settleApproval(input.orgId, approvalId, approvalKind, RISK_LEVEL.HIGH, autoApprove);
}
this.requestTaskApproval(input.orgId, id);
this.audit.record({
orgId: input.orgId,
entityType: "task",
@@ -303,6 +276,37 @@ export class OrganizationService {
return id;
}
/** Asks for approval of a task's current content when policy or file access needs it and no review already covers that content. */
requestTaskApproval(orgId: string, taskId: string): void {
const task = this.db.prepare(`select t.title, t.description, t.risk, t.requires_filesystem, ${TASK_SNAPSHOT_SQL} as snapshot from tasks t where t.id = ? and t.org_id = ?`)
.get(taskId, orgId) as { title: string; description: string; risk: RiskLevel; requires_filesystem: number; snapshot: string };
const policy = this.policy.evaluateTaskCreation(orgId, task.risk);
if (!policy.approvalRequired && task.requires_filesystem !== 1) return;
const latest = this.db.prepare(`
select subject_snapshot from approvals
where org_id = ? and source_entity_type = 'task' and source_entity_id = ? and kind in ${TASK_APPROVAL_KINDS_SQL}
order by rowid desc limit 1
`).get(orgId, taskId) as { subject_snapshot: string | null } | undefined;
if (latest?.subject_snapshot === task.snapshot) return;
const superseded = this.db.prepare(`
update approvals set state = ? where org_id = ? and source_entity_type = 'task' and source_entity_id = ? and kind in ${TASK_APPROVAL_KINDS_SQL} and state = ?
`).run(APPROVAL_STATE.SUPERSEDED, orgId, taskId, APPROVAL_STATE.PENDING);
if (superseded.changes > 0) console.info(`[approvals] task ${taskId} changed; its pending review was superseded`);
const now = new Date().toISOString();
const approvalId = createId("app");
const approvalKind = policy.approvalRequired ? "task_risk" : "task_execution";
const autoApprove = autonomy(this.db, orgId).executionMode === "ai_ceo";
const decision = approvalDecision(this.db, orgId, autoApprove, now);
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type, requested_by_id, source_entity_type, source_entity_id,
state, decided_by_employee_id, created_at, decided_at, subject_snapshot
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(approvalId, orgId, approvalKind, task.title, task.description, RISK_LEVEL.HIGH, "system", null, "task", taskId,
decision.state, decision.decidedBy, now, decision.decidedAt, task.snapshot);
this.settleApproval(orgId, approvalId, approvalKind, RISK_LEVEL.HIGH, autoApprove);
}
/** Routes a pending approval to its decider, or records the auto-approval in the audit trail. */
private settleApproval(orgId: string, approvalId: string, kind: string, risk: typeof RISK_LEVEL[keyof typeof RISK_LEVEL], autoApproved: boolean): void {
if (!autoApproved) {
+4
View File
@@ -0,0 +1,4 @@
/** The task content an approval covers, as SQL over a `tasks t` row; one expression for recording and checking keeps them comparable. */
export const TASK_SNAPSHOT_SQL = "json_object('title', t.title, 'description', t.description, 'assignee', t.assignee_agent_id, 'risk', t.risk, 'workspace', t.workspace_id, 'filesystem', t.requires_filesystem)";
export const TASK_APPROVAL_KINDS_SQL = "('task_risk', 'task_execution')";
+7 -1
View File
@@ -23,7 +23,12 @@ interface TaskRow {
export class TaskMutations {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync, private readonly createTask: (input: CreateTaskInput) => string) {
constructor(
private readonly db: DatabaseSync,
private readonly createTask: (input: CreateTaskInput) => string,
/** Asks for a fresh approval when an edit changed content an approval covered. */
private readonly requestApproval: (orgId: string, taskId: string) => void,
) {
this.audit = new AuditService(db);
}
@@ -48,6 +53,7 @@ export class TaskMutations {
where id = ? and org_id = ?
`).run(input.title.trim(), input.description.trim(), input.assigneeAgentId, input.risk, input.priority, input.parentTaskId, now, input.taskId, input.orgId);
if (result.changes === 0) throw new Error("Task was not found.");
this.requestApproval(input.orgId, input.taskId);
this.audit.record({
orgId: input.orgId,
entityType: "task",
+7 -13
View File
@@ -1,3 +1,4 @@
import { TASK_APPROVAL_KINDS_SQL, TASK_SNAPSHOT_SQL } from "./task-approval";
import type { DatabaseSync } from "node:sqlite";
import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain";
import { createId } from "../../shared/ids";
@@ -11,11 +12,6 @@ import type {
WorkspaceRow,
} from "./task-prompts";
interface TaskApprovalRow {
id: string;
state: string;
}
export function requireTask(db: DatabaseSync, orgId: string, taskId: string): TaskExecutionTaskRow {
const task = db.prepare("select * from tasks where id = ? and org_id = ?").get(taskId, orgId) as TaskExecutionTaskRow | undefined;
if (!task) throw new Error("Task was not found.");
@@ -45,16 +41,14 @@ export function assertExecutionApproval(db: DatabaseSync, orgId: string, task: T
export function executionApprovalProblem(db: DatabaseSync, orgId: string, task: TaskExecutionTaskRow): string | null {
if (task.risk !== RISK_LEVEL.HIGH && task.requires_filesystem !== 1) return null;
const approval = db.prepare(`
select id, state
from approvals
where org_id = ?
and source_entity_type = 'task'
and source_entity_id = ?
and kind in ('task_risk', 'task_execution')
order by created_at desc
select a.state, a.subject_snapshot = (select ${TASK_SNAPSHOT_SQL} from tasks t where t.id = a.source_entity_id) as covers_current
from approvals a
where a.org_id = ? and a.source_entity_type = 'task' and a.source_entity_id = ? and a.kind in ${TASK_APPROVAL_KINDS_SQL}
order by a.rowid desc
limit 1
`).get(orgId, task.id) as TaskApprovalRow | undefined;
`).get(orgId, task.id) as { state: string; covers_current: number | null } | undefined;
if (!approval || approval.state !== APPROVAL_STATE.APPROVED) return "Approved task execution approval is required.";
if (approval.covers_current !== 1) return "The task changed after it was approved; the changed task needs a fresh approval.";
return null;
}
+1
View File
@@ -387,6 +387,7 @@ export const zh: Record<string, string> = {
"Account label, e.g. sales": "账号标签,例如 sales",
"Add account": "添加账号",
"limited": "受限",
"Replaced by an edit": "已因编辑被替换",
"Revoke {name}": "撤销 {name}",
"Grant {name} to": "把 {name} 授予",
"Grant to…": "授予给…",
+4 -3
View File
@@ -276,7 +276,8 @@ function DecidedRow({
last: boolean;
}) {
const approved = approval.state === APPROVAL_STATE.APPROVED;
const decidedLabel = approved ? t("Approved") : t("Declined");
const superseded = approval.state === APPROVAL_STATE.SUPERSEDED;
const decidedLabel = approved ? t("Approved") : superseded ? t("Replaced by an edit") : t("Declined");
return (
<div
className="row"
@@ -287,11 +288,11 @@ function DecidedRow({
borderBottom: last ? "none" : "1px solid var(--border-soft)",
}}
>
<Icon name={approved ? "check" : "x"} size={14} color={approved ? "var(--green)" : "var(--red)"} />
<Icon name={approved ? "check" : "x"} size={14} color={approved ? "var(--green)" : superseded ? "var(--text-3)" : "var(--red)"} />
<div style={{ flex: 1, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{approval.title}</div>
<Avatar name={requester} size="sm" bot />
<div style={{ fontSize: 12, color: "var(--text-3)", width: 180, textAlign: "right", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{decidedLabel} · {formatRelative(approval.decidedAt ?? approval.createdAt)} · {decider.split(" ")[0]}
{decidedLabel} · {formatRelative(approval.decidedAt ?? approval.createdAt)}{superseded ? "" : ` · ${decider.split(" ")[0]}`}
</div>
</div>
);
+2 -1
View File
@@ -16,7 +16,8 @@ export { SEARCH_ENTITY_TYPES, type SearchEntityType, type SearchResultRecord } f
export type { BrowserActionPolicyRecord, BrowserActionRecord, BrowserSessionContainerRecord, SocialAccountGrantRecord, SocialAccountRecord, SocialApprovalRouteRecord, SocialAuditRecord } from "./social-domain";
export type { AppSnapshot, UiLanguage, UiPreferences, UiTheme } from "./snapshot-domain";
export const TASK_STATUS = { BACKLOG: "backlog", IN_PROGRESS: "in_progress", REVIEW: "review", DONE: "done" } as const;
export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined" } as const;
/** Superseded: a pending review replaced by one for newer content. */
export const APPROVAL_STATE = { PENDING: "pending", APPROVED: "approved", DECLINED: "declined", SUPERSEDED: "superseded" } as const;
export const RISK_LEVEL = { LOW: "low", MEDIUM: "medium", HIGH: "high" } as const;
export const TASK_PRIORITY = { LOW: "low", NORMAL: "normal", HIGH: "high" } as const;
/** How a task's output should read: conversational prose carries house style; technical and structured do not. */