diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 8188b031..c86ddb6e 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -205,13 +205,38 @@ jobs: ALTER ROLE forge_architect_plan_resolver PASSWORD 'forge_plan_resolver_test'; ALTER ROLE forge_architect_plan_history_reader PASSWORD 'forge_plan_history_reader_test'; ALTER ROLE forge_packet_issuer PASSWORD 'forge_packet_issuer_test'; + ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test'; SQL - - name: Reconcile fresh-install root references with zero-scan proof - run: npm run project-roots:reconcile-expansion + - name: Materialize legacy root references through the dedicated principal + env: + FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL: postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@localhost:5432/forge_epic_172_ci_test + run: | + while :; do + rows="$(psql "$FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL" --no-align --tuples-only --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 + - name: Capture the post-drain root journal watermark + id: root_journal_watermark + env: + FORGE_DATABASE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_epic_172_ci_test + run: | + watermark="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command "SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton;")" + if [[ ! "$watermark" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo 'The post-drain root journal watermark is malformed.' >&2 + exit 1 + fi + echo "FORGE_ROOT_REF_CUTOVER_WATERMARK=$watermark" >> "$GITHUB_ENV" + - name: Reconcile fresh-install root references through the dedicated principal + run: npm run project-roots:reconcile-expansion -- --through "$FORGE_ROOT_REF_CUTOVER_WATERMARK" --actor 11111111-1111-4111-8111-111111111111 --apply + env: + FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL: postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@localhost:5432/forge_epic_172_ci_test + - name: Build the separately gated concurrent root-reference index + run: npm run project-roots:build-concurrent-index -- --apply env: FORGE_DATABASE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_epic_172_ci_test - name: Apply the separately gated strict root-reference cutover - run: npm run project-roots:strict-cutover -- --apply + run: npm run project-roots:strict-cutover -- --through "$FORGE_ROOT_REF_CUTOVER_WATERMARK" --apply env: FORGE_DATABASE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_epic_172_ci_test - name: Provision the ordinary app release-reader boundary @@ -275,7 +300,10 @@ jobs: 'local_effect_recovery_actions', 's4_max_attempt_finalizations', 'local_projection_archive_operations', 'local_projection_archive_operation_checkpoints', - 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation' + '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_write_contexts' ] LOOP FOREACH table_privilege IN ARRAY ARRAY[ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER' @@ -340,7 +368,10 @@ jobs: 'local_effect_recovery_actions', 's4_max_attempt_finalizations', 'local_projection_archive_operations', 'local_projection_archive_operation_checkpoints', - 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation' + '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_write_contexts' ]; projection_tables constant text[] := ARRAY[ 'forge_epic_172_s3_release_state', 'work_package_local_projection_sources', diff --git a/web/__tests__/epic-172-s4-context.test.ts b/web/__tests__/epic-172-s4-context.test.ts index 690da623..e98eca15 100644 --- a/web/__tests__/epic-172-s4-context.test.ts +++ b/web/__tests__/epic-172-s4-context.test.ts @@ -167,6 +167,8 @@ describe('Epic 172 S4 PostgreSQL CI contract', () => { 'local_projection_archive_operations', 'local_projection_archive_operation_checkpoints', 'filesystem_mcp_decision_nonce_claims', + 'project_root_change_journal_counter', + 'project_root_change_journal', ]) { expect(ordinaryInventory).not.toContain(`'${table}'`) expect(protectedInventory).toContain(`'${table}'`) diff --git a/web/__tests__/project-root-change-journal.test.ts b/web/__tests__/project-root-change-journal.test.ts new file mode 100644 index 00000000..84f08732 --- /dev/null +++ b/web/__tests__/project-root-change-journal.test.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES, + parseProjectRootChangeJournalEntry, +} from '@/lib/mcps/project-root-change-journal' + +const migration = readFileSync( + fileURLToPath(new URL('../db/migrations/0027_epic_172_s4_packet_context.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 validEntry = () => ({ + generation: '1', + operationId: '00000000-0000-4000-8000-000000000001', + projectId: '00000000-0000-4000-8000-000000000002', + outcome: 'root_update', + rootBindingRevision: '2', + grantDecisionRevision: '3', + occurredAt: new Date('2026-07-27T00:00:00.000Z'), +}) + +describe('project root change journal contract', () => { + it('shares the exact closed outcome tuple with the bounded parser', () => { + expect(PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES).toEqual(['insert', 'root_update', 'archive']) + expect(migration).toContain("outcome IN ('insert','root_update','archive')") + expect(schema).toContain("in ('insert','root_update','archive')") + expect(parseProjectRootChangeJournalEntry(validEntry())).toEqual(expect.objectContaining({ + generation: BigInt(1), + outcome: 'root_update', + rootBindingRevision: BigInt(2), + grantDecisionRevision: BigInt(3), + })) + }) + + it.each([ + 'root-update', 'deleted_row', 'deleted-row', 'delete', 'unknown', null, 7, + ])('rejects stale or unknown outcome %#', (outcome) => { + expect(() => parseProjectRootChangeJournalEntry({ ...validEntry(), outcome })).toThrow(/outcome/i) + }) + + it.each([ + ['generation', '0'], + ['generation', '-1'], + ['generation', 1], + ['rootBindingRevision', '0'], + ['rootBindingRevision', '-1'], + ['grantDecisionRevision', '0'], + ['grantDecisionRevision', '-1'], + ['operationId', 'not-a-uuid'], + ['projectId', '00000000-0000-0000-0000-000000000000'], + ['occurredAt', new Date('invalid')], + ['unexpected', 'value'], + ])('rejects malformed bounded field %s', (field, replacement) => { + const entry = validEntry() as Record + entry[field] = replacement + expect(() => parseProjectRootChangeJournalEntry(entry)).toThrow() + }) + + it('allows null only when a project has no applicable positive revision yet', () => { + expect(parseProjectRootChangeJournalEntry({ + ...validEntry(), outcome: 'insert', rootBindingRevision: null, grantDecisionRevision: null, + })).toEqual(expect.objectContaining({ rootBindingRevision: null, grantDecisionRevision: null })) + }) + + it('uses a protected gap-free counter and an append-only, path-free trigger journal', () => { + const journalStart = migration.indexOf('CREATE TABLE public.project_root_change_journal (') + const journalEnd = migration.indexOf('--> statement-breakpoint', journalStart) + const journalTable = migration.slice(journalStart, journalEnd) + expect(journalTable).toContain("outcome IN ('insert','root_update','archive')") + expect(journalTable).not.toMatch(/local_path|root_ref|jsonb|reason|text\s+NOT NULL.*content/i) + expect(migration).toContain('CREATE TABLE public.project_root_change_journal_counter') + expect(migration).toContain('UPDATE public.project_root_change_journal_counter counter') + expect(migration).toContain('RETURNING counter.last_generation INTO STRICT v_generation') + expect(migration).not.toMatch(/project_root_change_journal[\s\S]{0,120}(?:bigserial|identity|nextval)/i) + const appendStart = migration.indexOf('CREATE OR REPLACE FUNCTION forge.append_project_root_change_journal_v1()') + const appendEnd = migration.indexOf('--> statement-breakpoint', appendStart) + const appendFunction = migration.slice(appendStart, appendEnd) + expect(appendFunction.match(/INSERT INTO public\.project_root_change_journal/g)).toHaveLength(1) + expect(appendFunction).not.toMatch(/FOR UPDATE.*projects|projects.*FOR UPDATE/i) + expect(migration).toContain("NEW.archived_at IS NOT NULL AND OLD.archived_at IS NULL") + expect(migration).toContain("v_outcome := 'archive'") + expect(migration).toContain("v_outcome := 'root_update'") + expect(migration).toContain('BEFORE UPDATE OR DELETE ON public.project_root_change_journal') + expect(migration).toContain("RAISE EXCEPTION 'project root change journal is append-only'") + }) + + it('keeps the journal protected, owned, and absent from the premature unique-index cutover', () => { + for (const objectName of ['project_root_change_journal', 'project_root_change_journal_counter']) { + expect(bootstrap).toContain(`'${objectName}'`) + expect(migration).toContain(`ALTER TABLE public.${objectName} OWNER TO forge_s4_routines_owner`) + } + for (const routine of [ + 'append_project_root_change_journal_v1', + 'reject_project_root_change_journal_mutation_v1', + ]) { + expect(bootstrap).toContain(`'${routine}'`) + expect(migration).toContain(`REVOKE ALL ON FUNCTION forge.${routine}() FROM PUBLIC`) + } + expect(migration).toContain('REVOKE ALL ON public.architect_plan_versions') + expect(migration).toContain('public.project_root_change_journal FROM PUBLIC') + expect(bootstrap).toContain('where acl.grantee <> table_row.relowner') + expect(schema).toContain("export const projectRootChangeJournal = pgTable('project_root_change_journal'") + expect(migration).not.toContain('CREATE UNIQUE INDEX projects_root_ref_idx') + expect(schema).not.toContain("uniqueIndex('projects_root_ref_idx')") + }) +}) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts new file mode 100644 index 00000000..c018f624 --- /dev/null +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -0,0 +1,510 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + parseProjectRootReconciliationCommand, + PROJECT_ROOT_RECONCILIATION_STATES, +} 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') +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 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 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 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') +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') + +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'") + 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' + expect(parseProjectRootReconciliationCommand(['--through', '0', '--actor', actor])).toEqual({ + actorId: actor, apply: false, throughGeneration: BigInt(0), + }) + expect(() => parseProjectRootReconciliationCommand(['--through', '-1', '--actor', actor, '--apply'])).toThrow('non-negative') + expect(() => parseProjectRootReconciliationCommand(['--through', '1', '--actor', actor, '--admin'])).toThrow('Unknown') + }) + + it('uses path-free append-only operation, checkpoint, and outcome contracts', () => { + for (const table of [ + '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') + expect(migration).toContain('project_root_reconciliation_outcomes_append_only_v1') + expect(migration).toContain("state IN ('running','complete')") + const reconciliationSchema = migration.slice( + migration.indexOf('CREATE TABLE public.project_root_reconciliation_operations'), + migration.indexOf('CREATE OR REPLACE FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1()'), + ) + 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']) + }) + + 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') + expect(migration).toContain('project-root operation identity cannot be hijacked') + expect(migration).toContain('operation_row.actor_id = p_actor_id') + 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') + expect(migration).toContain('transaction_id bigint NOT NULL') + expect(migration).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(migration).toContain('project-root write context is absent or stale') + expect(migration).toContain('project_root_reconciliation_write_contexts_append_only_v1') + expect(migration).toContain('project-root write context is immutable outside fixed completion') + expect(migration).toContain('CREATE CONSTRAINT TRIGGER project_root_reconciliation_write_contexts_commit_v1') + expect(migration).toContain('DEFERRABLE INITIALLY DEFERRED') + expect(migration).toContain('project-root write context must complete before commit') + expect(migration).toContain('forge.lock_project_root_reconciliation_authority_v1') + 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).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')) + }) + + it('uses the dedicated login with only canonical helper state columns and fixed routines', () => { + expect(reconcileScript).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') + expect(reconcileScript).toContain("forge_project_root_reconciler") + expect(reconcileScript).toContain('reconcileFilesystemGrantsForProject') + expect(reconcileScript).toContain('hasBoundFilesystemAuthority') + expect(bootstrap).toContain("'forge_project_root_reconciler'") + expect(migration).not.toContain('GRANT SELECT ON public.projects, public.tasks, public.work_packages') + expect(migration).not.toContain('GRANT UPDATE (status, error_message, updated_at) ON public.tasks TO forge_project_root_reconciler') + expect(bootstrap).toContain('grant select on table public.projects, public.tasks, public.work_packages') + expect(bootstrap).toContain('grant update (status, error_message, updated_at) on table public.tasks to forge_project_root_reconciler') + expect(bootstrap).toContain('grant update (status, blocked_reason, metadata, updated_at) on table public.work_packages to forge_project_root_reconciler') + expect(bootstrap).not.toContain('grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler') + expect(bootstrap).not.toContain('grant update (id) on table public.projects, public.filesystem_mcp_grant_approvals') + expect(bootstrap).not.toContain('grant update (project_id) on table public.project_filesystem_current_decision_pointers') + expect(bootstrap).not.toContain('grant update (work_package_id) on table public.filesystem_mcp_current_decision_pointers') + expect(migration).not.toContain('GRANT EXECUTE ON FUNCTION forge.advance_local_projection_head_v1') + expect(bootstrap).toContain('grant execute on function forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text) to forge_project_root_reconciler') + expect(migration).toContain('public.project_root_reconciliation_operations') + expect(migration).toContain('REVOKE ALL ON FUNCTION forge.begin_project_root_reconciliation_v1') + for (const table of [ + 'project_root_reconciliation_operations', + 'project_root_reconciliation_checkpoints', + 'project_root_reconciliation_outcomes', + ]) expect(webCi).toContain(`'${table}'`) + }) + + 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'") + 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('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') + expect(rootAuthorityPackageFixture).toContain('filesystem.project.search') + }) + + 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('--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 --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', () => { + 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'") + 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') + 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("'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', () => { + 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("'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') + 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( + upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh'), + ) + }) + + 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') + 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('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("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("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("('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') + expect(privilegeProof).toContain('has_database_privilege') + expect(privilegeProof).toContain('pg_auth_members') + 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("--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') + 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') + }) + + 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 SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;') + 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_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') + 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', () => { + 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('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'") + }) + + 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(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 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', () => { + expect(reconcileScript).toContain('suppressPhasePersistence: true') + expect(reconciliation).toContain('suppressPhasePersistence?: boolean') + 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:') + 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\')') + }) + + 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') + 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(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") + 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') + 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) + 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(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') + 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(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/db/migrations/0026_epic_172_s3_grant_lifecycle.sql b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql index b979d78d..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 ( ( @@ -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 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 +$$; -- 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" @@ -418,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 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 b6ce989c..546cce36 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -41,6 +41,9 @@ BEGIN ) OR NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'forge_local_projection_archiver' AND rolcanlogin AND NOT rolinherit AND NOT rolsuper + ) OR NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles + WHERE rolname = 'forge_project_root_reconciler' AND rolcanlogin AND NOT rolinherit AND NOT rolsuper ) THEN RAISE EXCEPTION 'dedicated S4 logins must be bootstrapped before migration' USING ERRCODE = '42501'; @@ -360,77 +363,473 @@ CREATE TRIGGER projects_root_ref_renull_guard_v1 BEFORE UPDATE OF root_ref ON public.projects FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_ref_renull_v1(); --> statement-breakpoint -CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects (root_ref); ---> statement-breakpoint +-- Kept as a protected compatibility tombstone for the C5 expansion window. +-- C6 never invokes it; durable operation rows below replace its singleton use. CREATE TABLE public.project_root_ref_reconciliation ( singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), last_project_id uuid, rows_updated bigint NOT NULL DEFAULT 0 CHECK (rows_updated >= 0), - state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','running','complete')), + state text NOT NULL DEFAULT 'superseded' CHECK (state IN ('superseded','complete')), updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp() ); INSERT INTO public.project_root_ref_reconciliation (singleton) VALUES (true); --> statement-breakpoint +-- Retained only so an upgraded operator receives an explicit refusal instead +-- of silently running the superseded singleton algorithm. CREATE OR REPLACE FUNCTION forge.reconcile_project_root_refs_v1(p_batch_size integer) RETURNS TABLE (batch_rows integer, remaining_nulls bigint, reconciliation_state text) LANGUAGE plpgsql SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'singleton root-reference reconciliation is superseded; use project-roots:reconcile-expansion with an actor and watermark' + USING ERRCODE = '55000'; +END; +$$; +--> statement-breakpoint +-- C5 records only bounded root-change identities during the expand window. +-- C6 owns watermark/reconciliation and the later concurrent unique index. +CREATE TABLE public.project_root_change_journal_counter ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + last_generation bigint NOT NULL DEFAULT 0 CHECK (last_generation >= 0) +); +INSERT INTO public.project_root_change_journal_counter (singleton) VALUES (true); +--> statement-breakpoint +CREATE TABLE public.project_root_change_journal ( + generation bigint PRIMARY KEY CHECK (generation > 0), + operation_id uuid NOT NULL UNIQUE DEFAULT pg_catalog.gen_random_uuid(), + project_id uuid NOT NULL REFERENCES public.projects(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + outcome text NOT NULL CHECK (outcome IN ('insert','root_update','archive')), + root_binding_revision bigint CHECK (root_binding_revision IS NULL OR root_binding_revision > 0), + grant_decision_revision bigint CHECK (grant_decision_revision IS NULL OR grant_decision_revision > 0), + occurred_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp() +); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.append_project_root_change_journal_v1() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER SET search_path = pg_catalog, public AS $$ DECLARE - v_checkpoint public.project_root_ref_reconciliation%ROWTYPE; - v_rows integer; - v_remaining bigint; - v_last_id uuid; + v_generation bigint; + v_outcome text; BEGIN - IF p_batch_size NOT BETWEEN 1 AND 1000 THEN - RAISE EXCEPTION 'root-reference batch size must be between 1 and 1000' - USING ERRCODE = '22023'; + IF TG_OP = 'INSERT' THEN + v_outcome := 'insert'; + ELSIF NEW.archived_at IS NOT NULL AND OLD.archived_at IS NULL THEN + -- Archive wins if the same update also repoints a root-bearing field. + v_outcome := 'archive'; + ELSIF NEW.local_path IS DISTINCT FROM OLD.local_path + OR NEW.root_ref IS DISTINCT FROM OLD.root_ref + OR NEW.root_binding_revision IS DISTINCT FROM OLD.root_binding_revision THEN + v_outcome := 'root_update'; + ELSE + RETURN NEW; END IF; - SELECT checkpoint.* INTO STRICT v_checkpoint - FROM public.project_root_ref_reconciliation checkpoint - WHERE checkpoint.singleton - FOR UPDATE; + -- This UPDATE locks only the singleton counter. It and the append insert + -- share the caller transaction, so rollback cannot leave a generation gap. + UPDATE public.project_root_change_journal_counter counter + SET last_generation = counter.last_generation + 1 + WHERE counter.singleton + RETURNING counter.last_generation INTO STRICT v_generation; + INSERT INTO public.project_root_change_journal ( + generation, operation_id, project_id, outcome, + root_binding_revision, grant_decision_revision, occurred_at + ) VALUES ( + v_generation, pg_catalog.gen_random_uuid(), NEW.id, v_outcome, + NULLIF(NEW.root_binding_revision, 0), NULLIF(NEW.grant_decision_revision, 0), + pg_catalog.clock_timestamp() + ); + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER projects_root_change_journal_v1 + AFTER INSERT OR UPDATE ON public.projects + FOR EACH ROW EXECUTE FUNCTION forge.append_project_root_change_journal_v1(); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.reject_project_root_change_journal_mutation_v1() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'project root change journal is append-only' + USING ERRCODE = '55000'; +END; +$$; +CREATE TRIGGER project_root_change_journal_append_only_v1 + BEFORE UPDATE OR DELETE ON public.project_root_change_journal + FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_change_journal_mutation_v1(); +--> statement-breakpoint +-- C6 keeps the expansion journal authoritative while a bounded external +-- reconciler applies the existing TypeScript S3 projection in the same +-- transaction. These rows deliberately contain identities and counters only. +CREATE TABLE public.project_root_reconciliation_operations ( + operation_id uuid PRIMARY KEY, + actor_id uuid NOT NULL, + through_generation bigint NOT NULL CHECK (through_generation >= 0), + last_processed_generation bigint NOT NULL DEFAULT 0 CHECK (last_processed_generation >= 0), + last_project_id uuid, + batch_count bigint NOT NULL DEFAULT 0 CHECK (batch_count >= 0), + cumulative_count bigint NOT NULL DEFAULT 0 CHECK (cumulative_count >= 0), + state text NOT NULL DEFAULT 'running' CHECK (state IN ('running','complete')), + created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + completed_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT project_root_reconciliation_operation_progress_chk CHECK ( + last_processed_generation <= through_generation + AND cumulative_count = last_processed_generation + AND ((state = 'running' AND completed_at IS NULL) OR (state = 'complete' AND completed_at IS NOT NULL)) + ) +); +CREATE UNIQUE INDEX project_root_reconciliation_one_live_idx + ON public.project_root_reconciliation_operations ((true)) WHERE state = 'running'; +--> statement-breakpoint +CREATE TABLE public.project_root_reconciliation_checkpoints ( + operation_id uuid NOT NULL REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, + checkpoint_generation bigint NOT NULL CHECK (checkpoint_generation >= 0), + actor_id uuid NOT NULL, + through_generation bigint NOT NULL CHECK (through_generation >= 0), + last_processed_generation bigint NOT NULL CHECK (last_processed_generation >= 0), + last_project_id uuid, + batch_count bigint NOT NULL CHECK (batch_count >= 0), + cumulative_count bigint NOT NULL CHECK (cumulative_count >= 0), + state text NOT NULL CHECK (state IN ('running','complete')), + checkpointed_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + PRIMARY KEY (operation_id, checkpoint_generation), + CONSTRAINT project_root_reconciliation_checkpoint_shape_chk CHECK ( + last_processed_generation <= through_generation + AND cumulative_count = last_processed_generation + ) +); +CREATE TABLE public.project_root_reconciliation_outcomes ( + generation bigint PRIMARY KEY REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT, + operation_id uuid NOT NULL REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, + actor_id uuid NOT NULL, + project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE RESTRICT, + outcome text NOT NULL CHECK (outcome IN ('insert','root_update','archive')), + recorded_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp() +); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'project-root reconciliation history is append-only' USING ERRCODE = '55000'; +END; +$$; +CREATE TRIGGER project_root_reconciliation_checkpoints_append_only_v1 + BEFORE UPDATE OR DELETE ON public.project_root_reconciliation_checkpoints + FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1(); +CREATE TRIGGER project_root_reconciliation_outcomes_append_only_v1 + BEFORE UPDATE OR DELETE ON public.project_root_reconciliation_outcomes + FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1(); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.assert_project_root_reconciler_v1() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +DECLARE v_role pg_catalog.pg_roles%ROWTYPE; +BEGIN + IF session_user <> 'forge_project_root_reconciler' THEN + RAISE EXCEPTION 'project-root reconciliation requires its fixed login' USING ERRCODE = '42501'; + END IF; + SELECT * INTO STRICT v_role FROM pg_catalog.pg_roles WHERE rolname = session_user; + IF NOT v_role.rolcanlogin OR v_role.rolinherit OR v_role.rolsuper OR v_role.rolcreatedb + OR v_role.rolcreaterole OR v_role.rolreplication OR v_role.rolbypassrls THEN + RAISE EXCEPTION 'project-root reconciler role attributes are unsafe' USING ERRCODE = '42501'; + END IF; + IF EXISTS (SELECT 1 FROM pg_catalog.pg_auth_members m WHERE m.member = session_user::regrole OR m.roleid = session_user::regrole) THEN + RAISE EXCEPTION 'project-root reconciler must not have role memberships' USING ERRCODE = '42501'; + END IF; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.assert_project_root_journal_window_v1(p_through_generation bigint) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_counter bigint; v_max bigint; v_count bigint; +BEGIN + IF p_through_generation < 0 THEN RAISE EXCEPTION 'root journal watermark must be non-negative' USING ERRCODE = '22023'; END IF; + SELECT last_generation INTO STRICT v_counter FROM public.project_root_change_journal_counter WHERE singleton FOR UPDATE; + SELECT coalesce(max(generation), 0), count(*) INTO v_max, v_count FROM public.project_root_change_journal; + IF v_counter <> p_through_generation OR v_max <> p_through_generation OR v_count <> p_through_generation THEN + RAISE EXCEPTION 'root journal watermark is not contiguous and exact' USING ERRCODE = '55000'; + END IF; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.materialize_project_root_ref_expansion_v1(p_batch_size integer) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_rows integer; +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + IF p_batch_size NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION 'root-reference materialization batch must be 1 through 100' USING ERRCODE = '22023'; END IF; WITH candidates AS ( - SELECT project.id - FROM public.projects project - WHERE project.root_ref IS NULL - AND (v_checkpoint.last_project_id IS NULL OR project.id > v_checkpoint.last_project_id) - ORDER BY project.id - LIMIT p_batch_size - FOR UPDATE + SELECT project.id FROM public.projects project WHERE project.root_ref IS NULL + ORDER BY project.id LIMIT p_batch_size FOR UPDATE ), populated AS ( - UPDATE public.projects project - SET root_ref = pg_catalog.gen_random_uuid() - FROM candidates - WHERE project.id = candidates.id - AND project.root_ref IS NULL + UPDATE public.projects project SET root_ref = pg_catalog.gen_random_uuid() + FROM candidates WHERE project.id = candidates.id AND project.root_ref IS NULL RETURNING project.id - ) - SELECT pg_catalog.count(*)::integer, - (pg_catalog.array_agg(id ORDER BY id DESC))[1] - INTO v_rows, v_last_id - FROM populated; - - SELECT pg_catalog.count(*) INTO v_remaining - FROM public.projects project - WHERE project.root_ref IS NULL; - - UPDATE public.project_root_ref_reconciliation checkpoint - SET last_project_id = CASE - WHEN v_remaining = 0 THEN checkpoint.last_project_id - WHEN v_rows > 0 THEN v_last_id - ELSE NULL - END, - rows_updated = checkpoint.rows_updated + v_rows, - state = CASE WHEN v_remaining = 0 THEN 'complete' ELSE 'running' END, + ) SELECT count(*)::integer INTO v_rows FROM populated; + RETURN v_rows; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.begin_project_root_reconciliation_v1(p_operation_id uuid, p_actor_id uuid, p_through_generation bigint) +RETURNS TABLE(operation_id uuid, actor_id uuid, through_generation bigint, state text, last_processed_generation bigint) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + PERFORM forge.assert_project_root_journal_window_v1(p_through_generation); + IF p_operation_id IS NULL THEN + SELECT * INTO v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.actor_id = p_actor_id AND operation_row.through_generation = p_through_generation + ORDER BY operation_row.created_at DESC LIMIT 1 FOR UPDATE; + ELSE + SELECT * INTO v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.operation_id = p_operation_id FOR UPDATE; + END IF; + IF FOUND THEN + IF v_operation.actor_id <> p_actor_id OR v_operation.through_generation <> p_through_generation THEN + RAISE EXCEPTION 'project-root operation identity cannot be hijacked' USING ERRCODE = '42501'; + END IF; + ELSE + IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations AS operation_row WHERE operation_row.state = 'running') THEN + RAISE EXCEPTION 'a project-root reconciliation operation is already live' USING ERRCODE = '55P03'; + END IF; + INSERT INTO public.project_root_reconciliation_operations(operation_id, actor_id, through_generation, state, completed_at) + VALUES (coalesce(p_operation_id, pg_catalog.gen_random_uuid()), p_actor_id, p_through_generation, + CASE WHEN p_through_generation = 0 THEN 'complete' ELSE 'running' END, + CASE WHEN p_through_generation = 0 THEN pg_catalog.clock_timestamp() ELSE NULL END) + RETURNING * 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, 0, v_operation.actor_id, v_operation.through_generation, 0, NULL, 0, 0, v_operation.state); + END IF; + RETURN QUERY SELECT v_operation.operation_id, v_operation.actor_id, v_operation.through_generation, v_operation.state, v_operation.last_processed_generation; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.claim_project_root_reconciliation_batch_v1(p_operation_id uuid, p_actor_id uuid, p_batch_size integer) +RETURNS TABLE(generation bigint, project_id uuid, outcome text, root_binding_revision bigint, grant_decision_revision bigint) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +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 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 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_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 +CREATE TABLE public.project_root_reconciliation_write_contexts ( + operation_id uuid NOT NULL, + generation bigint NOT NULL, + actor_id uuid NOT NULL, + 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, + 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 $$ +DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; v_journal public.project_root_change_journal%ROWTYPE; +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE operation_id=p_operation_id FOR UPDATE; + SELECT * INTO STRICT v_journal FROM public.project_root_change_journal WHERE generation=p_generation FOR UPDATE; + PERFORM 1 FROM public.projects WHERE id=p_project_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 OR p_generation > v_operation.through_generation OR v_journal.project_id <> p_project_id OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE generation=p_generation) THEN RAISE EXCEPTION 'project-root write context is not claimable' USING ERRCODE='42501'; END IF; + INSERT INTO public.project_root_reconciliation_write_contexts(operation_id,generation,actor_id,project_id,backend_pid,transaction_id) + VALUES(p_operation_id,p_generation,p_actor_id,p_project_id,pg_catalog.pg_backend_pid(),pg_catalog.txid_current()); +END; $$; +REVOKE ALL ON TABLE public.project_root_reconciliation_write_contexts FROM PUBLIC; +CREATE OR REPLACE FUNCTION forge.reject_project_root_reconciliation_write_context_mutation_v1() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$ +BEGIN + IF TG_OP = 'DELETE' OR NEW.operation_id IS DISTINCT FROM OLD.operation_id + OR NEW.generation IS DISTINCT FROM OLD.generation OR NEW.actor_id IS DISTINCT FROM OLD.actor_id + OR NEW.project_id IS DISTINCT FROM OLD.project_id OR NEW.backend_pid IS DISTINCT FROM OLD.backend_pid + OR NEW.transaction_id IS DISTINCT FROM OLD.transaction_id OR NEW.entered_at IS DISTINCT FROM OLD.entered_at + OR OLD.completed_at IS NOT NULL OR NEW.completed_at IS NULL THEN + RAISE EXCEPTION 'project-root write context is immutable outside fixed completion' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END; $$; +CREATE TRIGGER project_root_reconciliation_write_contexts_append_only_v1 +BEFORE UPDATE OR DELETE ON public.project_root_reconciliation_write_contexts +FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_reconciliation_write_context_mutation_v1(); +CREATE OR REPLACE FUNCTION forge.assert_project_root_reconciliation_write_context_committed_v1() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ +DECLARE v_context public.project_root_reconciliation_write_contexts%ROWTYPE; +BEGIN + SELECT * INTO STRICT v_context FROM public.project_root_reconciliation_write_contexts context_row + WHERE context_row.operation_id = NEW.operation_id AND context_row.generation = NEW.generation; + IF v_context.completed_at IS NULL OR NOT EXISTS ( + SELECT 1 FROM public.project_root_reconciliation_outcomes outcome_row + JOIN public.project_root_change_journal journal_row ON journal_row.generation = outcome_row.generation + JOIN public.project_root_reconciliation_operations operation_row ON operation_row.operation_id = outcome_row.operation_id + WHERE outcome_row.generation = v_context.generation AND outcome_row.operation_id = v_context.operation_id + AND outcome_row.actor_id = v_context.actor_id AND outcome_row.project_id = v_context.project_id + AND outcome_row.outcome = journal_row.outcome AND journal_row.project_id = v_context.project_id + AND operation_row.last_processed_generation >= v_context.generation + ) THEN + RAISE EXCEPTION 'project-root write context must complete before commit' USING ERRCODE = '55000'; + END IF; + RETURN NULL; +END; $$; +CREATE CONSTRAINT TRIGGER project_root_reconciliation_write_contexts_commit_v1 +AFTER INSERT OR UPDATE ON public.project_root_reconciliation_write_contexts +DEFERRABLE INITIALLY DEFERRED FOR EACH ROW +EXECUTE FUNCTION forge.assert_project_root_reconciliation_write_context_committed_v1(); +ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner; +REVOKE ALL ON FUNCTION forge.assert_project_root_reconciliation_write_context_committed_v1() FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.reject_project_root_reconciliation_write_context_mutation_v1() FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) FROM PUBLIC; +ALTER FUNCTION forge.assert_project_root_reconciliation_write_context_committed_v1() OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.reject_project_root_reconciliation_write_context_mutation_v1() OWNER TO forge_s4_routines_owner; +GRANT EXECUTE ON FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; +ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner; +CREATE OR REPLACE FUNCTION forge.lock_project_root_reconciliation_authority_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 $$ +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context_row WHERE context_row.operation_id=p_operation_id AND context_row.actor_id=p_actor_id AND context_row.generation=p_generation AND context_row.project_id=p_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 authority lock has no active write context' USING ERRCODE='42501'; END IF; + PERFORM 1 FROM public.filesystem_mcp_grant_approvals approval_row WHERE approval_row.project_id=p_project_id ORDER BY approval_row.id FOR UPDATE; + PERFORM 1 FROM public.project_filesystem_grant_decisions decision_row WHERE decision_row.project_id=p_project_id ORDER BY decision_row.id FOR UPDATE; + PERFORM 1 FROM public.project_filesystem_current_decision_pointers pointer_row WHERE pointer_row.project_id=p_project_id FOR UPDATE; + PERFORM 1 FROM public.filesystem_mcp_current_decision_pointers pointer_row JOIN public.work_packages package_row ON package_row.id=pointer_row.work_package_id JOIN public.tasks task_row ON task_row.id=package_row.task_id WHERE task_row.project_id=p_project_id ORDER BY pointer_row.work_package_id FOR UPDATE; +END; $$; +REVOKE ALL ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; +ALTER FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner; +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.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 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 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; $$; +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 +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; v_journal public.project_root_change_journal%ROWTYPE; +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 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 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 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 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 AS operation_row + SET last_processed_generation = p_generation, last_project_id = p_project_id, + 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 checkpoint.singleton; - - RETURN QUERY SELECT v_rows, v_remaining, - CASE WHEN v_remaining = 0 THEN 'complete'::text ELSE 'running'::text END; + 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 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; $$; --> statement-breakpoint @@ -7128,10 +7527,25 @@ REVOKE ALL ON public.architect_plan_versions, public.architect_plan_entries, public.local_projection_archive_operations, public.local_projection_archive_operation_checkpoints, public.filesystem_mcp_decision_nonce_claims, - public.project_root_ref_reconciliation FROM PUBLIC; + public.project_root_ref_reconciliation, + public.project_root_change_journal_counter, + public.project_root_change_journal, + public.project_root_reconciliation_operations, + public.project_root_reconciliation_checkpoints, + public.project_root_reconciliation_outcomes FROM PUBLIC; +REVOKE ALL ON public.project_root_change_journal FROM PUBLIC; REVOKE ALL ON FUNCTION forge.fill_project_root_ref_on_insert_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.guard_project_root_ref_renull_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.reconcile_project_root_refs_v1(integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.append_project_root_change_journal_v1() FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.reject_project_root_change_journal_mutation_v1() FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.assert_project_root_reconciler_v1() FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.assert_project_root_journal_window_v1(bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.materialize_project_root_ref_expansion_v1(integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.begin_project_root_reconciliation_v1(uuid,uuid,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.claim_project_root_reconciliation_batch_v1(uuid,uuid,integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.complete_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.s4_protected_paths_enabled_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.guard_s4_approval_gate_review_head_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.reject_s4_retained_mutation_v1() FROM PUBLIC; @@ -7237,6 +7651,17 @@ GRANT EXECUTE ON FUNCTION forge.apply_local_projection_overlimit_archive_v2(uuid GRANT EXECUTE ON FUNCTION forge.resume_local_projection_overlimit_archive_v2(uuid,uuid,text) TO forge_local_projection_archiver; GRANT EXECUTE ON FUNCTION forge.rollback_local_projection_overlimit_archive_v2(uuid,uuid,text) TO forge_local_projection_archiver; GRANT EXECUTE ON FUNCTION forge.cancel_local_projection_overlimit_archive_v2(uuid,uuid,text) TO forge_local_projection_archiver; +GRANT EXECUTE ON FUNCTION forge.begin_project_root_reconciliation_v1(uuid,uuid,bigint) TO forge_project_root_reconciler; +GRANT EXECUTE ON FUNCTION forge.claim_project_root_reconciliation_batch_v1(uuid,uuid,integer) TO forge_project_root_reconciler; +GRANT EXECUTE ON FUNCTION forge.complete_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid,text) TO forge_project_root_reconciler; +GRANT EXECUTE ON FUNCTION forge.materialize_project_root_ref_expansion_v1(integer) TO forge_project_root_reconciler; +-- Canonical TypeScript S3 reconciliation needs these exact ordinary state +-- columns. It receives no insert/delete nor any protected journal authority. +-- PostgreSQL requires UPDATE privilege to acquire FOR UPDATE locks. These are +-- immutable/key columns only; the reconciler never writes them. +-- PostgreSQL also requires an UPDATE privilege for the canonical helper's +-- FOR UPDATE lock on the current projection-head rows. The immutable key is +-- sufficient; this login receives no mutable head-column privilege. -- The bootstrap fence temporarily gives the incoming owner CREATE on the two -- containing schemas because PostgreSQL requires it for SET OWNER. The -- finalizer revokes both grants before it verifies the permanent boundary. @@ -7260,10 +7685,24 @@ ALTER TABLE public.local_effect_recovery_actions OWNER TO forge_s4_routines_owne ALTER TABLE public.local_projection_archive_operations OWNER TO forge_s4_routines_owner; ALTER TABLE public.local_projection_archive_operation_checkpoints OWNER TO forge_s4_routines_owner; ALTER TABLE public.filesystem_mcp_decision_nonce_claims OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_change_journal_counter OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_change_journal OWNER TO forge_s4_routines_owner; ALTER TABLE public.project_root_ref_reconciliation OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_reconciliation_operations OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_reconciliation_checkpoints OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_reconciliation_outcomes OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.fill_project_root_ref_on_insert_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.guard_project_root_ref_renull_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.reconcile_project_root_refs_v1(integer) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.append_project_root_change_journal_v1() OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.reject_project_root_change_journal_mutation_v1() OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.assert_project_root_reconciler_v1() OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.assert_project_root_journal_window_v1(bigint) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.materialize_project_root_ref_expansion_v1(integer) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.begin_project_root_reconciliation_v1(uuid,uuid,bigint) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.claim_project_root_reconciliation_batch_v1(uuid,uuid,integer) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.complete_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid,text) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.s4_protected_paths_enabled_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.guard_s4_approval_gate_review_head_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.reject_s4_retained_mutation_v1() OWNER TO forge_s4_routines_owner; diff --git a/web/db/schema.ts b/web/db/schema.ts index d5099356..3953170c 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -265,21 +265,99 @@ export const projects = pgTable('projects', { createdAt: timestamp('created_at', tsOpts).defaultNow().notNull(), updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), archivedAt: timestamp('archived_at', tsOpts), -}, (t) => [ - uniqueIndex('projects_root_ref_idx').on(t.rootRef), -]) +}) export type Project = InferSelectModel export type NewProject = InferInsertModel +/** C5 compatibility tombstone; C6 never uses this singleton. */ export const projectRootRefReconciliation = pgTable('project_root_ref_reconciliation', { singleton: boolean('singleton').primaryKey().default(true), lastProjectId: uuid('last_project_id'), rowsUpdated: bigint('rows_updated', { mode: 'bigint' }).notNull().default(sql`0`), - state: text('state').notNull().default('pending'), + state: text('state').notNull().default('superseded'), updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), }) +export const projectRootChangeJournalCounter = pgTable('project_root_change_journal_counter', { + singleton: boolean('singleton').primaryKey().default(true), + lastGeneration: bigint('last_generation', { mode: 'bigint' }).notNull().default(sql`0`), +}, (t) => [ + check('project_root_change_journal_counter_generation_chk', sql`${t.lastGeneration} >= 0`), +]) + +export const projectRootChangeJournal = pgTable('project_root_change_journal', { + generation: bigint('generation', { mode: 'bigint' }).primaryKey(), + operationId: uuid('operation_id').notNull().defaultRandom().unique(), + projectId: uuid('project_id').notNull().references(() => projects.id, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + outcome: text('outcome').notNull(), + rootBindingRevision: bigint('root_binding_revision', { mode: 'bigint' }), + grantDecisionRevision: bigint('grant_decision_revision', { mode: 'bigint' }), + occurredAt: timestamp('occurred_at', tsOpts).defaultNow().notNull(), +}, (t) => [ + check('project_root_change_journal_generation_chk', sql`${t.generation} > 0`), + check('project_root_change_journal_outcome_chk', sql`${t.outcome} in ('insert','root_update','archive')`), + check('project_root_change_journal_root_binding_revision_chk', sql`${t.rootBindingRevision} is null or ${t.rootBindingRevision} > 0`), + check('project_root_change_journal_grant_decision_revision_chk', sql`${t.grantDecisionRevision} is null or ${t.grantDecisionRevision} > 0`), +]) + +export const projectRootReconciliationOperations = pgTable('project_root_reconciliation_operations', { + operationId: uuid('operation_id').primaryKey(), + actorId: uuid('actor_id').notNull(), + throughGeneration: bigint('through_generation', { mode: 'bigint' }).notNull(), + lastProcessedGeneration: bigint('last_processed_generation', { mode: 'bigint' }).notNull().default(sql`0`), + lastProjectId: uuid('last_project_id'), + batchCount: bigint('batch_count', { mode: 'bigint' }).notNull().default(sql`0`), + cumulativeCount: bigint('cumulative_count', { mode: 'bigint' }).notNull().default(sql`0`), + state: text('state').notNull().default('running'), + createdAt: timestamp('created_at', tsOpts).defaultNow().notNull(), + completedAt: timestamp('completed_at', tsOpts), + updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), +}, (t) => [ + uniqueIndex('project_root_reconciliation_one_live_idx').on(sql`(true)`).where(sql`${t.state} = 'running'`), + check('project_root_reconciliation_operation_progress_chk', sql` + ${t.throughGeneration} >= 0 and ${t.lastProcessedGeneration} >= 0 + and ${t.lastProcessedGeneration} <= ${t.throughGeneration} + and ${t.cumulativeCount} = ${t.lastProcessedGeneration} + and ${t.state} in ('running','complete') + and ((${t.state} = 'running' and ${t.completedAt} is null) or (${t.state} = 'complete' and ${t.completedAt} is not null)) + `), +]) + +export const projectRootReconciliationCheckpoints = pgTable('project_root_reconciliation_checkpoints', { + operationId: uuid('operation_id').notNull().references(() => projectRootReconciliationOperations.operationId, { onDelete: 'restrict' }), + checkpointGeneration: bigint('checkpoint_generation', { mode: 'bigint' }).notNull(), + actorId: uuid('actor_id').notNull(), + throughGeneration: bigint('through_generation', { mode: 'bigint' }).notNull(), + lastProcessedGeneration: bigint('last_processed_generation', { mode: 'bigint' }).notNull(), + lastProjectId: uuid('last_project_id'), + batchCount: bigint('batch_count', { mode: 'bigint' }).notNull(), + cumulativeCount: bigint('cumulative_count', { mode: 'bigint' }).notNull(), + state: text('state').notNull(), + checkpointedAt: timestamp('checkpointed_at', tsOpts).defaultNow().notNull(), +}, (t) => [ + primaryKey({ columns: [t.operationId, t.checkpointGeneration] }), + check('project_root_reconciliation_checkpoint_shape_chk', sql` + ${t.checkpointGeneration} >= 0 and ${t.throughGeneration} >= 0 + and ${t.lastProcessedGeneration} >= 0 and ${t.lastProcessedGeneration} <= ${t.throughGeneration} + and ${t.cumulativeCount} = ${t.lastProcessedGeneration} and ${t.state} in ('running','complete') + `), +]) + +export const projectRootReconciliationOutcomes = pgTable('project_root_reconciliation_outcomes', { + generation: bigint('generation', { mode: 'bigint' }).primaryKey().references(() => projectRootChangeJournal.generation, { onDelete: 'restrict' }), + operationId: uuid('operation_id').notNull().references(() => projectRootReconciliationOperations.operationId, { onDelete: 'restrict' }), + actorId: uuid('actor_id').notNull(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'restrict' }), + outcome: text('outcome').notNull(), + recordedAt: timestamp('recorded_at', tsOpts).defaultNow().notNull(), +}, (t) => [ + check('project_root_reconciliation_outcome_kind_chk', sql`${t.outcome} in ('insert','root_update','archive')`), +]) + // --------------------------------------------------------------------------- // Epic 172 release authentication and transition substrate // @@ -1824,6 +1902,40 @@ 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(), + generation: bigint('generation', { mode: 'bigint' }).notNull(), + actorId: uuid('actor_id').notNull(), + projectId: uuid('project_id').notNull(), + backendPid: integer('backend_pid').notNull(), + transactionId: bigint('transaction_id', { mode: 'bigint' }).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({ 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( 's4_protected_review_source_reads', { diff --git a/web/lib/mcps/filesystem-grant-reconciliation.ts b/web/lib/mcps/filesystem-grant-reconciliation.ts index 2d7c6c91..2bf44e08 100644 --- a/web/lib/mcps/filesystem-grant-reconciliation.ts +++ b/web/lib/mcps/filesystem-grant-reconciliation.ts @@ -619,6 +619,8 @@ async function applyCanonicalProjection(input: { taskProjectionScopeById: ReadonlyMap transitionAuthorityByPackageId?: ReadonlyMap forcePackageIds?: ReadonlySet + /** Internal root-journal mode; never sourced from an operator request. */ + suppressPhasePersistence?: boolean tx: GrantTransaction }): Promise> { const recoveredTaskIds = new Set() @@ -641,9 +643,11 @@ async function applyCanonicalProjection(input: { if (!check.blocked) { if (!parsedMarker && !forcePersist) continue const grant = projectFilesystemGrantFromAuthority(input.projectAuthority) - const effective = grant - ? phasesWithEffective(pkg.metadata, projectFilesystemEffectivePhase(grant)) - : forcePersist ? currentPhases : undefined + const effective = input.suppressPhasePersistence + ? undefined + : grant + ? phasesWithEffective(pkg.metadata, projectFilesystemEffectivePhase(grant)) + : forcePersist ? currentPhases : undefined const recovering = Boolean(parsedMarker) const [updated] = await input.tx .update(workPackages) @@ -806,6 +810,7 @@ async function reconcileLockedProjectRows(input: { projectAuthority: ProjectFilesystemDecisionAuthority | null taskRows: readonly LockedTask[] transitionAuthorityByPackageId?: ReadonlyMap + suppressPhasePersistence?: boolean trigger: FilesystemGrantProjectReconciliationTrigger tx: GrantTransaction now: Date @@ -830,6 +835,7 @@ async function reconcileLockedProjectRows(input: { input.taskRows.map((task) => [task.id, task.localProjectionScopeState]), ), transitionAuthorityByPackageId: input.transitionAuthorityByPackageId, + suppressPhasePersistence: input.suppressPhasePersistence, tx: input.tx, }) for (const task of input.taskRows) { @@ -864,6 +870,8 @@ export async function reconcileFilesystemGrantsForProject( lockedProject: LockedProject 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.') @@ -896,18 +904,23 @@ export async function reconcileFilesystemGrantsForProject( .where(inArray(workPackages.taskId, taskRows.map((task) => task.id))) .orderBy(workPackages.id) .for('update') - const packageDecisionRows = await tx.select() + 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 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) { @@ -915,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) } @@ -961,6 +979,7 @@ export async function reconcileFilesystemGrantsForProject( projectAuthority: currentProjectDecision ? projectDecisionAuthority(currentProjectDecision) : null, taskRows, transitionAuthorityByPackageId, + suppressPhasePersistence: input.suppressPhasePersistence, trigger: input.trigger, tx, }) diff --git a/web/lib/mcps/project-root-change-journal.ts b/web/lib/mcps/project-root-change-journal.ts new file mode 100644 index 00000000..4e29a50e --- /dev/null +++ b/web/lib/mcps/project-root-change-journal.ts @@ -0,0 +1,74 @@ +export const PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES = Object.freeze([ + 'insert', + 'root_update', + 'archive', +] as const) + +export type ProjectRootChangeJournalOutcome = typeof PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES[number] + +export type ProjectRootChangeJournalEntry = Readonly<{ + generation: bigint + operationId: string + projectId: string + outcome: ProjectRootChangeJournalOutcome + rootBindingRevision: bigint | null + grantDecisionRevision: bigint | null + occurredAt: Date +}> + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const NON_NEGATIVE_DECIMAL = /^(?:0|[1-9][0-9]*)$/ +const POSITIVE_DECIMAL = /^[1-9][0-9]*$/ + +function parseDecimal(value: unknown, field: string, positive: boolean): bigint { + if (typeof value !== 'string' || !(positive ? POSITIVE_DECIMAL : NON_NEGATIVE_DECIMAL).test(value)) { + throw new Error(`Project root change journal ${field} must be a canonical ${positive ? 'positive' : 'non-negative'} decimal string`) + } + return BigInt(value) +} + +function parseOptionalPositiveDecimal(value: unknown, field: string): bigint | null { + return value === null ? null : parseDecimal(value, field, true) +} + +function parseUuid(value: unknown, field: string): string { + if (typeof value !== 'string' || !UUID.test(value)) { + throw new Error(`Project root change journal ${field} must be a canonical UUID`) + } + return value +} + +function parseOccurredAt(value: unknown): Date { + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) { + throw new Error('Project root change journal occurredAt must be a valid database timestamp') + } + return value +} + +/** Parses only the bounded, path-free journal row shape returned by PostgreSQL. */ +export function parseProjectRootChangeJournalEntry(value: unknown): ProjectRootChangeJournalEntry { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Project root change journal entry must be an object') + } + const row = value as Record + const keys = Object.keys(row).sort() + const expectedKeys = [ + 'generation', 'grantDecisionRevision', 'occurredAt', 'operationId', + 'outcome', 'projectId', 'rootBindingRevision', + ] + if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) { + throw new Error('Project root change journal entry has an unknown or missing field') + } + if (!PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES.includes(row.outcome as ProjectRootChangeJournalOutcome)) { + throw new Error('Project root change journal outcome is invalid') + } + return Object.freeze({ + generation: parseDecimal(row.generation, 'generation', true), + operationId: parseUuid(row.operationId, 'operationId'), + projectId: parseUuid(row.projectId, 'projectId'), + outcome: row.outcome as ProjectRootChangeJournalOutcome, + rootBindingRevision: parseOptionalPositiveDecimal(row.rootBindingRevision, 'rootBindingRevision'), + grantDecisionRevision: parseOptionalPositiveDecimal(row.grantDecisionRevision, 'grantDecisionRevision'), + occurredAt: parseOccurredAt(row.occurredAt), + }) +} diff --git a/web/lib/mcps/project-root-reconciliation.ts b/web/lib/mcps/project-root-reconciliation.ts new file mode 100644 index 00000000..ba554e06 --- /dev/null +++ b/web/lib/mcps/project-root-reconciliation.ts @@ -0,0 +1,81 @@ +import { PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES, type ProjectRootChangeJournalOutcome } from '@/lib/mcps/project-root-change-journal' + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const NON_NEGATIVE_DECIMAL = /^(?:0|[1-9][0-9]*)$/ + +export const PROJECT_ROOT_RECONCILIATION_STATES = Object.freeze(['running', 'complete'] as const) +export type ProjectRootReconciliationState = typeof PROJECT_ROOT_RECONCILIATION_STATES[number] + +export type ProjectRootReconciliationCommand = Readonly<{ + actorId: string + apply: boolean + throughGeneration: bigint +}> + +export type ClaimedProjectRootChange = Readonly<{ + generation: bigint + grantDecisionRevision: bigint | null + outcome: ProjectRootChangeJournalOutcome + projectId: string + rootBindingRevision: bigint | null +}> + +function parseUuid(value: string | undefined, name: string): string { + if (!value || !UUID.test(value)) throw new Error(`${name} must be a canonical UUID`) + return value +} + +function parseGeneration(value: string | undefined): bigint { + if (!value || !NON_NEGATIVE_DECIMAL.test(value)) { + throw new Error('--through must be a non-negative canonical generation') + } + return BigInt(value) +} + +/** The operator command is intentionally actionless until its literal --apply. */ +export function parseProjectRootReconciliationCommand(argv: readonly string[]): ProjectRootReconciliationCommand { + let actor: string | undefined + let through: string | undefined + let apply = false + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + if (token === '--apply') { + if (apply) throw new Error('--apply may appear only once') + apply = true + continue + } + if (token === '--actor') { + if (actor !== undefined) throw new Error('--actor may appear only once') + actor = argv[++index] + continue + } + if (token === '--through') { + if (through !== undefined) throw new Error('--through may appear only once') + through = argv[++index] + continue + } + throw new Error(`Unknown project-root reconciliation argument: ${token}`) + } + return Object.freeze({ actorId: parseUuid(actor, '--actor'), apply, throughGeneration: parseGeneration(through) }) +} + +export function parseClaimedProjectRootChange(value: Record): ClaimedProjectRootChange { + const generation = value.generation + const projectId = value.projectId + const outcome = value.outcome + const parseOptionalRevision = (candidate: unknown, name: string): bigint | null => { + if (candidate === null) return null + if (typeof candidate !== 'string' || !/^[1-9][0-9]*$/.test(candidate)) throw new Error(`${name} must be null or positive`) + return BigInt(candidate) + } + if (typeof generation !== 'string' || !/^[1-9][0-9]*$/.test(generation)) throw new Error('claimed generation must be positive') + if (typeof projectId !== 'string' || !UUID.test(projectId)) throw new Error('claimed projectId must be a UUID') + if (!PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES.includes(outcome as ProjectRootChangeJournalOutcome)) throw new Error('claimed outcome is invalid') + return Object.freeze({ + generation: BigInt(generation), + grantDecisionRevision: parseOptionalRevision(value.grantDecisionRevision, 'grantDecisionRevision'), + outcome: outcome as ProjectRootChangeJournalOutcome, + projectId, + rootBindingRevision: parseOptionalRevision(value.rootBindingRevision, 'rootBindingRevision'), + }) +} diff --git a/web/package.json b/web/package.json index 83a2811f..709d702d 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,8 @@ "protocol:inspect-local-projection-overlimit": "tsx scripts/inspect-local-projection-overlimit.ts", "protocol:archive-local-projection-overlimit": "tsx scripts/archive-local-projection-overlimit.ts", "session-credentials:reconcile": "tsx scripts/reconcile-session-credentials.ts", - "project-roots:reconcile-expansion": "bash scripts/ci/reconcile-migration-0027-root-refs.sh", + "project-roots:reconcile-expansion": "tsx scripts/reconcile-project-root-expansion.ts", + "project-roots:build-concurrent-index": "tsx scripts/build-project-root-ref-index.ts", "project-roots:strict-cutover": "bash scripts/ci/cutover-migration-0027-root-ref.sh", "test:migration-0027-upgrade": "bash scripts/ci/prove-migration-0027-upgrade.sh", "auth:reset-password": "tsx scripts/reset-password.ts", diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 9d01a15d..5f2f32a6 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -10,6 +10,7 @@ const LOGIN_ROLES = [ 'forge_review_source_resolver', 'forge_s4_recovery_operator', 'forge_local_projection_archiver', + 'forge_project_root_reconciler', ] as const const OWNER = 'forge_s4_routines_owner' const PROTECTED_ROLES = [OWNER, ...LOGIN_ROLES] as const @@ -25,6 +26,12 @@ const OWNED_TABLES = [ 'work_package_local_run_evidence', '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_write_contexts', 's4_completion_handoffs', 's4_protected_review_sources', 's4_protected_review_source_reads', @@ -100,11 +107,46 @@ async function main(): Promise { forge_packet_issuer, forge_review_source_resolver, forge_s4_recovery_operator, - forge_local_projection_archiver + forge_local_projection_archiver, + forge_project_root_reconciler `) 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. @@ -137,6 +179,10 @@ async function main(): Promise { await admin.unsafe(`grant usage on schema forge to ${LOGIN_ROLES.join(', ')}`) await admin`grant execute on function forge.read_epic_172_enablement_state_v1() to ${admin(OWNER)}` await admin`grant select, update on table public.work_package_local_projection_heads to ${admin(OWNER)}` + await admin`grant select on table public.projects, public.tasks, public.work_packages, public.filesystem_mcp_grant_approvals, public.filesystem_mcp_current_decision_pointers, public.project_filesystem_grant_decisions, public.project_filesystem_current_decision_pointers, public.work_package_local_projection_heads to forge_project_root_reconciler` + await admin`grant update (status, error_message, updated_at) on table public.tasks to forge_project_root_reconciler` + await admin`grant update (status, blocked_reason, metadata, updated_at) on table public.work_packages to forge_project_root_reconciler` + await admin`grant execute on function forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text) to forge_project_root_reconciler` const [{ protectedOwnershipCount }] = await admin<{ protectedOwnershipCount: number }[]>` select (( select count(*) from pg_catalog.pg_class relation @@ -198,7 +244,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) or membership.member = any(array[ '${OWNER}'::regrole, @@ -208,7 +255,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) <> 1 or ( select pg_catalog.count(*) @@ -256,7 +304,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) or membership.member = any(array[ '${OWNER}'::regrole, @@ -266,7 +315,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) <> 1 or ( select pg_catalog.count(*) @@ -340,6 +390,15 @@ async function main(): Promise { ,'fill_project_root_ref_on_insert_v1' ,'guard_project_root_ref_renull_v1' ,'reconcile_project_root_refs_v1' + ,'append_project_root_change_journal_v1' + ,'reject_project_root_change_journal_mutation_v1' + ,'assert_project_root_reconciler_v1' + ,'assert_project_root_journal_window_v1' + ,'materialize_project_root_ref_expansion_v1' + ,'begin_project_root_reconciliation_v1' + ,'claim_project_root_reconciliation_batch_v1' + ,'complete_project_root_reconciliation_generation_v1' + ,'reject_project_root_reconciliation_history_mutation_v1' ,'s4_protected_paths_enabled_v1' ,'guard_s4_approval_gate_review_head_v1' ,'bind_architect_replan_entry_v1' @@ -395,7 +454,7 @@ async function main(): Promise { ) acl where acl.grantee = 0 and acl.privilege_type = 'EXECUTE' ) - ) <> 61 then + ) <> 70 then raise exception 'The S4 routine owner or PUBLIC boundary is incomplete' using errcode = '42501'; end if; @@ -449,7 +508,8 @@ async function main(): Promise { 'forge_packet_issuer', 'forge_review_source_resolver', 'forge_s4_recovery_operator', - 'forge_local_projection_archiver' + 'forge_local_projection_archiver', + 'forge_project_root_reconciler' ]) role_name where not pg_catalog.has_schema_privilege(role_name, 'forge', 'usage') or pg_catalog.has_schema_privilege(role_name, 'forge', 'create') @@ -501,7 +561,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) or membership.member = any(array[ '${OWNER}'::regrole, @@ -511,7 +572,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) then raise exception 'A finalized S4 protected principal retains a membership edge' @@ -528,7 +590,8 @@ async function main(): Promise { 'forge_packet_issuer', 'forge_review_source_resolver', 'forge_s4_recovery_operator', - 'forge_local_projection_archiver' + 'forge_local_projection_archiver', + 'forge_project_root_reconciler' ]) and role.rolpassword is not null ) or exists ( @@ -541,7 +604,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) then raise exception 'A finalized S4 principal retains a password or role setting' @@ -556,7 +620,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) or exists ( select 1 from pg_catalog.pg_proc routine @@ -567,7 +632,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) or exists ( select 1 from pg_catalog.pg_namespace namespace_row @@ -578,7 +644,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) or exists ( select 1 from pg_catalog.pg_database database_row @@ -589,7 +656,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) then raise exception 'A dedicated S4 login owns a database object' diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts new file mode 100644 index 00000000..7d61eef3 --- /dev/null +++ b/web/scripts/build-project-root-ref-index.ts @@ -0,0 +1,48 @@ +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.') + } + const adminUrl = process.env.FORGE_DATABASE_ADMIN_URL?.trim() + 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` + 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: 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') + index = undefined + } + // PostgreSQL rejects CONCURRENTLY inside a transaction. Keep this isolated + // from reconciliation, whose dedicated login never receives this URL. + 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 { + await admin`select pg_catalog.pg_advisory_unlock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))`.catch(() => undefined) + await admin.end({ timeout: 5 }) + } +} + +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : 'Concurrent index build failed.') + process.exitCode = 1 +}) diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 45be5467..9cf02314 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -2,41 +2,57 @@ set -euo pipefail : "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" -if [[ "${1:-}" != '--apply' ]]; then - echo 'Strict root-reference cutover is actionless without the explicit --apply flag.' >&2 +if [[ $# -eq 1 && "${1:-}" == '--apply' ]]; then + through="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command "SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton;")" +elif [[ "${1:-}" == '--through' && "${2:-}" =~ ^(0|[1-9][0-9]*)$ && "${3:-}" == '--apply' && $# -eq 3 ]]; then + through="$2" +else + echo 'Usage: npm run project-roots:strict-cutover -- --through --apply' >&2 exit 2 fi -psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 <<'SQL' +psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 < 'disabled' THEN - RAISE EXCEPTION 'strict root-reference cutover requires Step 0 to remain disabled' - USING ERRCODE = '55000'; + 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_ref_reconciliation - WHERE singleton AND state = 'complete' - ) OR EXISTS (SELECT 1 FROM public.projects WHERE root_ref IS NULL) THEN - RAISE EXCEPTION 'strict root-reference cutover requires completed reconciliation and a zero-null scan' - USING ERRCODE = '55000'; - END IF; + SELECT 1 FROM pg_catalog.pg_index index_row + JOIN pg_catalog.pg_class index_class ON index_class.oid = index_row.indexrelid + JOIN pg_catalog.pg_am access_method ON access_method.oid = index_class.relam + JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = 'public.projects'::regclass AND attribute_row.attname = 'root_ref' + WHERE index_row.indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') + AND index_row.indrelid = 'public.projects'::regclass AND index_row.indisunique AND index_row.indisvalid AND index_row.indisready + AND access_method.amname = 'btree' AND index_row.indnkeyatts = 1 AND index_row.indnatts = 1 + 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 pg_catalog.pg_constraint - WHERE conrelid = 'public.projects'::pg_catalog.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; + 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; + -- 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; SQL -echo 'Strict root-reference cutover completed; Step 0 and S4 activation state were not changed.' +echo "Strict root-reference cutover completed at watermark ${through}; Step 0 remains disabled." 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..107db52b --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh @@ -0,0 +1,47 @@ +#!/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}" <<'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; } 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..97e85113 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_id='27000000-0000-4000-8000-000000000700' +new_root_ref='27000000-0000-4000-8000-000000000703' +actor_id='11111111-1111-4111-8111-111111111111' + +usage() { + echo 'Usage: prove-migration-0027-root-authority-reconciliation.sh --prepare|--assert' >&2 + exit 2 +} + +phase="${1:-}" +case "$phase" in + --prepare|--assert) [[ $# -eq 1 ]] || usage ;; + *) usage ;; +esac +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +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-contention.sh b/web/scripts/ci/prove-migration-0027-root-contention.sh new file mode 100755 index 00000000..40399096 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-contention.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' +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +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#*|}" +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 +( 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" ]] || 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" == 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 +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-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh new file mode 100644 index 00000000..7de70061 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +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 ! grep -F -- "$expected_message" "$output" >/dev/null; 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 +} + +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' + +# 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 + 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-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh new file mode 100644 index 00000000..1c6ab9ee --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +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)" + +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 +} + +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" + 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 + 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() { + local phase='relation grant option mutation' + local output_file="${proof_tmpdir}/${phase}.log" + 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 </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 + 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, +# 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' \ + '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;' + +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;' + +expect_grant_option_rejection + +# 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 + +[[ "$(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-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh new file mode 100755 index 00000000..b9e4d9c9 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +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#*://}" +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, 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%%|*}" +[[ "$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" 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_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" + 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' "$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' "$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)" +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 3 'division by zero' psql "$url" --set ON_ERROR_STOP=1 <&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-root-stale-context.sh b/web/scripts/ci/prove-migration-0027-root-stale-context.sh new file mode 100755 index 00000000..3fc0a02d --- /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 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 3 'division by zero' psql "$url" --set ON_ERROR_STOP=1 < 1000 )); then - echo 'FORGE_ROOT_REF_RECONCILE_BATCH_SIZE must be an integer from 1 to 1000.' >&2 - exit 1 -fi - -preflight="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 --command " - SELECT state FROM public.forge_epic_172_enablement_state WHERE singleton_id = 'epic-172'; -")" -if [[ "${preflight}" != 'disabled' ]]; then - echo "Root-reference reconciliation requires the existing Step 0 state disabled; got ${preflight}." >&2 - exit 1 -fi - -for ((attempt = 1; attempt <= 100000; attempt += 1)); do - result="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --field-separator '|' --set ON_ERROR_STOP=1 \ - --command "SELECT * FROM forge.reconcile_project_root_refs_v1(${batch_size});")" - IFS='|' read -r batch_rows remaining state <<<"${result}" - if [[ ! "${batch_rows}" =~ ^[0-9]+$ || ! "${remaining}" =~ ^[0-9]+$ ]]; then - echo "Unexpected reconciliation result: ${result}" >&2 +# Compatibility shim for the populated-upgrade proof. It uses the short-lived +# admin connection only to provision the disposable login and capture a +# watermark; the reconciliation process itself receives only the dedicated URL. +if [[ $# -eq 0 ]]; then + : "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" + 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#*://}" + export FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" + while :; do + rows="$(psql "$FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command "SELECT forge.materialize_project_root_ref_expansion_v1(100);")" + [[ "$rows" =~ ^[0-9]+$ ]] || { echo 'Legacy root materialization returned an invalid row count.' >&2; exit 1; } + [[ "$rows" == '0' ]] && break + done + watermark="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton;")" + if [[ ! "$watermark" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo 'The root journal watermark is malformed.' >&2 exit 1 fi - echo "Root-reference batch ${attempt}: updated ${batch_rows}; remaining ${remaining}." - if [[ "${state}" == 'complete' && "${remaining}" == '0' ]]; then - break - fi - if (( attempt == 100000 )); then - echo 'Root-reference reconciliation exceeded its deterministic batch limit.' >&2 - exit 1 - fi -done - -zero_scan="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 \ - --command "SELECT count(*) FROM public.projects WHERE root_ref IS NULL;")" -if [[ "${zero_scan}" != '0' ]]; then - echo "Root-reference zero scan failed with ${zero_scan} null rows." >&2 - exit 1 + exec npx tsx scripts/reconcile-project-root-expansion.ts \ + --through "$watermark" --actor 11111111-1111-4111-8111-111111111111 --apply fi -echo 'Root-reference reconciliation completed with a zero-null scan.' +exec npx tsx scripts/reconcile-project-root-expansion.ts "$@" 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..25fe9c15 --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -0,0 +1,202 @@ +\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","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 + ), + ( + '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","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 + ), + ( + '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, + -- 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 + ); + +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$; 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$; 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..077d533b --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -0,0 +1,212 @@ +\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 + 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; + 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 + FROM root_authority_assertion_inputs; + 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.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 + 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 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 + 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 + ) + ) 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', + '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; + 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 ( + 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 + ) + ) 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', + '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; + 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 ( + 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 + SELECT jsonb_build_object( + 'blockedReason', package_row.blocked_reason, + '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; + RAISE EXCEPTION 'root authority recovery package convergence is not canonical: %', v_package_actual; + 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 + 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 + 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$; +COMMIT; 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..ed8dd5cc --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -0,0 +1,324 @@ +\set ON_ERROR_STOP on + +CREATE TEMP TABLE root_reconciler_expected_privileges ( + category text NOT NULL, + entry text NOT NULL, + PRIMARY KEY (category, entry) +); +CREATE TEMP TABLE root_reconciler_actual_privileges ( + category text NOT NULL, + 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'), + ('grant_option_relation'), + ('grant_option_column'), + ('grant_option_routine'), + ('grant_option_schema'), + ('grant_option_database'), + ('grant_option_sequence'), + ('routine'), + ('schema'), + ('database'), + ('sequence'), + ('ownership'), + ('membership'); + +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'), + ('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'), + ('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)'), + -- 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'), + ('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 ( + 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); +-- 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. + +-- 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_' || 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, 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 +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 + role_row pg_catalog.pg_roles%ROWTYPE; + diff jsonb; +BEGIN + 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_privilege_categories + UNION + 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; +END; +$proof$; + +DROP TABLE root_reconciler_actual_privileges; +DROP TABLE root_reconciler_expected_privileges; +DROP TABLE root_reconciler_privilege_categories; 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 new file mode 100644 index 00000000..1a541d26 --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql @@ -0,0 +1,25 @@ +\set ON_ERROR_STOP on +BEGIN; +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 + 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; +$proof$; +COMMIT; diff --git a/web/scripts/reconcile-project-root-expansion.ts b/web/scripts/reconcile-project-root-expansion.ts new file mode 100644 index 00000000..92c52a98 --- /dev/null +++ b/web/scripts/reconcile-project-root-expansion.ts @@ -0,0 +1,115 @@ +import '../lib/load-env' +import postgres from 'postgres' +import { eq, sql } from 'drizzle-orm' +import { parseClaimedProjectRootChange, parseProjectRootReconciliationCommand } from '@/lib/mcps/project-root-reconciliation' + +const usage = 'Usage: npm run project-roots:reconcile-expansion -- --through --actor --apply' + +function reconcilerUrl(): string { + const value = process.env.FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL?.trim() + if (!value) throw new Error('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL is required.') + const parsed = new URL(value) + if (decodeURIComponent(parsed.username) !== 'forge_project_root_reconciler') { + throw new Error('Project-root reconciliation requires the dedicated forge_project_root_reconciler login.') + } + return value +} + +async function main(): Promise { + const command = parseProjectRootReconciliationCommand(process.argv.slice(2)) + if (!command.apply) { + console.log(JSON.stringify({ mode: 'dry-run', through: command.throughGeneration.toString(), actor: command.actorId })) + return + } + const url = reconcilerUrl() + // The canonical S3 helper uses the repository's shared Drizzle transaction. + // Set it before the dynamic imports so every mutation stays on this dedicated + // login and the fixed routine completion remains in that same transaction. + process.env.DATABASE_URL = url + const [{ db, closeDb }, schema, reconciliation] = await Promise.all([ + import('@/db'), + import('@/db/schema'), + import('@/lib/mcps/filesystem-grant-reconciliation'), + ]) + const observer = postgres(url, { max: 1, onnotice: () => {} }) + try { + const [operation] = await observer<{ + operationId: string; state: string; lastProcessedGeneration: string; throughGeneration: string + }[]>` + select operation_id as "operationId", state, last_processed_generation::text as "lastProcessedGeneration", + through_generation::text as "throughGeneration" + from forge.begin_project_root_reconciliation_v1(null, ${command.actorId}::uuid, ${command.throughGeneration.toString()}::bigint) + ` + if (!operation) throw new Error('Project-root reconciliation operation was not created.') + if (operation.state === 'complete') { + console.log(JSON.stringify({ mode: 'complete-replay', operationId: operation.operationId, through: operation.throughGeneration })) + return + } + const batchSize = Number(process.env.FORGE_PROJECT_ROOT_RECONCILE_BATCH_SIZE ?? '25') + if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 100) throw new Error('FORGE_ROOT_REF_RECONCILE_BATCH_SIZE must be 1 through 100.') + for (;;) { + const result = await db.transaction(async (tx) => { + await tx.execute(sql`set local lock_timeout = '5s'`) + await tx.execute(sql`set local statement_timeout = '30s'`) + const claimedRows = await tx.execute(sql` + select generation::text as generation, project_id as "projectId", outcome, + root_binding_revision::text as "rootBindingRevision", grant_decision_revision::text as "grantDecisionRevision" + from forge.claim_project_root_reconciliation_batch_v1( + ${operation.operationId}::uuid, ${command.actorId}::uuid, ${batchSize} + ) + `) + 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)) + if (!project) throw new Error('Journal project disappeared before reconciliation.') + const hasBoundFilesystemAuthority = project.rootBindingRevision > BigInt(0) + || project.grantDecisionRevision > BigInt(0) + // A legacy root-ref materialization can only be a no-authority + // outcome while both authority revisions are the canonical zero. + if (change.outcome !== 'insert' && hasBoundFilesystemAuthority) { + await reconciliation.reconcileFilesystemGrantsForProject(tx, { + actorId: command.actorId, + grantDecisionRevision: project.grantDecisionRevision.toString(), + lockedProject: project, + nextMcpConfig: reconciliation.filesystemMcpConfigAfterRootRepoint({ + grantDecisionRevision: project.grantDecisionRevision, + mcpConfig: project.mcpConfig, + rootBindingRevision: project.rootBindingRevision, + }), + suppressPhasePersistence: true, + rootReconciliationContext: { operationId: operation.operationId, actorId: command.actorId, generation: change.generation.toString(), projectId: change.projectId }, + trigger: 'project_root_repoint', + }) + } + 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 } + }) + if (result.completed) break + console.log(JSON.stringify({ mode: 'applied-batch', operationId: operation.operationId, processed: result.processed })) + } + console.log(JSON.stringify({ mode: 'complete', operationId: operation.operationId, through: command.throughGeneration.toString() })) + } finally { + await observer.end({ timeout: 5 }) + await closeDb() + } +} + +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : usage) + process.exitCode = 1 +})