diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index fd82c3b3..c86ddb6e 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -303,7 +303,7 @@ jobs: 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', 'project_root_change_journal_counter', 'project_root_change_journal', 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', - 'project_root_reconciliation_outcomes' + 'project_root_reconciliation_outcomes', 'project_root_reconciliation_write_contexts' ] LOOP FOREACH table_privilege IN ARRAY ARRAY[ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER' @@ -371,7 +371,7 @@ jobs: 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', 'project_root_change_journal_counter', 'project_root_change_journal', 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', - 'project_root_reconciliation_outcomes' + 'project_root_reconciliation_outcomes', 'project_root_reconciliation_write_contexts' ]; projection_tables constant text[] := ARRAY[ 'forge_epic_172_s3_release_state', 'work_package_local_projection_sources', diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 12bb6c57..c018f624 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -7,15 +7,42 @@ import { } from '@/lib/mcps/project-root-reconciliation' const migration = readFileSync(fileURLToPath(new URL('../db/migrations/0027_epic_172_s4_packet_context.sql', import.meta.url)), 'utf8') +const s3Migration = readFileSync(fileURLToPath(new URL('../db/migrations/0026_epic_172_s3_grant_lifecycle.sql', import.meta.url)), 'utf8') const schema = readFileSync(fileURLToPath(new URL('../db/schema.ts', import.meta.url)), 'utf8') const bootstrap = readFileSync(fileURLToPath(new URL('../scripts/bootstrap-epic-172-s4-roles.ts', import.meta.url)), 'utf8') const reconcileScript = readFileSync(fileURLToPath(new URL('../scripts/reconcile-project-root-expansion.ts', import.meta.url)), 'utf8') 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({ @@ -30,6 +57,7 @@ describe('project-root expansion reconciliation boundary', () => { 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', 'project_root_reconciliation_outcomes', + 'project_root_reconciliation_write_contexts', ]) expect(migration).toContain(`CREATE TABLE public.${table}`) expect(migration).toContain('generation bigint PRIMARY KEY REFERENCES public.project_root_change_journal(generation)') expect(migration).toContain('project_root_reconciliation_checkpoints_append_only_v1') @@ -41,10 +69,44 @@ describe('project-root expansion reconciliation boundary', () => { ) expect(reconciliationSchema).not.toMatch(/(?:local_path|root_ref|reason|jsonb)/i) expect(schema).toContain("export const projectRootReconciliationOperations") + expect(schema).toContain("export const projectRootReconciliationWriteContexts") + expect(schema).toContain("project_root_reconciliation_write_context_generation_unique") expect(schema).toContain("project_root_reconciliation_one_live_idx") expect(PROJECT_ROOT_RECONCILIATION_STATES).toEqual(['running', 'complete']) }) + 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') @@ -53,6 +115,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('operation_row.through_generation = p_through_generation') expect(migration).toContain('project-root completion compare-and-set failed') expect(migration).toContain('project-root generation already has an immutable outcome') + const claimRoutine = migration.slice( + migration.indexOf('CREATE OR REPLACE FUNCTION forge.claim_project_root_reconciliation_batch_v1'), + migration.indexOf('CREATE TABLE public.project_root_reconciliation_write_contexts'), + ) + expect(claimRoutine).toContain('coalesce(max(journal_row.generation), 0)') + expect(claimRoutine).toContain('FROM public.project_root_change_journal AS journal_row') + expect(claimRoutine).not.toContain('coalesce(max(generation), 0)') + expect(claimRoutine).toContain('SELECT journal_row.generation, journal_row.project_id, journal_row.outcome, journal_row.root_binding_revision, journal_row.grant_decision_revision') expect(migration).toContain('forge.materialize_project_root_ref_expansion_v1') expect(migration).toContain('CREATE TABLE public.project_root_reconciliation_write_contexts') expect(migration).toContain('backend_pid integer NOT NULL') @@ -68,6 +138,15 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('approval_row.project_id=p_project_id ORDER BY approval_row.id FOR UPDATE') expect(migration).toContain('decision_row.project_id=p_project_id ORDER BY decision_row.id FOR UPDATE') expect(migration).toContain('project-root authority lock has no active write context') + expect(migration).toContain('project_root_reconciler_task_update_guard_v1') + expect(migration).toContain("OLD.status NOT IN ('running','failed') OR NEW.status <> 'approved'") + expect(migration).toContain('project-root task update has no active write context') + expect(migration).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', () => { @@ -96,20 +175,335 @@ describe('project-root expansion reconciliation boundary', () => { ]) 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') - expect(reconciliation).toContain('input.suppressPhasePersistence ? undefined : grant') + const normalizedReconciliation = reconciliation.replace(/\s+/g, ' ') + expect(normalizedReconciliation).toMatch( + /const effective = input\.suppressPhasePersistence \? undefined : grant \? phasesWithEffective\(pkg\.metadata, projectFilesystemEffectivePhase\(grant\)\) : forcePersist \? currentPhases : undefined/, + ) expect(reconcileScript).not.toContain('--suppress') + expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(reconcileScript).toContain('rootReconciliationContext:') + 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 f727fa0c..546cce36 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -645,36 +645,44 @@ DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; BEGIN PERFORM forge.assert_project_root_reconciler_v1(); IF p_batch_size NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION 'project-root reconciliation batch must be 1 through 100' USING ERRCODE = '22023'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations - WHERE operation_id = p_operation_id; - IF (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) <> v_operation.through_generation - OR (SELECT coalesce(max(generation), 0) FROM public.project_root_change_journal) <> v_operation.through_generation - OR (SELECT count(*) FROM public.project_root_change_journal) <> v_operation.through_generation THEN + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.operation_id = p_operation_id; + IF (SELECT counter_row.last_generation FROM public.project_root_change_journal_counter AS counter_row WHERE counter_row.singleton) <> v_operation.through_generation + OR (SELECT coalesce(max(journal_row.generation), 0) FROM public.project_root_change_journal AS journal_row) <> v_operation.through_generation + OR (SELECT count(*) FROM public.project_root_change_journal AS journal_row) <> v_operation.through_generation THEN RAISE EXCEPTION 'project-root journal changed before batch claim' USING ERRCODE = '55000'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations - WHERE operation_id = p_operation_id FOR UPDATE; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.operation_id = p_operation_id FOR UPDATE; IF v_operation.actor_id <> p_actor_id OR v_operation.state <> 'running' THEN RAISE EXCEPTION 'project-root operation is not claimable by this actor' USING ERRCODE = '42501'; END IF; RETURN QUERY - SELECT journal.generation, journal.project_id, journal.outcome, journal.root_binding_revision, journal.grant_decision_revision - FROM public.project_root_change_journal journal - WHERE journal.generation > v_operation.last_processed_generation AND journal.generation <= v_operation.through_generation - ORDER BY journal.generation LIMIT p_batch_size FOR UPDATE; + SELECT journal_row.generation, journal_row.project_id, journal_row.outcome, journal_row.root_binding_revision, journal_row.grant_decision_revision + FROM public.project_root_change_journal AS journal_row + WHERE journal_row.generation > v_operation.last_processed_generation AND journal_row.generation <= v_operation.through_generation + ORDER BY journal_row.generation LIMIT p_batch_size FOR UPDATE; END; $$; --> statement-breakpoint CREATE TABLE public.project_root_reconciliation_write_contexts ( - operation_id uuid NOT NULL REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, - generation bigint NOT NULL REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT, + operation_id uuid NOT NULL, + generation bigint NOT NULL, actor_id uuid NOT NULL, - project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE RESTRICT, - backend_pid integer NOT NULL CHECK (backend_pid > 0), - transaction_id bigint NOT NULL CHECK (transaction_id > 0), + project_id uuid NOT NULL, + backend_pid integer NOT NULL CONSTRAINT project_root_reconciliation_write_context_backend_pid_chk CHECK (backend_pid > 0), + transaction_id bigint NOT NULL CONSTRAINT project_root_reconciliation_write_context_transaction_id_chk CHECK (transaction_id > 0), + -- Record wall-clock entry time; a long root reconciliation transaction must + -- not collapse every context timestamp to its transaction start. entered_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), completed_at timestamptz, - PRIMARY KEY (operation_id, generation), - UNIQUE (generation), - CONSTRAINT project_root_reconciliation_write_context_shape_chk CHECK ((completed_at IS NULL) OR completed_at >= entered_at) + CONSTRAINT project_root_reconciliation_write_contexts_pkey PRIMARY KEY (operation_id, generation), + CONSTRAINT project_root_reconciliation_write_context_generation_unique UNIQUE (generation), + CONSTRAINT project_root_reconciliation_write_context_shape_chk CHECK ((completed_at IS NULL) OR completed_at >= entered_at), + CONSTRAINT project_root_reconciliation_write_contexts_operation_id_fkey + FOREIGN KEY (operation_id) REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, + CONSTRAINT project_root_reconciliation_write_contexts_generation_fkey + FOREIGN KEY (generation) REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT, + CONSTRAINT project_root_reconciliation_write_contexts_project_id_fkey + FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE RESTRICT ); CREATE OR REPLACE FUNCTION forge.enter_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ @@ -688,7 +696,6 @@ BEGIN 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; $$; -ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner; 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 $$ @@ -728,10 +735,14 @@ 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 @@ -744,6 +755,40 @@ BEGIN 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 @@ -754,36 +799,36 @@ DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; v_jou BEGIN PERFORM forge.assert_project_root_reconciler_v1(); IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context WHERE context.operation_id=p_operation_id AND context.generation=p_generation AND context.actor_id=p_actor_id AND context.project_id=p_project_id AND context.backend_pid=pg_catalog.pg_backend_pid() AND context.transaction_id=pg_catalog.txid_current() AND context.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root write context is absent or stale' USING ERRCODE='42501'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE operation_id = p_operation_id; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row WHERE operation_row.operation_id = p_operation_id; PERFORM forge.assert_project_root_journal_window_v1(v_operation.through_generation); - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE operation_id = p_operation_id FOR UPDATE; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row WHERE operation_row.operation_id = p_operation_id FOR UPDATE; IF v_operation.actor_id <> p_actor_id OR v_operation.state <> 'running' OR p_generation <> v_operation.last_processed_generation + 1 THEN RAISE EXCEPTION 'project-root completion compare-and-set failed' USING ERRCODE = '40001'; END IF; - SELECT * INTO STRICT v_journal FROM public.project_root_change_journal WHERE generation = p_generation FOR UPDATE; + SELECT * INTO STRICT v_journal FROM public.project_root_change_journal AS journal_row WHERE journal_row.generation = p_generation FOR UPDATE; IF v_journal.project_id <> p_project_id OR v_journal.outcome <> p_outcome OR p_generation > v_operation.through_generation THEN RAISE EXCEPTION 'project-root journal outcome changed or is incoherent' USING ERRCODE = '22023'; END IF; - IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE generation = p_generation) THEN + IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes AS outcome_row WHERE outcome_row.generation = p_generation) THEN RAISE EXCEPTION 'project-root generation already has an immutable outcome' USING ERRCODE = '23505'; END IF; INSERT INTO public.project_root_reconciliation_outcomes(generation, operation_id, actor_id, project_id, outcome) VALUES (p_generation, p_operation_id, p_actor_id, p_project_id, p_outcome); - UPDATE public.project_root_reconciliation_operations + UPDATE public.project_root_reconciliation_operations AS operation_row SET last_processed_generation = p_generation, last_project_id = p_project_id, - batch_count = batch_count + 1, cumulative_count = cumulative_count + 1, - state = CASE WHEN p_generation = through_generation THEN 'complete' ELSE 'running' END, - completed_at = CASE WHEN p_generation = through_generation THEN pg_catalog.clock_timestamp() ELSE NULL END, + batch_count = operation_row.batch_count + 1, cumulative_count = operation_row.cumulative_count + 1, + state = CASE WHEN p_generation = operation_row.through_generation THEN 'complete' ELSE 'running' END, + completed_at = CASE WHEN p_generation = operation_row.through_generation THEN pg_catalog.clock_timestamp() ELSE NULL END, updated_at = pg_catalog.clock_timestamp() - WHERE operation_id = p_operation_id RETURNING * INTO v_operation; + WHERE operation_row.operation_id = p_operation_id RETURNING operation_row.* INTO v_operation; INSERT INTO public.project_root_reconciliation_checkpoints( operation_id, checkpoint_generation, actor_id, through_generation, last_processed_generation, last_project_id, batch_count, cumulative_count, state ) VALUES (v_operation.operation_id, v_operation.last_processed_generation, v_operation.actor_id, v_operation.through_generation, v_operation.last_processed_generation, v_operation.last_project_id, v_operation.batch_count, v_operation.cumulative_count, v_operation.state); - UPDATE public.project_root_reconciliation_write_contexts SET completed_at=pg_catalog.clock_timestamp() - WHERE operation_id=p_operation_id AND generation=p_generation AND backend_pid=pg_catalog.pg_backend_pid() AND transaction_id=pg_catalog.txid_current() AND completed_at IS NULL; + UPDATE public.project_root_reconciliation_write_contexts AS context_row SET completed_at=pg_catalog.clock_timestamp() + WHERE context_row.operation_id=p_operation_id AND context_row.generation=p_generation AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL; RETURN QUERY SELECT v_operation.state, v_operation.last_processed_generation; END; $$; diff --git a/web/db/schema.ts b/web/db/schema.ts index 46c14e47..3953170c 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1902,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 79122310..2bf44e08 100644 --- a/web/lib/mcps/filesystem-grant-reconciliation.ts +++ b/web/lib/mcps/filesystem-grant-reconciliation.ts @@ -871,6 +871,7 @@ export async function reconcileFilesystemGrantsForProject( nextMcpConfig: ProjectMcpConfig trigger: FilesystemGrantProjectReconciliationTrigger suppressPhasePersistence?: boolean + rootReconciliationContext?: { operationId: string; actorId: string; generation: string; projectId: string } }, ): Promise { if (!input.actorId.trim()) throw new Error('Grant reconciliation requires an actor.') @@ -903,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) { @@ -922,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) } diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 4fdddb2b..5f2f32a6 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -31,6 +31,7 @@ const OWNED_TABLES = [ 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', 'project_root_reconciliation_outcomes', + 'project_root_reconciliation_write_contexts', 's4_completion_handoffs', 's4_protected_review_sources', 's4_protected_review_source_reads', @@ -112,6 +113,40 @@ async function main(): Promise { await admin`revoke create on schema forge from ${admin(OWNER)}` await admin`revoke create on schema public from ${admin(OWNER)}` await admin`grant execute on function forge.read_epic_172_enablement_state_v1() to ${admin(OWNER)}` + // These six routines are trigger-only: migrations create their triggers + // before this bootstrap runs, and the owning trigger tables invoke them. + // No runtime login needs direct EXECUTE, so remove PostgreSQL's default + // PUBLIC function grant without opening a reconciler capability. + await admin.unsafe(` + revoke execute on function + public.forge_epic_172_reject_mutation_v1(), + public.forge_epic_172_reject_project_hard_delete_v1(), + public.forge_preallocate_filesystem_decision_pointer(), + public.forge_preallocate_project_filesystem_decision_pointer(), + public.forge_reject_filesystem_grant_history_mutation(), + public.forge_reject_project_filesystem_decision_mutation() + from public + `) + const [{ publicTriggerExecuteCount }] = await admin<{ publicTriggerExecuteCount: number }[]>` + select count(*)::integer as "publicTriggerExecuteCount" + from pg_catalog.pg_proc routine + cross join lateral pg_catalog.aclexplode( + coalesce(routine.proacl, pg_catalog.acldefault('f', routine.proowner)) + ) acl + where routine.oid = any(array[ + 'public.forge_epic_172_reject_mutation_v1()'::pg_catalog.regprocedure, + 'public.forge_epic_172_reject_project_hard_delete_v1()'::pg_catalog.regprocedure, + 'public.forge_preallocate_filesystem_decision_pointer()'::pg_catalog.regprocedure, + 'public.forge_preallocate_project_filesystem_decision_pointer()'::pg_catalog.regprocedure, + 'public.forge_reject_filesystem_grant_history_mutation()'::pg_catalog.regprocedure, + 'public.forge_reject_project_filesystem_decision_mutation()'::pg_catalog.regprocedure + ]) + and acl.grantee = 0 + and acl.privilege_type = 'EXECUTE' + ` + if (publicTriggerExecuteCount !== 0) { + throw new Error('Trigger-only S3 helpers retain an unsafe PUBLIC EXECUTE grant.') + } // S4 claims and archive transitions take row locks on the bounded S3 // current-head set. PostgreSQL requires UPDATE privilege for SELECT ... // FOR UPDATE even though these routines never modify the head rows. diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts index 663edd95..7d61eef3 100644 --- a/web/scripts/build-project-root-ref-index.ts +++ b/web/scripts/build-project-root-ref-index.ts @@ -1,6 +1,13 @@ import '../lib/load-env' import postgres from 'postgres' +type IndexState = { + exists: boolean + canonical: boolean + valid: boolean + ready: boolean +} + async function main(): Promise { if (process.argv.slice(2).join(' ') !== '--apply') { throw new Error('Concurrent root-reference index creation is actionless without --apply.') @@ -9,17 +16,28 @@ async function main(): Promise { if (!adminUrl) throw new Error('FORGE_DATABASE_ADMIN_URL is required for the short-lived concurrent DDL step.') const admin = postgres(adminUrl, { max: 1, onnotice: () => {} }) try { + await admin`select pg_catalog.pg_advisory_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))` + const inspect = async () => admin` + 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. - await admin.unsafe( - 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL', - ) - const [index] = await admin<{ valid: boolean }[]>` - select indisvalid as valid from pg_catalog.pg_index - where indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass - ` - if (!index?.valid) throw new Error('Concurrent projects(root_ref) index is not valid.') + if (!index?.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 }) } } diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 9fffbabd..9cf02314 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -14,12 +14,24 @@ fi 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'; END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_index index_row + JOIN pg_catalog.pg_class index_class ON index_class.oid = index_row.indexrelid + 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 public.project_root_reconciliation_operations operation WHERE operation.through_generation = v_through AND operation.state = 'complete' @@ -31,17 +43,13 @@ BEGIN OR EXISTS (SELECT 1 FROM public.projects WHERE root_ref IS NULL) THEN RAISE EXCEPTION 'strict root-reference cutover requires exact completed watermark coverage' USING ERRCODE = '55000'; END IF; - IF NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_index index_row - WHERE index_row.indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass AND index_row.indisvalid - ) THEN RAISE EXCEPTION 'strict root-reference cutover requires a valid concurrent projects(root_ref) index' USING ERRCODE = '55000'; END IF; IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint WHERE conrelid = 'public.projects'::regclass AND conname = 'projects_root_ref_not_null_proof') THEN ALTER TABLE public.projects ADD CONSTRAINT projects_root_ref_not_null_proof CHECK (root_ref IS NOT NULL) NOT VALID; 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; 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 < 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 index fbc7cab5..92c52a98 100644 --- a/web/scripts/reconcile-project-root-expansion.ts +++ b/web/scripts/reconcile-project-root-expansion.ts @@ -61,8 +61,9 @@ async function main(): Promise { const claimed = (claimedRows as unknown as Array>).map(parseClaimedProjectRootChange) if (claimed.length === 0) return { completed: true, processed: 0 } for (const change of claimed) { + await tx.execute(sql`select forge.enter_project_root_reconciliation_generation_v1(${operation.operationId}::uuid, ${command.actorId}::uuid, ${change.generation.toString()}::bigint, ${change.projectId}::uuid)`) const [project] = await tx.select().from(schema.projects) - .where(eq(schema.projects.id, change.projectId)).for('update') + .where(eq(schema.projects.id, change.projectId)) if (!project) throw new Error('Journal project disappeared before reconciliation.') const hasBoundFilesystemAuthority = project.rootBindingRevision > BigInt(0) || project.grantDecisionRevision > BigInt(0) @@ -79,15 +80,22 @@ async function main(): Promise { rootBindingRevision: project.rootBindingRevision, }), suppressPhasePersistence: true, + rootReconciliationContext: { operationId: operation.operationId, actorId: command.actorId, generation: change.generation.toString(), projectId: change.projectId }, trigger: 'project_root_repoint', }) } - await tx.execute(sql` + const completionRows = await tx.execute(sql` select * from forge.complete_project_root_reconciliation_generation_v1( ${operation.operationId}::uuid, ${command.actorId}::uuid, ${change.generation.toString()}::bigint, ${change.projectId}::uuid, ${change.outcome} ) `) + const completion = (completionRows as unknown as Array<{ state?: unknown; last_processed_generation?: unknown }>)[0] + if (!completion || (completion.state !== 'running' && completion.state !== 'complete') + || String(completion.last_processed_generation) !== change.generation.toString()) { + throw new Error('Project-root completion returned an invalid state.') + } + if (completion.state === 'complete') return { completed: true, processed: claimed.length } } return { completed: false, processed: claimed.length } })