From e54f498d4e40c6d0aa2f63bfbc9c0bb75a8e6f9c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:54:58 +0800 Subject: [PATCH 01/53] feat: enter root reconciliation write context --- web/__tests__/project-root-reconciliation.test.ts | 3 +++ web/lib/mcps/filesystem-grant-reconciliation.ts | 6 ++++++ web/scripts/reconcile-project-root-expansion.ts | 4 +++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 2e3b2b63..f1fc8085 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -103,6 +103,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(reconciliation).toContain('suppressPhasePersistence?: boolean') expect(reconciliation).toContain('input.suppressPhasePersistence ? undefined : grant') expect(reconcileScript).not.toContain('--suppress') + expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(reconcileScript).toContain('rootReconciliationContext:') + expect(reconciliation).toContain('forge.lock_project_root_reconciliation_authority_v1') }) it('keeps concurrent index DDL separate and strict cutover watermark-fenced', () => { diff --git a/web/lib/mcps/filesystem-grant-reconciliation.ts b/web/lib/mcps/filesystem-grant-reconciliation.ts index 79122310..19c6304a 100644 --- a/web/lib/mcps/filesystem-grant-reconciliation.ts +++ b/web/lib/mcps/filesystem-grant-reconciliation.ts @@ -871,6 +871,7 @@ export async function reconcileFilesystemGrantsForProject( nextMcpConfig: ProjectMcpConfig trigger: FilesystemGrantProjectReconciliationTrigger suppressPhasePersistence?: boolean + rootReconciliationContext?: { operationId: string; actorId: string; generation: string; projectId: string } }, ): Promise { if (!input.actorId.trim()) throw new Error('Grant reconciliation requires an actor.') @@ -903,6 +904,11 @@ export async function reconcileFilesystemGrantsForProject( .where(inArray(workPackages.taskId, taskRows.map((task) => task.id))) .orderBy(workPackages.id) .for('update') + if (input.rootReconciliationContext) { + const context = input.rootReconciliationContext + if (context.projectId !== input.lockedProject.id) throw httpError('Root reconciliation context project changed.', 409) + await tx.execute(sql`select forge.lock_project_root_reconciliation_authority_v1(${context.operationId}::uuid, ${context.actorId}::uuid, ${context.generation}::bigint, ${context.projectId}::uuid)`) + } const packageDecisionRows = await tx.select() .from(filesystemMcpGrantApprovals) .where(eq(filesystemMcpGrantApprovals.projectId, input.lockedProject.id)) diff --git a/web/scripts/reconcile-project-root-expansion.ts b/web/scripts/reconcile-project-root-expansion.ts index fbc7cab5..22ce4049 100644 --- a/web/scripts/reconcile-project-root-expansion.ts +++ b/web/scripts/reconcile-project-root-expansion.ts @@ -61,8 +61,9 @@ async function main(): Promise { const claimed = (claimedRows as unknown as Array>).map(parseClaimedProjectRootChange) if (claimed.length === 0) return { completed: true, processed: 0 } for (const change of claimed) { + await tx.execute(sql`select forge.enter_project_root_reconciliation_generation_v1(${operation.operationId}::uuid, ${command.actorId}::uuid, ${change.generation.toString()}::bigint, ${change.projectId}::uuid)`) const [project] = await tx.select().from(schema.projects) - .where(eq(schema.projects.id, change.projectId)).for('update') + .where(eq(schema.projects.id, change.projectId)) if (!project) throw new Error('Journal project disappeared before reconciliation.') const hasBoundFilesystemAuthority = project.rootBindingRevision > BigInt(0) || project.grantDecisionRevision > BigInt(0) @@ -79,6 +80,7 @@ async function main(): Promise { rootBindingRevision: project.rootBindingRevision, }), suppressPhasePersistence: true, + rootReconciliationContext: { operationId: operation.operationId, actorId: command.actorId, generation: change.generation.toString(), projectId: change.projectId }, trigger: 'project_root_repoint', }) } From 75dc9e53734672d8e0bce4fffd4c83d210ac1aca Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:56:18 +0800 Subject: [PATCH 02/53] fix: use prelocked root authority reads --- .../project-root-reconciliation.test.ts | 1 + .../mcps/filesystem-grant-reconciliation.ts | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index f1fc8085..7e706b79 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -106,6 +106,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(reconcileScript).toContain('rootReconciliationContext:') expect(reconciliation).toContain('forge.lock_project_root_reconciliation_authority_v1') + expect(reconciliation).toContain('input.rootReconciliationContext ? packageDecisionQuery : packageDecisionQuery.for(\'update\')') }) it('keeps concurrent index DDL separate and strict cutover watermark-fenced', () => { diff --git a/web/lib/mcps/filesystem-grant-reconciliation.ts b/web/lib/mcps/filesystem-grant-reconciliation.ts index 19c6304a..2bf44e08 100644 --- a/web/lib/mcps/filesystem-grant-reconciliation.ts +++ b/web/lib/mcps/filesystem-grant-reconciliation.ts @@ -909,18 +909,18 @@ export async function reconcileFilesystemGrantsForProject( if (context.projectId !== input.lockedProject.id) throw httpError('Root reconciliation context project changed.', 409) await tx.execute(sql`select forge.lock_project_root_reconciliation_authority_v1(${context.operationId}::uuid, ${context.actorId}::uuid, ${context.generation}::bigint, ${context.projectId}::uuid)`) } - const packageDecisionRows = await tx.select() + const packageDecisionQuery = tx.select() .from(filesystemMcpGrantApprovals) .where(eq(filesystemMcpGrantApprovals.projectId, input.lockedProject.id)) .orderBy(filesystemMcpGrantApprovals.id) - .for('update') - const projectDecisionRows = await tx.select().from(projectFilesystemGrantDecisions) + const packageDecisionRows = await (input.rootReconciliationContext ? packageDecisionQuery : packageDecisionQuery.for('update')) + const projectDecisionQuery = tx.select().from(projectFilesystemGrantDecisions) .where(eq(projectFilesystemGrantDecisions.projectId, input.lockedProject.id)) .orderBy(projectFilesystemGrantDecisions.id) - .for('update') - const [projectPointer] = await tx.select().from(projectFilesystemCurrentDecisionPointers) + const projectDecisionRows = await (input.rootReconciliationContext ? projectDecisionQuery : projectDecisionQuery.for('update')) + const projectPointerQuery = tx.select().from(projectFilesystemCurrentDecisionPointers) .where(eq(projectFilesystemCurrentDecisionPointers.projectId, input.lockedProject.id)) - .for('update') + const [projectPointer] = await (input.rootReconciliationContext ? projectPointerQuery : projectPointerQuery.for('update')) if (!projectPointer) throw httpError('Project filesystem decision authority is not initialized.', 409) const currentProjectDecision = projectDecisionPointerParent(projectPointer, projectDecisionRows) if (currentProjectDecision === undefined) { @@ -928,14 +928,19 @@ export async function reconcileFilesystemGrantsForProject( } const pointerRows = packageRows.length === 0 ? [] - : await tx.select() + : await (input.rootReconciliationContext + ? tx.select() .from(filesystemMcpCurrentDecisionPointers) .where(inArray( filesystemMcpCurrentDecisionPointers.workPackageId, packageRows.map((pkg) => pkg.id), )) .orderBy(filesystemMcpCurrentDecisionPointers.workPackageId) - .for('update') + : tx.select() + .from(filesystemMcpCurrentDecisionPointers) + .where(inArray(filesystemMcpCurrentDecisionPointers.workPackageId, packageRows.map((pkg) => pkg.id))) + .orderBy(filesystemMcpCurrentDecisionPointers.workPackageId) + .for('update')) if (pointerRows.length !== packageRows.length) { throw httpError('Filesystem decision authority is not initialized for every package.', 409) } From fe5442cd86525ccd747d402df88fda7494f7d9d9 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:58:56 +0800 Subject: [PATCH 03/53] feat: fence root reconciler task updates --- .../project-root-reconciliation.test.ts | 3 +++ .../0027_epic_172_s4_packet_context.sql | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 982d465b..0cf4a457 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -68,6 +68,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('approval_row.project_id=p_project_id ORDER BY approval_row.id FOR UPDATE') expect(migration).toContain('decision_row.project_id=p_project_id ORDER BY decision_row.id FOR UPDATE') expect(migration).toContain('project-root authority lock has no active write context') + expect(migration).toContain('project_root_reconciler_task_update_guard_v1') + expect(migration).toContain("OLD.status NOT IN ('running','failed') OR NEW.status <> 'approved'") + expect(migration).toContain('project-root task update has no active write context') expect(migration.indexOf('project_root_reconciliation_write_contexts_commit_v1')).toBeLessThan(migration.indexOf('ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner')) expect(migration).toContain('ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner') }) diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index d312a985..a1398fa2 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -748,6 +748,22 @@ END; $$; REVOKE ALL ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) FROM PUBLIC; ALTER FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner; GRANT EXECUTE ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; +CREATE OR REPLACE FUNCTION forge.guard_project_root_reconciler_task_update_v1() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ +BEGIN + IF session_user <> 'forge_project_root_reconciler' THEN RETURN NEW; END IF; + IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context_row WHERE context_row.project_id=OLD.project_id AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root task update has no active write context' USING ERRCODE='42501'; END IF; + IF OLD.status NOT IN ('running','failed') OR NEW.status <> 'approved' OR NEW.error_message IS NOT NULL + OR (to_jsonb(NEW) - ARRAY['status','error_message','updated_at']) IS DISTINCT FROM (to_jsonb(OLD) - ARRAY['status','error_message','updated_at']) THEN + RAISE EXCEPTION 'project-root task update is outside canonical convergence' USING ERRCODE='42501'; + END IF; + NEW.updated_at := pg_catalog.transaction_timestamp(); + RETURN NEW; +END; $$; +REVOKE ALL ON FUNCTION forge.guard_project_root_reconciler_task_update_v1() FROM PUBLIC; +CREATE TRIGGER project_root_reconciler_task_update_guard_v1 +BEFORE UPDATE ON public.tasks FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_reconciler_task_update_v1(); +ALTER FUNCTION forge.guard_project_root_reconciler_task_update_v1() OWNER TO forge_s4_routines_owner; CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid, p_outcome text) RETURNS TABLE(state text, last_processed_generation bigint) LANGUAGE plpgsql From d2805a5e79abf9d76795c423b808f6121ad4900a Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:05:16 +0800 Subject: [PATCH 04/53] feat: extract filesystem grant block validator --- web/__tests__/project-root-reconciliation.test.ts | 6 ++++++ .../migrations/0026_epic_172_s3_grant_lifecycle.sql | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 7a754260..4a49de10 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -7,6 +7,7 @@ import { } from '@/lib/mcps/project-root-reconciliation' const migration = readFileSync(fileURLToPath(new URL('../db/migrations/0027_epic_172_s4_packet_context.sql', import.meta.url)), 'utf8') +const s3Migration = readFileSync(fileURLToPath(new URL('../db/migrations/0026_epic_172_s3_grant_lifecycle.sql', import.meta.url)), 'utf8') const schema = readFileSync(fileURLToPath(new URL('../db/schema.ts', import.meta.url)), 'utf8') const bootstrap = readFileSync(fileURLToPath(new URL('../scripts/bootstrap-epic-172-s4-roles.ts', import.meta.url)), 'utf8') const reconcileScript = readFileSync(fileURLToPath(new URL('../scripts/reconcile-project-root-expansion.ts', import.meta.url)), 'utf8') @@ -16,6 +17,11 @@ const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover- const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') describe('project-root expansion reconciliation boundary', () => { + it('defines the pure reusable canonical filesystem grant-block validator', () => { + expect(s3Migration).toContain('CREATE FUNCTION forge_is_canonical_filesystem_grant_block_v2(p_block jsonb)') + expect(s3Migration).toContain('RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE') + expect(s3Migration).toContain("'project_grant_removed','project_grant_narrowed','project_root_repoint'") + }) it('parses only the literal actor/watermark/apply command and keeps dry-run actionless', () => { const actor = '123e4567-e89b-42d3-a456-426614174000' expect(parseProjectRootReconciliationCommand(['--through', '0', '--actor', actor])).toEqual({ diff --git a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql index b979d78d..44b8b5fa 100644 --- a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql +++ b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql @@ -411,6 +411,19 @@ BEFORE UPDATE OR DELETE ON "project_filesystem_grant_decisions" FOR EACH ROW EXECUTE FUNCTION "forge_reject_project_filesystem_decision_mutation"(); -- SQL mirrors the closed TypeScript S3 hold-state union. Older marker-shaped +-- Pure reusable validator; the table constraint below preserves the legacy +-- status/absence semantics while this predicate owns the marker grammar. +CREATE FUNCTION forge_is_canonical_filesystem_grant_block_v2(p_block jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT (jsonb_typeof(p_block)='object' + AND p_block ?& ARRAY['schemaVersion','kind','source','taskDisposition','autoRetryable','terminalFailure','requirementKeys','requestedCapabilities','recoveryAction','blockFingerprint','blockedAt','holdKind','grantPhase','grantConsumed','grantDecisionRevision','revocationReason'] + AND p_block - ARRAY['schemaVersion','kind','source','taskDisposition','autoRetryable','terminalFailure','requirementKeys','requestedCapabilities','recoveryAction','blockFingerprint','blockedAt','holdKind','grantPhase','grantConsumed','grantDecisionRevision','revocationReason']='{}'::jsonb + AND p_block->'schemaVersion'='2'::jsonb AND p_block->>'kind'='filesystem_grant' AND p_block->>'source'='filesystem-grant-approval' AND p_block->>'taskDisposition'='operator_hold' AND p_block->'autoRetryable'='false'::jsonb AND p_block->'terminalFailure'='false'::jsonb + AND forge_is_canonical_bounded_string_set(p_block->'requirementKeys',256,240) AND forge_is_canonical_bounded_string_set(p_block->'requestedCapabilities',3,240) AND forge_is_canonical_filesystem_capability_set(p_block->'requestedCapabilities') + AND p_block->>'recoveryAction'='approve_project_filesystem_context' AND (p_block->>'blockFingerprint') ~ '^sha256:[0-9a-f]{64}$' AND forge_is_canonical_utc_timestamp(p_block->>'blockedAt') + AND ((p_block->>'holdKind'='approval_required' AND p_block->>'grantPhase' IN ('none','proposed','not_issued') AND p_block->'grantConsumed'='false'::jsonb AND p_block->'grantDecisionRevision'='null'::jsonb AND p_block->'revocationReason'='null'::jsonb) OR (p_block->>'holdKind'='denied_required' AND p_block->>'grantPhase'='denied' AND p_block->'grantConsumed'='false'::jsonb AND (p_block->'grantDecisionRevision'='null'::jsonb OR (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$') AND p_block->'revocationReason'='null'::jsonb) OR (p_block->>'holdKind'='revoked_required' AND p_block->>'grantPhase'='revoked' AND p_block->'grantConsumed'='false'::jsonb AND (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' AND p_block->>'revocationReason' IN ('project_grant_removed','project_grant_narrowed','project_root_repoint')) OR (p_block->>'holdKind'='consumed_once' AND p_block->>'grantPhase'='approved' AND p_block->'grantConsumed'='true'::jsonb AND (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' AND p_block->'revocationReason'='null'::jsonb)) + ) IS TRUE +$$; -- JSON remains retained as data but is not S3 recovery authority; every -- version-2 writer is constrained to exact S3 keys, bounds, and blocked status. ALTER TABLE "work_packages" From 4426077572e510319cd1783b55066880fab03f49 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:09:14 +0800 Subject: [PATCH 05/53] fix: qualify root reconciliation SQL references --- .../project-root-reconciliation.test.ts | 8 ++++ .../0027_epic_172_s4_packet_context.sql | 44 +++++++++---------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 4a49de10..3417e79d 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -59,6 +59,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('operation_row.through_generation = p_through_generation') expect(migration).toContain('project-root completion compare-and-set failed') expect(migration).toContain('project-root generation already has an immutable outcome') + const claimRoutine = migration.slice( + migration.indexOf('CREATE OR REPLACE FUNCTION forge.claim_project_root_reconciliation_batch_v1'), + migration.indexOf('CREATE TABLE public.project_root_reconciliation_write_contexts'), + ) + expect(claimRoutine).toContain('coalesce(max(journal_row.generation), 0)') + expect(claimRoutine).toContain('FROM public.project_root_change_journal AS journal_row') + expect(claimRoutine).not.toContain('coalesce(max(generation), 0)') + expect(claimRoutine).toContain('SELECT journal_row.generation, journal_row.project_id, journal_row.outcome, journal_row.root_binding_revision, journal_row.grant_decision_revision') expect(migration).toContain('forge.materialize_project_root_ref_expansion_v1') expect(migration).toContain('CREATE TABLE public.project_root_reconciliation_write_contexts') expect(migration).toContain('backend_pid integer NOT NULL') diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 46dedd93..5f2c6ac3 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -645,21 +645,21 @@ DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; BEGIN PERFORM forge.assert_project_root_reconciler_v1(); IF p_batch_size NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION 'project-root reconciliation batch must be 1 through 100' USING ERRCODE = '22023'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations - WHERE operation_id = p_operation_id; - IF (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) <> v_operation.through_generation - OR (SELECT coalesce(max(generation), 0) FROM public.project_root_change_journal) <> v_operation.through_generation - OR (SELECT count(*) FROM public.project_root_change_journal) <> v_operation.through_generation THEN + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.operation_id = p_operation_id; + IF (SELECT counter_row.last_generation FROM public.project_root_change_journal_counter AS counter_row WHERE counter_row.singleton) <> v_operation.through_generation + OR (SELECT coalesce(max(journal_row.generation), 0) FROM public.project_root_change_journal AS journal_row) <> v_operation.through_generation + OR (SELECT count(*) FROM public.project_root_change_journal AS journal_row) <> v_operation.through_generation THEN RAISE EXCEPTION 'project-root journal changed before batch claim' USING ERRCODE = '55000'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations - WHERE operation_id = p_operation_id FOR UPDATE; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.operation_id = p_operation_id FOR UPDATE; IF v_operation.actor_id <> p_actor_id OR v_operation.state <> 'running' THEN RAISE EXCEPTION 'project-root operation is not claimable by this actor' USING ERRCODE = '42501'; END IF; RETURN QUERY - SELECT journal.generation, journal.project_id, journal.outcome, journal.root_binding_revision, journal.grant_decision_revision - FROM public.project_root_change_journal journal - WHERE journal.generation > v_operation.last_processed_generation AND journal.generation <= v_operation.through_generation - ORDER BY journal.generation LIMIT p_batch_size FOR UPDATE; + SELECT journal_row.generation, journal_row.project_id, journal_row.outcome, journal_row.root_binding_revision, journal_row.grant_decision_revision + FROM public.project_root_change_journal AS journal_row + WHERE journal_row.generation > v_operation.last_processed_generation AND journal_row.generation <= v_operation.through_generation + ORDER BY journal_row.generation LIMIT p_batch_size FOR UPDATE; END; $$; --> statement-breakpoint @@ -774,36 +774,36 @@ DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; v_jou BEGIN PERFORM forge.assert_project_root_reconciler_v1(); IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context WHERE context.operation_id=p_operation_id AND context.generation=p_generation AND context.actor_id=p_actor_id AND context.project_id=p_project_id AND context.backend_pid=pg_catalog.pg_backend_pid() AND context.transaction_id=pg_catalog.txid_current() AND context.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root write context is absent or stale' USING ERRCODE='42501'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE operation_id = p_operation_id; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row WHERE operation_row.operation_id = p_operation_id; PERFORM forge.assert_project_root_journal_window_v1(v_operation.through_generation); - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE operation_id = p_operation_id FOR UPDATE; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row WHERE operation_row.operation_id = p_operation_id FOR UPDATE; IF v_operation.actor_id <> p_actor_id OR v_operation.state <> 'running' OR p_generation <> v_operation.last_processed_generation + 1 THEN RAISE EXCEPTION 'project-root completion compare-and-set failed' USING ERRCODE = '40001'; END IF; - SELECT * INTO STRICT v_journal FROM public.project_root_change_journal WHERE generation = p_generation FOR UPDATE; + SELECT * INTO STRICT v_journal FROM public.project_root_change_journal AS journal_row WHERE journal_row.generation = p_generation FOR UPDATE; IF v_journal.project_id <> p_project_id OR v_journal.outcome <> p_outcome OR p_generation > v_operation.through_generation THEN RAISE EXCEPTION 'project-root journal outcome changed or is incoherent' USING ERRCODE = '22023'; END IF; - IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE generation = p_generation) THEN + IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes AS outcome_row WHERE outcome_row.generation = p_generation) THEN RAISE EXCEPTION 'project-root generation already has an immutable outcome' USING ERRCODE = '23505'; END IF; INSERT INTO public.project_root_reconciliation_outcomes(generation, operation_id, actor_id, project_id, outcome) VALUES (p_generation, p_operation_id, p_actor_id, p_project_id, p_outcome); - UPDATE public.project_root_reconciliation_operations + UPDATE public.project_root_reconciliation_operations AS operation_row SET last_processed_generation = p_generation, last_project_id = p_project_id, - batch_count = batch_count + 1, cumulative_count = cumulative_count + 1, - state = CASE WHEN p_generation = through_generation THEN 'complete' ELSE 'running' END, - completed_at = CASE WHEN p_generation = through_generation THEN pg_catalog.clock_timestamp() ELSE NULL END, + batch_count = operation_row.batch_count + 1, cumulative_count = operation_row.cumulative_count + 1, + state = CASE WHEN p_generation = operation_row.through_generation THEN 'complete' ELSE 'running' END, + completed_at = CASE WHEN p_generation = operation_row.through_generation THEN pg_catalog.clock_timestamp() ELSE NULL END, updated_at = pg_catalog.clock_timestamp() - WHERE operation_id = p_operation_id RETURNING * INTO v_operation; + WHERE operation_row.operation_id = p_operation_id RETURNING operation_row.* INTO v_operation; INSERT INTO public.project_root_reconciliation_checkpoints( operation_id, checkpoint_generation, actor_id, through_generation, last_processed_generation, last_project_id, batch_count, cumulative_count, state ) VALUES (v_operation.operation_id, v_operation.last_processed_generation, v_operation.actor_id, v_operation.through_generation, v_operation.last_processed_generation, v_operation.last_project_id, v_operation.batch_count, v_operation.cumulative_count, v_operation.state); - UPDATE public.project_root_reconciliation_write_contexts SET completed_at=pg_catalog.clock_timestamp() - WHERE operation_id=p_operation_id AND generation=p_generation AND backend_pid=pg_catalog.pg_backend_pid() AND transaction_id=pg_catalog.txid_current() AND completed_at IS NULL; + UPDATE public.project_root_reconciliation_write_contexts AS context_row SET completed_at=pg_catalog.clock_timestamp() + WHERE context_row.operation_id=p_operation_id AND context_row.generation=p_generation AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL; RETURN QUERY SELECT v_operation.state, v_operation.last_processed_generation; END; $$; From 71d398e834efadc16f69712951646d48b649c076 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:11:15 +0800 Subject: [PATCH 06/53] refactor: use filesystem grant block validator --- .../0026_epic_172_s3_grant_lifecycle.sql | 69 +------------------ 1 file changed, 1 insertion(+), 68 deletions(-) diff --git a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql index 44b8b5fa..824c5050 100644 --- a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql +++ b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql @@ -431,74 +431,7 @@ ALTER TABLE "work_packages" CHECK ( NOT ("metadata" ? 'mcpGrantBlock') OR COALESCE("metadata"->'mcpGrantBlock'->>'schemaVersion', '') <> '2' - OR ( - ( - jsonb_typeof("metadata"->'mcpGrantBlock') = 'object' - AND "status" = 'blocked' - AND ("metadata"->'mcpGrantBlock') ?& ARRAY[ - 'schemaVersion','kind','source','taskDisposition','autoRetryable', - 'terminalFailure','requirementKeys','requestedCapabilities', - 'recoveryAction','blockFingerprint','blockedAt','holdKind', - 'grantPhase','grantConsumed','grantDecisionRevision','revocationReason' - ] - AND ("metadata"->'mcpGrantBlock') - ARRAY[ - 'schemaVersion','kind','source','taskDisposition','autoRetryable', - 'terminalFailure','requirementKeys','requestedCapabilities', - 'recoveryAction','blockFingerprint','blockedAt','holdKind', - 'grantPhase','grantConsumed','grantDecisionRevision','revocationReason' - ] = '{}'::jsonb - AND "metadata"->'mcpGrantBlock'->'schemaVersion' = '2'::jsonb - AND "metadata"->'mcpGrantBlock'->>'kind' = 'filesystem_grant' - AND "metadata"->'mcpGrantBlock'->>'source' = 'filesystem-grant-approval' - AND "metadata"->'mcpGrantBlock'->>'taskDisposition' = 'operator_hold' - AND "metadata"->'mcpGrantBlock'->'autoRetryable' = 'false'::jsonb - AND "metadata"->'mcpGrantBlock'->'terminalFailure' = 'false'::jsonb - AND forge_is_canonical_bounded_string_set( - "metadata"->'mcpGrantBlock'->'requirementKeys', 256, 240 - ) - AND forge_is_canonical_bounded_string_set( - "metadata"->'mcpGrantBlock'->'requestedCapabilities', 3, 240 - ) - AND forge_is_canonical_filesystem_capability_set( - "metadata"->'mcpGrantBlock'->'requestedCapabilities' - ) - AND "metadata"->'mcpGrantBlock'->>'recoveryAction' = 'approve_project_filesystem_context' - AND ("metadata"->'mcpGrantBlock'->>'blockFingerprint') ~ '^sha256:[0-9a-f]{64}$' - AND forge_is_canonical_utc_timestamp("metadata"->'mcpGrantBlock'->>'blockedAt') - AND ( - ( - "metadata"->'mcpGrantBlock'->>'holdKind' = 'approval_required' - AND "metadata"->'mcpGrantBlock'->>'grantPhase' IN ('none','proposed','not_issued') - AND "metadata"->'mcpGrantBlock'->'grantConsumed' = 'false'::jsonb - AND "metadata"->'mcpGrantBlock'->'grantDecisionRevision' = 'null'::jsonb - AND "metadata"->'mcpGrantBlock'->'revocationReason' = 'null'::jsonb - ) OR ( - "metadata"->'mcpGrantBlock'->>'holdKind' = 'denied_required' - AND "metadata"->'mcpGrantBlock'->>'grantPhase' = 'denied' - AND "metadata"->'mcpGrantBlock'->'grantConsumed' = 'false'::jsonb - AND ( - "metadata"->'mcpGrantBlock'->'grantDecisionRevision' = 'null'::jsonb - OR ("metadata"->'mcpGrantBlock'->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' - ) - AND "metadata"->'mcpGrantBlock'->'revocationReason' = 'null'::jsonb - ) OR ( - "metadata"->'mcpGrantBlock'->>'holdKind' = 'revoked_required' - AND "metadata"->'mcpGrantBlock'->>'grantPhase' = 'revoked' - AND "metadata"->'mcpGrantBlock'->'grantConsumed' = 'false'::jsonb - AND ("metadata"->'mcpGrantBlock'->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' - AND "metadata"->'mcpGrantBlock'->>'revocationReason' IN ( - 'project_grant_removed','project_grant_narrowed','project_root_repoint' - ) - ) OR ( - "metadata"->'mcpGrantBlock'->>'holdKind' = 'consumed_once' - AND "metadata"->'mcpGrantBlock'->>'grantPhase' = 'approved' - AND "metadata"->'mcpGrantBlock'->'grantConsumed' = 'true'::jsonb - AND ("metadata"->'mcpGrantBlock'->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' - AND "metadata"->'mcpGrantBlock'->'revocationReason' = 'null'::jsonb - ) - ) - ) IS TRUE - ) + OR ("status" = 'blocked' AND forge_is_canonical_filesystem_grant_block_v2("metadata"->'mcpGrantBlock') IS TRUE) ); --> statement-breakpoint -- The release ledger is owned by a separate NOLOGIN role. The administrator- From 6259c7933c27a541804a2564d52251156230d8cc Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:12:53 +0800 Subject: [PATCH 07/53] feat: fence root reconciler package updates --- .../project-root-reconciliation.test.ts | 3 +++ .../0027_epic_172_s4_packet_context.sql | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 3417e79d..ebd7dcb0 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -85,6 +85,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('project_root_reconciler_task_update_guard_v1') expect(migration).toContain("OLD.status NOT IN ('running','failed') OR NEW.status <> 'approved'") expect(migration).toContain('project-root task update has no active write context') + expect(migration).toContain('project_root_reconciler_package_update_guard_v1') + expect(migration).toContain('forge_is_canonical_filesystem_grant_block_v2(v_new_marker)') + expect(migration).toContain('project-root package marker removal is not canonical') expect(migration.indexOf('project_root_reconciliation_write_contexts_commit_v1')).toBeLessThan(migration.indexOf('ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner')) expect(migration).toContain('ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner') expect(migration.indexOf('GRANT EXECUTE ON FUNCTION forge.enter_project_root_reconciliation_generation_v1')).toBeLessThan(migration.indexOf('ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1')) diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 5f2c6ac3..1e31885d 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -764,6 +764,23 @@ REVOKE ALL ON FUNCTION forge.guard_project_root_reconciler_task_update_v1() FROM CREATE TRIGGER project_root_reconciler_task_update_guard_v1 BEFORE UPDATE ON public.tasks FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_reconciler_task_update_v1(); ALTER FUNCTION forge.guard_project_root_reconciler_task_update_v1() OWNER TO forge_s4_routines_owner; +CREATE OR REPLACE FUNCTION forge.guard_project_root_reconciler_package_update_v1() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ +DECLARE v_old_marker jsonb := OLD.metadata->'mcpGrantBlock'; v_new_marker jsonb := NEW.metadata->'mcpGrantBlock'; +BEGIN + IF session_user <> 'forge_project_root_reconciler' THEN RETURN NEW; END IF; + IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context_row JOIN public.tasks task_row ON task_row.project_id=context_row.project_id WHERE task_row.id=OLD.task_id AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root package update has no active write context' USING ERRCODE='42501'; END IF; + IF (to_jsonb(NEW)-ARRAY['status','blocked_reason','metadata','updated_at']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','blocked_reason','metadata','updated_at']) OR (NEW.metadata-'mcpGrantBlock') IS DISTINCT FROM (OLD.metadata-'mcpGrantBlock') THEN RAISE EXCEPTION 'project-root package update changed protected fields' USING ERRCODE='42501'; END IF; + IF v_new_marker IS NOT NULL THEN + IF NOT forge_is_canonical_filesystem_grant_block_v2(v_new_marker) OR (v_old_marker IS NOT NULL AND NOT forge_is_canonical_filesystem_grant_block_v2(v_old_marker)) OR NEW.status <> 'blocked' OR NEW.blocked_reason <> 'Filesystem context requires an operator decision before execution.' THEN RAISE EXCEPTION 'project-root package marker is not canonical' USING ERRCODE='42501'; END IF; + ELSIF v_old_marker IS NOT NULL THEN + IF NOT forge_is_canonical_filesystem_grant_block_v2(v_old_marker) OR OLD.status NOT IN ('blocked','failed') OR NEW.status <> 'ready' OR NEW.blocked_reason IS NOT NULL THEN RAISE EXCEPTION 'project-root package marker removal is not canonical' USING ERRCODE='42501'; END IF; + ELSE RAISE EXCEPTION 'project-root package update must change a canonical marker' USING ERRCODE='42501'; END IF; + NEW.updated_at := pg_catalog.transaction_timestamp(); RETURN NEW; +END; $$; +REVOKE ALL ON FUNCTION forge.guard_project_root_reconciler_package_update_v1() FROM PUBLIC; +CREATE TRIGGER project_root_reconciler_package_update_guard_v1 BEFORE UPDATE ON public.work_packages FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_reconciler_package_update_v1(); +ALTER FUNCTION forge.guard_project_root_reconciler_package_update_v1() OWNER TO forge_s4_routines_owner; CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid, p_outcome text) RETURNS TABLE(state text, last_processed_generation bigint) LANGUAGE plpgsql From 5238c2ae838d33f75d34906c198ed188abf1e182 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:17:01 +0800 Subject: [PATCH 08/53] fix: qualify filesystem grant validators --- web/__tests__/project-root-reconciliation.test.ts | 7 +++++++ web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql | 8 ++++---- web/db/migrations/0027_epic_172_s4_packet_context.sql | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ebd7dcb0..e859d966 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -21,6 +21,13 @@ describe('project-root expansion reconciliation boundary', () => { expect(s3Migration).toContain('CREATE FUNCTION forge_is_canonical_filesystem_grant_block_v2(p_block jsonb)') expect(s3Migration).toContain('RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE') expect(s3Migration).toContain("'project_grant_removed','project_grant_narrowed','project_root_repoint'") + expect(s3Migration).toContain('public.forge_is_canonical_bounded_string_set(p_block->\'requirementKeys\',256,240)') + expect(s3Migration).toContain('public.forge_is_canonical_filesystem_capability_set(p_block->\'requestedCapabilities\')') + expect(s3Migration).toContain("public.forge_is_canonical_utc_timestamp(p_block->>'blockedAt')") + expect(s3Migration).toContain('CHECK (public.forge_is_canonical_filesystem_capability_set("capabilities"))') + expect(s3Migration).toContain('public.forge_is_canonical_filesystem_grant_block_v2("metadata"->\'mcpGrantBlock\')') + expect(migration).toContain('public.forge_is_canonical_filesystem_grant_block_v2(v_new_marker)') + expect(migration).toContain('public.forge_is_canonical_filesystem_grant_block_v2(v_old_marker)') }) it('parses only the literal actor/watermark/apply command and keeps dry-run actionless', () => { const actor = '123e4567-e89b-42d3-a456-426614174000' diff --git a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql index 824c5050..6caa27b3 100644 --- a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql +++ b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql @@ -288,7 +288,7 @@ CREATE TABLE "project_filesystem_grant_decisions" ( CONSTRAINT "project_filesystem_grant_decisions_fingerprint_check" CHECK ("decision_fingerprint" ~ '^sha256:[0-9a-f]{64}$'), CONSTRAINT "project_filesystem_grant_decisions_capabilities_check" - CHECK (forge_is_canonical_filesystem_capability_set("capabilities")), + CHECK (public.forge_is_canonical_filesystem_capability_set("capabilities")), CONSTRAINT "project_filesystem_grant_decisions_prior_tuple_check" CHECK ( ( @@ -419,8 +419,8 @@ RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ AND p_block ?& ARRAY['schemaVersion','kind','source','taskDisposition','autoRetryable','terminalFailure','requirementKeys','requestedCapabilities','recoveryAction','blockFingerprint','blockedAt','holdKind','grantPhase','grantConsumed','grantDecisionRevision','revocationReason'] AND p_block - ARRAY['schemaVersion','kind','source','taskDisposition','autoRetryable','terminalFailure','requirementKeys','requestedCapabilities','recoveryAction','blockFingerprint','blockedAt','holdKind','grantPhase','grantConsumed','grantDecisionRevision','revocationReason']='{}'::jsonb AND p_block->'schemaVersion'='2'::jsonb AND p_block->>'kind'='filesystem_grant' AND p_block->>'source'='filesystem-grant-approval' AND p_block->>'taskDisposition'='operator_hold' AND p_block->'autoRetryable'='false'::jsonb AND p_block->'terminalFailure'='false'::jsonb - AND forge_is_canonical_bounded_string_set(p_block->'requirementKeys',256,240) AND forge_is_canonical_bounded_string_set(p_block->'requestedCapabilities',3,240) AND forge_is_canonical_filesystem_capability_set(p_block->'requestedCapabilities') - AND p_block->>'recoveryAction'='approve_project_filesystem_context' AND (p_block->>'blockFingerprint') ~ '^sha256:[0-9a-f]{64}$' AND forge_is_canonical_utc_timestamp(p_block->>'blockedAt') + AND public.forge_is_canonical_bounded_string_set(p_block->'requirementKeys',256,240) AND public.forge_is_canonical_bounded_string_set(p_block->'requestedCapabilities',3,240) AND public.forge_is_canonical_filesystem_capability_set(p_block->'requestedCapabilities') + AND p_block->>'recoveryAction'='approve_project_filesystem_context' AND (p_block->>'blockFingerprint') ~ '^sha256:[0-9a-f]{64}$' AND public.forge_is_canonical_utc_timestamp(p_block->>'blockedAt') AND ((p_block->>'holdKind'='approval_required' AND p_block->>'grantPhase' IN ('none','proposed','not_issued') AND p_block->'grantConsumed'='false'::jsonb AND p_block->'grantDecisionRevision'='null'::jsonb AND p_block->'revocationReason'='null'::jsonb) OR (p_block->>'holdKind'='denied_required' AND p_block->>'grantPhase'='denied' AND p_block->'grantConsumed'='false'::jsonb AND (p_block->'grantDecisionRevision'='null'::jsonb OR (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$') AND p_block->'revocationReason'='null'::jsonb) OR (p_block->>'holdKind'='revoked_required' AND p_block->>'grantPhase'='revoked' AND p_block->'grantConsumed'='false'::jsonb AND (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' AND p_block->>'revocationReason' IN ('project_grant_removed','project_grant_narrowed','project_root_repoint')) OR (p_block->>'holdKind'='consumed_once' AND p_block->>'grantPhase'='approved' AND p_block->'grantConsumed'='true'::jsonb AND (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' AND p_block->'revocationReason'='null'::jsonb)) ) IS TRUE $$; @@ -431,7 +431,7 @@ ALTER TABLE "work_packages" CHECK ( NOT ("metadata" ? 'mcpGrantBlock') OR COALESCE("metadata"->'mcpGrantBlock'->>'schemaVersion', '') <> '2' - OR ("status" = 'blocked' AND forge_is_canonical_filesystem_grant_block_v2("metadata"->'mcpGrantBlock') IS TRUE) + OR ("status" = 'blocked' AND public.forge_is_canonical_filesystem_grant_block_v2("metadata"->'mcpGrantBlock') IS TRUE) ); --> statement-breakpoint -- The release ledger is owned by a separate NOLOGIN role. The administrator- diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 1e31885d..84d88e5a 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -772,9 +772,9 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context_row JOIN public.tasks task_row ON task_row.project_id=context_row.project_id WHERE task_row.id=OLD.task_id AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root package update has no active write context' USING ERRCODE='42501'; END IF; IF (to_jsonb(NEW)-ARRAY['status','blocked_reason','metadata','updated_at']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','blocked_reason','metadata','updated_at']) OR (NEW.metadata-'mcpGrantBlock') IS DISTINCT FROM (OLD.metadata-'mcpGrantBlock') THEN RAISE EXCEPTION 'project-root package update changed protected fields' USING ERRCODE='42501'; END IF; IF v_new_marker IS NOT NULL THEN - IF NOT forge_is_canonical_filesystem_grant_block_v2(v_new_marker) OR (v_old_marker IS NOT NULL AND NOT forge_is_canonical_filesystem_grant_block_v2(v_old_marker)) OR NEW.status <> 'blocked' OR NEW.blocked_reason <> 'Filesystem context requires an operator decision before execution.' THEN RAISE EXCEPTION 'project-root package marker is not canonical' USING ERRCODE='42501'; END IF; + IF NOT public.forge_is_canonical_filesystem_grant_block_v2(v_new_marker) OR (v_old_marker IS NOT NULL AND NOT public.forge_is_canonical_filesystem_grant_block_v2(v_old_marker)) OR NEW.status <> 'blocked' OR NEW.blocked_reason <> 'Filesystem context requires an operator decision before execution.' THEN RAISE EXCEPTION 'project-root package marker is not canonical' USING ERRCODE='42501'; END IF; ELSIF v_old_marker IS NOT NULL THEN - IF NOT forge_is_canonical_filesystem_grant_block_v2(v_old_marker) OR OLD.status NOT IN ('blocked','failed') OR NEW.status <> 'ready' OR NEW.blocked_reason IS NOT NULL THEN RAISE EXCEPTION 'project-root package marker removal is not canonical' USING ERRCODE='42501'; END IF; + IF NOT public.forge_is_canonical_filesystem_grant_block_v2(v_old_marker) OR OLD.status NOT IN ('blocked','failed') OR NEW.status <> 'ready' OR NEW.blocked_reason IS NOT NULL THEN RAISE EXCEPTION 'project-root package marker removal is not canonical' USING ERRCODE='42501'; END IF; ELSE RAISE EXCEPTION 'project-root package update must change a canonical marker' USING ERRCODE='42501'; END IF; NEW.updated_at := pg_catalog.transaction_timestamp(); RETURN NEW; END; $$; From c6cbc72ac00c42c158b91bc12da8a3d6fb6fbe5e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:21:51 +0800 Subject: [PATCH 09/53] fix: stop root reconciliation after completion --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/scripts/reconcile-project-root-expansion.ts | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index e859d966..aab66c7f 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -134,6 +134,8 @@ describe('project-root expansion reconciliation boundary', () => { expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(reconcileScript).toContain('rootReconciliationContext:') expect(reconciliation).toContain('forge.lock_project_root_reconciliation_authority_v1') + expect(reconcileScript).toContain("completion.state === 'complete'") + expect(reconcileScript).toContain('Project-root completion returned an invalid state.') expect(reconciliation).toContain('input.rootReconciliationContext ? packageDecisionQuery : packageDecisionQuery.for(\'update\')') }) diff --git a/web/scripts/reconcile-project-root-expansion.ts b/web/scripts/reconcile-project-root-expansion.ts index 22ce4049..92c52a98 100644 --- a/web/scripts/reconcile-project-root-expansion.ts +++ b/web/scripts/reconcile-project-root-expansion.ts @@ -84,12 +84,18 @@ async function main(): Promise { trigger: 'project_root_repoint', }) } - await tx.execute(sql` + const completionRows = await tx.execute(sql` select * from forge.complete_project_root_reconciliation_generation_v1( ${operation.operationId}::uuid, ${command.actorId}::uuid, ${change.generation.toString()}::bigint, ${change.projectId}::uuid, ${change.outcome} ) `) + const completion = (completionRows as unknown as Array<{ state?: unknown; last_processed_generation?: unknown }>)[0] + if (!completion || (completion.state !== 'running' && completion.state !== 'complete') + || String(completion.last_processed_generation) !== change.generation.toString()) { + throw new Error('Project-root completion returned an invalid state.') + } + if (completion.state === 'complete') return { completed: true, processed: claimed.length } } return { completed: false, processed: claimed.length } }) From f25388b288676af5716d8317a131bc9e4d923573 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:24:52 +0800 Subject: [PATCH 10/53] test: make phase suppression assertion semantic --- web/__tests__/project-root-reconciliation.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index aab66c7f..7d27e859 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -129,7 +129,10 @@ describe('project-root expansion reconciliation boundary', () => { it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') - expect(reconciliation).toContain('input.suppressPhasePersistence ? undefined : grant') + const normalizedReconciliation = reconciliation.replace(/\s+/g, ' ') + expect(normalizedReconciliation).toMatch( + /const effective = input\.suppressPhasePersistence \? undefined : grant \? phasesWithEffective\(pkg\.metadata, projectFilesystemEffectivePhase\(grant\)\) : forcePersist \? currentPhases : undefined/, + ) expect(reconcileScript).not.toContain('--suppress') expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(reconcileScript).toContain('rootReconciliationContext:') From f928d1c81fe67011621cdd60eab4cef12c06bce5 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:32:08 +0800 Subject: [PATCH 11/53] fix: escape cutover SQL dollar quotes --- web/__tests__/project-root-reconciliation.test.ts | 3 +++ web/scripts/ci/cutover-migration-0027-root-ref.sh | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 7d27e859..f8892ddf 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -147,6 +147,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(indexScript).toContain('FORGE_DATABASE_ADMIN_URL') expect(reconcileScript).not.toContain('FORGE_DATABASE_ADMIN_URL') expect(cutoverScript).toContain('--through --apply') + expect(cutoverScript).toContain('DO \\$cutover\\$') + expect(cutoverScript).toContain('\\$cutover\\$;') + expect(cutoverScript).toContain('DECLARE v_through bigint := ${through}::bigint;') expect(cutoverScript).toContain('project_root_reconciliation_operations') expect(cutoverScript).toContain('projects_root_ref_idx') expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 9fffbabd..7ca81b3e 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -14,7 +14,7 @@ fi psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 < 'disabled' THEN @@ -41,7 +41,7 @@ BEGIN -- Compatibility marker only; C6 authority is the exact operation/outcome set above. UPDATE public.project_root_ref_reconciliation SET state = 'complete', updated_at = pg_catalog.clock_timestamp() WHERE singleton; END; -$cutover$; +\$cutover\$; ALTER TABLE public.projects VALIDATE CONSTRAINT projects_root_ref_not_null_proof; ALTER TABLE public.projects ALTER COLUMN root_ref SET NOT NULL; COMMIT; From 22f0bf5909c55b04233e79f884160bee0b1aa7dd Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:41:55 +0800 Subject: [PATCH 12/53] fix: restore root index cutover proof sequence --- web/__tests__/project-root-reconciliation.test.ts | 9 +++++++++ web/scripts/ci/cutover-migration-0027-root-ref.sh | 2 +- web/scripts/ci/prove-migration-0027-upgrade.sh | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index f8892ddf..f9fb862c 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -13,6 +13,7 @@ const bootstrap = readFileSync(fileURLToPath(new URL('../scripts/bootstrap-epic- const reconcileScript = readFileSync(fileURLToPath(new URL('../scripts/reconcile-project-root-expansion.ts', import.meta.url)), 'utf8') const reconciliation = readFileSync(fileURLToPath(new URL('../lib/mcps/filesystem-grant-reconciliation.ts', import.meta.url)), 'utf8') const indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project-root-ref-index.ts', import.meta.url)), 'utf8') +const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-upgrade.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -152,6 +153,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(cutoverScript).toContain('DECLARE v_through bigint := ${through}::bigint;') expect(cutoverScript).toContain('project_root_reconciliation_operations') expect(cutoverScript).toContain('projects_root_ref_idx') + expect(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") + expect(cutoverScript).toContain('strict root-reference cutover requires a valid concurrent projects(root_ref) index') + expect(upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh')).toBeLessThan( + upgradeProof.indexOf('npm run project-roots:build-concurrent-index -- --apply'), + ) + expect(upgradeProof.indexOf('npm run project-roots:build-concurrent-index -- --apply')).toBeLessThan( + upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply'), + ) expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') expect(webCi).toContain('Capture the post-drain root journal watermark') }) diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 7ca81b3e..6caa585e 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -33,7 +33,7 @@ BEGIN END IF; IF NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_index index_row - WHERE index_row.indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass AND index_row.indisvalid + WHERE index_row.indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND index_row.indisvalid ) THEN RAISE EXCEPTION 'strict root-reference cutover requires a valid concurrent projects(root_ref) index' USING ERRCODE = '55000'; END IF; IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint WHERE conrelid = 'public.projects'::regclass AND conname = 'projects_root_ref_not_null_proof') THEN ALTER TABLE public.projects ADD CONSTRAINT projects_root_ref_not_null_proof CHECK (root_ref IS NOT NULL) NOT VALID; diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 55d6a39b..6ae4d17d 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -56,6 +56,7 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != fi bash scripts/ci/reconcile-migration-0027-root-refs.sh +npm run project-roots:build-concurrent-index -- --apply bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-cutover-assertions.sql From 6e7614ecc1b888c06e8e74eda156f5b57c459ba2 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:48:50 +0800 Subject: [PATCH 13/53] fix: inventory root write contexts --- .github/workflows/web-ci.yml | 4 ++-- web/__tests__/project-root-reconciliation.test.ts | 1 + web/scripts/bootstrap-epic-172-s4-roles.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index fd82c3b3..c86ddb6e 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -303,7 +303,7 @@ jobs: 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', 'project_root_change_journal_counter', 'project_root_change_journal', 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', - 'project_root_reconciliation_outcomes' + 'project_root_reconciliation_outcomes', 'project_root_reconciliation_write_contexts' ] LOOP FOREACH table_privilege IN ARRAY ARRAY[ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER' @@ -371,7 +371,7 @@ jobs: 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', 'project_root_change_journal_counter', 'project_root_change_journal', 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', - 'project_root_reconciliation_outcomes' + 'project_root_reconciliation_outcomes', 'project_root_reconciliation_write_contexts' ]; projection_tables constant text[] := ARRAY[ 'forge_epic_172_s3_release_state', 'work_package_local_projection_sources', diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index f9fb862c..ebbdfc60 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -44,6 +44,7 @@ describe('project-root expansion reconciliation boundary', () => { 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', 'project_root_reconciliation_outcomes', + 'project_root_reconciliation_write_contexts', ]) expect(migration).toContain(`CREATE TABLE public.${table}`) expect(migration).toContain('generation bigint PRIMARY KEY REFERENCES public.project_root_change_journal(generation)') expect(migration).toContain('project_root_reconciliation_checkpoints_append_only_v1') diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 4fdddb2b..5a4a539b 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -31,6 +31,7 @@ const OWNED_TABLES = [ 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', 'project_root_reconciliation_outcomes', + 'project_root_reconciliation_write_contexts', 's4_completion_handoffs', 's4_protected_review_sources', 's4_protected_review_source_reads', From 82a721067076f7ec3b8ee7a2e355e49bfdf45895 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:36:44 +0800 Subject: [PATCH 14/53] feat: mirror root write contexts in schema --- .../project-root-reconciliation.test.ts | 2 ++ web/db/schema.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ebbdfc60..c835be5e 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -56,6 +56,8 @@ describe('project-root expansion reconciliation boundary', () => { ) expect(reconciliationSchema).not.toMatch(/(?:local_path|root_ref|reason|jsonb)/i) expect(schema).toContain("export const projectRootReconciliationOperations") + expect(schema).toContain("export const projectRootReconciliationWriteContexts") + expect(schema).toContain("project_root_reconciliation_write_context_generation_unique") expect(schema).toContain("project_root_reconciliation_one_live_idx") expect(PROJECT_ROOT_RECONCILIATION_STATES).toEqual(['running', 'complete']) }) diff --git a/web/db/schema.ts b/web/db/schema.ts index 46c14e47..d0b0918d 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1902,6 +1902,23 @@ export const s4ProtectedReviewSources = pgTable('s4_protected_review_sources', { createdAt: timestamp('created_at', tsOpts).defaultNow().notNull(), }) +export const projectRootReconciliationWriteContexts = pgTable('project_root_reconciliation_write_contexts', { + operationId: uuid('operation_id').notNull().references(() => projectRootReconciliationOperations.operationId, { onDelete: 'restrict' }), + generation: bigint('generation', { mode: 'bigint' }).notNull().references(() => projectRootChangeJournal.generation, { onDelete: 'restrict' }), + actorId: uuid('actor_id').notNull(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'restrict' }), + backendPid: integer('backend_pid').notNull(), + transactionId: bigint('transaction_id', { mode: 'bigint' }).notNull(), + enteredAt: timestamp('entered_at', tsOpts).defaultNow().notNull(), + completedAt: timestamp('completed_at', tsOpts), +}, (t) => [ + primaryKey({ columns: [t.operationId, t.generation] }), + unique('project_root_reconciliation_write_context_generation_unique').on(t.generation), + check('project_root_reconciliation_write_context_backend_pid_chk', sql`${t.backendPid} > 0`), + check('project_root_reconciliation_write_context_transaction_id_chk', sql`${t.transactionId} > 0`), + check('project_root_reconciliation_write_context_shape_chk', sql`${t.completedAt} is null or ${t.completedAt} >= ${t.enteredAt}`), +]) + export const s4ProtectedReviewSourceReads = pgTable( 's4_protected_review_source_reads', { From 8a17609ebe61b7cc3f58a0b4a39da5df69a34eef Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:38:30 +0800 Subject: [PATCH 15/53] fix: recover root index builds safely --- web/scripts/build-project-root-ref-index.ts | 25 ++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts index 663edd95..7a13e11d 100644 --- a/web/scripts/build-project-root-ref-index.ts +++ b/web/scripts/build-project-root-ref-index.ts @@ -9,17 +9,26 @@ async function main(): Promise { if (!adminUrl) throw new Error('FORGE_DATABASE_ADMIN_URL is required for the short-lived concurrent DDL step.') const admin = postgres(adminUrl, { max: 1, onnotice: () => {} }) try { + await admin`select pg_catalog.pg_advisory_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))` + const inspect = async () => admin<{ exists: boolean; canonical: boolean; valid: boolean; ready: boolean }[]>` + select true as exists, idx.indisvalid as valid, idx.indisready as ready, + (idx.indisunique and idx.indisvalid and idx.indisready and idx.indnkeyatts=1 and idx.indnatts=1 + and pg_catalog.pg_get_indexdef(idx.indexrelid) = 'CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects USING btree (root_ref) WHERE (root_ref IS NOT NULL)') as canonical + from pg_catalog.pg_index idx where idx.indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass + ` + let [index] = await inspect() + if (index && !index.canonical && index.valid && index.ready) throw new Error('projects_root_ref_idx exists with a noncanonical definition.') + if (index && (!index.valid || !index.ready)) { + await admin.unsafe('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') + index = undefined + } // PostgreSQL rejects CONCURRENTLY inside a transaction. Keep this isolated // from reconciliation, whose dedicated login never receives this URL. - await admin.unsafe( - 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL', - ) - const [index] = await admin<{ valid: boolean }[]>` - select indisvalid as valid from pg_catalog.pg_index - where indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass - ` - if (!index?.valid) throw new Error('Concurrent projects(root_ref) index is not valid.') + if (!index) await admin.unsafe('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL') + ;[index] = await inspect() + if (!index?.canonical) throw new Error('Concurrent projects(root_ref) index is not exact and valid.') } finally { + await admin`select pg_catalog.pg_advisory_unlock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))`.catch(() => undefined) await admin.end({ timeout: 5 }) } } From e9d173e6f9c359cea37eefb9525a06775796fdb5 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:40:04 +0800 Subject: [PATCH 16/53] fix: require exact root index at cutover --- web/scripts/ci/cutover-migration-0027-root-ref.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 6caa585e..14e60d88 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -14,6 +14,7 @@ fi psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 01:42:51 +0800 Subject: [PATCH 17/53] test: prove root index recovery lifecycle --- ...ove-migration-0027-root-index-lifecycle.sh | 21 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh diff --git a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh new file mode 100644 index 00000000..2d21385d --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +# The fixture deliberately makes the production concurrent unique build fail. +# PostgreSQL retains the same-name invalid index, giving the builder's recovery +# path a deterministic, non-timing-dependent input. +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 <<'SQL' +INSERT INTO public.projects (id, name, submitted_by, root_ref) +SELECT '27000000-0000-4000-8000-000000000050', 'Duplicate root index A', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; +INSERT INTO public.projects (id, name, submitted_by, root_ref) +SELECT '27000000-0000-4000-8000-000000000060', 'Duplicate root index B', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; +SQL +if npm run project-roots:build-concurrent-index -- --apply; then + echo 'Duplicate roots unexpectedly allowed the unique concurrent index build.' >&2; exit 1 +fi +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = 'public.projects_root_ref_idx'::regclass AND NOT indisvalid" >/dev/null +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "UPDATE public.projects SET root_ref = '27000000-0000-4000-8000-0000000000bb'::uuid WHERE id = '27000000-0000-4000-8000-000000000060'" +bash scripts/ci/reconcile-migration-0027-root-refs.sh +npm run project-roots:build-concurrent-index -- --apply +npm run project-roots:build-concurrent-index -- --apply diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 6ae4d17d..44bff453 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -56,6 +56,9 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != fi bash scripts/ci/reconcile-migration-0027-root-refs.sh +if [[ "${FORGE_ROOT_INDEX_LIFECYCLE_PROOF:-0}" == '1' ]]; then + bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh +fi npm run project-roots:build-concurrent-index -- --apply bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ From 3321a1f30e087b4e937f9a4408ce1187f906e208 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:47:13 +0800 Subject: [PATCH 18/53] fix: classify missing root index safely --- web/scripts/build-project-root-ref-index.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts index 7a13e11d..0f366260 100644 --- a/web/scripts/build-project-root-ref-index.ts +++ b/web/scripts/build-project-root-ref-index.ts @@ -11,20 +11,22 @@ async function main(): Promise { try { await admin`select pg_catalog.pg_advisory_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))` const inspect = async () => admin<{ exists: boolean; canonical: boolean; valid: boolean; ready: boolean }[]>` - select true as exists, idx.indisvalid as valid, idx.indisready as ready, - (idx.indisunique and idx.indisvalid and idx.indisready and idx.indnkeyatts=1 and idx.indnatts=1 - and pg_catalog.pg_get_indexdef(idx.indexrelid) = 'CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects USING btree (root_ref) WHERE (root_ref IS NOT NULL)') as canonical - from pg_catalog.pg_index idx where idx.indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass + with candidate as (select pg_catalog.to_regclass('public.projects_root_ref_idx') as index_oid) + select (candidate.index_oid is not null) as exists, + coalesce(idx.indisvalid, false) as valid, coalesce(idx.indisready, false) as ready, + coalesce(idx.indisunique and idx.indisvalid and idx.indisready and idx.indnkeyatts=1 and idx.indnatts=1 + and pg_catalog.pg_get_indexdef(idx.indexrelid) = 'CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects USING btree (root_ref) WHERE (root_ref IS NOT NULL)', false) as canonical + from candidate left join pg_catalog.pg_index idx on idx.indexrelid = candidate.index_oid ` let [index] = await inspect() - if (index && !index.canonical && index.valid && index.ready) throw new Error('projects_root_ref_idx exists with a noncanonical definition.') - if (index && (!index.valid || !index.ready)) { + if (index?.exists && !index.canonical && index.valid && index.ready) throw new Error('projects_root_ref_idx exists with a noncanonical definition.') + if (index?.exists && (!index.valid || !index.ready)) { await admin.unsafe('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') index = undefined } // PostgreSQL rejects CONCURRENTLY inside a transaction. Keep this isolated // from reconciliation, whose dedicated login never receives this URL. - if (!index) await admin.unsafe('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL') + if (!index?.exists) await admin.unsafe('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL') ;[index] = await inspect() if (!index?.canonical) throw new Error('Concurrent projects(root_ref) index is not exact and valid.') } finally { From 70384662f85a4c867b302d2106af731559fefd2c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:50:36 +0800 Subject: [PATCH 19/53] test: require root index lifecycle proof --- .../project-root-reconciliation.test.ts | 13 +++- ...ove-migration-0027-root-index-lifecycle.sh | 78 ++++++++++++++++++- .../ci/prove-migration-0027-upgrade.sh | 5 +- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index c835be5e..90ffa9bb 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -14,6 +14,7 @@ const reconcileScript = readFileSync(fileURLToPath(new URL('../scripts/reconcile const reconciliation = readFileSync(fileURLToPath(new URL('../lib/mcps/filesystem-grant-reconciliation.ts', import.meta.url)), 'utf8') const indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project-root-ref-index.ts', import.meta.url)), 'utf8') const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-upgrade.sh', import.meta.url)), 'utf8') +const indexLifecycleProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-index-lifecycle.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -159,11 +160,19 @@ describe('project-root expansion reconciliation boundary', () => { expect(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") expect(cutoverScript).toContain('strict root-reference cutover requires a valid concurrent projects(root_ref) index') expect(upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh')).toBeLessThan( - upgradeProof.indexOf('npm run project-roots:build-concurrent-index -- --apply'), + upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), ) - expect(upgradeProof.indexOf('npm run project-roots:build-concurrent-index -- --apply')).toBeLessThan( + expect(upgradeProof).not.toContain('FORGE_ROOT_INDEX_LIFECYCLE_PROOF') + expect(upgradeProof.match(/bash scripts\/ci\/prove-migration-0027-root-index-lifecycle\.sh/g)).toHaveLength(1) + expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh')).toBeLessThan( upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply'), ) + expect(indexLifecycleProof).toContain("expect_failure \"$canonical_index_refusal\"") + expect(indexLifecycleProof).toContain('assert_cutover_not_started') + expect(indexLifecycleProof).toContain('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL') + expect(indexLifecycleProof).toContain('projects_root_ref_idx exists with a noncanonical definition.') + expect(indexLifecycleProof).toContain('could not create unique index "projects_root_ref_idx"') + expect(indexLifecycleProof).toContain('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') expect(webCi).toContain('Capture the post-drain root journal watermark') }) diff --git a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh index 2d21385d..9b964fab 100644 --- a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -1,7 +1,72 @@ #!/usr/bin/env bash set -euo pipefail + : "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" +canonical_index_refusal='strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index' + +expect_failure() { + local expected_message="$1" + shift + local output + output="$(mktemp)" + if "$@" >"$output" 2>&1; then + cat "$output" >&2 + rm -f "$output" + echo "Expected command to fail: $*" >&2 + exit 1 + fi + if ! rg --fixed-strings --quiet "$expected_message" "$output"; then + cat "$output" >&2 + rm -f "$output" + echo "Expected failure message was absent: $expected_message" >&2 + exit 1 + fi + rm -f "$output" +} + +assert_cutover_not_started() { + local root_not_null proof_constraint + root_not_null="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT attnotnull FROM pg_catalog.pg_attribute WHERE attrelid = 'public.projects'::regclass AND attname = 'root_ref' AND NOT attisdropped")" + proof_constraint="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT count(*) FROM pg_catalog.pg_constraint WHERE conrelid = 'public.projects'::regclass AND conname = 'projects_root_ref_not_null_proof'")" + if [[ "$root_not_null" != 'f' || "$proof_constraint" != '0' ]]; then + echo 'Canonical-index refusal altered strict-cutover schema state.' >&2 + exit 1 + fi +} + +# Reconciliation has completed, so the missing-index refusal must happen before +# any concurrent DDL. This proves cutover itself is the boundary, not the builder. +expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply +assert_cutover_not_started + +# A valid but structurally wrong same-name index is never disposable production +# state. The production builder and cutover both refuse it without replacement. +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + 'CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL' +wrong_index_before="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ + "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" +if [[ -z "$wrong_index_before" ]]; then + echo 'Failed to create the deterministic wrong root-reference index.' >&2 + exit 1 +fi +expect_failure 'projects_root_ref_idx exists with a noncanonical definition.' \ + npm run project-roots:build-concurrent-index -- --apply +expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply +assert_cutover_not_started +wrong_index_after="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ + "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" +if [[ "$wrong_index_after" != "$wrong_index_before" ]]; then + echo 'The valid wrong root-reference index was replaced or changed.' >&2 + exit 1 +fi +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + 'DROP INDEX CONCURRENTLY public.projects_root_ref_idx' + # The fixture deliberately makes the production concurrent unique build fail. # PostgreSQL retains the same-name invalid index, giving the builder's recovery # path a deterministic, non-timing-dependent input. @@ -11,10 +76,17 @@ SELECT '27000000-0000-4000-8000-000000000050', 'Duplicate root index A', id, '27 INSERT INTO public.projects (id, name, submitted_by, root_ref) SELECT '27000000-0000-4000-8000-000000000060', 'Duplicate root index B', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; SQL -if npm run project-roots:build-concurrent-index -- --apply; then - echo 'Duplicate roots unexpectedly allowed the unique concurrent index build.' >&2; exit 1 +expect_failure 'could not create unique index "projects_root_ref_idx"' \ + npm run project-roots:build-concurrent-index -- --apply +invalid_index="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND (NOT indisvalid OR NOT indisready))")" +if [[ "$invalid_index" != 't' ]]; then + echo 'The failed concurrent build did not retain the expected invalid index.' >&2 + exit 1 fi -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = 'public.projects_root_ref_idx'::regclass AND NOT indisvalid" >/dev/null +expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply +assert_cutover_not_started psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "UPDATE public.projects SET root_ref = '27000000-0000-4000-8000-0000000000bb'::uuid WHERE id = '27000000-0000-4000-8000-000000000060'" bash scripts/ci/reconcile-migration-0027-root-refs.sh npm run project-roots:build-concurrent-index -- --apply diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 44bff453..60824b0f 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -56,10 +56,7 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != fi bash scripts/ci/reconcile-migration-0027-root-refs.sh -if [[ "${FORGE_ROOT_INDEX_LIFECYCLE_PROOF:-0}" == '1' ]]; then - bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh -fi -npm run project-roots:build-concurrent-index -- --apply +bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-cutover-assertions.sql From 560600f72f68e3023af6fdb3beec43c1ab72cc1c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:53:40 +0800 Subject: [PATCH 20/53] test: seed root authority project fixture --- .../project-root-reconciliation.test.ts | 12 ++ ...on-0027-root-authority-project-fixture.sql | 106 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 web/scripts/ci/sql/migration-0027-root-authority-project-fixture.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 90ffa9bb..bf8185ea 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -15,6 +15,7 @@ const reconciliation = readFileSync(fileURLToPath(new URL('../lib/mcps/filesyste const indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project-root-ref-index.ts', import.meta.url)), 'utf8') const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-upgrade.sh', import.meta.url)), 'utf8') const indexLifecycleProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-index-lifecycle.sh', import.meta.url)), 'utf8') +const rootAuthorityProjectFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-project-fixture.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -131,6 +132,17 @@ describe('project-root expansion reconciliation boundary', () => { ]) expect(webCi).toContain(`'${table}'`) }) + it('keeps the admin-only root-authority project fixture self-validating and incomplete', () => { + expect(rootAuthorityProjectFixture).toContain('root authority fixture must not run as the reconciler login') + expect(rootAuthorityProjectFixture).toContain("'27000000-0000-4000-8000-000000000700'") + expect(rootAuthorityProjectFixture).toContain("'27000000-0000-4000-8000-000000000702'") + expect(rootAuthorityProjectFixture).toContain('"requiredMcps":["filesystem","github"]') + expect(rootAuthorityProjectFixture).toContain('project_filesystem_current_decision_pointers') + expect(rootAuthorityProjectFixture).toContain("'^sha256:[0-9a-f]{64}$'") + expect(rootAuthorityProjectFixture).toContain('duplicate or ambiguous current authority') + expect(rootAuthorityProjectFixture).not.toContain('reconcile-project-root-expansion.ts') + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/sql/migration-0027-root-authority-project-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-project-fixture.sql new file mode 100644 index 00000000..f200846b --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-authority-project-fixture.sql @@ -0,0 +1,106 @@ +\set ON_ERROR_STOP on + +-- Admin-only disposable-fixture arrangement for ROOT-R2A. This is deliberately +-- not a production authority path and remains incomplete until R2A1b/R2A2 add +-- packages and invoke the dedicated reconciler command. +DO $fixture$ +BEGIN + IF current_user = 'forge_project_root_reconciler' THEN + RAISE EXCEPTION 'root authority fixture must not run as the reconciler login'; + END IF; +END; +$fixture$; + +INSERT INTO public.projects ( + id, name, submitted_by, root_ref, root_binding_revision, + grant_decision_revision, mcp_config +) VALUES ( + '27000000-0000-4000-8000-000000000700', + 'Root authority reconciliation fixture', + '27000000-0000-4000-8000-000000000001', + '27000000-0000-4000-8000-000000000701', + 1, + 1, + '{"profile":"default","requiredMcps":["filesystem","github"],"overrides":{}}'::jsonb +); + +INSERT INTO public.project_filesystem_grant_decisions ( + id, project_id, decision, capabilities, grant_decision_revision, + root_binding_revision, decision_fingerprint, decision_generation, + decided_by, decided_at +) VALUES ( + '27000000-0000-4000-8000-000000000702', + '27000000-0000-4000-8000-000000000700', + 'approved', + '["filesystem.project.read"]'::jsonb, + 1, + 1, + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 1, + '27000000-0000-4000-8000-000000000001', + '2026-07-28T00:00:00.000Z'::timestamptz +); + +UPDATE public.project_filesystem_current_decision_pointers +SET + current_decision_id = '27000000-0000-4000-8000-000000000702', + current_decision_project_id = '27000000-0000-4000-8000-000000000700', + current_decision_revision = 1, + current_root_binding_revision = 1, + current_decision_fingerprint = + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + current_decision_generation = 1, + pointer_generation = 1 +WHERE project_id = '27000000-0000-4000-8000-000000000700'; + +DO $assertions$ +DECLARE + project_row public.projects%ROWTYPE; + decision_row public.project_filesystem_grant_decisions%ROWTYPE; + pointer_row public.project_filesystem_current_decision_pointers%ROWTYPE; +BEGIN + SELECT * INTO STRICT project_row + FROM public.projects + WHERE id = '27000000-0000-4000-8000-000000000700'; + SELECT * INTO STRICT decision_row + FROM public.project_filesystem_grant_decisions + WHERE id = '27000000-0000-4000-8000-000000000702'; + SELECT * INTO STRICT pointer_row + FROM public.project_filesystem_current_decision_pointers + WHERE project_id = project_row.id; + + IF project_row.submitted_by <> '27000000-0000-4000-8000-000000000001'::uuid + OR project_row.root_ref IS NULL + OR project_row.root_binding_revision <= 0 + OR project_row.grant_decision_revision <= 0 + OR project_row.mcp_config <> '{"profile":"default","requiredMcps":["filesystem","github"],"overrides":{}}'::jsonb + OR decision_row.project_id <> project_row.id + OR decision_row.decision <> 'approved' + OR decision_row.capabilities <> '["filesystem.project.read"]'::jsonb + OR decision_row.grant_decision_revision <> project_row.grant_decision_revision + OR decision_row.root_binding_revision <> project_row.root_binding_revision + OR decision_row.decision_generation <> 1 + OR decision_row.decision_fingerprint !~ '^sha256:[0-9a-f]{64}$' + OR pointer_row.current_decision_id <> decision_row.id + OR pointer_row.current_decision_project_id <> project_row.id + OR pointer_row.current_decision_revision <> decision_row.grant_decision_revision + OR pointer_row.current_root_binding_revision <> decision_row.root_binding_revision + OR pointer_row.current_decision_generation <> decision_row.decision_generation + OR pointer_row.current_decision_fingerprint <> decision_row.decision_fingerprint + OR pointer_row.current_decision_fingerprint !~ '^sha256:[0-9a-f]{64}$' + OR pointer_row.pointer_generation <> decision_row.decision_generation + THEN + RAISE EXCEPTION 'root authority project fixture has invalid project decision or current pointer parentage'; + END IF; + + IF (SELECT count(*) FROM public.project_filesystem_grant_decisions + WHERE project_id = project_row.id) <> 1 + OR (SELECT count(*) FROM public.project_filesystem_current_decision_pointers + WHERE project_id = project_row.id) <> 1 + OR (SELECT count(*) FROM public.project_filesystem_current_decision_pointers + WHERE project_id = project_row.id AND current_decision_id IS NOT NULL) <> 1 + THEN + RAISE EXCEPTION 'root authority project fixture has duplicate or ambiguous current authority'; + END IF; +END; +$assertions$; From 1742838e37457cfa7264c8920d64a0cc82271518 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:54:54 +0800 Subject: [PATCH 21/53] test: use portable index proof matcher --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index bf8185ea..e341f0e5 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -180,6 +180,8 @@ describe('project-root expansion reconciliation boundary', () => { upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply'), ) expect(indexLifecycleProof).toContain("expect_failure \"$canonical_index_refusal\"") + expect(indexLifecycleProof).toContain('grep -F -- "$expected_message" "$output" >/dev/null') + expect(indexLifecycleProof).not.toContain('rg --fixed-strings') expect(indexLifecycleProof).toContain('assert_cutover_not_started') expect(indexLifecycleProof).toContain('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL') expect(indexLifecycleProof).toContain('projects_root_ref_idx exists with a noncanonical definition.') diff --git a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh index 9b964fab..f058288f 100644 --- a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -16,7 +16,7 @@ expect_failure() { echo "Expected command to fail: $*" >&2 exit 1 fi - if ! rg --fixed-strings --quiet "$expected_message" "$output"; then + if ! grep -F -- "$expected_message" "$output" >/dev/null; then cat "$output" >&2 rm -f "$output" echo "Expected failure message was absent: $expected_message" >&2 From c2cf6b2975929cf0dd08c34e04ace312a8135a48 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:59:13 +0800 Subject: [PATCH 22/53] test: seed root authority package fixture --- .../project-root-reconciliation.test.ts | 15 ++ ...on-0027-root-authority-package-fixture.sql | 200 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index e341f0e5..62f51ebd 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -16,6 +16,7 @@ const indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-upgrade.sh', import.meta.url)), 'utf8') const indexLifecycleProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-index-lifecycle.sh', import.meta.url)), 'utf8') const rootAuthorityProjectFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-project-fixture.sql', import.meta.url)), 'utf8') +const rootAuthorityPackageFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-package-fixture.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -143,6 +144,20 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityProjectFixture).not.toContain('reconcile-project-root-expansion.ts') }) + it('keeps the admin-only root-authority package fixture bounded to seed state', () => { + expect(rootAuthorityPackageFixture).toContain('requires the exact R2A1a project authority') + expect(rootAuthorityPackageFixture).toContain("'27000000-0000-4000-8000-000000000711'") + expect(rootAuthorityPackageFixture).toContain("'27000000-0000-4000-8000-000000000712'") + expect(rootAuthorityPackageFixture).toContain("'27000000-0000-4000-8000-000000000721'") + expect(rootAuthorityPackageFixture).toContain('public.forge_is_canonical_filesystem_grant_block_v2') + expect(rootAuthorityPackageFixture).toContain('mcpGrantPhases') + expect(rootAuthorityPackageFixture).toContain("metadata - 'mcpGrantBlock'") + expect(rootAuthorityPackageFixture).toContain('count(DISTINCT metadata->\'mcpGrantBlock\'->>\'blockFingerprint\')') + expect(rootAuthorityPackageFixture).toContain("head_kind = 'operator_hold'") + expect(rootAuthorityPackageFixture).toContain('invalid projection heads or sources') + expect(rootAuthorityPackageFixture).not.toContain('reconcile-project-root-expansion.ts') + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql new file mode 100644 index 00000000..b6f91a8e --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -0,0 +1,200 @@ +\set ON_ERROR_STOP on + +-- Admin-only fixture arrangement for ROOT-R2A. It depends on the exact +-- project authority seeded by migration-0027-root-authority-project-fixture.sql +-- and intentionally does not invoke reconciliation until R2A2. +DO $fixture$ +BEGIN + IF current_user = 'forge_project_root_reconciler' THEN + RAISE EXCEPTION 'root authority package fixture must not run as the reconciler login'; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM public.projects project_row + JOIN public.project_filesystem_grant_decisions decision_row + ON decision_row.id = '27000000-0000-4000-8000-000000000702'::uuid + AND decision_row.project_id = project_row.id + JOIN public.project_filesystem_current_decision_pointers pointer_row + ON pointer_row.project_id = project_row.id + AND pointer_row.current_decision_id = decision_row.id + WHERE project_row.id = '27000000-0000-4000-8000-000000000700'::uuid + AND project_row.root_binding_revision = 1 + AND project_row.grant_decision_revision = 1 + ) THEN + RAISE EXCEPTION 'root authority package fixture requires the exact R2A1a project authority'; + END IF; +END; +$fixture$; + +INSERT INTO public.tasks ( + id, project_id, submitted_by, title, prompt, status, local_projection_scope_state +) VALUES + ( + '27000000-0000-4000-8000-000000000710', + '27000000-0000-4000-8000-000000000700', + '27000000-0000-4000-8000-000000000001', + 'Root authority running convergence', + 'Fixture task for marker addition and replacement.', + 'running', + 'active' + ), + ( + '27000000-0000-4000-8000-000000000720', + '27000000-0000-4000-8000-000000000700', + '27000000-0000-4000-8000-000000000001', + 'Root authority legacy failed convergence', + 'Fixture task for marker removal and task recovery.', + 'failed', + 'active' + ); + +INSERT INTO public.work_packages ( + id, task_id, assigned_role, title, summary, status, sequence, + mcp_requirements, blocked_reason, metadata +) VALUES + ( + '27000000-0000-4000-8000-000000000711', + '27000000-0000-4000-8000-000000000710', + 'backend', + 'Root authority marker addition', + 'A ready package that later receives the canonical root hold.', + 'ready', + 1, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + NULL, + '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb + ), + ( + '27000000-0000-4000-8000-000000000712', + '27000000-0000-4000-8000-000000000710', + 'backend', + 'Root authority marker replacement', + 'A valid prior marker that later refreshes under the root change.', + 'blocked', + 2, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + 'Filesystem context requires an operator decision before execution.', + '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-replacement"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","blockedAt":"2026-07-28T00:00:01.000Z","holdKind":"approval_required","grantPhase":"none","grantConsumed":false,"grantDecisionRevision":null,"revocationReason":null}}'::jsonb + ), + ( + '27000000-0000-4000-8000-000000000721', + '27000000-0000-4000-8000-000000000720', + 'qa', + 'Root authority marker removal', + 'A valid prior marker that later clears through recovery.', + 'blocked', + 1, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + 'Filesystem context requires an operator decision before execution.', + '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-removal"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","blockedAt":"2026-07-28T00:00:02.000Z","holdKind":"consumed_once","grantPhase":"approved","grantConsumed":true,"grantDecisionRevision":"1","revocationReason":null}}'::jsonb + ); + +DO $assertions$ +DECLARE + expected_packages uuid[] := ARRAY[ + '27000000-0000-4000-8000-000000000711'::uuid, + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + ]; +BEGIN + IF (SELECT count(*) FROM public.tasks + WHERE id = ANY (ARRAY[ + '27000000-0000-4000-8000-000000000710'::uuid, + '27000000-0000-4000-8000-000000000720'::uuid + ]) AND project_id = '27000000-0000-4000-8000-000000000700'::uuid + AND local_projection_scope_state = 'active') <> 2 + OR NOT EXISTS (SELECT 1 FROM public.tasks WHERE id = '27000000-0000-4000-8000-000000000710'::uuid AND status = 'running') + OR NOT EXISTS (SELECT 1 FROM public.tasks WHERE id = '27000000-0000-4000-8000-000000000720'::uuid AND status = 'failed') + OR (SELECT count(*) FROM public.work_packages package_row + JOIN public.tasks task_row ON task_row.id = package_row.task_id + WHERE package_row.id = ANY (expected_packages) + AND task_row.project_id = '27000000-0000-4000-8000-000000000700'::uuid) <> 3 + THEN + RAISE EXCEPTION 'root authority package fixture has invalid task or project ownership'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id = '27000000-0000-4000-8000-000000000711'::uuid + AND status = 'ready' AND NOT (metadata ? 'mcpGrantBlock') + ) OR EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id = ANY (ARRAY[ + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + ]) + AND (status <> 'blocked' + OR public.forge_is_canonical_filesystem_grant_block_v2(metadata->'mcpGrantBlock') IS NOT TRUE) + ) OR EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id = ANY (expected_packages) + AND (metadata->'mcpGrantPhases' IS NULL OR metadata->'fixturePeer' IS NULL) + ) OR NOT EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id = '27000000-0000-4000-8000-000000000711'::uuid + AND metadata = '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb + ) OR EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id IN ( + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + ) + AND metadata - 'mcpGrantBlock' NOT IN ( + '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}}}'::jsonb, + '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}}}'::jsonb + ) + ) OR (SELECT count(DISTINCT metadata->'mcpGrantBlock'->>'blockFingerprint') + FROM public.work_packages + WHERE id IN ( + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + )) <> 2 + OR (SELECT count(DISTINCT metadata->'mcpGrantBlock'->>'blockedAt') + FROM public.work_packages + WHERE id IN ( + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + )) <> 2 + ) + THEN + RAISE EXCEPTION 'root authority package fixture has invalid marker or baseline metadata'; + END IF; + + IF (SELECT count(*) FROM public.filesystem_mcp_current_decision_pointers pointer_row + JOIN public.work_packages package_row ON package_row.id = pointer_row.work_package_id + WHERE package_row.id = ANY (expected_packages) + AND pointer_row.task_id = package_row.task_id + AND pointer_row.current_decision_id IS NULL + AND pointer_row.current_decision_task_id IS NULL + AND pointer_row.current_decision_work_package_id IS NULL + AND pointer_row.current_decision_revision IS NULL + AND pointer_row.current_decision_fingerprint IS NULL + AND pointer_row.pointer_version = 0 + AND pointer_row.pointer_fingerprint = 'empty:' || package_row.id::text) <> 3 + OR (SELECT count(*) FROM public.filesystem_mcp_current_decision_pointers + WHERE work_package_id = ANY (expected_packages)) <> 3 + THEN + RAISE EXCEPTION 'root authority package fixture has invalid or ambiguous package pointers'; + END IF; + + IF (SELECT count(*) FROM public.work_package_local_projection_heads head + JOIN public.work_packages package_row ON package_row.id = head.work_package_id + WHERE package_row.id = ANY (expected_packages)) <> 24 + OR (SELECT count(*) FROM public.work_package_local_projection_heads head + JOIN public.work_packages package_row ON package_row.id = head.work_package_id + WHERE package_row.id = ANY (expected_packages) + AND head.head_kind = 'operator_hold' + AND head.head_index = 5 + AND head.head_revision = 0 + AND head.current_source_id IS NULL + AND head.compare_and_set_fingerprint = head.head_fingerprint + AND head.head_fingerprint = 'head:v1:' || package_row.task_id::text || ':' || package_row.id::text || ':operator_hold:5') <> 3 + OR EXISTS ( + SELECT 1 FROM public.work_package_local_projection_sources + WHERE work_package_id = ANY (expected_packages) + ) + THEN + RAISE EXCEPTION 'root authority package fixture has invalid projection heads or sources'; + END IF; +END; +$assertions$; From bd7039226591f854cbc61bc3a5a55354aaffd6e9 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:01:07 +0800 Subject: [PATCH 23/53] fix: check root index before coverage --- .../project-root-reconciliation.test.ts | 8 +++++++ .../ci/cutover-migration-0027-root-ref.sh | 22 +++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 62f51ebd..12091e47 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -186,6 +186,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(cutoverScript).toContain('projects_root_ref_idx') expect(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") expect(cutoverScript).toContain('strict root-reference cutover requires a valid concurrent projects(root_ref) index') + const advisoryLock = cutoverScript.indexOf("pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))") + const indexGuard = cutoverScript.indexOf('strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index') + const coverageGuard = cutoverScript.indexOf('strict root-reference cutover requires exact completed watermark coverage') + const proofConstraint = cutoverScript.indexOf('ALTER TABLE public.projects ADD CONSTRAINT projects_root_ref_not_null_proof') + expect(advisoryLock).toBeGreaterThanOrEqual(0) + expect(advisoryLock).toBeLessThan(indexGuard) + expect(indexGuard).toBeLessThan(coverageGuard) + expect(coverageGuard).toBeLessThan(proofConstraint) expect(upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh')).toBeLessThan( upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), ) diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 14e60d88..9cf02314 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -21,17 +21,6 @@ BEGIN IF (SELECT state FROM public.forge_epic_172_enablement_state WHERE singleton_id = 'epic-172') <> 'disabled' THEN RAISE EXCEPTION 'strict root-reference cutover requires Step 0 to remain disabled' USING ERRCODE = '55000'; END IF; - IF NOT EXISTS ( - SELECT 1 FROM public.project_root_reconciliation_operations operation - WHERE operation.through_generation = v_through AND operation.state = 'complete' - AND operation.last_processed_generation = v_through AND operation.cumulative_count = v_through - ) OR (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) <> v_through - OR (SELECT coalesce(max(generation), 0) FROM public.project_root_change_journal) <> v_through - OR (SELECT count(*) FROM public.project_root_change_journal) <> v_through - OR (SELECT count(*) FROM public.project_root_reconciliation_outcomes) <> v_through - OR EXISTS (SELECT 1 FROM public.projects WHERE root_ref IS NULL) THEN - RAISE EXCEPTION 'strict root-reference cutover requires exact completed watermark coverage' USING ERRCODE = '55000'; - END IF; IF NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_index index_row JOIN pg_catalog.pg_class index_class ON index_class.oid = index_row.indexrelid @@ -43,6 +32,17 @@ BEGIN AND index_row.indexprs IS NULL AND index_row.indkey[0] = attribute_row.attnum AND pg_catalog.pg_get_expr(index_row.indpred, index_row.indrelid) = '(root_ref IS NOT NULL)' ) THEN RAISE EXCEPTION 'strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index' USING ERRCODE = '55000'; END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.project_root_reconciliation_operations operation + WHERE operation.through_generation = v_through AND operation.state = 'complete' + AND operation.last_processed_generation = v_through AND operation.cumulative_count = v_through + ) OR (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) <> v_through + OR (SELECT coalesce(max(generation), 0) FROM public.project_root_change_journal) <> v_through + OR (SELECT count(*) FROM public.project_root_change_journal) <> v_through + OR (SELECT count(*) FROM public.project_root_reconciliation_outcomes) <> v_through + OR EXISTS (SELECT 1 FROM public.projects WHERE root_ref IS NULL) THEN + RAISE EXCEPTION 'strict root-reference cutover requires exact completed watermark coverage' USING ERRCODE = '55000'; + END IF; IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint WHERE conrelid = 'public.projects'::regclass AND conname = 'projects_root_ref_not_null_proof') THEN ALTER TABLE public.projects ADD CONSTRAINT projects_root_ref_not_null_proof CHECK (root_ref IS NOT NULL) NOT VALID; END IF; From 76c425fbdd4b01dbe3c6b5a3b67ae6d9c1e6f1f0 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:12:47 +0800 Subject: [PATCH 24/53] test: prove root authority reconciliation --- .../project-root-reconciliation.test.ts | 36 ++++- ...tion-0027-root-authority-reconciliation.sh | 54 +++++++ .../ci/prove-migration-0027-upgrade.sh | 4 +- ...on-0027-root-authority-package-fixture.sql | 4 +- ...ot-authority-reconciliation-assertions.sql | 136 ++++++++++++++++++ 5 files changed, 228 insertions(+), 6 deletions(-) create mode 100755 web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh create mode 100644 web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 12091e47..9cd3503b 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -17,6 +17,8 @@ const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-mig const indexLifecycleProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-index-lifecycle.sh', import.meta.url)), 'utf8') const rootAuthorityProjectFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-project-fixture.sql', import.meta.url)), 'utf8') const rootAuthorityPackageFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-package-fixture.sql', import.meta.url)), 'utf8') +const rootAuthorityProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-authority-reconciliation.sh', import.meta.url)), 'utf8') +const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -133,7 +135,7 @@ describe('project-root expansion reconciliation boundary', () => { ]) expect(webCi).toContain(`'${table}'`) }) - it('keeps the admin-only root-authority project fixture self-validating and incomplete', () => { + it('keeps the admin-only root-authority project fixture self-validating', () => { expect(rootAuthorityProjectFixture).toContain('root authority fixture must not run as the reconciler login') expect(rootAuthorityProjectFixture).toContain("'27000000-0000-4000-8000-000000000700'") expect(rootAuthorityProjectFixture).toContain("'27000000-0000-4000-8000-000000000702'") @@ -156,6 +158,34 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityPackageFixture).toContain("head_kind = 'operator_hold'") expect(rootAuthorityPackageFixture).toContain('invalid projection heads or sources') expect(rootAuthorityPackageFixture).not.toContain('reconcile-project-root-expansion.ts') + expect(rootAuthorityPackageFixture).toContain('filesystem.project.search') + }) + + it('runs the bound-authority fixture through the real dedicated reconciler once', () => { + expect(rootAuthorityProof).toContain('migration-0027-root-authority-project-fixture.sql') + expect(rootAuthorityProof).toContain('migration-0027-root-authority-package-fixture.sql') + expect(rootAuthorityProof).toContain("UPDATE public.projects SET root_ref") + expect(rootAuthorityProof).toContain("outcome = 'root_update'") + expect(rootAuthorityProof).toContain("SELECT session_user") + expect(rootAuthorityProof).toContain('forge_project_root_reconciler') + expect(rootAuthorityProof.match(/bash scripts\/ci\/reconcile-migration-0027-root-refs\.sh/g)).toHaveLength(1) + expect(rootAuthorityProof).toContain('migration-0027-root-authority-reconciliation-assertions.sql') + expect(upgradeProof).toContain('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh') + expect(upgradeProof).not.toContain('bash scripts/ci/reconcile-migration-0027-root-refs.sh') + expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh')).toBeLessThan( + upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), + ) + }) + + it('asserts canonical package/task/head effects without direct protected authority mutation', () => { + expect(rootAuthorityAssertions).toContain('project_root_reconciliation_write_contexts') + expect(rootAuthorityAssertions).toContain("source_row.contribution->>'transition'") + expect(rootAuthorityAssertions).toContain("WHEN v_add THEN 'hold' WHEN v_refresh THEN 'refresh' WHEN v_recover THEN 'recovery'") + expect(rootAuthorityAssertions).toContain('forge_is_canonical_filesystem_grant_block_v2') + expect(rootAuthorityAssertions).toContain("metadata - 'mcpGrantBlock'") + expect(rootAuthorityAssertions).toContain('mcpGrantPhases') + expect(rootAuthorityAssertions).toContain('root authority reconciliation mutated protected decision or pointer authority directly') + expect(rootAuthorityAssertions).toContain('root authority task convergence did not recover running and failed tasks') }) it('selects phase suppression only in the internal root-journal caller', () => { @@ -185,7 +215,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(cutoverScript).toContain('project_root_reconciliation_operations') expect(cutoverScript).toContain('projects_root_ref_idx') expect(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") - expect(cutoverScript).toContain('strict root-reference cutover requires a valid concurrent projects(root_ref) index') + expect(cutoverScript).toContain('strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index') const advisoryLock = cutoverScript.indexOf("pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))") const indexGuard = cutoverScript.indexOf('strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index') const coverageGuard = cutoverScript.indexOf('strict root-reference cutover requires exact completed watermark coverage') @@ -194,7 +224,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(advisoryLock).toBeLessThan(indexGuard) expect(indexGuard).toBeLessThan(coverageGuard) expect(coverageGuard).toBeLessThan(proofConstraint) - expect(upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh')).toBeLessThan( + expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh')).toBeLessThan( upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), ) expect(upgradeProof).not.toContain('FORGE_ROOT_INDEX_LIFECYCLE_PROOF') diff --git a/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh new file mode 100755 index 00000000..6d431435 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +project_id='27000000-0000-4000-8000-000000000700' +new_root_ref='27000000-0000-4000-8000-000000000703' +actor_id='11111111-1111-4111-8111-111111111111' + +# These fixtures are deliberately arranged by the disposable administrator. +# The only reconciliation execution below is the existing dedicated-login shim. +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --file scripts/ci/sql/migration-0027-root-authority-project-fixture.sql +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --file scripts/ci/sql/migration-0027-root-authority-package-fixture.sql + +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "UPDATE public.projects SET root_ref = '${new_root_ref}'::uuid WHERE id = '${project_id}'::uuid" + +authority_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT generation FROM public.project_root_change_journal WHERE project_id = '${project_id}'::uuid AND outcome = 'root_update' ORDER BY generation DESC LIMIT 1")" +if [[ ! "$authority_generation" =~ ^[1-9][0-9]*$ ]]; then + echo 'The authority fixture did not create an exact root-update journal generation.' >&2 + exit 1 +fi + +# This is a direct proof that the same URL used by the compatibility shim is +# the fixed, least-privilege reconciler login. The shim itself owns the sole +# post-drain watermark capture and production CLI invocation. +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +reconciler_url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +if [[ "$(psql "$reconciler_url" --no-align --tuples-only --set ON_ERROR_STOP=1 --command 'SELECT session_user')" != 'forge_project_root_reconciler' ]]; then + echo 'The root-authority proof did not obtain the dedicated reconciler login.' >&2 + exit 1 +fi + +# Exactly one operation is created: the existing shim first drains any legacy +# null roots, captures its exact watermark, then runs the production CLI. +bash scripts/ci/reconcile-migration-0027-root-refs.sh + +through_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT through_generation FROM public.project_root_reconciliation_operations WHERE actor_id = '${actor_id}'::uuid ORDER BY created_at DESC LIMIT 1")" +if [[ ! "$through_generation" =~ ^[1-9][0-9]*$ ]] || (( authority_generation > through_generation )); then + echo 'The completed reconciliation operation does not cover the authority root-update generation.' >&2 + exit 1 +fi + +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --set authority_generation="$authority_generation" \ + --set through_generation="$through_generation" \ + --set actor_id="$actor_id" \ + --file scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 60824b0f..44048a59 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -55,7 +55,9 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != exit 1 fi -bash scripts/ci/reconcile-migration-0027-root-refs.sh +# The authority proof arranges its protected graph under the disposable admin, +# then invokes the existing reconciler shim once through its dedicated login. +bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql index b6f91a8e..67b8df8f 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -60,7 +60,7 @@ INSERT INTO public.work_packages ( 'A ready package that later receives the canonical root hold.', 'ready', 1, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, NULL, '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb ), @@ -72,7 +72,7 @@ INSERT INTO public.work_packages ( 'A valid prior marker that later refreshes under the root change.', 'blocked', 2, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, 'Filesystem context requires an operator decision before execution.', '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-replacement"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","blockedAt":"2026-07-28T00:00:01.000Z","holdKind":"approval_required","grantPhase":"none","grantConsumed":false,"grantDecisionRevision":null,"revocationReason":null}}'::jsonb ), diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql new file mode 100644 index 00000000..3be7e528 --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -0,0 +1,136 @@ +\set ON_ERROR_STOP on + +DO $assertions$ +DECLARE + v_project_id uuid := '27000000-0000-4000-8000-000000000700'::uuid; + v_task_running uuid := '27000000-0000-4000-8000-000000000710'::uuid; + v_task_failed uuid := '27000000-0000-4000-8000-000000000720'::uuid; + v_add uuid := '27000000-0000-4000-8000-000000000711'::uuid; + v_refresh uuid := '27000000-0000-4000-8000-000000000712'::uuid; + v_recover uuid := '27000000-0000-4000-8000-000000000721'::uuid; + v_actor uuid := :'actor_id'::uuid; + v_authority_generation bigint := :'authority_generation'::bigint; + v_through_generation bigint := :'through_generation'::bigint; +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM public.project_root_change_journal journal_row + WHERE journal_row.generation = v_authority_generation + AND journal_row.project_id = v_project_id + AND journal_row.outcome = 'root_update' + ) THEN + RAISE EXCEPTION 'root authority proof lost its exact root-update journal generation'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.project_root_reconciliation_operations operation_row + JOIN public.project_root_reconciliation_outcomes outcome_row + ON outcome_row.operation_id = operation_row.operation_id + AND outcome_row.generation = v_authority_generation + AND outcome_row.actor_id = v_actor + AND outcome_row.project_id = v_project_id + AND outcome_row.outcome = 'root_update' + JOIN public.project_root_reconciliation_write_contexts context_row + ON context_row.operation_id = operation_row.operation_id + AND context_row.generation = v_authority_generation + AND context_row.actor_id = v_actor + AND context_row.project_id = v_project_id + AND context_row.completed_at IS NOT NULL + WHERE operation_row.actor_id = v_actor + AND operation_row.through_generation = v_through_generation + AND operation_row.last_processed_generation = v_through_generation + AND operation_row.cumulative_count = v_through_generation + AND operation_row.state = 'complete' + AND operation_row.completed_at IS NOT NULL + ) THEN + RAISE EXCEPTION 'root authority generation was not completed by the dedicated operation/context lifecycle'; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.work_packages package_row + WHERE package_row.id = v_add + AND ( + package_row.status <> 'blocked' + OR package_row.blocked_reason <> 'Filesystem context requires an operator decision before execution.' + OR public.forge_is_canonical_filesystem_grant_block_v2(package_row.metadata->'mcpGrantBlock') IS NOT TRUE + OR package_row.metadata - 'mcpGrantBlock' IS DISTINCT FROM + '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb + OR package_row.metadata->'mcpGrantBlock'->'requestedCapabilities' <> '["filesystem.project.read","filesystem.project.search"]'::jsonb + ) + ) OR EXISTS ( + SELECT 1 FROM public.work_packages package_row + WHERE package_row.id = v_refresh + AND ( + package_row.status <> 'blocked' + OR package_row.blocked_reason <> 'Filesystem context requires an operator decision before execution.' + OR public.forge_is_canonical_filesystem_grant_block_v2(package_row.metadata->'mcpGrantBlock') IS NOT TRUE + OR package_row.metadata - 'mcpGrantBlock' IS DISTINCT FROM + '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}}}'::jsonb + OR package_row.metadata->'mcpGrantBlock'->'requestedCapabilities' <> '["filesystem.project.read","filesystem.project.search"]'::jsonb + ) + ) OR EXISTS ( + SELECT 1 FROM public.work_packages package_row + WHERE package_row.id = v_recover + AND ( + package_row.status <> 'ready' + OR package_row.blocked_reason IS NOT NULL + OR package_row.metadata ? 'mcpGrantBlock' + OR package_row.metadata IS DISTINCT FROM + '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}}}'::jsonb + ) + ) THEN + RAISE EXCEPTION 'root authority package marker, phase, or peer metadata convergence is not canonical'; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.tasks task_row + WHERE (task_row.id = v_task_running OR task_row.id = v_task_failed) + AND (task_row.status <> 'approved' OR task_row.error_message IS NOT NULL) + ) THEN + RAISE EXCEPTION 'root authority task convergence did not recover running and failed tasks'; + END IF; + + IF (SELECT count(*) FROM public.work_package_local_projection_heads head + JOIN public.work_package_local_projection_sources source_row + ON source_row.id = head.current_source_id + AND source_row.task_id = head.current_source_task_id + AND source_row.work_package_id = head.current_source_work_package_id + AND source_row.source_kind = head.current_source_kind + AND source_row.source_revision = head.current_source_revision + AND source_row.source_fingerprint = head.current_source_fingerprint + WHERE head.work_package_id IN (v_add, v_refresh, v_recover) + AND head.head_kind = 'operator_hold' + AND head.head_revision = 1 + AND head.current_source_kind = 'operator_hold' + AND head.current_source_revision = 1 + AND head.current_source_fingerprint ~ '^sha256:[0-9a-f]{64}$' + AND head.compare_and_set_fingerprint ~ '^sha256:[0-9a-f]{64}$' + AND source_row.contribution->>'transition' = CASE head.work_package_id + WHEN v_add THEN 'hold' WHEN v_refresh THEN 'refresh' WHEN v_recover THEN 'recovery' END + AND source_row.contribution->>'operatorHold' = CASE head.work_package_id + WHEN v_recover THEN 'false' ELSE 'true' END + ) <> 3 THEN + RAISE EXCEPTION 'root authority projection-head compare-and-set effects are incomplete'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM public.project_filesystem_current_decision_pointers pointer_row + WHERE pointer_row.project_id = v_project_id + AND pointer_row.current_decision_id = '27000000-0000-4000-8000-000000000702'::uuid + AND pointer_row.current_decision_project_id = v_project_id + AND pointer_row.current_decision_revision = 1 + AND pointer_row.current_root_binding_revision = 1 + AND pointer_row.current_decision_fingerprint = + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + AND pointer_row.pointer_generation = 1 + ) OR (SELECT count(*) FROM public.project_filesystem_grant_decisions WHERE project_id = v_project_id) <> 1 + OR EXISTS ( + SELECT 1 FROM public.filesystem_mcp_current_decision_pointers pointer_row + WHERE pointer_row.work_package_id IN (v_add, v_refresh, v_recover) + AND (pointer_row.current_decision_id IS NOT NULL OR pointer_row.pointer_version <> 0) + ) THEN + RAISE EXCEPTION 'root authority reconciliation mutated protected decision or pointer authority directly'; + END IF; +END; +$assertions$; From f462f60c12e920b22e33441a1c36b745ff9223c7 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:20:35 +0800 Subject: [PATCH 25/53] test: serialize root proof reconciliation --- .../project-root-reconciliation.test.ts | 45 ++++--- ...tion-0027-root-authority-reconciliation.sh | 93 ++++++++------- ...ove-migration-0027-root-index-lifecycle.sh | 111 +++++++++++------- .../ci/prove-migration-0027-upgrade.sh | 11 +- ...ot-authority-reconciliation-assertions.sql | 21 +++- 5 files changed, 171 insertions(+), 110 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 9cd3503b..91b07f53 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -161,20 +161,17 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityPackageFixture).toContain('filesystem.project.search') }) - it('runs the bound-authority fixture through the real dedicated reconciler once', () => { + it('splits bound-authority fixture preparation from post-drain assertions', () => { expect(rootAuthorityProof).toContain('migration-0027-root-authority-project-fixture.sql') expect(rootAuthorityProof).toContain('migration-0027-root-authority-package-fixture.sql') expect(rootAuthorityProof).toContain("UPDATE public.projects SET root_ref") expect(rootAuthorityProof).toContain("outcome = 'root_update'") - expect(rootAuthorityProof).toContain("SELECT session_user") - expect(rootAuthorityProof).toContain('forge_project_root_reconciler') - expect(rootAuthorityProof.match(/bash scripts\/ci\/reconcile-migration-0027-root-refs\.sh/g)).toHaveLength(1) + expect(rootAuthorityProof).toContain('--prepare|--assert') + expect(rootAuthorityProof).toContain('case "$phase" in') + expect(rootAuthorityProof).not.toContain('reconcile-migration-0027-root-refs.sh') expect(rootAuthorityProof).toContain('migration-0027-root-authority-reconciliation-assertions.sql') - expect(upgradeProof).toContain('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh') - expect(upgradeProof).not.toContain('bash scripts/ci/reconcile-migration-0027-root-refs.sh') - expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh')).toBeLessThan( - upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), - ) + expect(upgradeProof).toContain('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare') + expect(upgradeProof).toContain('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert') }) it('asserts canonical package/task/head effects without direct protected authority mutation', () => { @@ -186,6 +183,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityAssertions).toContain('mcpGrantPhases') expect(rootAuthorityAssertions).toContain('root authority reconciliation mutated protected decision or pointer authority directly') expect(rootAuthorityAssertions).toContain('root authority task convergence did not recover running and failed tasks') + expect(rootAuthorityAssertions).toContain('the single root reconciliation operation does not cover every prepared journal generation') }) it('selects phase suppression only in the internal root-journal caller', () => { @@ -224,14 +222,21 @@ describe('project-root expansion reconciliation boundary', () => { expect(advisoryLock).toBeLessThan(indexGuard) expect(indexGuard).toBeLessThan(coverageGuard) expect(coverageGuard).toBeLessThan(proofConstraint) - expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh')).toBeLessThan( - upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), - ) + const authorityPrepare = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare') + const indexPrepare = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare') + const reconcile = upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh') + const authorityAssert = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert') + const indexRecover = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --recover') + const cutover = upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply') + expect(authorityPrepare).toBeGreaterThanOrEqual(0) + expect(authorityPrepare).toBeLessThan(indexPrepare) + expect(indexPrepare).toBeLessThan(reconcile) + expect(reconcile).toBeLessThan(authorityAssert) + expect(authorityAssert).toBeLessThan(indexRecover) + expect(indexRecover).toBeLessThan(cutover) + expect(upgradeProof.match(/bash scripts\/ci\/reconcile-migration-0027-root-refs\.sh/g)).toHaveLength(1) expect(upgradeProof).not.toContain('FORGE_ROOT_INDEX_LIFECYCLE_PROOF') - expect(upgradeProof.match(/bash scripts\/ci\/prove-migration-0027-root-index-lifecycle\.sh/g)).toHaveLength(1) - expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh')).toBeLessThan( - upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply'), - ) + expect(upgradeProof.match(/bash scripts\/ci\/prove-migration-0027-root-index-lifecycle\.sh/g)).toHaveLength(2) expect(indexLifecycleProof).toContain("expect_failure \"$canonical_index_refusal\"") expect(indexLifecycleProof).toContain('grep -F -- "$expected_message" "$output" >/dev/null') expect(indexLifecycleProof).not.toContain('rg --fixed-strings') @@ -240,6 +245,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(indexLifecycleProof).toContain('projects_root_ref_idx exists with a noncanonical definition.') expect(indexLifecycleProof).toContain('could not create unique index "projects_root_ref_idx"') expect(indexLifecycleProof).toContain('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') + expect(indexLifecycleProof).toContain('--prepare|--recover') + expect(indexLifecycleProof).not.toContain('reconcile-migration-0027-root-refs.sh') + const authorityAssertBody = rootAuthorityProof.slice(rootAuthorityProof.indexOf(' --assert)')) + const indexRecoverBody = indexLifecycleProof.slice(indexLifecycleProof.indexOf(' --recover)')) + expect(authorityAssertBody).not.toContain('UPDATE public.projects') + expect(authorityAssertBody).not.toContain('INSERT INTO public.projects') + expect(indexRecoverBody).not.toContain('UPDATE public.projects') + expect(indexRecoverBody).not.toContain('INSERT INTO public.projects') expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') expect(webCi).toContain('Capture the post-drain root journal watermark') }) diff --git a/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh index 6d431435..97e85113 100755 --- a/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh +++ b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh @@ -1,54 +1,57 @@ #!/usr/bin/env bash set -euo pipefail -: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" - project_id='27000000-0000-4000-8000-000000000700' new_root_ref='27000000-0000-4000-8000-000000000703' actor_id='11111111-1111-4111-8111-111111111111' -# These fixtures are deliberately arranged by the disposable administrator. -# The only reconciliation execution below is the existing dedicated-login shim. -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ - --file scripts/ci/sql/migration-0027-root-authority-project-fixture.sql -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ - --file scripts/ci/sql/migration-0027-root-authority-package-fixture.sql - -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ - "UPDATE public.projects SET root_ref = '${new_root_ref}'::uuid WHERE id = '${project_id}'::uuid" - -authority_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ - "SELECT generation FROM public.project_root_change_journal WHERE project_id = '${project_id}'::uuid AND outcome = 'root_update' ORDER BY generation DESC LIMIT 1")" -if [[ ! "$authority_generation" =~ ^[1-9][0-9]*$ ]]; then - echo 'The authority fixture did not create an exact root-update journal generation.' >&2 - exit 1 -fi +usage() { + echo 'Usage: prove-migration-0027-root-authority-reconciliation.sh --prepare|--assert' >&2 + exit 2 +} -# This is a direct proof that the same URL used by the compatibility shim is -# the fixed, least-privilege reconciler login. The shim itself owns the sole -# post-drain watermark capture and production CLI invocation. -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ - "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null -authority="${FORGE_DATABASE_ADMIN_URL#*://}" -reconciler_url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" -if [[ "$(psql "$reconciler_url" --no-align --tuples-only --set ON_ERROR_STOP=1 --command 'SELECT session_user')" != 'forge_project_root_reconciler' ]]; then - echo 'The root-authority proof did not obtain the dedicated reconciler login.' >&2 - exit 1 -fi - -# Exactly one operation is created: the existing shim first drains any legacy -# null roots, captures its exact watermark, then runs the production CLI. -bash scripts/ci/reconcile-migration-0027-root-refs.sh - -through_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ - "SELECT through_generation FROM public.project_root_reconciliation_operations WHERE actor_id = '${actor_id}'::uuid ORDER BY created_at DESC LIMIT 1")" -if [[ ! "$through_generation" =~ ^[1-9][0-9]*$ ]] || (( authority_generation > through_generation )); then - echo 'The completed reconciliation operation does not cover the authority root-update generation.' >&2 - exit 1 -fi +phase="${1:-}" +case "$phase" in + --prepare|--assert) [[ $# -eq 1 ]] || usage ;; + *) usage ;; +esac +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ - --set authority_generation="$authority_generation" \ - --set through_generation="$through_generation" \ - --set actor_id="$actor_id" \ - --file scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +case "$phase" in + --prepare) + # These fixtures are deliberately arranged by the disposable administrator. + # Preparation writes journal rows only; the upgrade orchestrator owns the + # single post-drain reconciliation operation for every prepared fixture. + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --file scripts/ci/sql/migration-0027-root-authority-project-fixture.sql + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --file scripts/ci/sql/migration-0027-root-authority-package-fixture.sql + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "UPDATE public.projects SET root_ref = '${new_root_ref}'::uuid WHERE id = '${project_id}'::uuid" + authority_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT generation FROM public.project_root_change_journal WHERE project_id = '${project_id}'::uuid AND outcome = 'root_update' ORDER BY generation DESC LIMIT 1")" + if [[ ! "$authority_generation" =~ ^[1-9][0-9]*$ ]]; then + echo 'The authority fixture did not create an exact root-update journal generation.' >&2 + exit 1 + fi + ;; + --assert) + authority_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT generation FROM public.project_root_change_journal WHERE project_id = '${project_id}'::uuid AND outcome = 'root_update' ORDER BY generation DESC LIMIT 1")" + operation_row="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --set ON_ERROR_STOP=1 --command \ + "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id = '${actor_id}'::uuid ORDER BY created_at DESC LIMIT 1")" + operation_id="${operation_row%%|*}" + through_generation="${operation_row#*|}" + if [[ ! "$authority_generation" =~ ^[1-9][0-9]*$ ]] || [[ ! "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || [[ ! "$through_generation" =~ ^[1-9][0-9]*$ ]] || (( authority_generation > through_generation )); then + echo 'The completed reconciliation operation does not cover the authority root-update generation.' >&2 + exit 1 + fi + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --set authority_generation="$authority_generation" \ + --set through_generation="$through_generation" \ + --set operation_id="$operation_id" \ + --set actor_id="$actor_id" \ + --file scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql + ;; + *) usage ;; +esac diff --git a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh index f058288f..7de70061 100644 --- a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" - canonical_index_refusal='strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index' expect_failure() { @@ -37,57 +35,82 @@ assert_cutover_not_started() { fi } -# Reconciliation has completed, so the missing-index refusal must happen before -# any concurrent DDL. This proves cutover itself is the boundary, not the builder. -expect_failure "$canonical_index_refusal" \ - bash scripts/ci/cutover-migration-0027-root-ref.sh --apply -assert_cutover_not_started +usage() { + echo 'Usage: prove-migration-0027-root-index-lifecycle.sh --prepare|--recover' >&2 + exit 2 +} + +phase="${1:-}" +case "$phase" in + --prepare|--recover) [[ $# -eq 1 ]] || usage ;; + *) usage ;; +esac +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +case "$phase" in + --prepare) + # The index guard runs before watermark coverage, so this remains an exact + # cutover refusal even though preparation has not reconciled journal rows. + expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply + assert_cutover_not_started # A valid but structurally wrong same-name index is never disposable production # state. The production builder and cutover both refuse it without replacement. -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ - 'CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL' -wrong_index_before="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ - "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" -if [[ -z "$wrong_index_before" ]]; then - echo 'Failed to create the deterministic wrong root-reference index.' >&2 - exit 1 -fi -expect_failure 'projects_root_ref_idx exists with a noncanonical definition.' \ - npm run project-roots:build-concurrent-index -- --apply -expect_failure "$canonical_index_refusal" \ - bash scripts/ci/cutover-migration-0027-root-ref.sh --apply -assert_cutover_not_started -wrong_index_after="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ - "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" -if [[ "$wrong_index_after" != "$wrong_index_before" ]]; then - echo 'The valid wrong root-reference index was replaced or changed.' >&2 - exit 1 -fi -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ - 'DROP INDEX CONCURRENTLY public.projects_root_ref_idx' + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + 'CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL' + wrong_index_before="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ + "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" + if [[ -z "$wrong_index_before" ]]; then + echo 'Failed to create the deterministic wrong root-reference index.' >&2 + exit 1 + fi + expect_failure 'projects_root_ref_idx exists with a noncanonical definition.' \ + npm run project-roots:build-concurrent-index -- --apply + expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply + assert_cutover_not_started + wrong_index_after="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ + "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" + if [[ "$wrong_index_after" != "$wrong_index_before" ]]; then + echo 'The valid wrong root-reference index was replaced or changed.' >&2 + exit 1 + fi + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + 'DROP INDEX CONCURRENTLY public.projects_root_ref_idx' # The fixture deliberately makes the production concurrent unique build fail. # PostgreSQL retains the same-name invalid index, giving the builder's recovery # path a deterministic, non-timing-dependent input. -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 <<'SQL' + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 <<'SQL' INSERT INTO public.projects (id, name, submitted_by, root_ref) SELECT '27000000-0000-4000-8000-000000000050', 'Duplicate root index A', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; INSERT INTO public.projects (id, name, submitted_by, root_ref) SELECT '27000000-0000-4000-8000-000000000060', 'Duplicate root index B', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; SQL -expect_failure 'could not create unique index "projects_root_ref_idx"' \ - npm run project-roots:build-concurrent-index -- --apply -invalid_index="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ - "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND (NOT indisvalid OR NOT indisready))")" -if [[ "$invalid_index" != 't' ]]; then - echo 'The failed concurrent build did not retain the expected invalid index.' >&2 - exit 1 -fi -expect_failure "$canonical_index_refusal" \ - bash scripts/ci/cutover-migration-0027-root-ref.sh --apply -assert_cutover_not_started -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "UPDATE public.projects SET root_ref = '27000000-0000-4000-8000-0000000000bb'::uuid WHERE id = '27000000-0000-4000-8000-000000000060'" -bash scripts/ci/reconcile-migration-0027-root-refs.sh -npm run project-roots:build-concurrent-index -- --apply -npm run project-roots:build-concurrent-index -- --apply + expect_failure 'could not create unique index "projects_root_ref_idx"' \ + npm run project-roots:build-concurrent-index -- --apply + invalid_index="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND (NOT indisvalid OR NOT indisready))")" + if [[ "$invalid_index" != 't' ]]; then + echo 'The failed concurrent build did not retain the expected invalid index.' >&2 + exit 1 + fi + expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply + assert_cutover_not_started + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "UPDATE public.projects SET root_ref = '27000000-0000-4000-8000-0000000000bb'::uuid WHERE id = '27000000-0000-4000-8000-000000000060'" + ;; + --recover) + invalid_index="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND (NOT indisvalid OR NOT indisready))")" + if [[ "$invalid_index" != 't' ]]; then + echo 'The prepared invalid root-reference index was unexpectedly absent or valid.' >&2 + exit 1 + fi + npm run project-roots:build-concurrent-index -- --apply + npm run project-roots:build-concurrent-index -- --apply + ;; + *) usage ;; +esac diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 44048a59..8571717d 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -55,10 +55,13 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != exit 1 fi -# The authority proof arranges its protected graph under the disposable admin, -# then invokes the existing reconciler shim once through its dedicated login. -bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh -bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh +# Prepare every journal-producing fixture before the only post-drain reconcile. +# Assertions and index recovery are read/DDL-only after that completed operation. +bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare +bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare +bash scripts/ci/reconcile-migration-0027-root-refs.sh +bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert +bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --recover bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-cutover-assertions.sql diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql index 3be7e528..d0143d93 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -9,6 +9,7 @@ DECLARE v_refresh uuid := '27000000-0000-4000-8000-000000000712'::uuid; v_recover uuid := '27000000-0000-4000-8000-000000000721'::uuid; v_actor uuid := :'actor_id'::uuid; + v_operation_id uuid := :'operation_id'::uuid; v_authority_generation bigint := :'authority_generation'::bigint; v_through_generation bigint := :'through_generation'::bigint; BEGIN @@ -37,7 +38,8 @@ BEGIN AND context_row.actor_id = v_actor AND context_row.project_id = v_project_id AND context_row.completed_at IS NOT NULL - WHERE operation_row.actor_id = v_actor + WHERE operation_row.operation_id = v_operation_id + AND operation_row.actor_id = v_actor AND operation_row.through_generation = v_through_generation AND operation_row.last_processed_generation = v_through_generation AND operation_row.cumulative_count = v_through_generation @@ -47,6 +49,23 @@ BEGIN RAISE EXCEPTION 'root authority generation was not completed by the dedicated operation/context lifecycle'; END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.project_root_reconciliation_operations operation_row + WHERE operation_row.operation_id = v_operation_id + AND operation_row.through_generation = (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) + AND operation_row.last_processed_generation = operation_row.through_generation + AND operation_row.cumulative_count = operation_row.through_generation + AND operation_row.state = 'complete' + ) OR EXISTS ( + SELECT 1 FROM public.project_root_change_journal journal_row + LEFT JOIN public.project_root_reconciliation_outcomes outcome_row + ON outcome_row.generation = journal_row.generation + AND outcome_row.operation_id = v_operation_id + WHERE outcome_row.generation IS NULL + ) THEN + RAISE EXCEPTION 'the single root reconciliation operation does not cover every prepared journal generation'; + END IF; + IF EXISTS ( SELECT 1 FROM public.work_packages package_row WHERE package_row.id = v_add From bdc3b493d4e5fc287de9abdb52392cbb9c03f08e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:22:32 +0800 Subject: [PATCH 26/53] fix: type root index classifier state --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/scripts/build-project-root-ref-index.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 91b07f53..ac903424 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -204,6 +204,8 @@ describe('project-root expansion reconciliation boundary', () => { it('keeps concurrent index DDL separate and strict cutover watermark-fenced', () => { expect(indexScript).toContain('CREATE UNIQUE INDEX CONCURRENTLY') + expect(indexScript).toContain('type IndexState =') + expect(indexScript).toContain('let index: IndexState | undefined = (await inspect())[0]') expect(indexScript).toContain('FORGE_DATABASE_ADMIN_URL') expect(reconcileScript).not.toContain('FORGE_DATABASE_ADMIN_URL') expect(cutoverScript).toContain('--through --apply') diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts index 0f366260..7d61eef3 100644 --- a/web/scripts/build-project-root-ref-index.ts +++ b/web/scripts/build-project-root-ref-index.ts @@ -1,6 +1,13 @@ import '../lib/load-env' import postgres from 'postgres' +type IndexState = { + exists: boolean + canonical: boolean + valid: boolean + ready: boolean +} + async function main(): Promise { if (process.argv.slice(2).join(' ') !== '--apply') { throw new Error('Concurrent root-reference index creation is actionless without --apply.') @@ -10,7 +17,7 @@ async function main(): Promise { const admin = postgres(adminUrl, { max: 1, onnotice: () => {} }) try { await admin`select pg_catalog.pg_advisory_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))` - const inspect = async () => admin<{ exists: boolean; canonical: boolean; valid: boolean; ready: boolean }[]>` + const inspect = async () => admin` with candidate as (select pg_catalog.to_regclass('public.projects_root_ref_idx') as index_oid) select (candidate.index_oid is not null) as exists, coalesce(idx.indisvalid, false) as valid, coalesce(idx.indisready, false) as ready, @@ -18,7 +25,7 @@ async function main(): Promise { and pg_catalog.pg_get_indexdef(idx.indexrelid) = 'CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects USING btree (root_ref) WHERE (root_ref IS NOT NULL)', false) as canonical from candidate left join pg_catalog.pg_index idx on idx.indexrelid = candidate.index_oid ` - let [index] = await inspect() + let index: IndexState | undefined = (await inspect())[0] if (index?.exists && !index.canonical && index.valid && index.ready) throw new Error('projects_root_ref_idx exists with a noncanonical definition.') if (index?.exists && (!index.valid || !index.ready)) { await admin.unsafe('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') From 368d28d7cb81bdac54350387ff2afa5b3415b1e9 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:26:28 +0800 Subject: [PATCH 27/53] test: prove root reconciliation rollback --- .../project-root-reconciliation.test.ts | 14 ++++++++ ...ation-0027-root-reconciliation-negative.sh | 36 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 3 ++ ...oot-reconciliation-negative-assertions.sql | 12 +++++++ 4 files changed, 65 insertions(+) create mode 100755 web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh create mode 100644 web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ac903424..ea6c7c0a 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -19,6 +19,7 @@ const rootAuthorityProjectFixture = readFileSync(fileURLToPath(new URL('../scrip const rootAuthorityPackageFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-package-fixture.sql', import.meta.url)), 'utf8') const rootAuthorityProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-authority-reconciliation.sh', import.meta.url)), 'utf8') const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql', import.meta.url)), 'utf8') +const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -186,6 +187,19 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityAssertions).toContain('the single root reconciliation operation does not cover every prepared journal generation') }) + it('keeps the dedicated pre-reconciliation rollback proof bounded and resumable', () => { + expect(negativeProof).toContain('forge.begin_project_root_reconciliation_v1') + expect(negativeProof).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(negativeProof).toContain('SELECT 1/0') + expect(negativeProof).toContain('project-root operation identity cannot be hijacked') + expect(negativeProof).toContain('project-root authority lock has no active write context') + expect(negativeProof).toContain('migration-0027-root-reconciliation-negative-assertions.sql') + expect(negativeProof).not.toContain('reconcile-migration-0027-root-refs.sh') + expect(upgradeProof.indexOf('prove-migration-0027-root-reconciliation-negative.sh')).toBeLessThan( + upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh'), + ) + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh new file mode 100755 index 00000000..ffbdd59a --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +actor='11111111-1111-4111-8111-111111111111' +task='27000000-0000-4000-8000-000000000730' +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +while :; do + rows="$(psql "$url" -At --set ON_ERROR_STOP=1 --command 'SELECT forge.materialize_project_root_ref_expansion_v1(100)')" + [[ "$rows" =~ ^[0-9]+$ ]] || { echo 'root materialization returned an invalid row count' >&2; exit 1; } + [[ "$rows" == 0 ]] && break +done +through="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton')" +gen_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command 'SELECT generation, project_id FROM public.project_root_change_journal WHERE generation=1')" +generation="${gen_project%%|*}"; project="${gen_project#*|}" +[[ "$generation" == 1 && "$project" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ ]] || { echo 'negative proof requires journal generation one and a positive watermark' >&2; exit 1; } +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "INSERT INTO public.tasks(id,project_id,submitted_by,title,prompt,status) SELECT '${task}'::uuid, '${project}'::uuid, submitted_by, 'Root rollback proof', 'rollback only', 'running' FROM public.projects WHERE id='${project}'::uuid" +operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, state, last_processed_generation FROM forge.begin_project_root_reconciliation_v1(NULL,'${actor}'::uuid,${through}::bigint)")" +operation_id="${operation%%|*}" +[[ "$operation" == *'|running|0' && "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof did not create the exact live operation' >&2; exit 1; } + +expect_failure() { local text="$1"; shift; local output; output="$(mktemp)"; if "$@" >"$output" 2>&1; then cat "$output"; unlink "$output"; exit 1; fi; grep -F -- "$text" "$output" >/dev/null || { cat "$output"; unlink "$output"; exit 1; }; unlink "$output"; } +expect_failure 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" +expect_failure 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" +expect_failure 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" +expect_failure 'project-root authority lock has no active write context' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.lock_project_root_reconciliation_authority_v1('${operation_id}'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" +expect_failure 'rollback sentinel' psql "$url" --set ON_ERROR_STOP=1 < 'running') + OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id=:'operation_id'::uuid AND generation=:'generation'::bigint) + OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE operation_id=:'operation_id'::uuid AND generation=:'generation'::bigint) + OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations WHERE operation_id=:'operation_id'::uuid AND state='running' AND last_processed_generation=0) + OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_checkpoints WHERE operation_id=:'operation_id'::uuid AND checkpoint_generation=0 AND last_processed_generation=0) THEN + RAISE EXCEPTION 'negative context rollback advanced protected reconciliation state'; + END IF; +END; +$proof$; From 62423f571dc82d3700454c47f19c2b64701abe1e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:29:59 +0800 Subject: [PATCH 28/53] fix: parse root authority package fixture --- .../ci/sql/migration-0027-root-authority-package-fixture.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql index 67b8df8f..e1d0607c 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -155,7 +155,6 @@ BEGIN '27000000-0000-4000-8000-000000000712'::uuid, '27000000-0000-4000-8000-000000000721'::uuid )) <> 2 - ) THEN RAISE EXCEPTION 'root authority package fixture has invalid marker or baseline metadata'; END IF; From 36bf6d7c199462dd0ca247f81c5819df3ef4cb0f Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:32:26 +0800 Subject: [PATCH 29/53] test: prove root reconciliation replay --- .../project-root-reconciliation.test.ts | 12 +++++++ ...gration-0027-root-reconciliation-replay.sh | 35 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + 3 files changed, 48 insertions(+) create mode 100755 web/scripts/ci/prove-migration-0027-root-reconciliation-replay.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ea6c7c0a..58c7b4b2 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -20,6 +20,7 @@ const rootAuthorityPackageFixture = readFileSync(fileURLToPath(new URL('../scrip const rootAuthorityProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-authority-reconciliation.sh', import.meta.url)), 'utf8') const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql', import.meta.url)), 'utf8') const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') +const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -200,6 +201,17 @@ describe('project-root expansion reconciliation boundary', () => { ) }) + it('replays the completed reconciliation through the exact dedicated CLI without mutation', () => { + expect(replayProof).toContain('"mode":"complete-replay"') + expect(replayProof).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') + expect(replayProof).toContain('project_root_reconciliation_write_contexts') + expect(replayProof).toContain('work_package_local_projection_heads') + expect(replayProof).toContain('Completed root reconciliation replay mutated protected state.') + expect(upgradeProof.indexOf('prove-migration-0027-root-reconciliation-replay.sh')).toBeGreaterThan( + upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh'), + ) + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-replay.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-replay.sh new file mode 100755 index 00000000..56558992 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-replay.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +actor='11111111-1111-4111-8111-111111111111' +operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" +operation_id="${operation%%|*}" +through="${operation#*|}" +[[ "$operation_id" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ ]] || { echo 'Completed root reconciliation operation is unavailable for replay proof.' >&2; exit 1; } + +snapshot() { + psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command " + WITH protected_rows AS ( + SELECT 'operation:' || to_jsonb(o)::text AS line FROM public.project_root_reconciliation_operations o WHERE o.operation_id='${operation_id}'::uuid + UNION ALL SELECT 'checkpoint:' || to_jsonb(c)::text FROM public.project_root_reconciliation_checkpoints c WHERE c.operation_id='${operation_id}'::uuid + UNION ALL SELECT 'context:' || to_jsonb(c)::text FROM public.project_root_reconciliation_write_contexts c WHERE c.operation_id='${operation_id}'::uuid + UNION ALL SELECT 'outcome:' || to_jsonb(o)::text FROM public.project_root_reconciliation_outcomes o WHERE o.operation_id='${operation_id}'::uuid + UNION ALL SELECT 'head:' || to_jsonb(h)::text FROM public.work_package_local_projection_heads h JOIN public.tasks t ON t.id=h.task_id WHERE t.project_id='27000000-0000-4000-8000-000000000700'::uuid + UNION ALL SELECT 'pointer:' || to_jsonb(p)::text FROM public.project_filesystem_current_decision_pointers p WHERE p.project_id='27000000-0000-4000-8000-000000000700'::uuid + UNION ALL SELECT 'package-pointer:' || to_jsonb(p)::text FROM public.filesystem_mcp_current_decision_pointers p JOIN public.tasks t ON t.id=p.task_id WHERE t.project_id='27000000-0000-4000-8000-000000000700'::uuid + UNION ALL SELECT 'task:' || to_jsonb(t)::text FROM public.tasks t WHERE t.project_id='27000000-0000-4000-8000-000000000700'::uuid + UNION ALL SELECT 'package:' || to_jsonb(p)::text FROM public.work_packages p JOIN public.tasks t ON t.id=p.task_id WHERE t.project_id='27000000-0000-4000-8000-000000000700'::uuid + ) SELECT count(*)::text || '|' || md5(coalesce(string_agg(line, E'\\n' ORDER BY line), '')) FROM protected_rows" +} + +before="$(snapshot)" +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +output="$(FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL="$url" npx tsx scripts/reconcile-project-root-expansion.ts --through "$through" --actor "$actor" --apply)" +grep -F -- '"mode":"complete-replay"' <<<"$output" >/dev/null || { echo "Unexpected root reconciliation replay result: $output" >&2; exit 1; } +after="$(snapshot)" +if [[ "$after" != "$before" ]]; then + echo 'Completed root reconciliation replay mutated protected state.' >&2 + exit 1 +fi diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 4b8daa39..755a87df 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -64,6 +64,7 @@ bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh bash scripts/ci/reconcile-migration-0027-root-refs.sh bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert +bash scripts/ci/prove-migration-0027-root-reconciliation-replay.sh bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --recover bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ From da51a83a7323925f15d18600aa98f3f8c1b8c836 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:34:36 +0800 Subject: [PATCH 30/53] test: prove root reconciler privileges --- .../project-root-reconciliation.test.ts | 9 ++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + ...-root-reconciler-privileges-assertions.sql | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 58c7b4b2..ba6926db 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -21,6 +21,7 @@ const rootAuthorityProof = readFileSync(fileURLToPath(new URL('../scripts/ci/pro const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql', import.meta.url)), 'utf8') const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') +const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -212,6 +213,14 @@ describe('project-root expansion reconciliation boundary', () => { ) }) + it('keeps the dedicated reconciler privilege proof catalog-driven and mandatory', () => { + expect(privilegeProof).toContain('has_table_privilege') + expect(privilegeProof).toContain('has_column_privilege') + expect(privilegeProof).toContain('has_function_privilege') + expect(privilegeProof).toContain('project_root_reconciliation_write_contexts') + expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 755a87df..7de0100a 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -59,6 +59,7 @@ fi # Assertions and index recovery are read/DDL-only after that completed operation. bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare +psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql # This creates (but never advances) the exact live operation. The shim resumes # it below after its now-empty materialization pass. bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql new file mode 100644 index 00000000..08627feb --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -0,0 +1,21 @@ +\set ON_ERROR_STOP on +DO $proof$ +DECLARE r pg_catalog.pg_roles%ROWTYPE; +BEGIN + SELECT * INTO STRICT r FROM pg_catalog.pg_roles WHERE rolname='forge_project_root_reconciler'; + IF NOT r.rolcanlogin OR r.rolinherit OR r.rolsuper OR r.rolcreaterole OR r.rolcreatedb OR r.rolreplication OR r.rolbypassrls + OR EXISTS (SELECT 1 FROM pg_catalog.pg_auth_members WHERE member='forge_project_root_reconciler'::regrole OR roleid='forge_project_root_reconciler'::regrole) THEN + RAISE EXCEPTION 'root reconciler role attributes or memberships exceed the closed contract'; + END IF; + IF NOT has_table_privilege('forge_project_root_reconciler','public.projects','SELECT') + OR NOT has_table_privilege('forge_project_root_reconciler','public.tasks','SELECT') + OR NOT has_column_privilege('forge_project_root_reconciler','public.tasks','status','UPDATE') + OR NOT has_column_privilege('forge_project_root_reconciler','public.work_packages','metadata','UPDATE') + OR has_table_privilege('forge_project_root_reconciler','public.tasks','INSERT, DELETE, TRUNCATE, UPDATE') + OR has_table_privilege('forge_project_root_reconciler','public.project_root_reconciliation_write_contexts','SELECT, INSERT, UPDATE, DELETE') + OR has_function_privilege('forge_project_root_reconciler','forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)','EXECUTE') IS NOT TRUE + OR has_function_privilege('forge_project_root_reconciler','forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid)','EXECUTE') IS NOT TRUE + OR has_function_privilege('forge_app_test','forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)','EXECUTE') + THEN RAISE EXCEPTION 'root reconciler catalog privileges do not match the closed runtime contract'; END IF; +END; +$proof$; From 9c59003d535f9186758d977e9959f616ab8b52c2 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:36:44 +0800 Subject: [PATCH 31/53] fix: assert root rollback error --- .../ci/prove-migration-0027-root-reconciliation-negative.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index ffbdd59a..7b10a34d 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -26,7 +26,9 @@ expect_failure 'project-root operation identity cannot be hijacked' psql "$url" expect_failure 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" expect_failure 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" expect_failure 'project-root authority lock has no active write context' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.lock_project_root_reconciliation_authority_v1('${operation_id}'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" -expect_failure 'rollback sentinel' psql "$url" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 02:39:04 +0800 Subject: [PATCH 32/53] test: prove stale root contexts --- .../project-root-reconciliation.test.ts | 11 +++++++++ ...prove-migration-0027-root-stale-context.sh | 24 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + 3 files changed, 36 insertions(+) create mode 100755 web/scripts/ci/prove-migration-0027-root-stale-context.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ba6926db..5a8d5e8d 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -22,6 +22,7 @@ const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/c const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') +const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -221,6 +222,16 @@ describe('project-root expansion reconciliation boundary', () => { expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') }) + it('rejects stale and fabricated root reconciliation context reuse before the CLI resumes', () => { + expect(staleContextProof).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(staleContextProof).toContain('forge.complete_project_root_reconciliation_generation_v1') + expect(staleContextProof).toContain("set_config('forge.project_root_context','forged',true)") + expect(staleContextProof).toContain('project-root write context is absent or stale') + expect(upgradeProof.indexOf('prove-migration-0027-root-stale-context.sh')).toBeLessThan( + upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh'), + ) + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-stale-context.sh b/web/scripts/ci/prove-migration-0027-root-stale-context.sh new file mode 100755 index 00000000..9121a172 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-stale-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" +actor='11111111-1111-4111-8111-111111111111'; task='27000000-0000-4000-8000-000000000730' +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, last_project_id FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" +operation_id="${operation%%|*}" +project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT project_id FROM public.project_root_change_journal WHERE generation=1')" +[[ "$operation_id" =~ ^[0-9a-f-]{36}$ && "$project" =~ ^[0-9a-f-]{36}$ ]] || { echo 'stale context proof lacks the prepared operation' >&2; exit 1; } +expect_failure() { local text="$1"; shift; local out; out="$(mktemp)"; if "$@" >"$out" 2>&1; then cat "$out"; unlink "$out"; exit 1; fi; grep -F -- "$text" "$out" >/dev/null || { cat "$out"; unlink "$out"; exit 1; }; unlink "$out"; } +# A valid context permits the fenced task transition only in this transaction; +# its deliberate abort makes the subsequent backend/session attempts stale. +expect_failure 'division by zero' psql "$url" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 02:41:10 +0800 Subject: [PATCH 33/53] fix: bind root negative assertion inputs --- .../project-root-reconciliation.test.ts | 8 +++++++ ...oot-reconciliation-negative-assertions.sql | 21 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 5a8d5e8d..bf0c8567 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -23,6 +23,7 @@ const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-mi const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') +const negativeAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -232,6 +233,13 @@ describe('project-root expansion reconciliation boundary', () => { ) }) + it('binds negative assertion inputs outside the dollar-quoted PL/pgSQL body', () => { + expect(negativeAssertions).toContain('CREATE TEMP TABLE root_negative_assertion_inputs') + expect(negativeAssertions).toContain("VALUES (:'operation_id'::uuid, :'task_id'::uuid, :'generation'::bigint)") + expect(negativeAssertions).toContain('FROM root_negative_assertion_inputs') + expect(negativeAssertions).not.toContain("id=:'task_id'") + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql index 7a2a9408..8a1e220c 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql @@ -1,11 +1,22 @@ \set ON_ERROR_STOP on +CREATE TEMP TABLE root_negative_assertion_inputs ( + operation_id uuid NOT NULL, + task_id uuid NOT NULL, + generation bigint NOT NULL CHECK (generation > 0) +) ON COMMIT DROP; +INSERT INTO root_negative_assertion_inputs (operation_id, task_id, generation) +VALUES (:'operation_id'::uuid, :'task_id'::uuid, :'generation'::bigint); + DO $proof$ +DECLARE v_operation_id uuid; v_task_id uuid; v_generation bigint; BEGIN - IF EXISTS (SELECT 1 FROM public.tasks WHERE id=:'task_id'::uuid AND status <> 'running') - OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id=:'operation_id'::uuid AND generation=:'generation'::bigint) - OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE operation_id=:'operation_id'::uuid AND generation=:'generation'::bigint) - OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations WHERE operation_id=:'operation_id'::uuid AND state='running' AND last_processed_generation=0) - OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_checkpoints WHERE operation_id=:'operation_id'::uuid AND checkpoint_generation=0 AND last_processed_generation=0) THEN + SELECT operation_id, task_id, generation INTO STRICT v_operation_id, v_task_id, v_generation + FROM root_negative_assertion_inputs; + IF EXISTS (SELECT 1 FROM public.tasks WHERE id=v_task_id AND status <> 'running') + OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id=v_operation_id AND generation=v_generation) + OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE operation_id=v_operation_id AND generation=v_generation) + OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations WHERE operation_id=v_operation_id AND state='running' AND last_processed_generation=0) + OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_checkpoints WHERE operation_id=v_operation_id AND checkpoint_generation=0 AND last_processed_generation=0) THEN RAISE EXCEPTION 'negative context rollback advanced protected reconciliation state'; END IF; END; From a855c8a1c12fafbc265a28876d74198630904367 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:43:52 +0800 Subject: [PATCH 34/53] test: prove root reconciliation contention --- .../project-root-reconciliation.test.ts | 9 ++++++ .../prove-migration-0027-root-contention.sh | 30 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + 3 files changed, 40 insertions(+) create mode 100755 web/scripts/ci/prove-migration-0027-root-contention.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index bf0c8567..3318818d 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -24,6 +24,7 @@ const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migr const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') const negativeAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql', import.meta.url)), 'utf8') +const contentionProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-contention.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -240,6 +241,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(negativeAssertions).not.toContain("id=:'task_id'") }) + it('uses two dedicated sessions and bounded lock-timeout contention before the CLI resumes', () => { + expect(contentionProof).toContain('forge.claim_project_root_reconciliation_batch_v1') + expect(contentionProof).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(contentionProof).toContain("lock_timeout='250ms'") + expect(contentionProof).toContain('canceling statement due to lock timeout') + expect(upgradeProof.indexOf('prove-migration-0027-root-contention.sh')).toBeLessThan(upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh')) + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-contention.sh b/web/scripts/ci/prove-migration-0027-root-contention.sh new file mode 100755 index 00000000..3c54b1c8 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-contention.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" +actor='11111111-1111-4111-8111-111111111111' +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" +op="${operation%%|*}"; through="${operation#*|}" +project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT project_id FROM public.project_root_change_journal WHERE generation=1')" +[[ "$op" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ && "$project" =~ ^[0-9a-f-]{36}$ ]] || { echo 'contention proof lacks a live operation' >&2; exit 1; } +barrier="$(mktemp -d)"; ready="$barrier/ready"; release="$barrier/release"; winner_out="$barrier/winner"; loser_out="$barrier/loser" +cleanup() { [[ -n "${winner_pid:-}" ]] && kill "$winner_pid" 2>/dev/null || true; rm -rf "$barrier"; } +trap cleanup EXIT +( psql "$url" --set ON_ERROR_STOP=1 <"$winner_out" 2>&1 & winner_pid=$! +for _ in $(seq 1 100); do [[ -f "$ready" ]] && break; sleep 0.05; done +[[ -f "$ready" ]] || { cat "$winner_out" >&2; exit 1; } +if psql "$url" --set ON_ERROR_STOP=1 --command "BEGIN; SET LOCAL lock_timeout='250ms'; SET LOCAL statement_timeout='1s'; SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${actor}'::uuid,1); COMMIT;" >"$loser_out" 2>&1; then cat "$loser_out" >&2; exit 1; fi +grep -F -- 'canceling statement due to lock timeout' "$loser_out" >/dev/null || { cat "$loser_out" >&2; exit 1; } +touch "$release"; wait "$winner_pid"; unset winner_pid +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "DO \$\$ BEGIN IF (SELECT count(*) FROM public.project_root_reconciliation_outcomes WHERE operation_id='${op}'::uuid AND generation=1) <> 1 OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id='${op}'::uuid AND generation=1 AND completed_at IS NULL) THEN RAISE EXCEPTION 'contention proof left an ambiguous reconciliation lineage'; END IF; END \$\$;" diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 5fc7106d..f665b647 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -64,6 +64,7 @@ psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file scripts/ci/sql/m # it below after its now-empty materialization pass. bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh bash scripts/ci/prove-migration-0027-root-stale-context.sh +bash scripts/ci/prove-migration-0027-root-contention.sh bash scripts/ci/reconcile-migration-0027-root-refs.sh bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert bash scripts/ci/prove-migration-0027-root-reconciliation-replay.sh From 758c579bb4e2a569ffc2c0451399342a429f2e3e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:46:17 +0800 Subject: [PATCH 35/53] test: keep negative reconciliation inputs transactional --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ .../migration-0027-root-reconciliation-negative-assertions.sql | 2 ++ 2 files changed, 4 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 3318818d..16fec0b6 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -235,9 +235,11 @@ describe('project-root expansion reconciliation boundary', () => { }) it('binds negative assertion inputs outside the dollar-quoted PL/pgSQL body', () => { + expect(negativeAssertions).toContain('BEGIN;') expect(negativeAssertions).toContain('CREATE TEMP TABLE root_negative_assertion_inputs') expect(negativeAssertions).toContain("VALUES (:'operation_id'::uuid, :'task_id'::uuid, :'generation'::bigint)") expect(negativeAssertions).toContain('FROM root_negative_assertion_inputs') + expect(negativeAssertions).toContain('COMMIT;') expect(negativeAssertions).not.toContain("id=:'task_id'") }) diff --git a/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql index 8a1e220c..1a541d26 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql @@ -1,4 +1,5 @@ \set ON_ERROR_STOP on +BEGIN; CREATE TEMP TABLE root_negative_assertion_inputs ( operation_id uuid NOT NULL, task_id uuid NOT NULL, @@ -21,3 +22,4 @@ BEGIN END IF; END; $proof$; +COMMIT; From ec91143b7d654ab1361c20a0cfc017f5da3733ad Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:53:13 +0800 Subject: [PATCH 36/53] test: diagnose root proof psql failures --- .../project-root-reconciliation.test.ts | 8 +++++++ .../prove-migration-0027-root-contention.sh | 23 +++++++++++-------- ...ation-0027-root-reconciliation-negative.sh | 2 +- ...prove-migration-0027-root-stale-context.sh | 2 +- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 16fec0b6..1b5137b8 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -248,9 +248,17 @@ describe('project-root expansion reconciliation boundary', () => { expect(contentionProof).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(contentionProof).toContain("lock_timeout='250ms'") expect(contentionProof).toContain('canceling statement due to lock timeout') + expect(contentionProof).toContain("[[ \"$loser_status\" == 3 ]]") + expect(contentionProof).toContain("'${outcome}'") + expect(contentionProof).not.toContain('FROM public.project_root_change_journal WHERE generation=1));') expect(upgradeProof.indexOf('prove-migration-0027-root-contention.sh')).toBeLessThan(upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh')) }) + it('requires every deliberate negative psql probe to return SQL-error exit status three', () => { + expect(negativeProof).toContain('expected psql exit 3') + expect(staleContextProof).toContain('expected psql exit 3') + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-contention.sh b/web/scripts/ci/prove-migration-0027-root-contention.sh index 3c54b1c8..d6af3bd3 100755 --- a/web/scripts/ci/prove-migration-0027-root-contention.sh +++ b/web/scripts/ci/prove-migration-0027-root-contention.sh @@ -4,10 +4,13 @@ set -euo pipefail actor='11111111-1111-4111-8111-111111111111' authority="${FORGE_DATABASE_ADMIN_URL#*://}" url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" -operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" +fail_phase() { local phase="$1" output="$2"; echo "root contention proof failed during ${phase}" >&2; [[ -f "$output" ]] && cat "$output" >&2; exit 1; } +run_success() { local phase="$1"; shift; local output; output="$(mktemp)"; if ! "$@" >"$output" 2>&1; then fail_phase "$phase" "$output"; fi; cat "$output"; unlink "$output"; } +operation="$(run_success 'load live operation' psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" op="${operation%%|*}"; through="${operation#*|}" -project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT project_id FROM public.project_root_change_journal WHERE generation=1')" -[[ "$op" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ && "$project" =~ ^[0-9a-f-]{36}$ ]] || { echo 'contention proof lacks a live operation' >&2; exit 1; } +generation_row="$(run_success 'load generation-one journal outcome' psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command 'SELECT project_id, outcome FROM public.project_root_change_journal WHERE generation=1')" +project="${generation_row%%|*}"; outcome="${generation_row#*|}" +[[ "$op" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ && "$project" =~ ^[0-9a-f-]{36}$ && "$outcome" =~ ^(insert|root_update|archive)$ ]] || { echo 'contention proof lacks a live operation or closed journal outcome' >&2; exit 1; } barrier="$(mktemp -d)"; ready="$barrier/ready"; release="$barrier/release"; winner_out="$barrier/winner"; loser_out="$barrier/loser" cleanup() { [[ -n "${winner_pid:-}" ]] && kill "$winner_pid" 2>/dev/null || true; rm -rf "$barrier"; } trap cleanup EXIT @@ -18,13 +21,15 @@ SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${ SELECT forge.enter_project_root_reconciliation_generation_v1('${op}'::uuid,'${actor}'::uuid,1,'${project}'::uuid); \! touch '${ready}' \! while [ ! -f '${release}' ]; do sleep 0.05; done -SELECT * FROM forge.complete_project_root_reconciliation_generation_v1('${op}'::uuid,'${actor}'::uuid,1,'${project}'::uuid,(SELECT outcome FROM public.project_root_change_journal WHERE generation=1)); +SELECT * FROM forge.complete_project_root_reconciliation_generation_v1('${op}'::uuid,'${actor}'::uuid,1,'${project}'::uuid,'${outcome}'); COMMIT; SQL ) >"$winner_out" 2>&1 & winner_pid=$! for _ in $(seq 1 100); do [[ -f "$ready" ]] && break; sleep 0.05; done -[[ -f "$ready" ]] || { cat "$winner_out" >&2; exit 1; } -if psql "$url" --set ON_ERROR_STOP=1 --command "BEGIN; SET LOCAL lock_timeout='250ms'; SET LOCAL statement_timeout='1s'; SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${actor}'::uuid,1); COMMIT;" >"$loser_out" 2>&1; then cat "$loser_out" >&2; exit 1; fi -grep -F -- 'canceling statement due to lock timeout' "$loser_out" >/dev/null || { cat "$loser_out" >&2; exit 1; } -touch "$release"; wait "$winner_pid"; unset winner_pid -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "DO \$\$ BEGIN IF (SELECT count(*) FROM public.project_root_reconciliation_outcomes WHERE operation_id='${op}'::uuid AND generation=1) <> 1 OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id='${op}'::uuid AND generation=1 AND completed_at IS NULL) THEN RAISE EXCEPTION 'contention proof left an ambiguous reconciliation lineage'; END IF; END \$\$;" +[[ -f "$ready" ]] || fail_phase 'wait for winner context barrier' "$winner_out" +if psql "$url" --set ON_ERROR_STOP=1 --command "BEGIN; SET LOCAL lock_timeout='250ms'; SET LOCAL statement_timeout='1s'; SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${actor}'::uuid,1); COMMIT;" >"$loser_out" 2>&1; then fail_phase 'require loser lock-timeout rejection' "$loser_out"; else loser_status=$?; fi +[[ "$loser_status" == 3 ]] || { echo "root contention proof expected loser psql exit 3, got ${loser_status}" >&2; fail_phase 'require loser lock-timeout rejection' "$loser_out"; } +grep -F -- 'canceling statement due to lock timeout' "$loser_out" >/dev/null || fail_phase 'require loser lock-timeout rejection' "$loser_out" +touch "$release" +if wait "$winner_pid"; then unset winner_pid; else winner_status=$?; echo "root contention proof winner psql exited ${winner_status}" >&2; fail_phase 'complete winner reconciliation' "$winner_out"; fi +run_success 'assert one completed contention lineage' psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "DO \$\$ BEGIN IF (SELECT count(*) FROM public.project_root_reconciliation_outcomes WHERE operation_id='${op}'::uuid AND generation=1) <> 1 OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id='${op}'::uuid AND generation=1 AND completed_at IS NULL) THEN RAISE EXCEPTION 'contention proof left an ambiguous reconciliation lineage'; END IF; END \$\$;" >/dev/null diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index 7b10a34d..3223adf2 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -21,7 +21,7 @@ operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --comma operation_id="${operation%%|*}" [[ "$operation" == *'|running|0' && "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof did not create the exact live operation' >&2; exit 1; } -expect_failure() { local text="$1"; shift; local output; output="$(mktemp)"; if "$@" >"$output" 2>&1; then cat "$output"; unlink "$output"; exit 1; fi; grep -F -- "$text" "$output" >/dev/null || { cat "$output"; unlink "$output"; exit 1; }; unlink "$output"; } +expect_failure() { local text="$1"; shift; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == 3 ]] || { echo "negative reconciliation proof expected psql exit 3 for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } expect_failure 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" expect_failure 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" expect_failure 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" diff --git a/web/scripts/ci/prove-migration-0027-root-stale-context.sh b/web/scripts/ci/prove-migration-0027-root-stale-context.sh index 9121a172..8d163d06 100755 --- a/web/scripts/ci/prove-migration-0027-root-stale-context.sh +++ b/web/scripts/ci/prove-migration-0027-root-stale-context.sh @@ -8,7 +8,7 @@ operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON operation_id="${operation%%|*}" project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT project_id FROM public.project_root_change_journal WHERE generation=1')" [[ "$operation_id" =~ ^[0-9a-f-]{36}$ && "$project" =~ ^[0-9a-f-]{36}$ ]] || { echo 'stale context proof lacks the prepared operation' >&2; exit 1; } -expect_failure() { local text="$1"; shift; local out; out="$(mktemp)"; if "$@" >"$out" 2>&1; then cat "$out"; unlink "$out"; exit 1; fi; grep -F -- "$text" "$out" >/dev/null || { cat "$out"; unlink "$out"; exit 1; }; unlink "$out"; } +expect_failure() { local text="$1"; shift; local out status; out="$(mktemp)"; if "$@" >"$out" 2>&1; then echo "stale reconciliation proof expected psql failure: ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; else status=$?; fi; [[ "$status" == 3 ]] || { echo "stale reconciliation proof expected psql exit 3 for ${text}, got ${status}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; grep -F -- "$text" "$out" >/dev/null || { echo "stale reconciliation proof received the wrong psql error for ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; unlink "$out"; } # A valid context permits the fenced task transition only in this transaction; # its deliberate abort makes the subsequent backend/session attempts stale. expect_failure 'division by zero' psql "$url" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 02:59:32 +0800 Subject: [PATCH 37/53] test: distinguish root proof psql statuses --- web/__tests__/project-root-reconciliation.test.ts | 10 ++++++---- .../ci/prove-migration-0027-root-contention.sh | 2 +- ...ve-migration-0027-root-reconciliation-negative.sh | 12 ++++++------ .../ci/prove-migration-0027-root-stale-context.sh | 10 +++++----- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 1b5137b8..36de0005 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -248,15 +248,17 @@ describe('project-root expansion reconciliation boundary', () => { expect(contentionProof).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(contentionProof).toContain("lock_timeout='250ms'") expect(contentionProof).toContain('canceling statement due to lock timeout') - expect(contentionProof).toContain("[[ \"$loser_status\" == 3 ]]") + expect(contentionProof).toContain("[[ \"$loser_status\" == 1 ]]") expect(contentionProof).toContain("'${outcome}'") expect(contentionProof).not.toContain('FROM public.project_root_change_journal WHERE generation=1));') expect(upgradeProof.indexOf('prove-migration-0027-root-contention.sh')).toBeLessThan(upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh')) }) - it('requires every deliberate negative psql probe to return SQL-error exit status three', () => { - expect(negativeProof).toContain('expected psql exit 3') - expect(staleContextProof).toContain('expected psql exit 3') + it('requires invocation-specific psql exit statuses for command and script rejections', () => { + expect(negativeProof).toContain("expect_failure 1 'project-root operation identity cannot be hijacked'") + expect(negativeProof).toContain("expect_failure 3 'division by zero'") + expect(staleContextProof).toContain("expect_failure 1 'project-root task update has no active write context'") + expect(staleContextProof).toContain("expect_failure 3 'division by zero'") }) it('selects phase suppression only in the internal root-journal caller', () => { diff --git a/web/scripts/ci/prove-migration-0027-root-contention.sh b/web/scripts/ci/prove-migration-0027-root-contention.sh index d6af3bd3..40399096 100755 --- a/web/scripts/ci/prove-migration-0027-root-contention.sh +++ b/web/scripts/ci/prove-migration-0027-root-contention.sh @@ -28,7 +28,7 @@ SQL for _ in $(seq 1 100); do [[ -f "$ready" ]] && break; sleep 0.05; done [[ -f "$ready" ]] || fail_phase 'wait for winner context barrier' "$winner_out" if psql "$url" --set ON_ERROR_STOP=1 --command "BEGIN; SET LOCAL lock_timeout='250ms'; SET LOCAL statement_timeout='1s'; SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${actor}'::uuid,1); COMMIT;" >"$loser_out" 2>&1; then fail_phase 'require loser lock-timeout rejection' "$loser_out"; else loser_status=$?; fi -[[ "$loser_status" == 3 ]] || { echo "root contention proof expected loser psql exit 3, got ${loser_status}" >&2; fail_phase 'require loser lock-timeout rejection' "$loser_out"; } +[[ "$loser_status" == 1 ]] || { echo "root contention proof expected command-mode loser psql exit 1, got ${loser_status}" >&2; fail_phase 'require loser lock-timeout rejection' "$loser_out"; } grep -F -- 'canceling statement due to lock timeout' "$loser_out" >/dev/null || fail_phase 'require loser lock-timeout rejection' "$loser_out" touch "$release" if wait "$winner_pid"; then unset winner_pid; else winner_status=$?; echo "root contention proof winner psql exited ${winner_status}" >&2; fail_phase 'complete winner reconciliation' "$winner_out"; fi diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index 3223adf2..e0490ef1 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -21,14 +21,14 @@ operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --comma operation_id="${operation%%|*}" [[ "$operation" == *'|running|0' && "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof did not create the exact live operation' >&2; exit 1; } -expect_failure() { local text="$1"; shift; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == 3 ]] || { echo "negative reconciliation proof expected psql exit 3 for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } -expect_failure 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" -expect_failure 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" -expect_failure 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" -expect_failure 'project-root authority lock has no active write context' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.lock_project_root_reconciliation_authority_v1('${operation_id}'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" +expect_failure() { local expected_status="$1" text="$2"; shift 2; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == "$expected_status" ]] || { echo "negative reconciliation proof expected psql exit ${expected_status} for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } +expect_failure 1 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" +expect_failure 1 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" +expect_failure 1 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" +expect_failure 1 'project-root authority lock has no active write context' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.lock_project_root_reconciliation_authority_v1('${operation_id}'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" # psql must stop on this exact server error. The following admin psql command # is a fresh session and proves the aborted transaction left no durable state. -expect_failure 'division by zero' psql "$url" --set ON_ERROR_STOP=1 <&2; exit 1; } -expect_failure() { local text="$1"; shift; local out status; out="$(mktemp)"; if "$@" >"$out" 2>&1; then echo "stale reconciliation proof expected psql failure: ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; else status=$?; fi; [[ "$status" == 3 ]] || { echo "stale reconciliation proof expected psql exit 3 for ${text}, got ${status}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; grep -F -- "$text" "$out" >/dev/null || { echo "stale reconciliation proof received the wrong psql error for ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; unlink "$out"; } +expect_failure() { local expected_status="$1" text="$2"; shift 2; local out status; out="$(mktemp)"; if "$@" >"$out" 2>&1; then echo "stale reconciliation proof expected psql failure: ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; else status=$?; fi; [[ "$status" == "$expected_status" ]] || { echo "stale reconciliation proof expected psql exit ${expected_status} for ${text}, got ${status}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; grep -F -- "$text" "$out" >/dev/null || { echo "stale reconciliation proof received the wrong psql error for ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; unlink "$out"; } # A valid context permits the fenced task transition only in this transaction; # its deliberate abort makes the subsequent backend/session attempts stale. -expect_failure 'division by zero' psql "$url" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 03:03:41 +0800 Subject: [PATCH 38/53] test: bind root authority assertion inputs --- .../project-root-reconciliation.test.ts | 6 ++++++ ...ot-authority-reconciliation-assertions.sql | 21 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 36de0005..e4e69ff0 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -181,6 +181,12 @@ describe('project-root expansion reconciliation boundary', () => { }) it('asserts canonical package/task/head effects without direct protected authority mutation', () => { + expect(rootAuthorityAssertions).toContain('BEGIN;') + expect(rootAuthorityAssertions).toContain('CREATE TEMP TABLE root_authority_assertion_inputs') + expect(rootAuthorityAssertions).toContain("VALUES (:'actor_id'::uuid, :'operation_id'::uuid, :'authority_generation'::bigint, :'through_generation'::bigint)") + expect(rootAuthorityAssertions).toContain('FROM root_authority_assertion_inputs') + expect(rootAuthorityAssertions).toContain('COMMIT;') + expect(rootAuthorityAssertions).not.toContain("v_actor uuid := :'actor_id'::uuid") expect(rootAuthorityAssertions).toContain('project_root_reconciliation_write_contexts') expect(rootAuthorityAssertions).toContain("source_row.contribution->>'transition'") expect(rootAuthorityAssertions).toContain("WHEN v_add THEN 'hold' WHEN v_refresh THEN 'refresh' WHEN v_recover THEN 'recovery'") diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql index d0143d93..e681441b 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -1,4 +1,13 @@ \set ON_ERROR_STOP on +BEGIN; +CREATE TEMP TABLE root_authority_assertion_inputs ( + actor_id uuid NOT NULL, + operation_id uuid NOT NULL, + authority_generation bigint NOT NULL CHECK (authority_generation > 0), + through_generation bigint NOT NULL CHECK (through_generation > 0) +) ON COMMIT DROP; +INSERT INTO root_authority_assertion_inputs (actor_id, operation_id, authority_generation, through_generation) +VALUES (:'actor_id'::uuid, :'operation_id'::uuid, :'authority_generation'::bigint, :'through_generation'::bigint); DO $assertions$ DECLARE @@ -8,11 +17,14 @@ DECLARE v_add uuid := '27000000-0000-4000-8000-000000000711'::uuid; v_refresh uuid := '27000000-0000-4000-8000-000000000712'::uuid; v_recover uuid := '27000000-0000-4000-8000-000000000721'::uuid; - v_actor uuid := :'actor_id'::uuid; - v_operation_id uuid := :'operation_id'::uuid; - v_authority_generation bigint := :'authority_generation'::bigint; - v_through_generation bigint := :'through_generation'::bigint; + v_actor uuid; + v_operation_id uuid; + v_authority_generation bigint; + v_through_generation bigint; BEGIN + SELECT actor_id, operation_id, authority_generation, through_generation + INTO STRICT v_actor, v_operation_id, v_authority_generation, v_through_generation + FROM root_authority_assertion_inputs; IF NOT EXISTS ( SELECT 1 FROM public.project_root_change_journal journal_row @@ -153,3 +165,4 @@ BEGIN END IF; END; $assertions$; +COMMIT; From b6d92aa5a5463d5dcf91feca831e6a68252764ee Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:09:24 +0800 Subject: [PATCH 39/53] test: align root authority recovery fixture --- .../project-root-reconciliation.test.ts | 6 +++ ...on-0027-root-authority-package-fixture.sql | 5 +- ...ot-authority-reconciliation-assertions.sql | 49 +++++++++++++++++-- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index e4e69ff0..6ed48ca6 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -196,6 +196,12 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityAssertions).toContain('root authority reconciliation mutated protected decision or pointer authority directly') expect(rootAuthorityAssertions).toContain('root authority task convergence did not recover running and failed tasks') expect(rootAuthorityAssertions).toContain('the single root reconciliation operation does not cover every prepared journal generation') + expect(rootAuthorityAssertions).toContain('root authority addition package convergence is not canonical') + expect(rootAuthorityAssertions).toContain('root authority refresh package convergence is not canonical') + expect(rootAuthorityAssertions).toContain('root authority recovery package convergence is not canonical') + expect(rootAuthorityAssertions).toContain("'phases', package_row.metadata->'mcpGrantPhases'") + expect(rootAuthorityAssertions).toContain("'errorMessage', task_row.error_message") + expect(rootAuthorityPackageFixture).toContain("'[]'::jsonb") }) it('keeps the dedicated pre-reconciliation rollback proof bounded and resumable', () => { diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql index e1d0607c..961f13b0 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -84,7 +84,10 @@ INSERT INTO public.work_packages ( 'A valid prior marker that later clears through recovery.', 'blocked', 1, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + -- A prior filesystem marker can only recover after the package no longer + -- has a blocking filesystem requirement. A root repoint revokes every + -- still-required filesystem capability and therefore refreshes its hold. + '[]'::jsonb, 'Filesystem context requires an operator decision before execution.', '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-removal"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","blockedAt":"2026-07-28T00:00:02.000Z","holdKind":"consumed_once","grantPhase":"approved","grantConsumed":true,"grantDecisionRevision":"1","revocationReason":null}}'::jsonb ); diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql index e681441b..80266f06 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -21,6 +21,8 @@ DECLARE v_operation_id uuid; v_authority_generation bigint; v_through_generation bigint; + v_package_actual jsonb; + v_task_actual jsonb; BEGIN SELECT actor_id, operation_id, authority_generation, through_generation INTO STRICT v_actor, v_operation_id, v_authority_generation, v_through_generation @@ -89,7 +91,19 @@ BEGIN '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb OR package_row.metadata->'mcpGrantBlock'->'requestedCapabilities' <> '["filesystem.project.read","filesystem.project.search"]'::jsonb ) - ) OR EXISTS ( + ) THEN + SELECT jsonb_build_object( + 'blockedReason', package_row.blocked_reason, + 'marker', package_row.metadata->'mcpGrantBlock', + 'peer', package_row.metadata->'fixturePeer', + 'phases', package_row.metadata->'mcpGrantPhases', + 'status', package_row.status + ) INTO STRICT v_package_actual + FROM public.work_packages package_row WHERE package_row.id = v_add; + RAISE EXCEPTION 'root authority addition package convergence is not canonical: %', v_package_actual; + END IF; + + IF EXISTS ( SELECT 1 FROM public.work_packages package_row WHERE package_row.id = v_refresh AND ( @@ -100,7 +114,19 @@ BEGIN '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}}}'::jsonb OR package_row.metadata->'mcpGrantBlock'->'requestedCapabilities' <> '["filesystem.project.read","filesystem.project.search"]'::jsonb ) - ) OR EXISTS ( + ) THEN + SELECT jsonb_build_object( + 'blockedReason', package_row.blocked_reason, + 'marker', package_row.metadata->'mcpGrantBlock', + 'peer', package_row.metadata->'fixturePeer', + 'phases', package_row.metadata->'mcpGrantPhases', + 'status', package_row.status + ) INTO STRICT v_package_actual + FROM public.work_packages package_row WHERE package_row.id = v_refresh; + RAISE EXCEPTION 'root authority refresh package convergence is not canonical: %', v_package_actual; + END IF; + + IF EXISTS ( SELECT 1 FROM public.work_packages package_row WHERE package_row.id = v_recover AND ( @@ -111,7 +137,15 @@ BEGIN '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}}}'::jsonb ) ) THEN - RAISE EXCEPTION 'root authority package marker, phase, or peer metadata convergence is not canonical'; + SELECT jsonb_build_object( + 'blockedReason', package_row.blocked_reason, + 'marker', package_row.metadata->'mcpGrantBlock', + 'peer', package_row.metadata->'fixturePeer', + 'phases', package_row.metadata->'mcpGrantPhases', + 'status', package_row.status + ) INTO STRICT v_package_actual + FROM public.work_packages package_row WHERE package_row.id = v_recover; + RAISE EXCEPTION 'root authority recovery package convergence is not canonical: %', v_package_actual; END IF; IF EXISTS ( @@ -119,7 +153,14 @@ BEGIN WHERE (task_row.id = v_task_running OR task_row.id = v_task_failed) AND (task_row.status <> 'approved' OR task_row.error_message IS NOT NULL) ) THEN - RAISE EXCEPTION 'root authority task convergence did not recover running and failed tasks'; + SELECT jsonb_agg(jsonb_build_object( + 'errorMessage', task_row.error_message, + 'id', task_row.id, + 'status', task_row.status + ) ORDER BY task_row.id) INTO STRICT v_task_actual + FROM public.tasks task_row + WHERE task_row.id = v_task_running OR task_row.id = v_task_failed; + RAISE EXCEPTION 'root authority task convergence did not recover running and failed tasks: %', v_task_actual; END IF; IF (SELECT count(*) FROM public.work_package_local_projection_heads head From f4673300b45e3cb65877535a7b8577e297f22139 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:14:47 +0800 Subject: [PATCH 40/53] test: make root authority additions admissible --- web/__tests__/project-root-reconciliation.test.ts | 4 ++++ .../ci/sql/migration-0027-root-authority-package-fixture.sql | 4 ++-- ...igration-0027-root-authority-reconciliation-assertions.sql | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 6ed48ca6..0a1c6da3 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -200,8 +200,12 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityAssertions).toContain('root authority refresh package convergence is not canonical') expect(rootAuthorityAssertions).toContain('root authority recovery package convergence is not canonical') expect(rootAuthorityAssertions).toContain("'phases', package_row.metadata->'mcpGrantPhases'") + expect(rootAuthorityAssertions).toContain("'requirements', package_row.mcp_requirements") expect(rootAuthorityAssertions).toContain("'errorMessage', task_row.error_message") expect(rootAuthorityPackageFixture).toContain("'[]'::jsonb") + expect(rootAuthorityPackageFixture).toContain('"requirement":"required"') + expect(rootAuthorityPackageFixture).toContain('"assignedRole":"backend"') + expect(rootAuthorityPackageFixture).toContain('"fallback":{"action":"block","message":""}') }) it('keeps the dedicated pre-reconciliation rollback proof bounded and resumable', () => { diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql index 961f13b0..25fe9c15 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -60,7 +60,7 @@ INSERT INTO public.work_packages ( 'A ready package that later receives the canonical root hold.', 'ready', 1, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, + '[{"mcpId":"filesystem","requirement":"required","assignedRole":"backend","fallback":{"action":"block","message":""},"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, NULL, '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb ), @@ -72,7 +72,7 @@ INSERT INTO public.work_packages ( 'A valid prior marker that later refreshes under the root change.', 'blocked', 2, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, + '[{"mcpId":"filesystem","requirement":"required","assignedRole":"backend","fallback":{"action":"block","message":""},"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, 'Filesystem context requires an operator decision before execution.', '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-replacement"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","blockedAt":"2026-07-28T00:00:01.000Z","holdKind":"approval_required","grantPhase":"none","grantConsumed":false,"grantDecisionRevision":null,"revocationReason":null}}'::jsonb ), diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql index 80266f06..077d533b 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -97,6 +97,7 @@ BEGIN 'marker', package_row.metadata->'mcpGrantBlock', 'peer', package_row.metadata->'fixturePeer', 'phases', package_row.metadata->'mcpGrantPhases', + 'requirements', package_row.mcp_requirements, 'status', package_row.status ) INTO STRICT v_package_actual FROM public.work_packages package_row WHERE package_row.id = v_add; @@ -120,6 +121,7 @@ BEGIN 'marker', package_row.metadata->'mcpGrantBlock', 'peer', package_row.metadata->'fixturePeer', 'phases', package_row.metadata->'mcpGrantPhases', + 'requirements', package_row.mcp_requirements, 'status', package_row.status ) INTO STRICT v_package_actual FROM public.work_packages package_row WHERE package_row.id = v_refresh; @@ -142,6 +144,7 @@ BEGIN 'marker', package_row.metadata->'mcpGrantBlock', 'peer', package_row.metadata->'fixturePeer', 'phases', package_row.metadata->'mcpGrantPhases', + 'requirements', package_row.mcp_requirements, 'status', package_row.status ) INTO STRICT v_package_actual FROM public.work_packages package_row WHERE package_row.id = v_recover; From 07072ae12ec3703284c3e7d6f0375e47f6812bb7 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:32:45 +0800 Subject: [PATCH 41/53] test: close root reconciler privilege allowlist --- .../project-root-reconciliation.test.ts | 8 + ...-root-reconciler-privileges-assertions.sql | 193 ++++++++++++++++-- 2 files changed, 186 insertions(+), 15 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 0a1c6da3..611ff8ff 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -233,9 +233,17 @@ describe('project-root expansion reconciliation boundary', () => { }) it('keeps the dedicated reconciler privilege proof catalog-driven and mandatory', () => { + expect(privilegeProof).toContain('root_reconciler_expected_privileges') + expect(privilegeProof).toContain('root_reconciler_actual_privileges') expect(privilegeProof).toContain('has_table_privilege') expect(privilegeProof).toContain('has_column_privilege') expect(privilegeProof).toContain('has_function_privilege') + expect(privilegeProof).toContain('has_sequence_privilege') + expect(privilegeProof).toContain('has_schema_privilege') + expect(privilegeProof).toContain('has_database_privilege') + expect(privilegeProof).toContain('pg_auth_members') + expect(privilegeProof).toContain('pg_get_function_identity_arguments') + expect(privilegeProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeProof).toContain('project_root_reconciliation_write_contexts') expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') }) diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index 08627feb..e2d8f69e 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -1,21 +1,184 @@ \set ON_ERROR_STOP on +BEGIN; + +CREATE TEMP TABLE root_reconciler_expected_privileges ( + category text NOT NULL, + entry text NOT NULL, + PRIMARY KEY (category, entry) +) ON COMMIT DROP; +CREATE TEMP TABLE root_reconciler_actual_privileges ( + category text NOT NULL, + entry text NOT NULL, + PRIMARY KEY (category, entry) +) ON COMMIT DROP; + +INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES + ('relation', 'public.projects:SELECT'), + ('relation', 'public.tasks:SELECT'), + ('relation', 'public.work_packages:SELECT'), + ('relation', 'public.filesystem_mcp_grant_approvals:SELECT'), + ('relation', 'public.filesystem_mcp_current_decision_pointers:SELECT'), + ('relation', 'public.project_filesystem_grant_decisions:SELECT'), + ('relation', 'public.project_filesystem_current_decision_pointers:SELECT'), + ('relation', 'public.work_package_local_projection_heads:SELECT'), + ('column_update', 'public.tasks.status'), + ('column_update', 'public.tasks.error_message'), + ('column_update', 'public.tasks.updated_at'), + ('column_update', 'public.work_packages.status'), + ('column_update', 'public.work_packages.blocked_reason'), + ('column_update', 'public.work_packages.metadata'), + ('column_update', 'public.work_packages.updated_at'), + ('routine', 'forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text)'), + ('routine', 'forge.begin_project_root_reconciliation_v1(uuid,uuid,bigint)'), + ('routine', 'forge.claim_project_root_reconciliation_batch_v1(uuid,uuid,integer)'), + ('routine', 'forge.complete_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid,text)'), + ('routine', 'forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)'), + ('routine', 'forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid)'), + ('routine', 'forge.materialize_project_root_ref_expansion_v1(integer)'), + ('schema', 'public:USAGE'), + ('schema', 'forge:USAGE'), + ('database', 'CONNECT'), + ('database', 'TEMPORARY'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'relation', namespace_row.nspname || '.' || relation.relname || ':' || privilege.privilege +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +CROSS JOIN (VALUES ('SELECT'), ('INSERT'), ('UPDATE'), ('DELETE'), ('TRUNCATE'), ('REFERENCES'), ('TRIGGER'), ('MAINTAIN')) AS privilege(privilege) +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege); +-- The scan intentionally includes protected relations such as +-- public.project_root_reconciliation_write_contexts: absent from the allowlist +-- means every effective direct relation privilege is rejected. + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'column_update', namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = relation.oid +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND attribute_row.attnum > 0 + AND NOT attribute_row.attisdropped + AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, 'UPDATE'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.pg_get_function_identity_arguments(routine.oid), ', ', ',') || ')' +FROM pg_catalog.pg_proc routine +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace +WHERE namespace_row.nspname IN ('public', 'forge') + AND routine.prokind IN ('f', 'p') + AND pg_catalog.has_function_privilege('forge_project_root_reconciler', routine.oid, 'EXECUTE'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'schema', namespace_row.nspname || ':' || privilege.privilege +FROM pg_catalog.pg_namespace namespace_row +CROSS JOIN (VALUES ('USAGE'), ('CREATE')) AS privilege(privilege) +WHERE namespace_row.nspname IN ('public', 'forge') + AND pg_catalog.has_schema_privilege('forge_project_root_reconciler', namespace_row.oid, privilege.privilege); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'database', privilege.privilege +FROM pg_catalog.pg_database database_row +CROSS JOIN (VALUES ('CONNECT'), ('TEMPORARY'), ('CREATE')) AS privilege(privilege) +WHERE database_row.datname = pg_catalog.current_database() + AND pg_catalog.has_database_privilege('forge_project_root_reconciler', database_row.oid, privilege.privilege); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'sequence', namespace_row.nspname || '.' || sequence_row.relname || ':' || privilege.privilege +FROM pg_catalog.pg_class sequence_row +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = sequence_row.relnamespace +CROSS JOIN (VALUES ('USAGE'), ('SELECT'), ('UPDATE')) AS privilege(privilege) +WHERE namespace_row.nspname IN ('public', 'forge') + AND sequence_row.relkind = 'S' + AND pg_catalog.has_sequence_privilege('forge_project_root_reconciler', sequence_row.oid, privilege.privilege); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'ownership', 'database:' || database_row.datname +FROM pg_catalog.pg_database database_row +WHERE database_row.datname = pg_catalog.current_database() + AND database_row.datdba = 'forge_project_root_reconciler'::regrole +UNION ALL +SELECT 'ownership', 'schema:' || namespace_row.nspname +FROM pg_catalog.pg_namespace namespace_row +WHERE namespace_row.nspname IN ('public', 'forge') + AND namespace_row.nspowner = 'forge_project_root_reconciler'::regrole +UNION ALL +SELECT 'ownership', 'relation:' || namespace_row.nspname || '.' || relation.relname +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relowner = 'forge_project_root_reconciler'::regrole +UNION ALL +SELECT 'ownership', 'routine:' || namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.pg_get_function_identity_arguments(routine.oid), ', ', ',') || ')' +FROM pg_catalog.pg_proc routine +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace +WHERE namespace_row.nspname IN ('public', 'forge') + AND routine.proowner = 'forge_project_root_reconciler'::regrole; + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'membership', granted.rolname || '<-' || member.rolname || ':admin=' || membership.admin_option || ':inherit=' || membership.inherit_option || ':set=' || membership.set_option +FROM pg_catalog.pg_auth_members membership +JOIN pg_catalog.pg_roles granted ON granted.oid = membership.roleid +JOIN pg_catalog.pg_roles member ON member.oid = membership.member +WHERE membership.member = 'forge_project_root_reconciler'::regrole + OR membership.roleid = 'forge_project_root_reconciler'::regrole; + DO $proof$ -DECLARE r pg_catalog.pg_roles%ROWTYPE; +DECLARE + role_row pg_catalog.pg_roles%ROWTYPE; + diff jsonb; BEGIN - SELECT * INTO STRICT r FROM pg_catalog.pg_roles WHERE rolname='forge_project_root_reconciler'; - IF NOT r.rolcanlogin OR r.rolinherit OR r.rolsuper OR r.rolcreaterole OR r.rolcreatedb OR r.rolreplication OR r.rolbypassrls - OR EXISTS (SELECT 1 FROM pg_catalog.pg_auth_members WHERE member='forge_project_root_reconciler'::regrole OR roleid='forge_project_root_reconciler'::regrole) THEN - RAISE EXCEPTION 'root reconciler role attributes or memberships exceed the closed contract'; + SELECT * INTO STRICT role_row + FROM pg_catalog.pg_roles + WHERE rolname = 'forge_project_root_reconciler'; + IF NOT role_row.rolcanlogin OR role_row.rolinherit OR role_row.rolsuper OR role_row.rolcreaterole + OR role_row.rolcreatedb OR role_row.rolreplication OR role_row.rolbypassrls THEN + RAISE EXCEPTION 'root reconciler role attributes exceed the closed contract: %', + jsonb_build_object('canlogin', role_row.rolcanlogin, 'inherit', role_row.rolinherit, + 'superuser', role_row.rolsuper, 'createrole', role_row.rolcreaterole, + 'createdb', role_row.rolcreatedb, 'replication', role_row.rolreplication, + 'bypassrls', role_row.rolbypassrls); + END IF; + + SELECT coalesce(jsonb_agg(jsonb_build_object( + 'category', category, + 'missing', missing, + 'unexpected', unexpected + ) ORDER BY category), '[]'::jsonb) + INTO diff + FROM ( + SELECT categories.category, + coalesce((SELECT jsonb_agg(entry ORDER BY entry) FROM ( + SELECT expected.entry + FROM root_reconciler_expected_privileges expected + WHERE expected.category = categories.category + EXCEPT + SELECT actual.entry + FROM root_reconciler_actual_privileges actual + WHERE actual.category = categories.category + ) missing_rows), '[]'::jsonb) AS missing, + coalesce((SELECT jsonb_agg(entry ORDER BY entry) FROM ( + SELECT actual.entry + FROM root_reconciler_actual_privileges actual + WHERE actual.category = categories.category + EXCEPT + SELECT expected.entry + FROM root_reconciler_expected_privileges expected + WHERE expected.category = categories.category + ) unexpected_rows), '[]'::jsonb) AS unexpected + FROM ( + SELECT category FROM root_reconciler_expected_privileges + UNION + SELECT category FROM root_reconciler_actual_privileges + ) categories + ) category_diffs + WHERE missing <> '[]'::jsonb OR unexpected <> '[]'::jsonb; + + IF diff <> '[]'::jsonb THEN + RAISE EXCEPTION 'root reconciler effective privilege allowlist mismatch: %', diff; END IF; - IF NOT has_table_privilege('forge_project_root_reconciler','public.projects','SELECT') - OR NOT has_table_privilege('forge_project_root_reconciler','public.tasks','SELECT') - OR NOT has_column_privilege('forge_project_root_reconciler','public.tasks','status','UPDATE') - OR NOT has_column_privilege('forge_project_root_reconciler','public.work_packages','metadata','UPDATE') - OR has_table_privilege('forge_project_root_reconciler','public.tasks','INSERT, DELETE, TRUNCATE, UPDATE') - OR has_table_privilege('forge_project_root_reconciler','public.project_root_reconciliation_write_contexts','SELECT, INSERT, UPDATE, DELETE') - OR has_function_privilege('forge_project_root_reconciler','forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)','EXECUTE') IS NOT TRUE - OR has_function_privilege('forge_project_root_reconciler','forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid)','EXECUTE') IS NOT TRUE - OR has_function_privilege('forge_app_test','forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)','EXECUTE') - THEN RAISE EXCEPTION 'root reconciler catalog privileges do not match the closed runtime contract'; END IF; END; $proof$; +COMMIT; From 0484f0198e1cf5fbf0837799ff848c703ad25f20 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:36:43 +0800 Subject: [PATCH 42/53] test: prove root reconciler privilege allowlist mutations --- .../project-root-reconciliation.test.ts | 15 ++++ ...027-root-reconciler-privilege-mutations.sh | 73 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + ...-root-reconciler-privileges-assertions.sql | 9 ++- 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 611ff8ff..7b9a957e 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -22,6 +22,7 @@ const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/c const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') +const privilegeMutationProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh', import.meta.url)), 'utf8') const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') const negativeAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql', import.meta.url)), 'utf8') const contentionProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-contention.sh', import.meta.url)), 'utf8') @@ -248,6 +249,20 @@ describe('project-root expansion reconciliation boundary', () => { expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') }) + it('proves the exact closed privilege allowlist rejects direct, column, and PUBLIC-effective mutations', () => { + expect(privilegeMutationProof).toContain('GRANT INSERT ON TABLE public.projects TO forge_project_root_reconciler;') + expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') + expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') + expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') + expect(privilegeMutationProof).toContain('public.projects:INSERT') + expect(privilegeMutationProof).toContain('public.tasks.title') + expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') + expect(privilegeMutationProof).toContain('\\i ${assertion_file}') + expect(privilegeMutationProof).not.toContain('has_table_privilege') + expect(privilegeMutationProof).toContain('fresh clean allowlist assertion') + expect(upgradeProof).toContain('prove-migration-0027-root-reconciler-privilege-mutations.sh') + }) + it('rejects stale and fabricated root reconciliation context reuse before the CLI resumes', () => { expect(staleContextProof).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(staleContextProof).toContain('forge.complete_project_root_reconciliation_generation_v1') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh new file mode 100644 index 00000000..a2e231b5 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +assertion_file="${script_dir}/sql/migration-0027-root-reconciler-privileges-assertions.sql" +proof_tmpdir="$(mktemp -d)" + +cleanup() { + rm -rf "${proof_tmpdir}" +} +trap cleanup EXIT + +fail_with_output() { + local phase="$1" + local output_file="$2" + echo "Root reconciler privilege mutation proof failed during ${phase}." >&2 + cat "${output_file}" >&2 + exit 1 +} + +expect_allowlist_rejection() { + local phase="$1" + local expected_entry="$2" + local grant_sql="$3" + local output_file="${proof_tmpdir}/${phase}.log" + local status=0 + + set +e + psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 >"${output_file}" 2>&1 </dev/null; then + fail_with_output "${phase}: exact allowlist rejection was absent" "${output_file}" + fi + if ! grep -F -- "${expected_entry}" "${output_file}" >/dev/null; then + fail_with_output "${phase}: expected unexpected-set entry ${expected_entry} was absent" "${output_file}" + fi +} + +# Every mutation is uncommitted. The assertion failure closes that psql session, +# which rolls the transaction back before the following fresh-session probe. +expect_allowlist_rejection \ + 'relation privilege mutation' \ + 'public.projects:INSERT' \ + 'GRANT INSERT ON TABLE public.projects TO forge_project_root_reconciler;' + +expect_allowlist_rejection \ + 'column update privilege mutation' \ + 'public.tasks.title' \ + 'GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;' + +# This intentionally exercises an effective privilege inherited from PUBLIC, +# rather than only a direct grant to the dedicated login. +expect_allowlist_rejection \ + 'public routine execute mutation' \ + 'forge.read_epic_172_enablement_state_v1()' \ + 'GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;' + +clean_output="${proof_tmpdir}/clean.log" +if ! psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file "${assertion_file}" >"${clean_output}" 2>&1; then + fail_with_output 'fresh clean allowlist assertion' "${clean_output}" +fi diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index f665b647..c104a0e9 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -60,6 +60,7 @@ fi bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +bash scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh # This creates (but never advances) the exact live operation. The shim resumes # it below after its now-empty materialization pass. bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index e2d8f69e..eec6f067 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -1,16 +1,15 @@ \set ON_ERROR_STOP on -BEGIN; CREATE TEMP TABLE root_reconciler_expected_privileges ( category text NOT NULL, entry text NOT NULL, PRIMARY KEY (category, entry) -) ON COMMIT DROP; +); CREATE TEMP TABLE root_reconciler_actual_privileges ( category text NOT NULL, entry text NOT NULL, PRIMARY KEY (category, entry) -) ON COMMIT DROP; +); INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES ('relation', 'public.projects:SELECT'), @@ -181,4 +180,6 @@ BEGIN END IF; END; $proof$; -COMMIT; + +DROP TABLE root_reconciler_actual_privileges; +DROP TABLE root_reconciler_expected_privileges; From 52d798a62c9ae4e1307af919575addcb084283ce Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:38:06 +0800 Subject: [PATCH 43/53] fix: gate root reconciler maintain privilege by server version --- web/__tests__/project-root-reconciliation.test.ts | 3 +++ ...ration-0027-root-reconciler-privileges-assertions.sql | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 7b9a957e..98b22327 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -237,6 +237,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeProof).toContain('root_reconciler_expected_privileges') expect(privilegeProof).toContain('root_reconciler_actual_privileges') expect(privilegeProof).toContain('has_table_privilege') + expect(privilegeProof).toContain("unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER']::text[])") + expect(privilegeProof).toContain("SELECT 'MAINTAIN'") + expect(privilegeProof).toContain("current_setting('server_version_num')::integer >= 170000") expect(privilegeProof).toContain('has_column_privilege') expect(privilegeProof).toContain('has_function_privilege') expect(privilegeProof).toContain('has_sequence_privilege') diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index eec6f067..d943caca 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -43,7 +43,14 @@ INSERT INTO root_reconciler_actual_privileges (category, entry) SELECT 'relation', namespace_row.nspname || '.' || relation.relname || ':' || privilege.privilege FROM pg_catalog.pg_class relation JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace -CROSS JOIN (VALUES ('SELECT'), ('INSERT'), ('UPDATE'), ('DELETE'), ('TRUNCATE'), ('REFERENCES'), ('TRIGGER'), ('MAINTAIN')) AS privilege(privilege) +CROSS JOIN ( + SELECT privilege + FROM unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER']::text[]) AS supported(privilege) + UNION ALL + -- MAINTAIN is a PostgreSQL 17 relation privilege; PostgreSQL 16 rejects it. + SELECT 'MAINTAIN' + WHERE pg_catalog.current_setting('server_version_num')::integer >= 170000 +) AS privilege WHERE namespace_row.nspname IN ('public', 'forge') AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') AND pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege); From 2ed915c1aba772058b1b50b1807d46b99e124ccf Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:41:44 +0800 Subject: [PATCH 44/53] test: isolate root reconciliation binding rejections --- .../project-root-reconciliation.test.ts | 8 +++ ...ation-0027-root-reconciliation-negative.sh | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 98b22327..1e8d4b2d 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -215,6 +215,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(negativeProof).toContain('SELECT 1/0') expect(negativeProof).toContain('project-root operation identity cannot be hijacked') expect(negativeProof).toContain('project-root authority lock has no active write context') + expect(negativeProof).toContain("'correct-next-generation wrong-project binding'") + expect(negativeProof).toContain("'wrong-generation correct-journal-project binding'") + expect(negativeProof).toContain("WHERE project_id <> '${project}'::uuid") + expect(negativeProof).toContain('WHERE generation > ${generation} AND generation <= ${through}') + expect(negativeProof).toContain('snapshot_reconciliation_state') + expect(negativeProof).toContain("'runtime_audits'") + expect(negativeProof).toContain("'projection_heads'") + expect(negativeProof).toContain("'packages'") expect(negativeProof).toContain('migration-0027-root-reconciliation-negative-assertions.sql') expect(negativeProof).not.toContain('reconcile-migration-0027-root-refs.sh') expect(upgradeProof.indexOf('prove-migration-0027-root-reconciliation-negative.sh')).toBeLessThan( diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index e0490ef1..5b643d78 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -21,6 +21,57 @@ operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --comma operation_id="${operation%%|*}" [[ "$operation" == *'|running|0' && "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof did not create the exact live operation' >&2; exit 1; } +snapshot_reconciliation_state() { + psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command " + WITH snapshots AS ( + SELECT 'operations' AS category, coalesce(jsonb_agg(to_jsonb(operation_row) ORDER BY operation_row.operation_id)::text, '[]') AS payload FROM public.project_root_reconciliation_operations operation_row + UNION ALL SELECT 'checkpoints', coalesce(jsonb_agg(to_jsonb(checkpoint_row) ORDER BY checkpoint_row.operation_id, checkpoint_row.checkpoint_generation)::text, '[]') FROM public.project_root_reconciliation_checkpoints checkpoint_row + UNION ALL SELECT 'outcomes', coalesce(jsonb_agg(to_jsonb(outcome_row) ORDER BY outcome_row.generation)::text, '[]') FROM public.project_root_reconciliation_outcomes outcome_row + UNION ALL SELECT 'contexts', coalesce(jsonb_agg(to_jsonb(context_row) ORDER BY context_row.operation_id, context_row.generation)::text, '[]') FROM public.project_root_reconciliation_write_contexts context_row + UNION ALL SELECT 'journal', coalesce(jsonb_agg(to_jsonb(journal_row) ORDER BY journal_row.generation)::text, '[]') FROM public.project_root_change_journal journal_row + UNION ALL SELECT 'runtime_audits', coalesce(jsonb_agg(to_jsonb(audit_row) ORDER BY audit_row.id)::text, '[]') FROM public.filesystem_mcp_runtime_audits audit_row + UNION ALL SELECT 'project_pointers', coalesce(jsonb_agg(to_jsonb(pointer_row) ORDER BY pointer_row.project_id)::text, '[]') FROM public.project_filesystem_current_decision_pointers pointer_row + UNION ALL SELECT 'package_pointers', coalesce(jsonb_agg(to_jsonb(pointer_row) ORDER BY pointer_row.work_package_id)::text, '[]') FROM public.filesystem_mcp_current_decision_pointers pointer_row + UNION ALL SELECT 'projection_heads', coalesce(jsonb_agg(to_jsonb(head_row) ORDER BY head_row.id)::text, '[]') FROM public.work_package_local_projection_heads head_row + UNION ALL SELECT 'tasks', coalesce(jsonb_agg(to_jsonb(task_row) ORDER BY task_row.id)::text, '[]') FROM public.tasks task_row + UNION ALL SELECT 'packages', coalesce(jsonb_agg(to_jsonb(package_row) ORDER BY package_row.id)::text, '[]') FROM public.work_packages package_row + ) + SELECT md5(string_agg(category || ':' || payload, E'\\n' ORDER BY category)) FROM snapshots" +} + +expect_isolated_rejection() { + local phase="$1" expected_text="$2" generation_input="$3" project_input="$4" output status before after + before="$(snapshot_reconciliation_state)" + output="$(mktemp)" + if psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,${generation_input}::bigint,'${project_input}'::uuid)" >"$output" 2>&1; then + echo "negative reconciliation proof ${phase} unexpectedly succeeded" >&2 + cat "$output" >&2 + unlink "$output" + exit 1 + else + status=$? + fi + if [[ "$status" != 1 ]] || ! grep -F -- "$expected_text" "$output" >/dev/null; then + echo "negative reconciliation proof ${phase} received an unexpected psql status/error (status=${status})" >&2 + cat "$output" >&2 + unlink "$output" + exit 1 + fi + unlink "$output" + after="$(snapshot_reconciliation_state)" + [[ "$before" == "$after" && "$after" =~ ^[0-9a-f]{32}$ ]] || { echo "negative reconciliation proof ${phase} changed protected reconciliation state" >&2; exit 1; } +} + +wrong_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command "SELECT project_id FROM public.project_root_change_journal WHERE project_id <> '${project}'::uuid ORDER BY generation LIMIT 1")" +[[ "$wrong_project" =~ ^[0-9a-f-]{36}$ && "$wrong_project" != "$project" ]] || { echo 'negative proof lacks a distinct journal project for project-binding isolation' >&2; exit 1; } +expect_isolated_rejection 'correct-next-generation wrong-project binding' 'project-root write context is not claimable' "$generation" "$wrong_project" + +wrong_generation_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT generation, project_id FROM public.project_root_change_journal WHERE generation > ${generation} AND generation <= ${through} ORDER BY generation LIMIT 1")" +wrong_generation="${wrong_generation_project%%|*}" +wrong_generation_project_id="${wrong_generation_project#*|}" +[[ "$wrong_generation" =~ ^[1-9][0-9]*$ && "$wrong_generation" != "$generation" && "$wrong_generation_project_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof lacks a later journal generation for generation-sequence isolation' >&2; exit 1; } +expect_isolated_rejection 'wrong-generation correct-journal-project binding' 'project-root write context is not claimable' "$wrong_generation" "$wrong_generation_project_id" + expect_failure() { local expected_status="$1" text="$2"; shift 2; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == "$expected_status" ]] || { echo "negative reconciliation proof expected psql exit ${expected_status} for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } expect_failure 1 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" expect_failure 1 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" From 30b0fab82a30770590c3ccc5b89122171a5850f3 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:49:00 +0800 Subject: [PATCH 45/53] fix: close root reconciler routine allowlist --- .../project-root-reconciliation.test.ts | 21 ++++++++- web/scripts/bootstrap-epic-172-s4-roles.ts | 34 ++++++++++++++ ...ration-0027-ordinary-app-trigger-writes.sh | 46 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + ...-root-reconciler-privileges-assertions.sql | 9 +++- 5 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 1e8d4b2d..f2b65bf9 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -23,6 +23,7 @@ const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-mi const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') const privilegeMutationProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh', import.meta.url)), 'utf8') +const ordinaryAppTriggerProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh', import.meta.url)), 'utf8') const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') const negativeAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql', import.meta.url)), 'utf8') const contentionProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-contention.sh', import.meta.url)), 'utf8') @@ -254,7 +255,25 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeProof).toContain('has_schema_privilege') expect(privilegeProof).toContain('has_database_privilege') expect(privilegeProof).toContain('pg_auth_members') - expect(privilegeProof).toContain('pg_get_function_identity_arguments') + expect(privilegeProof).toContain('oidvectortypes(routine.proargtypes)') + expect(privilegeProof).toContain('public.forge_epic_172_s3_release_state:SELECT') + expect(privilegeProof).toContain('public.forge_is_canonical_bounded_string_set(jsonb,integer,integer)') + expect(privilegeProof).toContain('public.forge_is_canonical_filesystem_capability_set(jsonb)') + expect(privilegeProof).toContain('public.forge_is_canonical_filesystem_grant_block_v2(jsonb)') + expect(privilegeProof).toContain('public.forge_is_canonical_utc_timestamp(text)') + expect(privilegeProof).toContain('pure, immutable input validators') + expect(bootstrap).toContain('public.forge_preallocate_filesystem_decision_pointer()') + expect(bootstrap).toContain('public.forge_preallocate_project_filesystem_decision_pointer()') + expect(bootstrap).toContain('public.forge_reject_filesystem_grant_history_mutation()') + expect(bootstrap).toContain('public.forge_reject_project_filesystem_decision_mutation()') + expect(bootstrap).toContain('These six routines are trigger-only') + expect(bootstrap).toContain('publicTriggerExecuteCount') + expect(bootstrap).toContain('Trigger-only S3 helpers retain an unsafe PUBLIC EXECUTE grant.') + expect(ordinaryAppTriggerProof).toContain('postgresql://forge_app_test:forge_app_test@') + expect(ordinaryAppTriggerProof).toContain('public.project_filesystem_current_decision_pointers') + expect(ordinaryAppTriggerProof).toContain('public.filesystem_mcp_current_decision_pointers') + expect(ordinaryAppTriggerProof).toContain('work_package_local_projection_heads') + expect(upgradeProof).toContain('prove-migration-0027-ordinary-app-trigger-writes.sh') expect(privilegeProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeProof).toContain('project_root_reconciliation_write_contexts') expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 5a4a539b..5f2f32a6 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -113,6 +113,40 @@ async function main(): Promise { await admin`revoke create on schema forge from ${admin(OWNER)}` await admin`revoke create on schema public from ${admin(OWNER)}` await admin`grant execute on function forge.read_epic_172_enablement_state_v1() to ${admin(OWNER)}` + // These six routines are trigger-only: migrations create their triggers + // before this bootstrap runs, and the owning trigger tables invoke them. + // No runtime login needs direct EXECUTE, so remove PostgreSQL's default + // PUBLIC function grant without opening a reconciler capability. + await admin.unsafe(` + revoke execute on function + public.forge_epic_172_reject_mutation_v1(), + public.forge_epic_172_reject_project_hard_delete_v1(), + public.forge_preallocate_filesystem_decision_pointer(), + public.forge_preallocate_project_filesystem_decision_pointer(), + public.forge_reject_filesystem_grant_history_mutation(), + public.forge_reject_project_filesystem_decision_mutation() + from public + `) + const [{ publicTriggerExecuteCount }] = await admin<{ publicTriggerExecuteCount: number }[]>` + select count(*)::integer as "publicTriggerExecuteCount" + from pg_catalog.pg_proc routine + cross join lateral pg_catalog.aclexplode( + coalesce(routine.proacl, pg_catalog.acldefault('f', routine.proowner)) + ) acl + where routine.oid = any(array[ + 'public.forge_epic_172_reject_mutation_v1()'::pg_catalog.regprocedure, + 'public.forge_epic_172_reject_project_hard_delete_v1()'::pg_catalog.regprocedure, + 'public.forge_preallocate_filesystem_decision_pointer()'::pg_catalog.regprocedure, + 'public.forge_preallocate_project_filesystem_decision_pointer()'::pg_catalog.regprocedure, + 'public.forge_reject_filesystem_grant_history_mutation()'::pg_catalog.regprocedure, + 'public.forge_reject_project_filesystem_decision_mutation()'::pg_catalog.regprocedure + ]) + and acl.grantee = 0 + and acl.privilege_type = 'EXECUTE' + ` + if (publicTriggerExecuteCount !== 0) { + throw new Error('Trigger-only S3 helpers retain an unsafe PUBLIC EXECUTE grant.') + } // S4 claims and archive transitions take row locks on the bounded S3 // current-head set. PostgreSQL requires UPDATE privilege for SELECT ... // FOR UPDATE even though these routines never modify the head rows. diff --git a/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh new file mode 100644 index 00000000..537a8ade --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${FORGE_MIGRATION_0027_DATABASE_URL:?Set the disposable PostgreSQL 0027 upgrade database URL.}" +: "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" + +authority="${FORGE_MIGRATION_0027_DATABASE_URL#*://}" +app_url="postgresql://forge_app_test:forge_app_test@${authority#*@}" +project_id='27000000-0000-4000-8000-0000000007a1' +task_id='27000000-0000-4000-8000-0000000007a2' +package_id='27000000-0000-4000-8000-0000000007a3' + +psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 <<'SQL' +GRANT CONNECT ON DATABASE forge_migration_0027_upgrade_test TO forge_app_test; +GRANT USAGE ON SCHEMA public TO forge_app_test; +GRANT INSERT ON TABLE public.projects, public.tasks, public.work_packages, + public.project_filesystem_current_decision_pointers, + public.filesystem_mcp_current_decision_pointers + TO forge_app_test; +SQL + +psql "${app_url}" --set ON_ERROR_STOP=1 \ + --set project_id="${project_id}" \ + --set task_id="${task_id}" \ + --set package_id="${package_id}" <<'SQL' +INSERT INTO public.projects (id, name) +VALUES (:'project_id'::uuid, 'Ordinary application trigger proof'); +INSERT INTO public.tasks (id, project_id, title, prompt) +VALUES (:'task_id'::uuid, :'project_id'::uuid, 'Ordinary application task', 'trigger proof'); +INSERT INTO public.work_packages (id, task_id, assigned_role, title, summary, status, sequence) +VALUES (:'package_id'::uuid, :'task_id'::uuid, 'backend', 'Ordinary application package', 'trigger proof', 'pending', 1); +SQL + +trigger_state="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 \ + --set project_id="${project_id}" \ + --set package_id="${package_id}" --command " + SELECT EXISTS ( + SELECT 1 FROM public.project_filesystem_current_decision_pointers + WHERE project_id = :'project_id'::uuid + ) AND EXISTS ( + SELECT 1 FROM public.filesystem_mcp_current_decision_pointers + WHERE work_package_id = :'package_id'::uuid + ) AND (SELECT count(*) FROM public.work_package_local_projection_heads + WHERE work_package_id = :'package_id'::uuid) = 8 + ")" +[[ "${trigger_state}" == 't' ]] || { echo 'ordinary application writes did not fire the S3 pointer and projection-head triggers' >&2; exit 1; } diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index c104a0e9..da3efe94 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -32,6 +32,7 @@ psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-expansion-assertions.sql psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-archive-assertions.sql +bash scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh echo 'Reconciling the legacy Redis session with its exact absolute expiry.' npm run session-credentials:reconcile diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index d943caca..a06e44ba 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -20,6 +20,7 @@ INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES ('relation', 'public.project_filesystem_grant_decisions:SELECT'), ('relation', 'public.project_filesystem_current_decision_pointers:SELECT'), ('relation', 'public.work_package_local_projection_heads:SELECT'), + ('relation', 'public.forge_epic_172_s3_release_state:SELECT'), ('column_update', 'public.tasks.status'), ('column_update', 'public.tasks.error_message'), ('column_update', 'public.tasks.updated_at'), @@ -34,6 +35,12 @@ INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES ('routine', 'forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)'), ('routine', 'forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid)'), ('routine', 'forge.materialize_project_root_ref_expansion_v1(integer)'), + -- These four functions are pure, immutable input validators. PUBLIC + -- execution is intentional and does not read or mutate reconciliation state. + ('routine', 'public.forge_is_canonical_bounded_string_set(jsonb,integer,integer)'), + ('routine', 'public.forge_is_canonical_filesystem_capability_set(jsonb)'), + ('routine', 'public.forge_is_canonical_filesystem_grant_block_v2(jsonb)'), + ('routine', 'public.forge_is_canonical_utc_timestamp(text)'), ('schema', 'public:USAGE'), ('schema', 'forge:USAGE'), ('database', 'CONNECT'), @@ -70,7 +77,7 @@ WHERE namespace_row.nspname IN ('public', 'forge') AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, 'UPDATE'); INSERT INTO root_reconciler_actual_privileges (category, entry) -SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.pg_get_function_identity_arguments(routine.oid), ', ', ',') || ')' +SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.oidvectortypes(routine.proargtypes), ', ', ',') || ')' FROM pg_catalog.pg_proc routine JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace WHERE namespace_row.nspname IN ('public', 'forge') From 392290e9b39d73bdcf9c00f2e6d1f06e1e0f8f26 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:55:27 +0800 Subject: [PATCH 46/53] test: prove root reconciler actor boundaries --- .../project-root-reconciliation.test.ts | 21 +++++++ ...ation-0027-root-reconciliation-negative.sh | 56 ++++++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index f2b65bf9..19d324af 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -218,6 +218,11 @@ describe('project-root expansion reconciliation boundary', () => { expect(negativeProof).toContain('project-root authority lock has no active write context') expect(negativeProof).toContain("'correct-next-generation wrong-project binding'") expect(negativeProof).toContain("'wrong-generation correct-journal-project binding'") + expect(negativeProof).toContain("'valid-entry wrong-actor ownership'") + expect(negativeProof).toContain("'composite completion wrong-actor context identity'") + expect(negativeProof).toContain("actor_b='44444444-4444-4444-8444-444444444444'") + expect(negativeProof).toContain('project-root write context is absent or stale') + expect(negativeProof).toContain('leave a context row behind') expect(negativeProof).toContain("WHERE project_id <> '${project}'::uuid") expect(negativeProof).toContain('WHERE generation > ${generation} AND generation <= ${through}') expect(negativeProof).toContain('snapshot_reconciliation_state') @@ -231,6 +236,22 @@ describe('project-root expansion reconciliation boundary', () => { ) }) + it('keeps completion context identity actor-bound before the non-disclosing operation CAS', () => { + const completionRoutine = migration.slice( + migration.indexOf('CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1'), + migration.indexOf( + 'CREATE OR REPLACE FUNCTION', + migration.indexOf('CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1') + 1, + ), + ) + // Actor is intentionally part of the active-context lookup. Moving it to a + // later actor-specific check would disclose another actor's live context. + expect(completionRoutine).toContain('context.operation_id=p_operation_id AND context.generation=p_generation AND context.actor_id=p_actor_id AND context.project_id=p_project_id AND context.backend_pid=pg_catalog.pg_backend_pid() AND context.transaction_id=pg_catalog.txid_current() AND context.completed_at IS NULL') + expect(completionRoutine.indexOf('context.actor_id=p_actor_id')).toBeLessThan( + completionRoutine.indexOf('v_operation.actor_id <> p_actor_id'), + ) + }) + it('replays the completed reconciliation through the exact dedicated CLI without mutation', () => { expect(replayProof).toContain('"mode":"complete-replay"') expect(replayProof).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index 5b643d78..b9e4d9c9 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -3,6 +3,7 @@ set -euo pipefail : "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" actor='11111111-1111-4111-8111-111111111111' +actor_b='44444444-4444-4444-8444-444444444444' task='27000000-0000-4000-8000-000000000730' psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null authority="${FORGE_DATABASE_ADMIN_URL#*://}" @@ -13,9 +14,12 @@ while :; do [[ "$rows" == 0 ]] && break done through="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton')" -gen_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command 'SELECT generation, project_id FROM public.project_root_change_journal WHERE generation=1')" -generation="${gen_project%%|*}"; project="${gen_project#*|}" -[[ "$generation" == 1 && "$project" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ ]] || { echo 'negative proof requires journal generation one and a positive watermark' >&2; exit 1; } +gen_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command 'SELECT generation, project_id, outcome FROM public.project_root_change_journal WHERE generation=1')" +generation="${gen_project%%|*}" +project_outcome="${gen_project#*|}" +project="${project_outcome%%|*}" +outcome="${project_outcome#*|}" +[[ "$generation" == 1 && "$project" =~ ^[0-9a-f-]{36}$ && "$outcome" =~ ^(insert|root_update|archive)$ && "$through" =~ ^[1-9][0-9]*$ ]] || { echo 'negative proof requires journal generation one and a positive watermark' >&2; exit 1; } psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "INSERT INTO public.tasks(id,project_id,submitted_by,title,prompt,status) SELECT '${task}'::uuid, '${project}'::uuid, submitted_by, 'Root rollback proof', 'rollback only', 'running' FROM public.projects WHERE id='${project}'::uuid" operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, state, last_processed_generation FROM forge.begin_project_root_reconciliation_v1(NULL,'${actor}'::uuid,${through}::bigint)")" operation_id="${operation%%|*}" @@ -40,10 +44,10 @@ snapshot_reconciliation_state() { } expect_isolated_rejection() { - local phase="$1" expected_text="$2" generation_input="$3" project_input="$4" output status before after + local phase="$1" expected_text="$2" actor_input="$3" generation_input="$4" project_input="$5" output status before after before="$(snapshot_reconciliation_state)" output="$(mktemp)" - if psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,${generation_input}::bigint,'${project_input}'::uuid)" >"$output" 2>&1; then + if psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor_input}'::uuid,${generation_input}::bigint,'${project_input}'::uuid)" >"$output" 2>&1; then echo "negative reconciliation proof ${phase} unexpectedly succeeded" >&2 cat "$output" >&2 unlink "$output" @@ -64,13 +68,51 @@ expect_isolated_rejection() { wrong_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command "SELECT project_id FROM public.project_root_change_journal WHERE project_id <> '${project}'::uuid ORDER BY generation LIMIT 1")" [[ "$wrong_project" =~ ^[0-9a-f-]{36}$ && "$wrong_project" != "$project" ]] || { echo 'negative proof lacks a distinct journal project for project-binding isolation' >&2; exit 1; } -expect_isolated_rejection 'correct-next-generation wrong-project binding' 'project-root write context is not claimable' "$generation" "$wrong_project" +expect_isolated_rejection 'correct-next-generation wrong-project binding' 'project-root write context is not claimable' "$actor" "$generation" "$wrong_project" wrong_generation_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT generation, project_id FROM public.project_root_change_journal WHERE generation > ${generation} AND generation <= ${through} ORDER BY generation LIMIT 1")" wrong_generation="${wrong_generation_project%%|*}" wrong_generation_project_id="${wrong_generation_project#*|}" [[ "$wrong_generation" =~ ^[1-9][0-9]*$ && "$wrong_generation" != "$generation" && "$wrong_generation_project_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof lacks a later journal generation for generation-sequence isolation' >&2; exit 1; } -expect_isolated_rejection 'wrong-generation correct-journal-project binding' 'project-root write context is not claimable' "$wrong_generation" "$wrong_generation_project_id" +expect_isolated_rejection 'wrong-generation correct-journal-project binding' 'project-root write context is not claimable' "$actor" "$wrong_generation" "$wrong_generation_project_id" + +# Every entry input below is valid except the logical actor: actor B cannot +# claim actor A's operation or leave a context row behind. +expect_isolated_rejection 'valid-entry wrong-actor ownership' 'project-root write context is not claimable' "$actor_b" "$generation" "$project" + +expect_composite_completion_context_rejection() { + local phase='composite completion wrong-actor context identity' output status before after + before="$(snapshot_reconciliation_state)" + output="$(mktemp)" + # The completion routine deliberately includes actor_id in its active-context + # lookup before the later operation CAS. Returning the generic stale-context + # error here avoids disclosing actor A's live context to actor B. + if psql "$url" --set ON_ERROR_STOP=1 >"$output" 2>&1 <&2 + cat "$output" >&2 + unlink "$output" + exit 1 + else + status=$? + fi + if [[ "$status" != 3 ]] || ! grep -F -- 'project-root write context is absent or stale' "$output" >/dev/null; then + echo "negative reconciliation proof ${phase} received an unexpected psql status/error (status=${status})" >&2 + cat "$output" >&2 + unlink "$output" + exit 1 + fi + unlink "$output" + after="$(snapshot_reconciliation_state)" + [[ "$before" == "$after" && "$after" =~ ^[0-9a-f]{32}$ ]] || { echo "negative reconciliation proof ${phase} changed protected reconciliation state or left an orphan context" >&2; exit 1; } +} + +expect_composite_completion_context_rejection expect_failure() { local expected_status="$1" text="$2"; shift 2; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == "$expected_status" ]] || { echo "negative reconciliation proof expected psql exit ${expected_status} for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } expect_failure 1 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" From 87bf113a3f066195d912b9b82998555781d4e62d Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:57:10 +0800 Subject: [PATCH 47/53] test: fix ordinary app trigger proof inputs --- .../project-root-reconciliation.test.ts | 5 +++++ ...ration-0027-ordinary-app-trigger-writes.sh | 21 ++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 19d324af..8c61152b 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -291,6 +291,11 @@ describe('project-root expansion reconciliation boundary', () => { expect(bootstrap).toContain('publicTriggerExecuteCount') expect(bootstrap).toContain('Trigger-only S3 helpers retain an unsafe PUBLIC EXECUTE grant.') expect(ordinaryAppTriggerProof).toContain('postgresql://forge_app_test:forge_app_test@') + expect(ordinaryAppTriggerProof).toContain("--set project_id=\"${project_id}\"") + expect(ordinaryAppTriggerProof).toContain("--set package_id=\"${package_id}\" <<'SQL'") + expect(ordinaryAppTriggerProof).toContain("WHERE project_id = :'project_id'::uuid") + expect(ordinaryAppTriggerProof).toContain("WHERE work_package_id = :'package_id'::uuid") + expect(ordinaryAppTriggerProof).not.toContain('--set package_id="${package_id}" --command') expect(ordinaryAppTriggerProof).toContain('public.project_filesystem_current_decision_pointers') expect(ordinaryAppTriggerProof).toContain('public.filesystem_mcp_current_decision_pointers') expect(ordinaryAppTriggerProof).toContain('work_package_local_projection_heads') diff --git a/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh index 537a8ade..107db52b 100644 --- a/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh +++ b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh @@ -33,14 +33,15 @@ SQL trigger_state="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 \ --set project_id="${project_id}" \ - --set package_id="${package_id}" --command " - SELECT EXISTS ( - SELECT 1 FROM public.project_filesystem_current_decision_pointers - WHERE project_id = :'project_id'::uuid - ) AND EXISTS ( - SELECT 1 FROM public.filesystem_mcp_current_decision_pointers - WHERE work_package_id = :'package_id'::uuid - ) AND (SELECT count(*) FROM public.work_package_local_projection_heads - WHERE work_package_id = :'package_id'::uuid) = 8 - ")" + --set package_id="${package_id}" <<'SQL' +SELECT EXISTS ( + SELECT 1 FROM public.project_filesystem_current_decision_pointers + WHERE project_id = :'project_id'::uuid +) AND EXISTS ( + SELECT 1 FROM public.filesystem_mcp_current_decision_pointers + WHERE work_package_id = :'package_id'::uuid +) AND (SELECT count(*) FROM public.work_package_local_projection_heads + WHERE work_package_id = :'package_id'::uuid) = 8; +SQL +)" [[ "${trigger_state}" == 't' ]] || { echo 'ordinary application writes did not fire the S3 pointer and projection-head triggers' >&2; exit 1; } From d22ad9d1d9f7ef43ce5b5583b12b6f24377507b1 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:01:44 +0800 Subject: [PATCH 48/53] fix: align root write context schema metadata --- .../project-root-reconciliation.test.ts | 32 +++++++++++++++++++ .../0027_epic_172_s4_packet_context.sql | 24 +++++++++----- web/db/schema.ts | 27 +++++++++++++--- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 8c61152b..b2839ada 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -75,6 +75,38 @@ describe('project-root expansion reconciliation boundary', () => { expect(PROJECT_ROOT_RECONCILIATION_STATES).toEqual(['running', 'complete']) }) + it('keeps protected write-context physical metadata aligned with Drizzle', () => { + const writeContextDdl = migration.slice( + migration.indexOf('CREATE TABLE public.project_root_reconciliation_write_contexts'), + migration.indexOf('CREATE OR REPLACE FUNCTION forge.enter_project_root_reconciliation_generation_v1'), + ) + const writeContextSchema = schema.slice( + schema.indexOf("export const projectRootReconciliationWriteContexts"), + schema.indexOf('export const s4ProtectedReviewSourceReads'), + ) + expect(writeContextDdl).toContain('DEFAULT pg_catalog.clock_timestamp()') + expect(writeContextSchema).toContain('default(sql`pg_catalog.clock_timestamp()`)') + for (const constraint of [ + 'project_root_reconciliation_write_contexts_pkey', + 'project_root_reconciliation_write_context_generation_unique', + 'project_root_reconciliation_write_context_backend_pid_chk', + 'project_root_reconciliation_write_context_transaction_id_chk', + 'project_root_reconciliation_write_context_shape_chk', + 'project_root_reconciliation_write_contexts_operation_id_fkey', + 'project_root_reconciliation_write_contexts_generation_fkey', + 'project_root_reconciliation_write_contexts_project_id_fkey', + ]) { + expect(writeContextDdl).toContain(constraint) + expect(writeContextSchema).toContain(constraint) + } + expect(writeContextDdl).toContain('FOREIGN KEY (operation_id) REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT') + expect(writeContextDdl).toContain('FOREIGN KEY (generation) REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT') + expect(writeContextDdl).toContain('FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE RESTRICT') + expect(writeContextSchema).toContain("foreignColumns: [projectRootReconciliationOperations.operationId]") + expect(writeContextSchema).toContain('foreignColumns: [projectRootChangeJournal.generation]') + expect(writeContextSchema).toContain('foreignColumns: [projects.id]') + }) + it('fences operation creation and completion against gaps, later commits, and hijack', () => { expect(migration).toContain('forge.assert_project_root_journal_window_v1') expect(migration).toContain('v_counter <> p_through_generation OR v_max <> p_through_generation OR v_count <> p_through_generation') diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 84d88e5a..546cce36 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -664,17 +664,25 @@ END; $$; --> statement-breakpoint CREATE TABLE public.project_root_reconciliation_write_contexts ( - operation_id uuid NOT NULL REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, - generation bigint NOT NULL REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT, + operation_id uuid NOT NULL, + generation bigint NOT NULL, actor_id uuid NOT NULL, - project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE RESTRICT, - backend_pid integer NOT NULL CHECK (backend_pid > 0), - transaction_id bigint NOT NULL CHECK (transaction_id > 0), + project_id uuid NOT NULL, + backend_pid integer NOT NULL CONSTRAINT project_root_reconciliation_write_context_backend_pid_chk CHECK (backend_pid > 0), + transaction_id bigint NOT NULL CONSTRAINT project_root_reconciliation_write_context_transaction_id_chk CHECK (transaction_id > 0), + -- Record wall-clock entry time; a long root reconciliation transaction must + -- not collapse every context timestamp to its transaction start. entered_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), completed_at timestamptz, - PRIMARY KEY (operation_id, generation), - UNIQUE (generation), - CONSTRAINT project_root_reconciliation_write_context_shape_chk CHECK ((completed_at IS NULL) OR completed_at >= entered_at) + CONSTRAINT project_root_reconciliation_write_contexts_pkey PRIMARY KEY (operation_id, generation), + CONSTRAINT project_root_reconciliation_write_context_generation_unique UNIQUE (generation), + CONSTRAINT project_root_reconciliation_write_context_shape_chk CHECK ((completed_at IS NULL) OR completed_at >= entered_at), + CONSTRAINT project_root_reconciliation_write_contexts_operation_id_fkey + FOREIGN KEY (operation_id) REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, + CONSTRAINT project_root_reconciliation_write_contexts_generation_fkey + FOREIGN KEY (generation) REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT, + CONSTRAINT project_root_reconciliation_write_contexts_project_id_fkey + FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE RESTRICT ); CREATE OR REPLACE FUNCTION forge.enter_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ diff --git a/web/db/schema.ts b/web/db/schema.ts index d0b0918d..3953170c 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1903,20 +1903,37 @@ export const s4ProtectedReviewSources = pgTable('s4_protected_review_sources', { }) export const projectRootReconciliationWriteContexts = pgTable('project_root_reconciliation_write_contexts', { - operationId: uuid('operation_id').notNull().references(() => projectRootReconciliationOperations.operationId, { onDelete: 'restrict' }), - generation: bigint('generation', { mode: 'bigint' }).notNull().references(() => projectRootChangeJournal.generation, { onDelete: 'restrict' }), + operationId: uuid('operation_id').notNull(), + generation: bigint('generation', { mode: 'bigint' }).notNull(), actorId: uuid('actor_id').notNull(), - projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), backendPid: integer('backend_pid').notNull(), transactionId: bigint('transaction_id', { mode: 'bigint' }).notNull(), - enteredAt: timestamp('entered_at', tsOpts).defaultNow().notNull(), + // Match the protected write-context DDL: this is wall-clock entry time, + // rather than PostgreSQL's transaction-start timestamp. + enteredAt: timestamp('entered_at', tsOpts).default(sql`pg_catalog.clock_timestamp()`).notNull(), completedAt: timestamp('completed_at', tsOpts), }, (t) => [ - primaryKey({ columns: [t.operationId, t.generation] }), + primaryKey({ name: 'project_root_reconciliation_write_contexts_pkey', columns: [t.operationId, t.generation] }), unique('project_root_reconciliation_write_context_generation_unique').on(t.generation), check('project_root_reconciliation_write_context_backend_pid_chk', sql`${t.backendPid} > 0`), check('project_root_reconciliation_write_context_transaction_id_chk', sql`${t.transactionId} > 0`), check('project_root_reconciliation_write_context_shape_chk', sql`${t.completedAt} is null or ${t.completedAt} >= ${t.enteredAt}`), + foreignKey({ + name: 'project_root_reconciliation_write_contexts_operation_id_fkey', + columns: [t.operationId], + foreignColumns: [projectRootReconciliationOperations.operationId], + }).onDelete('restrict'), + foreignKey({ + name: 'project_root_reconciliation_write_contexts_generation_fkey', + columns: [t.generation], + foreignColumns: [projectRootChangeJournal.generation], + }).onDelete('restrict'), + foreignKey({ + name: 'project_root_reconciliation_write_contexts_project_id_fkey', + columns: [t.projectId], + foreignColumns: [projects.id], + }).onDelete('restrict'), ]) export const s4ProtectedReviewSourceReads = pgTable( From 737e75cf6826e33e8e740f7aa827d3b62d719771 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:14:58 +0800 Subject: [PATCH 49/53] test: close root reconciler column privileges --- .../project-root-reconciliation.test.ts | 9 +++++++ ...027-root-reconciler-privilege-mutations.sh | 5 ++++ ...-root-reconciler-privileges-assertions.sql | 27 +++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index b2839ada..2f6fc1c7 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -303,6 +303,13 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeProof).toContain("SELECT 'MAINTAIN'") expect(privilegeProof).toContain("current_setting('server_version_num')::integer >= 170000") expect(privilegeProof).toContain('has_column_privilege') + expect(privilegeProof).toContain("VALUES ('SELECT'), ('INSERT'), ('UPDATE'), ('REFERENCES')") + expect(privilegeProof).toContain("'column_' || lower(privilege.privilege)") + expect(privilegeProof).toContain("NOT pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege)") + expect(privilegeProof).toContain("('column_select')") + expect(privilegeProof).toContain("('column_insert')") + expect(privilegeProof).toContain("('column_update')") + expect(privilegeProof).toContain("('column_references')") expect(privilegeProof).toContain('has_function_privilege') expect(privilegeProof).toContain('has_sequence_privilege') expect(privilegeProof).toContain('has_schema_privilege') @@ -340,10 +347,12 @@ describe('project-root expansion reconciliation boundary', () => { it('proves the exact closed privilege allowlist rejects direct, column, and PUBLIC-effective mutations', () => { expect(privilegeMutationProof).toContain('GRANT INSERT ON TABLE public.projects TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') + expect(privilegeMutationProof).toContain('GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeMutationProof).toContain('public.projects:INSERT') expect(privilegeMutationProof).toContain('public.tasks.title') + expect(privilegeMutationProof).toContain('public.project_root_reconciliation_write_contexts.actor_id') expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') expect(privilegeMutationProof).toContain('\\i ${assertion_file}') expect(privilegeMutationProof).not.toContain('has_table_privilege') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index a2e231b5..390765bb 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -60,6 +60,11 @@ expect_allowlist_rejection \ 'public.tasks.title' \ 'GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;' +expect_allowlist_rejection \ + 'column select privilege mutation' \ + 'public.project_root_reconciliation_write_contexts.actor_id' \ + 'GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;' + # This intentionally exercises an effective privilege inherited from PUBLIC, # rather than only a direct grant to the dedicated login. expect_allowlist_rejection \ diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index a06e44ba..e0cdea61 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -10,6 +10,22 @@ CREATE TEMP TABLE root_reconciler_actual_privileges ( entry text NOT NULL, PRIMARY KEY (category, entry) ); +CREATE TEMP TABLE root_reconciler_privilege_categories ( + category text PRIMARY KEY +); + +INSERT INTO root_reconciler_privilege_categories (category) VALUES + ('relation'), + ('column_select'), + ('column_insert'), + ('column_update'), + ('column_references'), + ('routine'), + ('schema'), + ('database'), + ('sequence'), + ('ownership'), + ('membership'); INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES ('relation', 'public.projects:SELECT'), @@ -65,16 +81,20 @@ WHERE namespace_row.nspname IN ('public', 'forge') -- public.project_root_reconciliation_write_contexts: absent from the allowlist -- means every effective direct relation privilege is rejected. +-- Record only column-specific effective grants. A table-level privilege of the +-- same class applies to every column and is already represented above. INSERT INTO root_reconciler_actual_privileges (category, entry) -SELECT 'column_update', namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname +SELECT 'column_' || lower(privilege.privilege), namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname FROM pg_catalog.pg_class relation JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = relation.oid +CROSS JOIN (VALUES ('SELECT'), ('INSERT'), ('UPDATE'), ('REFERENCES')) AS privilege(privilege) WHERE namespace_row.nspname IN ('public', 'forge') AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') AND attribute_row.attnum > 0 AND NOT attribute_row.attisdropped - AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, 'UPDATE'); + AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, privilege.privilege) + AND NOT pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege); INSERT INTO root_reconciler_actual_privileges (category, entry) SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.oidvectortypes(routine.proargtypes), ', ', ',') || ')' @@ -182,6 +202,8 @@ BEGIN WHERE expected.category = categories.category ) unexpected_rows), '[]'::jsonb) AS unexpected FROM ( + SELECT category FROM root_reconciler_privilege_categories + UNION SELECT category FROM root_reconciler_expected_privileges UNION SELECT category FROM root_reconciler_actual_privileges @@ -197,3 +219,4 @@ $proof$; DROP TABLE root_reconciler_actual_privileges; DROP TABLE root_reconciler_expected_privileges; +DROP TABLE root_reconciler_privilege_categories; From c7288810f74cda1080102c5484dbbeb703b52d69 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:20:22 +0800 Subject: [PATCH 50/53] test: close root reconciler grant options --- .../project-root-reconciliation.test.ts | 19 ++++ ...027-root-reconciler-privilege-mutations.sh | 37 +++++++ ...-root-reconciler-privileges-assertions.sql | 102 ++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 2f6fc1c7..36e37c3c 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -310,6 +310,22 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeProof).toContain("('column_insert')") expect(privilegeProof).toContain("('column_update')") expect(privilegeProof).toContain("('column_references')") + expect(privilegeProof).toContain("('grant_option_relation')") + expect(privilegeProof).toContain("('grant_option_column')") + expect(privilegeProof).toContain("('grant_option_routine')") + expect(privilegeProof).toContain("('grant_option_schema')") + expect(privilegeProof).toContain("('grant_option_database')") + expect(privilegeProof).toContain("('grant_option_sequence')") + expect(privilegeProof).toContain('pg_catalog.aclexplode') + expect(privilegeProof).toContain("pg_catalog.acldefault('r', relation.relowner)") + expect(privilegeProof).toContain("pg_catalog.acldefault('c', relation.relowner)") + expect(privilegeProof).toContain("pg_catalog.acldefault('f', routine.proowner)") + expect(privilegeProof).toContain("pg_catalog.acldefault('n', namespace_row.nspowner)") + expect(privilegeProof).toContain("pg_catalog.acldefault('d', database_row.datdba)") + expect(privilegeProof).toContain("pg_catalog.acldefault('s', sequence_row.relowner)") + expect(privilegeProof).toContain('acl_row.is_grantable') + expect(privilegeProof).toContain("CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC'") + expect(privilegeProof).toContain('direct/PUBLIC ACL rows are the complete effective grant-option surface') expect(privilegeProof).toContain('has_function_privilege') expect(privilegeProof).toContain('has_sequence_privilege') expect(privilegeProof).toContain('has_schema_privilege') @@ -348,15 +364,18 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeMutationProof).toContain('GRANT INSERT ON TABLE public.projects TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;') + expect(privilegeMutationProof).toContain('GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;') expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeMutationProof).toContain('public.projects:INSERT') expect(privilegeMutationProof).toContain('public.tasks.title') expect(privilegeMutationProof).toContain('public.project_root_reconciliation_write_contexts.actor_id') + expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${grantor}:grantee=forge_project_root_reconciler') expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') expect(privilegeMutationProof).toContain('\\i ${assertion_file}') expect(privilegeMutationProof).not.toContain('has_table_privilege') expect(privilegeMutationProof).toContain('fresh clean allowlist assertion') + expect(privilegeMutationProof).toContain('snapshot_application_acls') expect(upgradeProof).toContain('prove-migration-0027-root-reconciler-privilege-mutations.sh') }) diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index 390765bb..638e5848 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -20,6 +20,31 @@ fail_with_output() { exit 1 } +snapshot_application_acls() { + psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 --command " + WITH acl_rows AS ( + SELECT 'relation:' || namespace_row.nspname || '.' || relation.relname || ':' || coalesce(relation.relacl::text, '') AS entry + FROM pg_catalog.pg_class relation JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace + WHERE namespace_row.nspname IN ('public', 'forge') + UNION ALL + SELECT 'column:' || namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname || ':' || coalesce(attribute_row.attacl::text, '') + FROM pg_catalog.pg_class relation JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace + JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = relation.oid + WHERE namespace_row.nspname IN ('public', 'forge') AND attribute_row.attnum > 0 AND NOT attribute_row.attisdropped + UNION ALL + SELECT 'routine:' || namespace_row.nspname || '.' || routine.proname || '(' || pg_catalog.oidvectortypes(routine.proargtypes) || '):' || coalesce(routine.proacl::text, '') + FROM pg_catalog.pg_proc routine JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace + WHERE namespace_row.nspname IN ('public', 'forge') + UNION ALL + SELECT 'schema:' || namespace_row.nspname || ':' || coalesce(namespace_row.nspacl::text, '') + FROM pg_catalog.pg_namespace namespace_row WHERE namespace_row.nspname IN ('public', 'forge') + UNION ALL + SELECT 'database:' || database_row.datname || ':' || coalesce(database_row.datacl::text, '') + FROM pg_catalog.pg_database database_row WHERE database_row.datname = pg_catalog.current_database() + ) + SELECT md5(coalesce(string_agg(entry, E'\\n' ORDER BY entry), '')) FROM acl_rows" +} + expect_allowlist_rejection() { local phase="$1" local expected_entry="$2" @@ -50,6 +75,9 @@ SQL # Every mutation is uncommitted. The assertion failure closes that psql session, # which rolls the transaction back before the following fresh-session probe. +baseline_acl="$(snapshot_application_acls)" +[[ "${baseline_acl}" =~ ^[0-9a-f]{32}$ ]] || { echo 'root reconciler privilege mutation proof could not fingerprint baseline ACLs' >&2; exit 1; } + expect_allowlist_rejection \ 'relation privilege mutation' \ 'public.projects:INSERT' \ @@ -65,6 +93,13 @@ expect_allowlist_rejection \ 'public.project_root_reconciliation_write_contexts.actor_id' \ 'GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;' +grantor="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 --command 'SELECT current_user')" +[[ "${grantor}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo 'root reconciler privilege mutation proof received an unsafe grantor identity' >&2; exit 1; } +expect_allowlist_rejection \ + 'relation grant option mutation' \ + "public.projects:SELECT:grantor=${grantor}:grantee=forge_project_root_reconciler" \ + 'GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;' + # This intentionally exercises an effective privilege inherited from PUBLIC, # rather than only a direct grant to the dedicated login. expect_allowlist_rejection \ @@ -76,3 +111,5 @@ clean_output="${proof_tmpdir}/clean.log" if ! psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file "${assertion_file}" >"${clean_output}" 2>&1; then fail_with_output 'fresh clean allowlist assertion' "${clean_output}" fi + +[[ "$(snapshot_application_acls)" == "${baseline_acl}" ]] || { echo 'root reconciler privilege mutation proof left application ACL bytes changed after rollback' >&2; exit 1; } diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index e0cdea61..ed8dd5cc 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -20,6 +20,12 @@ INSERT INTO root_reconciler_privilege_categories (category) VALUES ('column_insert'), ('column_update'), ('column_references'), + ('grant_option_relation'), + ('grant_option_column'), + ('grant_option_routine'), + ('grant_option_schema'), + ('grant_option_database'), + ('grant_option_sequence'), ('routine'), ('schema'), ('database'), @@ -96,6 +102,102 @@ WHERE namespace_row.nspname IN ('public', 'forge') AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, privilege.privilege) AND NOT pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege); +-- Grant options are delegation authority. ACL rows identify their source; the +-- role has no memberships and owns no application objects (checked below), so +-- direct/PUBLIC ACL rows are the complete effective grant-option surface. +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_relation', + namespace_row.nspname || '.' || relation.relname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(relation.relacl, pg_catalog.acldefault('r', relation.relowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND ( + acl_row.privilege_type IN ('SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER') + OR (acl_row.privilege_type = 'MAINTAIN' + AND pg_catalog.current_setting('server_version_num')::integer >= 170000) + ); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_column', + namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = relation.oid +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(attribute_row.attacl, pg_catalog.acldefault('c', relation.relowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND attribute_row.attnum > 0 + AND NOT attribute_row.attisdropped + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type IN ('SELECT', 'INSERT', 'UPDATE', 'REFERENCES'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_routine', + namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.oidvectortypes(routine.proargtypes), ', ', ',') || '):EXECUTE' || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_proc routine +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(routine.proacl, pg_catalog.acldefault('f', routine.proowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND routine.prokind IN ('f', 'p') + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type = 'EXECUTE'; + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_schema', + namespace_row.nspname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_namespace namespace_row +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(namespace_row.nspacl, pg_catalog.acldefault('n', namespace_row.nspowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type IN ('USAGE', 'CREATE'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_database', + database_row.datname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_database database_row +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(database_row.datacl, pg_catalog.acldefault('d', database_row.datdba))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE database_row.datname = pg_catalog.current_database() + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type IN ('CONNECT', 'CREATE', 'TEMPORARY'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_sequence', + namespace_row.nspname || '.' || sequence_row.relname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_class sequence_row +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = sequence_row.relnamespace +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(sequence_row.relacl, pg_catalog.acldefault('s', sequence_row.relowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND sequence_row.relkind = 'S' + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type IN ('USAGE', 'SELECT', 'UPDATE'); + INSERT INTO root_reconciler_actual_privileges (category, entry) SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.oidvectortypes(routine.proargtypes), ', ', ',') || ')' FROM pg_catalog.pg_proc routine From 15751a686a4636edcdcdbe5dee0732b282601879 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:24:59 +0800 Subject: [PATCH 51/53] test: bind root grantor to mutation session --- .../project-root-reconciliation.test.ts | 6 ++- ...027-root-reconciler-privilege-mutations.sh | 51 ++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 36e37c3c..1c01b90e 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -365,12 +365,16 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;') + expect(privilegeMutationProof).toContain('SELECT session_user AS mutation_session_user, current_user AS mutation_current_user \\gset') + expect(privilegeMutationProof).toContain('ROOT_GRANT_OPTION_MUTATION_IDENTITY session=:mutation_session_user current=:mutation_current_user') + expect(privilegeMutationProof).toContain('PostgreSQL records ACL grantors as current_user') + expect(privilegeMutationProof).not.toContain("grantor=\"$(psql") expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeMutationProof).toContain('public.projects:INSERT') expect(privilegeMutationProof).toContain('public.tasks.title') expect(privilegeMutationProof).toContain('public.project_root_reconciliation_write_contexts.actor_id') - expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${grantor}:grantee=forge_project_root_reconciler') + expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${mutation_current_user}:grantee=forge_project_root_reconciler') expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') expect(privilegeMutationProof).toContain('\\i ${assertion_file}') expect(privilegeMutationProof).not.toContain('has_table_privilege') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index 638e5848..8ae597d1 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -73,6 +73,50 @@ SQL fi } +expect_grant_option_rejection() { + local phase='relation grant option mutation' + local output_file="${proof_tmpdir}/${phase}.log" + local status=0 identity_line mutation_session_user mutation_current_user expected_entry + + set +e + psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 >"${output_file}" 2>&1 <&2 + fi + expected_entry="public.projects:SELECT:grantor=${mutation_current_user}:grantee=forge_project_root_reconciler" + + if [[ "${status}" -ne 3 ]]; then + fail_with_output "${phase}: expected psql script rejection status 3, received ${status}" "${output_file}" + fi + if ! grep -F -- 'root reconciler effective privilege allowlist mismatch' "${output_file}" >/dev/null; then + fail_with_output "${phase}: exact allowlist rejection was absent" "${output_file}" + fi + if ! grep -F -- "${expected_entry}" "${output_file}" >/dev/null; then + fail_with_output "${phase}: expected exact unexpected-set entry ${expected_entry} was absent" "${output_file}" + fi +} + # Every mutation is uncommitted. The assertion failure closes that psql session, # which rolls the transaction back before the following fresh-session probe. baseline_acl="$(snapshot_application_acls)" @@ -93,12 +137,7 @@ expect_allowlist_rejection \ 'public.project_root_reconciliation_write_contexts.actor_id' \ 'GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;' -grantor="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 --command 'SELECT current_user')" -[[ "${grantor}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo 'root reconciler privilege mutation proof received an unsafe grantor identity' >&2; exit 1; } -expect_allowlist_rejection \ - 'relation grant option mutation' \ - "public.projects:SELECT:grantor=${grantor}:grantee=forge_project_root_reconciler" \ - 'GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;' +expect_grant_option_rejection # This intentionally exercises an effective privilege inherited from PUBLIC, # rather than only a direct grant to the dedicated login. From f6a85435ce044185922aed0f230acd0f25537386 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:30:21 +0800 Subject: [PATCH 52/53] test: use root owner for grant option mutation --- .../project-root-reconciliation.test.ts | 11 +++-- ...027-root-reconciler-privilege-mutations.sh | 40 ++++++++++++------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 1c01b90e..2434f49a 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -365,16 +365,19 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;') - expect(privilegeMutationProof).toContain('SELECT session_user AS mutation_session_user, current_user AS mutation_current_user \\gset') - expect(privilegeMutationProof).toContain('ROOT_GRANT_OPTION_MUTATION_IDENTITY session=:mutation_session_user current=:mutation_current_user') - expect(privilegeMutationProof).toContain('PostgreSQL records ACL grantors as current_user') + expect(privilegeMutationProof).toContain('pg_catalog.pg_get_userbyid(relation.relowner) AS mutation_owner_role') + expect(privilegeMutationProof).toContain("pg_catalog.pg_has_role(session_user, pg_catalog.pg_get_userbyid(relation.relowner), 'SET')") + expect(privilegeMutationProof).toContain('SET LOCAL ROLE :"mutation_owner_role";') + expect(privilegeMutationProof).toContain('RESET ROLE;') + expect(privilegeMutationProof).toContain('ROOT_GRANT_OPTION_MUTATION_IDENTITY owner=:mutation_owner_role session=:mutation_session_user current=:mutation_current_user can_set=:mutation_can_set_owner') + expect(privilegeMutationProof).toContain('PostgreSQL records the ACL grantor as the owner role that issued GRANT') expect(privilegeMutationProof).not.toContain("grantor=\"$(psql") expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeMutationProof).toContain('public.projects:INSERT') expect(privilegeMutationProof).toContain('public.tasks.title') expect(privilegeMutationProof).toContain('public.project_root_reconciliation_write_contexts.actor_id') - expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${mutation_current_user}:grantee=forge_project_root_reconciler') + expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${mutation_owner_role}:grantee=forge_project_root_reconciler') expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') expect(privilegeMutationProof).toContain('\\i ${assertion_file}') expect(privilegeMutationProof).not.toContain('has_table_privilege') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index 8ae597d1..dfdafb2a 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -76,35 +76,45 @@ SQL expect_grant_option_rejection() { local phase='relation grant option mutation' local output_file="${proof_tmpdir}/${phase}.log" - local status=0 identity_line mutation_session_user mutation_current_user expected_entry + local status=0 identity_line mutation_owner_role mutation_session_user mutation_current_user mutation_can_set_owner expected_entry set +e psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 >"${output_file}" 2>&1 <&2 - fi - expected_entry="public.projects:SELECT:grantor=${mutation_current_user}:grantee=forge_project_root_reconciler" + [[ "${mutation_can_set_owner}" == t && "${mutation_current_user}" == "${mutation_owner_role}" ]] || { + fail_with_output "${phase}: mutation session could not assume the catalog-derived owner role" "${output_file}" + } + # PostgreSQL records the ACL grantor as the owner role that issued GRANT. + # session_user remains in the proof output so inherited authority is visible. + expected_entry="public.projects:SELECT:grantor=${mutation_owner_role}:grantee=forge_project_root_reconciler" if [[ "${status}" -ne 3 ]]; then fail_with_output "${phase}: expected psql script rejection status 3, received ${status}" "${output_file}" From 445a66a1c01cae6c8940a92ce8d1a83254b06263 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:40:31 +0800 Subject: [PATCH 53/53] test: run root privilege mutations in upgrade proof --- web/__tests__/project-root-reconciliation.test.ts | 12 ++++++++++++ ...ation-0027-root-reconciler-privilege-mutations.sh | 10 ++++++++++ web/scripts/ci/prove-migration-0027-upgrade.sh | 2 ++ 3 files changed, 24 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 2434f49a..c018f624 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -383,7 +383,19 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeMutationProof).not.toContain('has_table_privilege') expect(privilegeMutationProof).toContain('fresh clean allowlist assertion') expect(privilegeMutationProof).toContain('snapshot_application_acls') + expect(privilegeMutationProof).toContain("echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_START'") + expect(privilegeMutationProof).toContain('ROOT_GRANT_OPTION_MUTATION_IDENTITY owner=') + expect(privilegeMutationProof).toContain('ROOT_RECONCILER_PRIVILEGE_MUTATION_REJECTION phase=${phase} entry=${expected_entry}') + expect(privilegeMutationProof).toContain("echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_SUCCESS'") expect(upgradeProof).toContain('prove-migration-0027-root-reconciler-privilege-mutations.sh') + const mutationStart = upgradeProof.indexOf("echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_PHASE_START'") + const mutationInvocation = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh') + const mutationSuccess = upgradeProof.indexOf("echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_PHASE_SUCCESS'") + const negativeProofStart = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh') + expect(mutationStart).toBeGreaterThanOrEqual(0) + expect(mutationStart).toBeLessThan(mutationInvocation) + expect(mutationInvocation).toBeLessThan(mutationSuccess) + expect(mutationSuccess).toBeLessThan(negativeProofStart) }) it('rejects stale and fabricated root reconciliation context reuse before the CLI resumes', () => { diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index dfdafb2a..1c6ab9ee 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -3,6 +3,8 @@ set -euo pipefail : "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" +echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_START' + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" assertion_file="${script_dir}/sql/migration-0027-root-reconciler-privileges-assertions.sql" proof_tmpdir="$(mktemp -d)" @@ -71,6 +73,9 @@ SQL if ! grep -F -- "${expected_entry}" "${output_file}" >/dev/null; then fail_with_output "${phase}: expected unexpected-set entry ${expected_entry} was absent" "${output_file}" fi + grep -F -- 'root reconciler effective privilege allowlist mismatch' "${output_file}" + grep -F -- "${expected_entry}" "${output_file}" + echo "ROOT_RECONCILER_PRIVILEGE_MUTATION_REJECTION phase=${phase} entry=${expected_entry}" } expect_grant_option_rejection() { @@ -125,6 +130,10 @@ SQL if ! grep -F -- "${expected_entry}" "${output_file}" >/dev/null; then fail_with_output "${phase}: expected exact unexpected-set entry ${expected_entry} was absent" "${output_file}" fi + printf '%s\n' "${identity_line}" + grep -F -- 'root reconciler effective privilege allowlist mismatch' "${output_file}" + grep -F -- "${expected_entry}" "${output_file}" + echo "ROOT_RECONCILER_PRIVILEGE_MUTATION_REJECTION phase=${phase} entry=${expected_entry}" } # Every mutation is uncommitted. The assertion failure closes that psql session, @@ -162,3 +171,4 @@ if ! psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file "${assertio fi [[ "$(snapshot_application_acls)" == "${baseline_acl}" ]] || { echo 'root reconciler privilege mutation proof left application ACL bytes changed after rollback' >&2; exit 1; } +echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_SUCCESS' diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index da3efe94..34a4a6da 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -61,7 +61,9 @@ fi bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_PHASE_START' bash scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_PHASE_SUCCESS' # This creates (but never advances) the exact live operation. The shim resumes # it below after its now-empty materialization pass. bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh