From 428def3c141bd6968a4f8c13feaab8272b390c78 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:29:48 +0800 Subject: [PATCH 1/6] feat: add canonical execution outcomes --- docs/adr/0010-execution-outcome-contract.md | 38 +++++ web/__tests__/execution-outcomes.test.ts | 83 ++++++++++ web/__tests__/work-package-handoff-db.test.ts | 5 + web/db/migrations/0029_execution_outcomes.sql | 42 ++++++ web/db/migrations/meta/_journal.json | 7 + web/db/schema.ts | 45 ++++++ web/lib/execution-outcomes.ts | 142 ++++++++++++++++++ web/worker/execution-outcomes.ts | 47 ++++++ web/worker/work-package-handoff.ts | 71 ++++++++- 9 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0010-execution-outcome-contract.md create mode 100644 web/__tests__/execution-outcomes.test.ts create mode 100644 web/db/migrations/0029_execution_outcomes.sql create mode 100644 web/lib/execution-outcomes.ts create mode 100644 web/worker/execution-outcomes.ts diff --git a/docs/adr/0010-execution-outcome-contract.md b/docs/adr/0010-execution-outcome-contract.md new file mode 100644 index 00000000..8ccf44da --- /dev/null +++ b/docs/adr/0010-execution-outcome-contract.md @@ -0,0 +1,38 @@ +# ADR 0010: Canonical execution outcomes + +## Status + +Accepted. + +## Decision + +Forge records one versioned execution outcome for each authoritative execution +or admission boundary. The new `execution_outcomes` ledger is keyed by +`(task_id, attempt_key)`, so a recovered worker updates the same outcome rather +than creating contradictory records. + +The v1 contract separates provider transport (`ok` or `error`) from the +semantic result. A successful provider response that contains a refusal is +therefore `transportStatus: 'ok'` and `result: 'refused'`; it is never treated +as completion. Stop reasons use a closed taxonomy. Summaries are redacted and +limited to 1,000 characters. Evidence references contain UUID record IDs only; +raw diagnostics remain in the existing artifact, task-log, command-audit, and +MCP records. + +`task_id` is required. Links to a work package, agent run, and queue attempt +are nullable because an admission decision can block before an agent run starts. +The existing lifecycle tables remain the source of truth for their respective +states. The ledger is an interpretation layer for reliability and verification +features. + +For v1, worker writes occur at admission blocks and terminal implementation +success/failure boundaries. Historical rows are deliberately not backfilled: +callers must treat a missing outcome as unavailable legacy evidence, not as a +successful execution. + +## Consequences + +Future reliability scoring, independent verification, and autonomy policy read +this contract instead of deriving meaning from free-text errors. This change +does not redesign retry behavior, create automatic retries, or alter task and +work-package state transitions. diff --git a/web/__tests__/execution-outcomes.test.ts b/web/__tests__/execution-outcomes.test.ts new file mode 100644 index 00000000..0cd88f2d --- /dev/null +++ b/web/__tests__/execution-outcomes.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + admissionBlockedOutcome, + executionFailureOutcome, + isExecutionOutcome, + normalizeExecutionOutcome, + outcomeEvidenceRefsFromArtifact, +} from '@/lib/execution-outcomes' + +const artifactId = '11111111-1111-4111-8111-111111111111' + +describe('execution outcome v1', () => { + it('keeps a semantic refusal separate from provider transport success', () => { + const outcome = executionFailureOutcome({ message: 'I cannot do that.', refused: true, retryable: false }) + expect(outcome).toMatchObject({ + transportStatus: 'ok', + result: 'refused', + stopReasonCode: 'model_refusal', + retryable: false, + }) + }) + + it('maps admission denial to a retryable semantic block', () => { + expect(admissionBlockedOutcome('MCP capability missing')).toMatchObject({ + transportStatus: 'ok', + result: 'blocked', + stopReasonCode: 'admission_denied', + retryable: true, + }) + }) + + it('maps validation failures independently from transport errors', () => { + expect(executionFailureOutcome({ message: 'test failed', retryable: false, validationFailed: true })) + .toMatchObject({ transportStatus: 'ok', result: 'failed', stopReasonCode: 'validation_failed' }) + }) + + it('classifies an unparseable provider response as execution failure, not transport failure', () => { + expect(executionFailureOutcome({ message: 'Provider returned invalid output JSON', retryable: true })) + .toMatchObject({ transportStatus: 'ok', result: 'failed', stopReasonCode: 'invalid_output' }) + }) + + it('redacts and bounds a persisted summary while retaining UUID evidence references', () => { + const normalized = normalizeExecutionOutcome({ + schemaVersion: 1, + transportStatus: 'error', + result: 'failed', + stopReasonCode: 'provider_transport_failure', + stopReasonSummary: `token=secret ${'x'.repeat(1_500)}`, + retryable: true, + evidenceRefs: [artifactId, artifactId], + verifierRequired: false, + verificationStatus: 'not_required', + }, (value) => String(value).replace('secret', '[REDACTED]')) + + expect(normalized.stopReasonSummary).toContain('[REDACTED]') + expect(normalized.stopReasonSummary).toHaveLength(1_000) + expect(normalized.evidenceRefs).toEqual([artifactId]) + expect(isExecutionOutcome(normalized)).toBe(true) + }) + + it('rejects non-UUID evidence references', () => { + expect(isExecutionOutcome({ + schemaVersion: 1, + transportStatus: 'ok', + result: 'completed', + stopReasonCode: null, + stopReasonSummary: null, + retryable: false, + evidenceRefs: ['raw transcript'], + verifierRequired: false, + verificationStatus: 'not_required', + })).toBe(false) + }) + + it('keeps a failure outcome writable when its artifact row is unavailable', () => { + const outcome = { + ...executionFailureOutcome({ message: 'provider disconnected', retryable: true }), + evidenceRefs: outcomeEvidenceRefsFromArtifact(null), + } + expect(outcome.evidenceRefs).toEqual([]) + expect(isExecutionOutcome(outcome)).toBe(true) + }) +}) diff --git a/web/__tests__/work-package-handoff-db.test.ts b/web/__tests__/work-package-handoff-db.test.ts index 068ec21c..ab37cd48 100644 --- a/web/__tests__/work-package-handoff-db.test.ts +++ b/web/__tests__/work-package-handoff-db.test.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ publishTaskEvent: vi.fn(), projectionContributions: [] as Array>, recordTaskLogBestEffort: vi.fn(), + upsertExecutionOutcome: vi.fn(), readS4RuntimeModeV1: vi.fn(), recoverLinkedS4LifecycleV2: vi.fn(), claimWorkPackageLifecycleV2: vi.fn(), @@ -128,6 +129,10 @@ vi.mock('@/worker/work-package-executor', () => ({ ['architect', 'security', 'security-review', 'security_review'].includes(role.trim().toLowerCase()), })) +vi.mock('@/worker/execution-outcomes', () => ({ + upsertExecutionOutcome: mocks.upsertExecutionOutcome, +})) + vi.mock('@/lib/mcps/filesystem-grant-reconciliation', async (importOriginal) => ({ ...await importOriginal(), loadCurrentProjectFilesystemDecision: mocks.loadCurrentProjectFilesystemDecision, diff --git a/web/db/migrations/0029_execution_outcomes.sql b/web/db/migrations/0029_execution_outcomes.sql new file mode 100644 index 00000000..3fcf510c --- /dev/null +++ b/web/db/migrations/0029_execution_outcomes.sql @@ -0,0 +1,42 @@ +-- Canonical, idempotent execution-outcome ledger. Existing lifecycle tables +-- remain authoritative; nullable links allow a pre-run admission block. +CREATE TABLE "execution_outcomes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "task_id" uuid NOT NULL, + "work_package_id" uuid, + "agent_run_id" uuid, + "task_attempt_id" uuid, + "attempt_key" text NOT NULL, + "schema_version" integer DEFAULT 1 NOT NULL, + "transport_status" text NOT NULL, + "result" text NOT NULL, + "stop_reason_code" text, + "stop_reason_summary" text, + "retryable" boolean NOT NULL, + "evidence_refs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "verifier_required" boolean DEFAULT false NOT NULL, + "verification_status" text DEFAULT 'not_required' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "execution_outcomes_schema_version_check" CHECK ("schema_version" = 1), + CONSTRAINT "execution_outcomes_transport_status_check" CHECK ("transport_status" IN ('ok', 'error')), + CONSTRAINT "execution_outcomes_result_check" CHECK ("result" IN ('completed', 'partial', 'refused', 'blocked', 'needs_attention', 'failed', 'cancelled')), + CONSTRAINT "execution_outcomes_stop_reason_code_check" CHECK ("stop_reason_code" IS NULL OR "stop_reason_code" IN ('provider_transport_failure', 'model_refusal', 'invalid_output', 'validation_failed', 'missing_capability', 'admission_denied', 'policy_blocked', 'security_blocked', 'missing_repository_context', 'timeout', 'context_limit', 'output_limit', 'retry_exhausted', 'human_cancelled', 'unknown')), + CONSTRAINT "execution_outcomes_verification_status_check" CHECK ("verification_status" IN ('not_required', 'pending', 'passed', 'failed', 'inconclusive')), + CONSTRAINT "execution_outcomes_verifier_consistency_check" CHECK (("verifier_required" AND "verification_status" IN ('pending', 'passed', 'failed', 'inconclusive')) OR (NOT "verifier_required" AND "verification_status" = 'not_required')), + CONSTRAINT "execution_outcomes_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE restrict ON UPDATE no action, + CONSTRAINT "execution_outcomes_work_package_id_work_packages_id_fk" FOREIGN KEY ("work_package_id") REFERENCES "public"."work_packages"("id") ON DELETE set null ON UPDATE no action, + CONSTRAINT "execution_outcomes_agent_run_id_agent_runs_id_fk" FOREIGN KEY ("agent_run_id") REFERENCES "public"."agent_runs"("id") ON DELETE set null ON UPDATE no action, + CONSTRAINT "execution_outcomes_task_attempt_id_task_attempts_id_fk" FOREIGN KEY ("task_attempt_id") REFERENCES "public"."task_attempts"("id") ON DELETE set null ON UPDATE no action +); +-- `evidence_refs` is constrained to UUIDs by the application contract. A +-- PostgreSQL JSONB CHECK cannot safely validate each array element without +-- coupling this durable ledger to a brittle JSON expression. +--> statement-breakpoint +CREATE UNIQUE INDEX "execution_outcomes_task_attempt_key_idx" ON "execution_outcomes" USING btree ("task_id","attempt_key"); +--> statement-breakpoint +CREATE INDEX "execution_outcomes_work_package_id_idx" ON "execution_outcomes" USING btree ("work_package_id"); +--> statement-breakpoint +CREATE INDEX "execution_outcomes_agent_run_id_idx" ON "execution_outcomes" USING btree ("agent_run_id"); +--> statement-breakpoint +CREATE INDEX "execution_outcomes_task_attempt_id_idx" ON "execution_outcomes" USING btree ("task_attempt_id"); diff --git a/web/db/migrations/meta/_journal.json b/web/db/migrations/meta/_journal.json index 48021810..d10adedd 100644 --- a/web/db/migrations/meta/_journal.json +++ b/web/db/migrations/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1784274000000, "tag": "0028_epic_172_s5_recovery_actions", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1785820800000, + "tag": "0029_execution_outcomes", + "breakpoints": true } ] } diff --git a/web/db/schema.ts b/web/db/schema.ts index 211a71d5..cf65def9 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1454,6 +1454,51 @@ export const agentRuns = pgTable( export type AgentRun = InferSelectModel export type NewAgentRun = InferInsertModel +// --------------------------------------------------------------------------- +// executionOutcomes +// --------------------------------------------------------------------------- +// Canonical, append-compatible outcome ledger. Existing task/package/run rows +// remain the authority for lifecycle state; this table records the normalized +// interpretation of one attempted execution. +export const executionOutcomes = pgTable( + 'execution_outcomes', + { + id: uuid('id').primaryKey().defaultRandom(), + taskId: uuid('task_id').notNull().references(() => tasks.id, { onDelete: 'restrict' }), + // Admission may stop before a run or queue-attempt row exists. + workPackageId: uuid('work_package_id').references(() => workPackages.id, { onDelete: 'set null' }), + agentRunId: uuid('agent_run_id').references(() => agentRuns.id, { onDelete: 'set null' }), + taskAttemptId: uuid('task_attempt_id').references(() => taskAttempts.id, { onDelete: 'set null' }), + attemptKey: text('attempt_key').notNull(), + schemaVersion: integer('schema_version').notNull().default(1), + transportStatus: text('transport_status').notNull(), + result: text('result').notNull(), + stopReasonCode: text('stop_reason_code'), + stopReasonSummary: text('stop_reason_summary'), + retryable: boolean('retryable').notNull(), + evidenceRefs: jsonb('evidence_refs').$type().notNull().default(sql`'[]'::jsonb`), + verifierRequired: boolean('verifier_required').notNull().default(false), + verificationStatus: text('verification_status').notNull().default('not_required'), + createdAt: timestamp('created_at', tsOpts).defaultNow().notNull(), + updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), + }, + (t) => [ + uniqueIndex('execution_outcomes_task_attempt_key_idx').on(t.taskId, t.attemptKey), + index('execution_outcomes_work_package_id_idx').on(t.workPackageId), + index('execution_outcomes_agent_run_id_idx').on(t.agentRunId), + index('execution_outcomes_task_attempt_id_idx').on(t.taskAttemptId), + check('execution_outcomes_schema_version_check', sql`${t.schemaVersion} = 1`), + check('execution_outcomes_transport_status_check', sql`${t.transportStatus} IN ('ok', 'error')`), + check('execution_outcomes_result_check', sql`${t.result} IN ('completed', 'partial', 'refused', 'blocked', 'needs_attention', 'failed', 'cancelled')`), + check('execution_outcomes_stop_reason_code_check', sql`${t.stopReasonCode} IS NULL OR ${t.stopReasonCode} IN ('provider_transport_failure', 'model_refusal', 'invalid_output', 'validation_failed', 'missing_capability', 'admission_denied', 'policy_blocked', 'security_blocked', 'missing_repository_context', 'timeout', 'context_limit', 'output_limit', 'retry_exhausted', 'human_cancelled', 'unknown')`), + check('execution_outcomes_verification_status_check', sql`${t.verificationStatus} IN ('not_required', 'pending', 'passed', 'failed', 'inconclusive')`), + check('execution_outcomes_verifier_consistency_check', sql`(${t.verifierRequired} AND ${t.verificationStatus} IN ('pending', 'passed', 'failed', 'inconclusive')) OR (NOT ${t.verifierRequired} AND ${t.verificationStatus} = 'not_required')`), + ], +) + +export type ExecutionOutcomeRow = InferSelectModel +export type NewExecutionOutcomeRow = InferInsertModel + // --------------------------------------------------------------------------- // artifacts // --------------------------------------------------------------------------- diff --git a/web/lib/execution-outcomes.ts b/web/lib/execution-outcomes.ts new file mode 100644 index 00000000..26624f12 --- /dev/null +++ b/web/lib/execution-outcomes.ts @@ -0,0 +1,142 @@ +/** Versioned, provider-neutral evidence contract for one attempted execution. */ +export const EXECUTION_OUTCOME_SCHEMA_VERSION = 1 as const + +export const EXECUTION_OUTCOME_RESULTS = [ + 'completed', 'partial', 'refused', 'blocked', 'needs_attention', 'failed', 'cancelled', +] as const +export type ExecutionOutcomeResult = typeof EXECUTION_OUTCOME_RESULTS[number] + +export const EXECUTION_STOP_REASON_CODES = [ + 'provider_transport_failure', + 'model_refusal', + 'invalid_output', + 'validation_failed', + 'missing_capability', + 'admission_denied', + 'policy_blocked', + 'security_blocked', + 'missing_repository_context', + 'timeout', + 'context_limit', + 'output_limit', + 'retry_exhausted', + 'human_cancelled', + 'unknown', +] as const +export type ExecutionStopReasonCode = typeof EXECUTION_STOP_REASON_CODES[number] + +export type ExecutionOutcome = { + schemaVersion: 1 + transportStatus: 'ok' | 'error' + result: ExecutionOutcomeResult + stopReasonCode: ExecutionStopReasonCode | null + stopReasonSummary: string | null + retryable: boolean + evidenceRefs: string[] + verifierRequired: boolean + verificationStatus: 'not_required' | 'pending' | 'passed' | 'failed' | 'inconclusive' +} + +const MAX_STOP_REASON_SUMMARY_LENGTH = 1_000 +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export function isExecutionOutcome(value: unknown): value is ExecutionOutcome { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const outcome = value as Partial + return outcome.schemaVersion === EXECUTION_OUTCOME_SCHEMA_VERSION + && (outcome.transportStatus === 'ok' || outcome.transportStatus === 'error') + && EXECUTION_OUTCOME_RESULTS.includes(outcome.result as ExecutionOutcomeResult) + && (outcome.stopReasonCode === null || EXECUTION_STOP_REASON_CODES.includes(outcome.stopReasonCode as ExecutionStopReasonCode)) + && (outcome.stopReasonSummary === null || typeof outcome.stopReasonSummary === 'string') + && typeof outcome.retryable === 'boolean' + && Array.isArray(outcome.evidenceRefs) && outcome.evidenceRefs.every((ref) => typeof ref === 'string' && UUID_PATTERN.test(ref)) + && typeof outcome.verifierRequired === 'boolean' + && ['not_required', 'pending', 'passed', 'failed', 'inconclusive'].includes(outcome.verificationStatus ?? '') +} + +/** + * Converts raw boundary evidence to the storage-safe form. Evidence refs are + * UUIDs only; technical content remains in its existing artifact/log record. + */ +export function normalizeExecutionOutcome( + outcome: ExecutionOutcome, + sanitize: (value: unknown) => string, +): ExecutionOutcome { + if (!isExecutionOutcome(outcome)) throw new Error('Execution outcome does not match schema v1.') + const summary = outcome.stopReasonSummary === null + ? null + : sanitize(outcome.stopReasonSummary).slice(0, MAX_STOP_REASON_SUMMARY_LENGTH) + return { + ...outcome, + evidenceRefs: [...new Set(outcome.evidenceRefs)].sort(), + stopReasonSummary: summary || null, + } +} + +export function executionFailureOutcome(input: { + message: string + retryable: boolean + validationFailed?: boolean + repositoryContextMissing?: boolean + refused?: boolean +}): ExecutionOutcome { + const message = input.message.toLowerCase() + const inferredCode: ExecutionStopReasonCode = message.includes('refus') + ? 'model_refusal' + : message.includes('unparseable') || message.includes('invalid output') || message.includes('valid JSON') + ? 'invalid_output' + : message.includes('timeout') || message.includes('timed out') + ? 'timeout' + : message.includes('context') && message.includes('limit') + ? 'context_limit' + : message.includes('output') && message.includes('limit') + ? 'output_limit' + : 'unknown' + const code: ExecutionStopReasonCode = input.refused + ? 'model_refusal' + : input.validationFailed + ? 'validation_failed' + : input.repositoryContextMissing + ? 'missing_repository_context' + : inferredCode + const semanticResult = code === 'model_refusal' ? 'refused' : input.repositoryContextMissing ? 'blocked' : 'failed' + const transportStatus = code === 'model_refusal' + || code === 'invalid_output' + || code === 'validation_failed' + || code === 'timeout' + || code === 'context_limit' + || code === 'output_limit' + || input.repositoryContextMissing + ? 'ok' + : 'error' + return { + schemaVersion: 1, + transportStatus, + result: semanticResult, + stopReasonCode: code, + stopReasonSummary: input.message, + retryable: input.retryable, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + } +} + +export function admissionBlockedOutcome(message: string, code: 'admission_denied' | 'policy_blocked' | 'security_blocked' = 'admission_denied'): ExecutionOutcome { + return { + schemaVersion: 1, + transportStatus: 'ok', + result: 'blocked', + stopReasonCode: code, + stopReasonSummary: message, + retryable: code === 'admission_denied', + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + } +} + +/** A missing artifact must never suppress an outcome write. */ +export function outcomeEvidenceRefsFromArtifact(artifact: { id: string } | null | undefined): string[] { + return artifact ? [artifact.id] : [] +} diff --git a/web/worker/execution-outcomes.ts b/web/worker/execution-outcomes.ts new file mode 100644 index 00000000..525cfb19 --- /dev/null +++ b/web/worker/execution-outcomes.ts @@ -0,0 +1,47 @@ +import { db } from '../db' +import { executionOutcomes } from '../db/schema' +import { normalizeExecutionOutcome, type ExecutionOutcome } from '../lib/execution-outcomes' +import { sanitizeWorkerMessage } from './redaction' + +/** Idempotently stores the latest canonical interpretation for one attempt. */ +export async function upsertExecutionOutcome(input: { + taskId: string + attemptKey: string + workPackageId?: string | null + agentRunId?: string | null + taskAttemptId?: string | null + outcome: ExecutionOutcome +}): Promise { + if (!input.attemptKey || input.attemptKey.length > 200) { + throw new Error('Execution outcome attempt key is required and bounded.') + } + const outcome = normalizeExecutionOutcome(input.outcome, sanitizeWorkerMessage) + await db + .insert(executionOutcomes) + .values({ + taskId: input.taskId, + attemptKey: input.attemptKey, + workPackageId: input.workPackageId ?? null, + agentRunId: input.agentRunId ?? null, + taskAttemptId: input.taskAttemptId ?? null, + ...outcome, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [executionOutcomes.taskId, executionOutcomes.attemptKey], + set: { + workPackageId: input.workPackageId ?? null, + agentRunId: input.agentRunId ?? null, + taskAttemptId: input.taskAttemptId ?? null, + transportStatus: outcome.transportStatus, + result: outcome.result, + stopReasonCode: outcome.stopReasonCode, + stopReasonSummary: outcome.stopReasonSummary, + retryable: outcome.retryable, + evidenceRefs: outcome.evidenceRefs, + verifierRequired: outcome.verifierRequired, + verificationStatus: outcome.verificationStatus, + updatedAt: new Date(), + }, + }) +} diff --git a/web/worker/work-package-handoff.ts b/web/worker/work-package-handoff.ts index 8f57fb31..ac31849a 100644 --- a/web/worker/work-package-handoff.ts +++ b/web/worker/work-package-handoff.ts @@ -70,6 +70,8 @@ import { } from './repository-evidence' import { explicitOptInFeatureFlagEnabled } from './feature-flags' import { sanitizeWorkerMessage } from './redaction' +import { upsertExecutionOutcome } from './execution-outcomes' +import { executionFailureOutcome, outcomeEvidenceRefsFromArtifact } from '../lib/execution-outcomes' import { recordTaskLogBestEffort } from './task-logs' import { packetCandidateGuard } from '../lib/mcps/packet-issuance-v2' import { localEffectCandidateGuard } from '../lib/mcps/local-run-evidence-v2' @@ -1896,9 +1898,10 @@ async function persistWorkPackageHandoffBlock(input: { }): Promise { input.assertOwned() const common = { pkg: input.pkg, project: input.project, taskId: input.taskId } + let result: HandoffAdmissionResult switch (input.decision.kind) { case 'broker': - return failWorkPackageForMcpBroker({ + result = await failWorkPackageForMcpBroker({ ...common, assertOwned: input.assertOwned, blocked: input.decision.blocked, @@ -1906,8 +1909,9 @@ async function persistWorkPackageHandoffBlock(input: { check: input.decision.check, warnings: input.decision.warnings, }) + break case 'filesystem_grant': - return failWorkPackageForFilesystemGrant({ + result = await failWorkPackageForFilesystemGrant({ ...common, assertOwned: input.assertOwned, blockedReason: input.decision.blockedReason, @@ -1916,13 +1920,38 @@ async function persistWorkPackageHandoffBlock(input: { requirementKeys: input.decision.requirementKeys, requestedCapabilities: input.decision.requestedCapabilities, }) + break case 'reserved_role': - return failWorkPackageForReservedRole({ + result = await failWorkPackageForReservedRole({ ...common, assertOwned: input.assertOwned, blockedReason: input.decision.blockedReason, }) + break } + if (result.status === 'blocked') { + await upsertExecutionOutcome({ + taskId: input.taskId, + workPackageId: input.pkg.id, + attemptKey: `work-package:${input.pkg.id}:admission`, + outcome: { + schemaVersion: 1, + transportStatus: 'ok', + result: 'blocked', + stopReasonCode: input.decision.kind === 'reserved_role' + ? 'policy_blocked' + : input.decision.kind === 'broker' + ? 'admission_denied' + : 'missing_capability', + stopReasonSummary: result.blockedReason, + retryable: !result.terminalBlock, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + }, + }) + } + return result } /** @@ -3260,6 +3289,23 @@ async function executeReadyWorkPackage( artifact = protectedArtifact ?? null } if (!artifact) throw new Error('Work package completion did not create a source artifact.') + await upsertExecutionOutcome({ + taskId, + workPackageId: nextPackage.id, + agentRunId: run.id, + attemptKey: `work-package:${nextPackage.id}:run:${run.id}`, + outcome: { + schemaVersion: 1, + transportStatus: 'ok', + result: 'completed', + stopReasonCode: null, + stopReasonSummary: null, + retryable: false, + evidenceRefs: [artifact.id], + verifierRequired: (nextPackage.reviewRequirement ?? 'both') !== 'none', + verificationStatus: (nextPackage.reviewRequirement ?? 'both') === 'none' ? 'not_required' : 'pending', + }, + }) const packageStatus = reviewGates.packageStatus === 'awaiting_review' || reviewGates.packageStatus === 'completed' ? reviewGates.packageStatus : null @@ -3484,6 +3530,25 @@ async function executeReadyWorkPackage( }) .returning() + // A failure outcome is authoritative even when the artifact write did not + // return a row. Keep evidence empty in that case rather than losing the + // outcome record or inventing an evidence reference. + await upsertExecutionOutcome({ + taskId, + workPackageId: nextPackage.id, + agentRunId: run.id, + attemptKey: `work-package:${nextPackage.id}:run:${run.id}`, + outcome: { + ...executionFailureOutcome({ + message, + retryable: !finalAttempt, + validationFailed: validationStatusForPackage === 'failed', + repositoryContextMissing: repositoryEvidenceBlocked, + }), + evidenceRefs: outcomeEvidenceRefsFromArtifact(artifact), + }, + }) + assertQueueClaimOwned(options) await publishTaskEventBestEffort(taskId, 'run:failed', { attemptNumber, From 87ef84b80503f78c0eca06246599fc7f8fe33997 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:18:39 +0000 Subject: [PATCH 2/6] fix: make installer-managed migration proofs tolerate post-0028 migrations The installer-managed migration CI gates hardcoded the migration ledger's "latest" state as exactly 29 rows ending at migration 0028's timestamp. That coincidentally matched main, but this PR adds migration 0029, so the ledger is now 30 rows and those hardcoded assertions reject a fully-successful migration. Also update repair-epic-172-legacy-release.ts: its checkpoint classifier only recognized ledgers of exactly 27/28/29 rows (positions 0026/0027/0028). Once later migrations exist, a fully-migrated database no longer matches any of those, so the idempotent installer re-run wrongly refuses the epic-172 legacy-release repair. The 0028 checkpoint now accepts ledgers of 29 rows or more, validating only that the first 29 entries are the known-good 0000-0028 ledger; later migrations are outside this script's scope and are governed by their own migration files and the CI journal-parity gate. --- .../ci/prove-installer-managed-migrations.sh | 4 +-- ...prove-installer-legacy-migration-repair.sh | 4 +-- web/scripts/repair-epic-172-legacy-release.ts | 33 ++++++++++++------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/scripts/ci/prove-installer-managed-migrations.sh b/scripts/ci/prove-installer-managed-migrations.sh index d2f744af..a3b198f3 100755 --- a/scripts/ci/prove-installer-managed-migrations.sh +++ b/scripts/ci/prove-installer-managed-migrations.sh @@ -49,8 +49,8 @@ assert_latest_and_clean() { PGPASSWORD="$FORGE_INSTALLER_MANAGED_ADMIN_PASSWORD" PGHOST="$FORGE_INSTALLER_MANAGED_ADMIN_HOST" PGUSER="$FORGE_INSTALLER_MANAGED_ADMIN_USER" PGDATABASE="$database_name" psql --set ON_ERROR_STOP=1 <<'SQL' DO $proof$ BEGIN - IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 - OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1784274000000 THEN + IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 30 + OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1785820800000 THEN RAISE EXCEPTION 'Managed installer did not apply the exact latest migration ledger'; END IF; IF pg_catalog.to_regclass('public.forge_epic_172_s3_release_state') IS NULL THEN diff --git a/web/scripts/ci/prove-installer-legacy-migration-repair.sh b/web/scripts/ci/prove-installer-legacy-migration-repair.sh index ee518549..813289c6 100644 --- a/web/scripts/ci/prove-installer-legacy-migration-repair.sh +++ b/web/scripts/ci/prove-installer-legacy-migration-repair.sh @@ -1307,8 +1307,8 @@ assert_unchanged managed-latest-once managed-latest-twice 'Managed latest rerun' admin_psql <<'SQL' DO $proof$ BEGIN - IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 - OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1784274000000 THEN + IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 30 + OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1785820800000 THEN RAISE EXCEPTION 'Managed sequence did not reach the exact latest ledger'; END IF; IF EXISTS ( diff --git a/web/scripts/repair-epic-172-legacy-release.ts b/web/scripts/repair-epic-172-legacy-release.ts index 612b4c29..94fdb1d1 100644 --- a/web/scripts/repair-epic-172-legacy-release.ts +++ b/web/scripts/repair-epic-172-legacy-release.ts @@ -1423,19 +1423,28 @@ async function main(): Promise { && lockedHashes.get(migration0025At) === legacy0025 ? 'legacy' : null - const position = count === 27 ? '0026' : count === 28 ? '0027' : count === 29 ? '0028' : null - const exactLedger = pair && position && lockedLedger.every((row, index) => { - const expected = currentLedger[index] - if (!expected || Number(row.created_at) !== expected[0]) return false - const expectedHash = pair === 'legacy' && expected[0] === migration0023At - ? legacy0023 - : pair === 'legacy' && expected[0] === migration0025At - ? legacy0025 - : expected[1] - return row.hash === expectedHash - }) + // Migrations applied after 0028 (e.g. 0029, 0030, ...) are outside this + // script's epic-172 legacy-release scope and are not individually + // fingerprinted here: their own migration files and the CI journal-parity + // gate are the source of truth for that later history. So a ledger that + // already reaches or exceeds the 0028 checkpoint is still an exact 0028 + // position for this script's purposes, as long as its first 29 entries + // are byte-for-byte the known-good 0000-0028 ledger. + const position = count === 27 ? '0026' : count === 28 ? '0027' : count >= 29 ? '0028' : null + const checkpointLength = position === '0026' ? 27 : position === '0027' ? 28 : 29 + const exactLedger = pair && position && lockedLedger.length >= checkpointLength + && lockedLedger.slice(0, checkpointLength).every((row, index) => { + const expected = currentLedger[index] + if (!expected || Number(row.created_at) !== expected[0]) return false + const expectedHash = pair === 'legacy' && expected[0] === migration0023At + ? legacy0023 + : pair === 'legacy' && expected[0] === migration0025At + ? legacy0025 + : expected[1] + return row.hash === expectedHash + }) if (!exactLedger || !position) { - throw new Error('Refusing legacy release repair: locked migration ledger is not an exact supported 0026, 0027, or 0028 position.') + throw new Error('Refusing legacy release repair: locked migration ledger is not an exact supported 0026, 0027, or 0028-or-later position.') } if (!await exactOptionalForgeAppRoleBoundary(sql)) { throw new Error('Refusing legacy release repair: literal forge role is outside the exact safe app-role boundary.') From 0e9cb69d74c74a05e36d4d67e23c1e205cfaacaf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:23:33 +0000 Subject: [PATCH 3/6] fix: extend downstream CI gates for the new execution_outcomes table Two more hardcoded main-era assumptions break once migration 0029 exists, both downstream of the gate fixed in the previous commit so CI never reached them yet: - migration-0027-expansion-assertions.sql pinned the post-0028 ledger to count 29 / max timestamp 1784274000000. apply-epic-172-s5-recovery- migration.sh actually migrates to the true latest (db:migrate, unpinned), so with 0029 present this needs to become 30 / 1785820800000. - The closed application-ACL inventory check in web-ci.yml enumerates every public table by name; execution_outcomes wasn't in any of its three arrays, so the ordinary-app boundary proof would reject it as an unaccounted-for table. Add it as its own operation_ledger_tables entry with the SELECT/INSERT/UPDATE privileges the upsert-only worker path actually uses (db/worker/execution-outcomes.ts has no DELETE). --- .github/workflows/web-ci.yml | 17 ++++++++++++++++- .../sql/migration-0027-expansion-assertions.sql | 8 ++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index e6e91576..bb85300a 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -268,6 +268,8 @@ jobs: ] LOOP EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO forge_app_test', table_name); END LOOP; + REVOKE ALL ON TABLE public.execution_outcomes FROM forge_app_test; + GRANT SELECT, INSERT, UPDATE ON TABLE public.execution_outcomes TO forge_app_test; GRANT USAGE, SELECT ON SEQUENCE public.task_logs_sequence_seq TO forge_app_test; END; $grant_s4_application_acl$; @@ -506,6 +508,9 @@ jobs: 'repository_command_audits', 'agent_configs', 'workforces', 'workforce_agents', 'app_settings', 'task_questions' ]; + operation_ledger_tables constant text[] := ARRAY[ + 'execution_outcomes' + ]; protected_tables constant text[] := ARRAY[ 'forge_release_signer_keys', 'forge_release_signer_key_lifecycle_audits', 'forge_epic_172_release_evidence', 'forge_epic_172_transition_authorizations', @@ -536,13 +541,23 @@ jobs: IF EXISTS ( SELECT 1 FROM pg_catalog.pg_tables WHERE schemaname = 'public' - AND tablename <> ALL (ordinary_tables || protected_tables || projection_tables) + AND tablename <> ALL (ordinary_tables || operation_ledger_tables || protected_tables || projection_tables) ) THEN RAISE EXCEPTION 'A public table is missing from the closed application ACL inventory'; END IF; FOREACH table_name IN ARRAY ordinary_tables LOOP EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO forge_app_test', table_name); END LOOP; + REVOKE ALL ON TABLE public.execution_outcomes FROM forge_app_test; + GRANT SELECT, INSERT, UPDATE ON TABLE public.execution_outcomes TO forge_app_test; + FOREACH table_privilege IN ARRAY ARRAY['DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER'] LOOP + IF has_table_privilege( + 'forge_app_test', 'public.execution_outcomes', table_privilege + ) THEN + RAISE EXCEPTION 'ordinary app has unexpected % on operation ledger table public.execution_outcomes', + table_privilege; + END IF; + END LOOP; FOREACH table_name IN ARRAY protected_tables LOOP EXECUTE format('REVOKE ALL ON TABLE public.%I FROM forge_app_test', table_name); FOREACH table_privilege IN ARRAY ARRAY[ diff --git a/web/scripts/ci/sql/migration-0027-expansion-assertions.sql b/web/scripts/ci/sql/migration-0027-expansion-assertions.sql index eb6f1ef6..70971c77 100644 --- a/web/scripts/ci/sql/migration-0027-expansion-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-expansion-assertions.sql @@ -13,12 +13,12 @@ BEGIN -- role.rolpassword IS NULL is verified by the administrator-only S4 -- bootstrap; pg_roles intentionally masks it from this ordinary migration -- proof. This block verifies every attribute visible to the ordinary login. - IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 29 - OR (SELECT count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 29 + IF (SELECT count(*) FROM drizzle.__drizzle_migrations) <> 30 + OR (SELECT count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 30 OR NOT EXISTS (SELECT 1 FROM drizzle.__drizzle_migrations WHERE created_at = 1784270400000) OR NOT EXISTS (SELECT 1 FROM drizzle.__drizzle_migrations WHERE created_at = 1784274000000) - OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1784274000000 THEN - RAISE EXCEPTION 'The normal migrator did not retain the immutable 0027 prefix and one ordered additive 0028 entry'; + OR (SELECT max(created_at) FROM drizzle.__drizzle_migrations) <> 1785820800000 THEN + RAISE EXCEPTION 'The normal migrator did not retain the immutable 0027/0028 release boundary while reaching the exact latest ledger'; END IF; IF (SELECT attnotnull FROM pg_catalog.pg_attribute From acc8766915f9d9d5490b4195aa20bb206e4aaba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:35:03 +0000 Subject: [PATCH 4/6] fix: update pinned migration-count literal in the overlimit-archive test This test reads scripts/ci/sql/migration-0027-expansion-assertions.sql as text and asserts it still contains specific hardcoded evidence strings, including the pre-0029 migration count. It wasn't updated when that SQL file's asserted count changed from 29 to 30 in the previous commit, so the zero-skip unit suite failed even though the SQL file itself was correct. --- web/__tests__/local-projection-overlimit-archive.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/__tests__/local-projection-overlimit-archive.test.ts b/web/__tests__/local-projection-overlimit-archive.test.ts index 923f481e..b4283a20 100644 --- a/web/__tests__/local-projection-overlimit-archive.test.ts +++ b/web/__tests__/local-projection-overlimit-archive.test.ts @@ -378,8 +378,8 @@ describe('local-projection over-limit operator commands', () => { 'utf8', ) for (const evidence of [ - 'count(*) FROM drizzle.__drizzle_migrations) <> 29', - 'count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 29', + 'count(*) FROM drizzle.__drizzle_migrations) <> 30', + 'count(DISTINCT created_at) FROM drizzle.__drizzle_migrations) <> 30', 'created_at = 1784270400000', 'created_at = 1784274000000', "role.rolname = 'forge_local_projection_archiver'", From 4b310a2168e0a039f18d4bb24e24fb29c0d63a9b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:40:49 +0000 Subject: [PATCH 5/6] fix: make execution-outcome ledger writes best-effort in the handoff path Orthogonal review finding: the three upsertExecutionOutcome call sites (admission block, run completion, run failure) were unguarded, unlike every other non-critical write in this file (publishTaskEventBestEffort, recordTaskLogBestEffort). A ledger write failure would abort completion before the work package is marked complete/awaiting-review, or abort failure recording before run:failed/artifact:created are published and before the failure is logged -- even though the underlying artifact/status writes had already durably succeeded. The ledger is idempotent on (taskId, attemptKey), so a missed write is safe to retry or backfill later; it should never block state that's already been persisted. Also fixes a stale CI-script echo string ("...at the 29-row ledger") left over from an earlier fix in this PR; the proof it labels runs at the fixed 0028 checkpoint regardless of how many later migrations exist. --- ...prove-installer-legacy-migration-repair.sh | 2 +- web/worker/work-package-handoff.ts | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/web/scripts/ci/prove-installer-legacy-migration-repair.sh b/web/scripts/ci/prove-installer-legacy-migration-repair.sh index 813289c6..035b995a 100644 --- a/web/scripts/ci/prove-installer-legacy-migration-repair.sh +++ b/web/scripts/ci/prove-installer-legacy-migration-repair.sh @@ -1321,7 +1321,7 @@ END; $proof$; SQL -echo 'Proving exact installer-grant normalization and near-miss refusal at the 29-row ledger.' +echo 'Proving exact installer-grant normalization and near-miss refusal at the 0028 checkpoint.' ensure_forge_app_role admin_psql <<'SQL' CREATE TABLE public.forge_legacy_ordinary_acl_probe ( diff --git a/web/worker/work-package-handoff.ts b/web/worker/work-package-handoff.ts index ac31849a..a06c1038 100644 --- a/web/worker/work-package-handoff.ts +++ b/web/worker/work-package-handoff.ts @@ -418,6 +418,25 @@ async function publishTaskEventBestEffort( } } +/** + * The outcome ledger is evidence for later verification/reporting, not the + * source of truth for work-package state. A ledger write failure must not + * block admission blocking, run completion, or run-failure recording (all of + * which have already durably persisted through their own writes) -- the + * upsert is idempotent on (taskId, attemptKey), so a missed write here is + * safe to retry or backfill later. + */ +async function upsertExecutionOutcomeBestEffort( + input: Parameters[0], +): Promise { + try { + await upsertExecutionOutcome(input) + } catch (err) { + const message = sanitizeWorkerMessage(err instanceof Error ? err.message : String(err)) + console.warn(`Failed to record execution outcome for ${input.attemptKey}: ${message}`) + } +} + async function continueWorkforceAfterPackageCompletionOrThrow( taskId: string, packageStatus: 'awaiting_review' | 'completed' | null, @@ -1930,7 +1949,7 @@ async function persistWorkPackageHandoffBlock(input: { break } if (result.status === 'blocked') { - await upsertExecutionOutcome({ + await upsertExecutionOutcomeBestEffort({ taskId: input.taskId, workPackageId: input.pkg.id, attemptKey: `work-package:${input.pkg.id}:admission`, @@ -3289,7 +3308,7 @@ async function executeReadyWorkPackage( artifact = protectedArtifact ?? null } if (!artifact) throw new Error('Work package completion did not create a source artifact.') - await upsertExecutionOutcome({ + await upsertExecutionOutcomeBestEffort({ taskId, workPackageId: nextPackage.id, agentRunId: run.id, @@ -3533,7 +3552,7 @@ async function executeReadyWorkPackage( // A failure outcome is authoritative even when the artifact write did not // return a row. Keep evidence empty in that case rather than losing the // outcome record or inventing an evidence reference. - await upsertExecutionOutcome({ + await upsertExecutionOutcomeBestEffort({ taskId, workPackageId: nextPackage.id, agentRunId: run.id, From a9ee4a667e4f2facc62967d4f583a65a2d302434 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 02:44:13 +0000 Subject: [PATCH 6/6] test: assert execution-outcome ledger writes for all three admission-block kinds Orthogonal review finding: the outcome-ledger writes in persistWorkPackageHandoffBlock were only mocked, never asserted, so the new kind -> stopReasonCode mapping (broker -> admission_denied, reserved_role -> policy_blocked, filesystem_grant -> missing_capability) and the terminalBlock -> retryable inversion were unverified by CI. Add one targeted assertion per existing test that already exercises each kind. The other two upsertExecutionOutcome call sites, in executeReadyWorkPackage's completion and failure branches, are not covered here: that function is currently unreachable in both production and this test file because isWorkPackageExecutionEnabled() is hardcoded to always return false, so no test can exercise it without restructuring the module's mocking boundary. --- web/__tests__/work-package-handoff-db.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/web/__tests__/work-package-handoff-db.test.ts b/web/__tests__/work-package-handoff-db.test.ts index ab37cd48..80560147 100644 --- a/web/__tests__/work-package-handoff-db.test.ts +++ b/web/__tests__/work-package-handoff-db.test.ts @@ -848,6 +848,20 @@ describe('handoffApprovedWorkPackages', () => { status: 'blocked', workPackageId: 'pkg-1', })) + expect(mocks.upsertExecutionOutcome).toHaveBeenCalledWith(expect.objectContaining({ + taskId: 'task-1', + workPackageId: 'pkg-1', + attemptKey: 'work-package:pkg-1:admission', + outcome: expect.objectContaining({ + result: 'blocked', + stopReasonCode: 'admission_denied', + stopReasonSummary: expect.stringContaining('planning context was not materialized'), + retryable: true, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + }), + })) }) it.each([ @@ -929,6 +943,20 @@ describe('handoffApprovedWorkPackages', () => { errorMessage: 'legacy_task_log_unavailable', status: 'failed', })) + expect(mocks.upsertExecutionOutcome).toHaveBeenCalledWith(expect.objectContaining({ + taskId: 'task-1', + workPackageId: 'pkg-review', + attemptKey: 'work-package:pkg-review:admission', + outcome: expect.objectContaining({ + result: 'blocked', + stopReasonCode: 'policy_blocked', + stopReasonSummary: expect.stringContaining('reserved for review gates'), + retryable: false, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + }), + })) }) it('rolls back a reserved-role block when ownership is lost inside its transaction', async () => { @@ -1028,6 +1056,20 @@ describe('handoffApprovedWorkPackages', () => { transition: 'hold', }), ]) + expect(mocks.upsertExecutionOutcome).toHaveBeenCalledWith(expect.objectContaining({ + taskId: 'task-1', + workPackageId: 'pkg-fs', + attemptKey: 'work-package:pkg-fs:admission', + outcome: expect.objectContaining({ + result: 'blocked', + stopReasonCode: 'missing_capability', + stopReasonSummary: expect.stringContaining('requires filesystem grant approval'), + retryable: true, + evidenceRefs: [], + verifierRequired: false, + verificationStatus: 'not_required', + }), + })) }) it('holds a stale project-level filesystem grant when the project grant was revoked', async () => {