Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/web-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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$;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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[
Expand Down
38 changes: 38 additions & 0 deletions docs/adr/0010-execution-outcome-contract.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions scripts/ci/prove-installer-managed-migrations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions web/__tests__/execution-outcomes.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
4 changes: 2 additions & 2 deletions web/__tests__/local-projection-overlimit-archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
47 changes: 47 additions & 0 deletions web/__tests__/work-package-handoff-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
publishTaskEvent: vi.fn(),
projectionContributions: [] as Array<Record<string, unknown>>,
recordTaskLogBestEffort: vi.fn(),
upsertExecutionOutcome: vi.fn(),
readS4RuntimeModeV1: vi.fn(),
recoverLinkedS4LifecycleV2: vi.fn(),
claimWorkPackageLifecycleV2: vi.fn(),
Expand Down Expand Up @@ -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<typeof import('@/lib/mcps/filesystem-grant-reconciliation')>(),
loadCurrentProjectFilesystemDecision: mocks.loadCurrentProjectFilesystemDecision,
Expand Down Expand Up @@ -843,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([
Expand Down Expand Up @@ -924,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 () => {
Expand Down Expand Up @@ -1023,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 () => {
Expand Down
42 changes: 42 additions & 0 deletions web/db/migrations/0029_execution_outcomes.sql
Original file line number Diff line number Diff line change
@@ -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");
7 changes: 7 additions & 0 deletions web/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
45 changes: 45 additions & 0 deletions web/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1454,6 +1454,51 @@ export const agentRuns = pgTable(
export type AgentRun = InferSelectModel<typeof agentRuns>
export type NewAgentRun = InferInsertModel<typeof agentRuns>

// ---------------------------------------------------------------------------
// 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<string[]>().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<typeof executionOutcomes>
export type NewExecutionOutcomeRow = InferInsertModel<typeof executionOutcomes>

// ---------------------------------------------------------------------------
// artifacts
// ---------------------------------------------------------------------------
Expand Down
Loading