Gate task execution on approval source refs

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 21:52:52 -04:00
1 parent 4dbd8997c0
commit c59be97b79
12 files changed
+183 -16

No files matched your search

@@ -0,0 +1,49 @@
export const approvalSourceRefsSchema = `
alter table approvals add column source_entity_type text;
alter table approvals add column source_entity_id text;
update approvals
set
source_entity_type = 'task',
source_entity_id = (
select tasks.id
from tasks
where tasks.org_id = approvals.org_id
and tasks.title = approvals.title
and tasks.description = approvals.details
order by tasks.created_at desc
limit 1
)
where kind = 'task_risk'
and source_entity_id is null
and exists (
select 1
from tasks
where tasks.org_id = approvals.org_id
and tasks.title = approvals.title
and tasks.description = approvals.details
);
update approvals
set
source_entity_type = 'scheduled_post',
source_entity_id = (
select scheduled_posts.id
from scheduled_posts
where scheduled_posts.approval_id = approvals.id
limit 1
)
where kind = 'social_publish'
and source_entity_id is null
and exists (
select 1
from scheduled_posts
where scheduled_posts.approval_id = approvals.id
);
create index if not exists idx_approvals_source
on approvals(org_id, source_entity_type, source_entity_id, kind, state);
insert into schema_migrations(version, name, applied_at)
values (30, 'approval_source_refs', datetime('now'));
`;
@@ -28,6 +28,7 @@ import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platfo
import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records";
import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutover-journal";
import { fileHandoffRecordsSchema } from "./migrations/029-file-handoff-records";
import { approvalSourceRefsSchema } from "./migrations/030-approval-source-refs";
interface Migration {
version: number;
@@ -65,6 +66,7 @@ const MIGRATIONS: Migration[] = [
{ version: 27, name: "social_audit_records", sql: socialAuditRecordsSchema },
{ version: 28, name: "migration_cutover_journal", sql: migrationCutoverJournalSchema },
{ version: 29, name: "file_handoff_records", sql: fileHandoffRecordsSchema },
{ version: 30, name: "approval_source_refs", sql: approvalSourceRefsSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -9,6 +9,8 @@ import { OrgGovernanceService } from "./org-governance-service";
interface ApprovalAuditContext {
sourceMessageId?: string;
sourceThreadId?: string;
sourceEntityType?: string;
sourceEntityId?: string;
}
export class ApprovalService {
@@ -35,8 +37,8 @@ export class ApprovalService {
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type, requested_by_id,
state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
input.orgId,
@@ -46,6 +48,8 @@ export class ApprovalService {
input.risk,
input.requestedByType,
input.requestedById,
context.sourceEntityType ?? null,
context.sourceEntityId ?? null,
APPROVAL_STATE.PENDING,
null,
now,
@@ -113,7 +113,12 @@ export class BoardInterventionService {
risk: RISK_LEVEL.HIGH,
requestedByType: requester.type,
requestedById: requester.id,
}, { sourceMessageId: input.messageId, sourceThreadId: input.threadId });
}, {
sourceMessageId: input.messageId,
sourceThreadId: input.threadId,
sourceEntityType: "chat_message",
sourceEntityId: input.messageId,
});
}
private applyBoardThreadState(input: RecordBoardInterventionInput, command: BoardCommand, target: BoardTarget): void {
@@ -229,7 +229,7 @@ export class ChatService {
risk: RISK_LEVEL.HIGH,
requestedByType: requester.type,
requestedById: requester.id,
}, { sourceMessageId: messageId, sourceThreadId: threadId });
}, { sourceMessageId: messageId, sourceThreadId: threadId, sourceEntityType: "chat_message", sourceEntityId: messageId });
}
private recordSlashInvocation(messageId: string, slash: SlashInvocation, resultRef: string | null, now: string): void {
@@ -190,8 +190,8 @@ export class OrganizationService {
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type,
requested_by_id, state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
approvalId,
input.orgId,
@@ -201,6 +201,8 @@ export class OrganizationService {
RISK_LEVEL.MEDIUM,
"system",
null,
"agent",
id,
APPROVAL_STATE.PENDING,
null,
now,
@@ -242,28 +244,32 @@ export class OrganizationService {
now,
);
const policy = this.policy.evaluateTaskCreation(input.orgId, input.risk);
if (policy.approvalRequired) {
const approvalRequired = policy.approvalRequired || input.requiresFilesystem;
if (approvalRequired) {
const approvalId = createId("app");
const approvalKind = policy.approvalRequired ? "task_risk" : "task_execution";
this.db.prepare(`
insert into approvals(
id, org_id, kind, title, details, risk, requested_by_type,
requested_by_id, state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
requested_by_id, source_entity_type, source_entity_id, state, decided_by_employee_id, created_at, decided_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
approvalId,
input.orgId,
"task_risk",
approvalKind,
input.title.trim(),
input.description.trim(),
RISK_LEVEL.HIGH,
"system",
null,
"task",
id,
APPROVAL_STATE.PENDING,
null,
now,
null,
);
this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: "task_risk", risk: RISK_LEVEL.HIGH });
this.governance.routeApproval({ orgId: input.orgId, approvalId, kind: approvalKind, risk: RISK_LEVEL.HIGH });
}
this.audit.record({
orgId: input.orgId,
@@ -262,7 +262,7 @@ export class SdlcService {
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
{ key: "schema_version", passed: schemaVersion >= 29, value: schemaVersion },
{ key: "schema_version", passed: schemaVersion >= 30, value: schemaVersion },
];
}
@@ -352,7 +352,7 @@ export class SocialService {
risk: policy.risk_level,
requestedByType: "employee",
requestedById: employeeId,
});
}, { sourceEntityType: "scheduled_post", sourceEntityId: post.id });
this.assignSocialApprovalRoute(orgId, approvalId, route);
return approvalId;
}
@@ -1,6 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
import type { RunTaskInput } from "../../shared/commands";
import { PROVIDER_EXECUTION_MODE } from "../../shared/domain";
import { APPROVAL_STATE, PROVIDER_EXECUTION_MODE, RISK_LEVEL } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { ProviderService } from "./provider-service";
@@ -61,6 +61,11 @@ interface WorkspaceRow {
repo_ref: string;
}
interface TaskApprovalRow {
id: string;
state: string;
}
export class TaskExecutionService {
private readonly audit: AuditService;
@@ -73,6 +78,7 @@ export class TaskExecutionService {
async runTask(input: RunTaskInput): Promise<string> {
const task = this.requireTask(input.orgId, input.taskId);
this.assertExecutionApproval(input.orgId, task);
const agent = this.requireAssignedAgent(input.orgId, task);
const provider = this.requireAgentProvider(input.orgId, agent);
const runId = this.createRun(input.orgId, task.id, agent.id, provider.id);
@@ -141,6 +147,23 @@ export class TaskExecutionService {
return agent;
}
private assertExecutionApproval(orgId: string, task: TaskExecutionTaskRow): void {
if (task.risk !== RISK_LEVEL.HIGH && task.requires_filesystem !== 1) return;
const approval = this.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
limit 1
`).get(orgId, task.id) as TaskApprovalRow | undefined;
if (!approval || approval.state !== APPROVAL_STATE.APPROVED) {
throw new Error("Approved task execution approval is required.");
}
}
private requireAgentProvider(orgId: string, agent: TaskExecutionAgentRow): TaskExecutionProviderRow {
if (!agent.model_provider_id) throw new Error("Task assignee requires a model provider.");
const provider = this.db.prepare(`