From 6d10944633698bf2fde2b53378f4fd960676dfbf Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:16:22 +0800 Subject: [PATCH 01/69] feat: journal project root changes during expansion --- .../project-root-change-journal.test.ts | 116 ++++++++++++++++++ .../0027_epic_172_s4_packet_context.sql | 91 +++++++++++++- web/db/schema.ts | 29 ++++- web/lib/mcps/project-root-change-journal.ts | 74 +++++++++++ web/scripts/bootstrap-epic-172-s4-roles.ts | 6 +- 5 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 web/__tests__/project-root-change-journal.test.ts create mode 100644 web/lib/mcps/project-root-change-journal.ts diff --git a/web/__tests__/project-root-change-journal.test.ts b/web/__tests__/project-root-change-journal.test.ts new file mode 100644 index 00000000..84f08732 --- /dev/null +++ b/web/__tests__/project-root-change-journal.test.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES, + parseProjectRootChangeJournalEntry, +} from '@/lib/mcps/project-root-change-journal' + +const migration = readFileSync( + fileURLToPath(new URL('../db/migrations/0027_epic_172_s4_packet_context.sql', import.meta.url)), + 'utf8', +) +const schema = readFileSync( + fileURLToPath(new URL('../db/schema.ts', import.meta.url)), + 'utf8', +) +const bootstrap = readFileSync( + fileURLToPath(new URL('../scripts/bootstrap-epic-172-s4-roles.ts', import.meta.url)), + 'utf8', +) + +const validEntry = () => ({ + generation: '1', + operationId: '00000000-0000-4000-8000-000000000001', + projectId: '00000000-0000-4000-8000-000000000002', + outcome: 'root_update', + rootBindingRevision: '2', + grantDecisionRevision: '3', + occurredAt: new Date('2026-07-27T00:00:00.000Z'), +}) + +describe('project root change journal contract', () => { + it('shares the exact closed outcome tuple with the bounded parser', () => { + expect(PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES).toEqual(['insert', 'root_update', 'archive']) + expect(migration).toContain("outcome IN ('insert','root_update','archive')") + expect(schema).toContain("in ('insert','root_update','archive')") + expect(parseProjectRootChangeJournalEntry(validEntry())).toEqual(expect.objectContaining({ + generation: BigInt(1), + outcome: 'root_update', + rootBindingRevision: BigInt(2), + grantDecisionRevision: BigInt(3), + })) + }) + + it.each([ + 'root-update', 'deleted_row', 'deleted-row', 'delete', 'unknown', null, 7, + ])('rejects stale or unknown outcome %#', (outcome) => { + expect(() => parseProjectRootChangeJournalEntry({ ...validEntry(), outcome })).toThrow(/outcome/i) + }) + + it.each([ + ['generation', '0'], + ['generation', '-1'], + ['generation', 1], + ['rootBindingRevision', '0'], + ['rootBindingRevision', '-1'], + ['grantDecisionRevision', '0'], + ['grantDecisionRevision', '-1'], + ['operationId', 'not-a-uuid'], + ['projectId', '00000000-0000-0000-0000-000000000000'], + ['occurredAt', new Date('invalid')], + ['unexpected', 'value'], + ])('rejects malformed bounded field %s', (field, replacement) => { + const entry = validEntry() as Record + entry[field] = replacement + expect(() => parseProjectRootChangeJournalEntry(entry)).toThrow() + }) + + it('allows null only when a project has no applicable positive revision yet', () => { + expect(parseProjectRootChangeJournalEntry({ + ...validEntry(), outcome: 'insert', rootBindingRevision: null, grantDecisionRevision: null, + })).toEqual(expect.objectContaining({ rootBindingRevision: null, grantDecisionRevision: null })) + }) + + it('uses a protected gap-free counter and an append-only, path-free trigger journal', () => { + const journalStart = migration.indexOf('CREATE TABLE public.project_root_change_journal (') + const journalEnd = migration.indexOf('--> statement-breakpoint', journalStart) + const journalTable = migration.slice(journalStart, journalEnd) + expect(journalTable).toContain("outcome IN ('insert','root_update','archive')") + expect(journalTable).not.toMatch(/local_path|root_ref|jsonb|reason|text\s+NOT NULL.*content/i) + expect(migration).toContain('CREATE TABLE public.project_root_change_journal_counter') + expect(migration).toContain('UPDATE public.project_root_change_journal_counter counter') + expect(migration).toContain('RETURNING counter.last_generation INTO STRICT v_generation') + expect(migration).not.toMatch(/project_root_change_journal[\s\S]{0,120}(?:bigserial|identity|nextval)/i) + const appendStart = migration.indexOf('CREATE OR REPLACE FUNCTION forge.append_project_root_change_journal_v1()') + const appendEnd = migration.indexOf('--> statement-breakpoint', appendStart) + const appendFunction = migration.slice(appendStart, appendEnd) + expect(appendFunction.match(/INSERT INTO public\.project_root_change_journal/g)).toHaveLength(1) + expect(appendFunction).not.toMatch(/FOR UPDATE.*projects|projects.*FOR UPDATE/i) + expect(migration).toContain("NEW.archived_at IS NOT NULL AND OLD.archived_at IS NULL") + expect(migration).toContain("v_outcome := 'archive'") + expect(migration).toContain("v_outcome := 'root_update'") + expect(migration).toContain('BEFORE UPDATE OR DELETE ON public.project_root_change_journal') + expect(migration).toContain("RAISE EXCEPTION 'project root change journal is append-only'") + }) + + it('keeps the journal protected, owned, and absent from the premature unique-index cutover', () => { + for (const objectName of ['project_root_change_journal', 'project_root_change_journal_counter']) { + expect(bootstrap).toContain(`'${objectName}'`) + expect(migration).toContain(`ALTER TABLE public.${objectName} OWNER TO forge_s4_routines_owner`) + } + for (const routine of [ + 'append_project_root_change_journal_v1', + 'reject_project_root_change_journal_mutation_v1', + ]) { + expect(bootstrap).toContain(`'${routine}'`) + expect(migration).toContain(`REVOKE ALL ON FUNCTION forge.${routine}() FROM PUBLIC`) + } + expect(migration).toContain('REVOKE ALL ON public.architect_plan_versions') + expect(migration).toContain('public.project_root_change_journal FROM PUBLIC') + expect(bootstrap).toContain('where acl.grantee <> table_row.relowner') + expect(schema).toContain("export const projectRootChangeJournal = pgTable('project_root_change_journal'") + expect(migration).not.toContain('CREATE UNIQUE INDEX projects_root_ref_idx') + expect(schema).not.toContain("uniqueIndex('projects_root_ref_idx')") + }) +}) diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index b6ce989c..21ebdbea 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -360,8 +360,6 @@ CREATE TRIGGER projects_root_ref_renull_guard_v1 BEFORE UPDATE OF root_ref ON public.projects FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_ref_renull_v1(); --> statement-breakpoint -CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects (root_ref); ---> statement-breakpoint CREATE TABLE public.project_root_ref_reconciliation ( singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), last_project_id uuid, @@ -434,6 +432,85 @@ BEGIN END; $$; --> statement-breakpoint +-- C5 records only bounded root-change identities during the expand window. +-- C6 owns watermark/reconciliation and the later concurrent unique index. +CREATE TABLE public.project_root_change_journal_counter ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + last_generation bigint NOT NULL DEFAULT 0 CHECK (last_generation >= 0) +); +INSERT INTO public.project_root_change_journal_counter (singleton) VALUES (true); +--> statement-breakpoint +CREATE TABLE public.project_root_change_journal ( + generation bigint PRIMARY KEY CHECK (generation > 0), + operation_id uuid NOT NULL UNIQUE DEFAULT pg_catalog.gen_random_uuid(), + project_id uuid NOT NULL REFERENCES public.projects(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + outcome text NOT NULL CHECK (outcome IN ('insert','root_update','archive')), + root_binding_revision bigint CHECK (root_binding_revision IS NULL OR root_binding_revision > 0), + grant_decision_revision bigint CHECK (grant_decision_revision IS NULL OR grant_decision_revision > 0), + occurred_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp() +); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.append_project_root_change_journal_v1() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE + v_generation bigint; + v_outcome text; +BEGIN + IF TG_OP = 'INSERT' THEN + v_outcome := 'insert'; + ELSIF NEW.archived_at IS NOT NULL AND OLD.archived_at IS NULL THEN + -- Archive wins if the same update also repoints a root-bearing field. + v_outcome := 'archive'; + ELSIF NEW.local_path IS DISTINCT FROM OLD.local_path + OR NEW.root_ref IS DISTINCT FROM OLD.root_ref + OR NEW.root_binding_revision IS DISTINCT FROM OLD.root_binding_revision THEN + v_outcome := 'root_update'; + ELSE + RETURN NEW; + END IF; + + -- This UPDATE locks only the singleton counter. It and the append insert + -- share the caller transaction, so rollback cannot leave a generation gap. + UPDATE public.project_root_change_journal_counter counter + SET last_generation = counter.last_generation + 1 + WHERE counter.singleton + RETURNING counter.last_generation INTO STRICT v_generation; + + INSERT INTO public.project_root_change_journal ( + generation, operation_id, project_id, outcome, + root_binding_revision, grant_decision_revision, occurred_at + ) VALUES ( + v_generation, pg_catalog.gen_random_uuid(), NEW.id, v_outcome, + NULLIF(NEW.root_binding_revision, 0), NULLIF(NEW.grant_decision_revision, 0), + pg_catalog.clock_timestamp() + ); + RETURN NEW; +END; +$$; +--> statement-breakpoint +CREATE TRIGGER projects_root_change_journal_v1 + AFTER INSERT OR UPDATE ON public.projects + FOR EACH ROW EXECUTE FUNCTION forge.append_project_root_change_journal_v1(); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.reject_project_root_change_journal_mutation_v1() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'project root change journal is append-only' + USING ERRCODE = '55000'; +END; +$$; +CREATE TRIGGER project_root_change_journal_append_only_v1 + BEFORE UPDATE OR DELETE ON public.project_root_change_journal + FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_change_journal_mutation_v1(); +--> statement-breakpoint CREATE TABLE public.architect_plan_versions ( task_id uuid NOT NULL, plan_artifact_id uuid NOT NULL, @@ -7128,10 +7205,14 @@ REVOKE ALL ON public.architect_plan_versions, public.architect_plan_entries, public.local_projection_archive_operations, public.local_projection_archive_operation_checkpoints, public.filesystem_mcp_decision_nonce_claims, - public.project_root_ref_reconciliation FROM PUBLIC; + public.project_root_ref_reconciliation, + public.project_root_change_journal_counter, + public.project_root_change_journal FROM PUBLIC; REVOKE ALL ON FUNCTION forge.fill_project_root_ref_on_insert_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.guard_project_root_ref_renull_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.reconcile_project_root_refs_v1(integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.append_project_root_change_journal_v1() FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.reject_project_root_change_journal_mutation_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.s4_protected_paths_enabled_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.guard_s4_approval_gate_review_head_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.reject_s4_retained_mutation_v1() FROM PUBLIC; @@ -7261,9 +7342,13 @@ ALTER TABLE public.local_projection_archive_operations OWNER TO forge_s4_routine ALTER TABLE public.local_projection_archive_operation_checkpoints OWNER TO forge_s4_routines_owner; ALTER TABLE public.filesystem_mcp_decision_nonce_claims OWNER TO forge_s4_routines_owner; ALTER TABLE public.project_root_ref_reconciliation OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_change_journal_counter OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_change_journal OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.fill_project_root_ref_on_insert_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.guard_project_root_ref_renull_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.reconcile_project_root_refs_v1(integer) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.append_project_root_change_journal_v1() OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.reject_project_root_change_journal_mutation_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.s4_protected_paths_enabled_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.guard_s4_approval_gate_review_head_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.reject_s4_retained_mutation_v1() OWNER TO forge_s4_routines_owner; diff --git a/web/db/schema.ts b/web/db/schema.ts index d5099356..7f403484 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -265,9 +265,7 @@ export const projects = pgTable('projects', { createdAt: timestamp('created_at', tsOpts).defaultNow().notNull(), updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), archivedAt: timestamp('archived_at', tsOpts), -}, (t) => [ - uniqueIndex('projects_root_ref_idx').on(t.rootRef), -]) +}) export type Project = InferSelectModel export type NewProject = InferInsertModel @@ -280,6 +278,31 @@ export const projectRootRefReconciliation = pgTable('project_root_ref_reconcilia updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), }) +export const projectRootChangeJournalCounter = pgTable('project_root_change_journal_counter', { + singleton: boolean('singleton').primaryKey().default(true), + lastGeneration: bigint('last_generation', { mode: 'bigint' }).notNull().default(sql`0`), +}, (t) => [ + check('project_root_change_journal_counter_generation_chk', sql`${t.lastGeneration} >= 0`), +]) + +export const projectRootChangeJournal = pgTable('project_root_change_journal', { + generation: bigint('generation', { mode: 'bigint' }).primaryKey(), + operationId: uuid('operation_id').notNull().defaultRandom().unique(), + projectId: uuid('project_id').notNull().references(() => projects.id, { + onDelete: 'restrict', + onUpdate: 'restrict', + }), + outcome: text('outcome').notNull(), + rootBindingRevision: bigint('root_binding_revision', { mode: 'bigint' }), + grantDecisionRevision: bigint('grant_decision_revision', { mode: 'bigint' }), + occurredAt: timestamp('occurred_at', tsOpts).defaultNow().notNull(), +}, (t) => [ + check('project_root_change_journal_generation_chk', sql`${t.generation} > 0`), + check('project_root_change_journal_outcome_chk', sql`${t.outcome} in ('insert','root_update','archive')`), + check('project_root_change_journal_root_binding_revision_chk', sql`${t.rootBindingRevision} is null or ${t.rootBindingRevision} > 0`), + check('project_root_change_journal_grant_decision_revision_chk', sql`${t.grantDecisionRevision} is null or ${t.grantDecisionRevision} > 0`), +]) + // --------------------------------------------------------------------------- // Epic 172 release authentication and transition substrate // diff --git a/web/lib/mcps/project-root-change-journal.ts b/web/lib/mcps/project-root-change-journal.ts new file mode 100644 index 00000000..4e29a50e --- /dev/null +++ b/web/lib/mcps/project-root-change-journal.ts @@ -0,0 +1,74 @@ +export const PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES = Object.freeze([ + 'insert', + 'root_update', + 'archive', +] as const) + +export type ProjectRootChangeJournalOutcome = typeof PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES[number] + +export type ProjectRootChangeJournalEntry = Readonly<{ + generation: bigint + operationId: string + projectId: string + outcome: ProjectRootChangeJournalOutcome + rootBindingRevision: bigint | null + grantDecisionRevision: bigint | null + occurredAt: Date +}> + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const NON_NEGATIVE_DECIMAL = /^(?:0|[1-9][0-9]*)$/ +const POSITIVE_DECIMAL = /^[1-9][0-9]*$/ + +function parseDecimal(value: unknown, field: string, positive: boolean): bigint { + if (typeof value !== 'string' || !(positive ? POSITIVE_DECIMAL : NON_NEGATIVE_DECIMAL).test(value)) { + throw new Error(`Project root change journal ${field} must be a canonical ${positive ? 'positive' : 'non-negative'} decimal string`) + } + return BigInt(value) +} + +function parseOptionalPositiveDecimal(value: unknown, field: string): bigint | null { + return value === null ? null : parseDecimal(value, field, true) +} + +function parseUuid(value: unknown, field: string): string { + if (typeof value !== 'string' || !UUID.test(value)) { + throw new Error(`Project root change journal ${field} must be a canonical UUID`) + } + return value +} + +function parseOccurredAt(value: unknown): Date { + if (!(value instanceof Date) || !Number.isFinite(value.getTime())) { + throw new Error('Project root change journal occurredAt must be a valid database timestamp') + } + return value +} + +/** Parses only the bounded, path-free journal row shape returned by PostgreSQL. */ +export function parseProjectRootChangeJournalEntry(value: unknown): ProjectRootChangeJournalEntry { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Project root change journal entry must be an object') + } + const row = value as Record + const keys = Object.keys(row).sort() + const expectedKeys = [ + 'generation', 'grantDecisionRevision', 'occurredAt', 'operationId', + 'outcome', 'projectId', 'rootBindingRevision', + ] + if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) { + throw new Error('Project root change journal entry has an unknown or missing field') + } + if (!PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES.includes(row.outcome as ProjectRootChangeJournalOutcome)) { + throw new Error('Project root change journal outcome is invalid') + } + return Object.freeze({ + generation: parseDecimal(row.generation, 'generation', true), + operationId: parseUuid(row.operationId, 'operationId'), + projectId: parseUuid(row.projectId, 'projectId'), + outcome: row.outcome as ProjectRootChangeJournalOutcome, + rootBindingRevision: parseOptionalPositiveDecimal(row.rootBindingRevision, 'rootBindingRevision'), + grantDecisionRevision: parseOptionalPositiveDecimal(row.grantDecisionRevision, 'grantDecisionRevision'), + occurredAt: parseOccurredAt(row.occurredAt), + }) +} diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 9d01a15d..dbac8bb4 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -25,6 +25,8 @@ const OWNED_TABLES = [ 'work_package_local_run_evidence', 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', + 'project_root_change_journal_counter', + 'project_root_change_journal', 's4_completion_handoffs', 's4_protected_review_sources', 's4_protected_review_source_reads', @@ -340,6 +342,8 @@ async function main(): Promise { ,'fill_project_root_ref_on_insert_v1' ,'guard_project_root_ref_renull_v1' ,'reconcile_project_root_refs_v1' + ,'append_project_root_change_journal_v1' + ,'reject_project_root_change_journal_mutation_v1' ,'s4_protected_paths_enabled_v1' ,'guard_s4_approval_gate_review_head_v1' ,'bind_architect_replan_entry_v1' @@ -395,7 +399,7 @@ async function main(): Promise { ) acl where acl.grantee = 0 and acl.privilege_type = 'EXECUTE' ) - ) <> 61 then + ) <> 63 then raise exception 'The S4 routine owner or PUBLIC boundary is incomplete' using errcode = '42501'; end if; From 68465588d607589528c8764d047d726ab40a7aff Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:21:16 +0800 Subject: [PATCH 02/69] fix: inventory root change journal ACLs --- .github/workflows/web-ci.yml | 6 ++++-- web/__tests__/epic-172-s4-context.test.ts | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 8188b031..06758ecc 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -275,7 +275,8 @@ jobs: 'local_effect_recovery_actions', 's4_max_attempt_finalizations', 'local_projection_archive_operations', 'local_projection_archive_operation_checkpoints', - 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation' + 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', + 'project_root_change_journal_counter', 'project_root_change_journal' ] LOOP FOREACH table_privilege IN ARRAY ARRAY[ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER' @@ -340,7 +341,8 @@ jobs: 'local_effect_recovery_actions', 's4_max_attempt_finalizations', 'local_projection_archive_operations', 'local_projection_archive_operation_checkpoints', - 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation' + 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', + 'project_root_change_journal_counter', 'project_root_change_journal' ]; projection_tables constant text[] := ARRAY[ 'forge_epic_172_s3_release_state', 'work_package_local_projection_sources', diff --git a/web/__tests__/epic-172-s4-context.test.ts b/web/__tests__/epic-172-s4-context.test.ts index 690da623..e98eca15 100644 --- a/web/__tests__/epic-172-s4-context.test.ts +++ b/web/__tests__/epic-172-s4-context.test.ts @@ -167,6 +167,8 @@ describe('Epic 172 S4 PostgreSQL CI contract', () => { 'local_projection_archive_operations', 'local_projection_archive_operation_checkpoints', 'filesystem_mcp_decision_nonce_claims', + 'project_root_change_journal_counter', + 'project_root_change_journal', ]) { expect(ordinaryInventory).not.toContain(`'${table}'`) expect(protectedInventory).toContain(`'${table}'`) From 2033cd7eaf066d5783ce2e29b6a0b2d8511f8017 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:50:52 +0800 Subject: [PATCH 03/69] feat: reconcile root change expansion --- .github/workflows/web-ci.yml | 39 +- .../project-root-reconciliation.test.ts | 83 +++++ .../0027_epic_172_s4_packet_context.sql | 347 +++++++++++++++--- web/db/schema.ts | 57 ++- web/lib/mcps/project-root-reconciliation.ts | 81 ++++ web/package.json | 3 +- web/scripts/bootstrap-epic-172-s4-roles.ts | 55 ++- web/scripts/build-project-root-ref-index.ts | 30 ++ .../ci/cutover-migration-0027-root-ref.sh | 44 ++- .../ci/reconcile-migration-0027-root-refs.sh | 58 ++- .../reconcile-project-root-expansion.ts | 106 ++++++ 11 files changed, 769 insertions(+), 134 deletions(-) create mode 100644 web/__tests__/project-root-reconciliation.test.ts create mode 100644 web/lib/mcps/project-root-reconciliation.ts create mode 100644 web/scripts/build-project-root-ref-index.ts create mode 100644 web/scripts/reconcile-project-root-expansion.ts diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 06758ecc..fd82c3b3 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -205,13 +205,38 @@ jobs: ALTER ROLE forge_architect_plan_resolver PASSWORD 'forge_plan_resolver_test'; ALTER ROLE forge_architect_plan_history_reader PASSWORD 'forge_plan_history_reader_test'; ALTER ROLE forge_packet_issuer PASSWORD 'forge_packet_issuer_test'; + ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test'; SQL - - name: Reconcile fresh-install root references with zero-scan proof - run: npm run project-roots:reconcile-expansion + - name: Materialize legacy root references through the dedicated principal + env: + FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL: postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@localhost:5432/forge_epic_172_ci_test + run: | + while :; do + rows="$(psql "$FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command "SELECT forge.materialize_project_root_ref_expansion_v1(100);")" + [[ "$rows" =~ ^[0-9]+$ ]] || { echo 'Root materialization returned an invalid row count.' >&2; exit 1; } + [[ "$rows" == '0' ]] && break + done + - name: Capture the post-drain root journal watermark + id: root_journal_watermark + env: + FORGE_DATABASE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_epic_172_ci_test + run: | + watermark="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command "SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton;")" + if [[ ! "$watermark" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo 'The post-drain root journal watermark is malformed.' >&2 + exit 1 + fi + echo "FORGE_ROOT_REF_CUTOVER_WATERMARK=$watermark" >> "$GITHUB_ENV" + - name: Reconcile fresh-install root references through the dedicated principal + run: npm run project-roots:reconcile-expansion -- --through "$FORGE_ROOT_REF_CUTOVER_WATERMARK" --actor 11111111-1111-4111-8111-111111111111 --apply + env: + FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL: postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@localhost:5432/forge_epic_172_ci_test + - name: Build the separately gated concurrent root-reference index + run: npm run project-roots:build-concurrent-index -- --apply env: FORGE_DATABASE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_epic_172_ci_test - name: Apply the separately gated strict root-reference cutover - run: npm run project-roots:strict-cutover -- --apply + run: npm run project-roots:strict-cutover -- --through "$FORGE_ROOT_REF_CUTOVER_WATERMARK" --apply env: FORGE_DATABASE_ADMIN_URL: postgresql://forge_e2e:forge@localhost:5432/forge_epic_172_ci_test - name: Provision the ordinary app release-reader boundary @@ -276,7 +301,9 @@ jobs: 'local_projection_archive_operations', 'local_projection_archive_operation_checkpoints', 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', - 'project_root_change_journal_counter', 'project_root_change_journal' + 'project_root_change_journal_counter', 'project_root_change_journal', + 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', + 'project_root_reconciliation_outcomes' ] LOOP FOREACH table_privilege IN ARRAY ARRAY[ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER' @@ -342,7 +369,9 @@ jobs: 'local_projection_archive_operations', 'local_projection_archive_operation_checkpoints', 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', - 'project_root_change_journal_counter', 'project_root_change_journal' + 'project_root_change_journal_counter', 'project_root_change_journal', + 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', + 'project_root_reconciliation_outcomes' ]; 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 new file mode 100644 index 00000000..921c57ab --- /dev/null +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + parseProjectRootReconciliationCommand, + PROJECT_ROOT_RECONCILIATION_STATES, +} from '@/lib/mcps/project-root-reconciliation' + +const migration = readFileSync(fileURLToPath(new URL('../db/migrations/0027_epic_172_s4_packet_context.sql', import.meta.url)), 'utf8') +const 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 indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project-root-ref-index.ts', 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('parses only the literal actor/watermark/apply command and keeps dry-run actionless', () => { + const actor = '123e4567-e89b-42d3-a456-426614174000' + expect(parseProjectRootReconciliationCommand(['--through', '0', '--actor', actor])).toEqual({ + actorId: actor, apply: false, throughGeneration: BigInt(0), + }) + expect(() => parseProjectRootReconciliationCommand(['--through', '-1', '--actor', actor, '--apply'])).toThrow('non-negative') + expect(() => parseProjectRootReconciliationCommand(['--through', '1', '--actor', actor, '--admin'])).toThrow('Unknown') + }) + + it('uses path-free append-only operation, checkpoint, and outcome contracts', () => { + for (const table of [ + 'project_root_reconciliation_operations', + 'project_root_reconciliation_checkpoints', + 'project_root_reconciliation_outcomes', + ]) expect(migration).toContain(`CREATE TABLE public.${table}`) + expect(migration).toContain('generation bigint PRIMARY KEY REFERENCES public.project_root_change_journal(generation)') + expect(migration).toContain('project_root_reconciliation_checkpoints_append_only_v1') + expect(migration).toContain('project_root_reconciliation_outcomes_append_only_v1') + expect(migration).toContain("state IN ('running','complete')") + const reconciliationSchema = migration.slice( + migration.indexOf('CREATE TABLE public.project_root_reconciliation_operations'), + migration.indexOf('CREATE OR REPLACE FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1()'), + ) + expect(reconciliationSchema).not.toMatch(/(?:local_path|root_ref|reason|jsonb)/i) + expect(schema).toContain("export const projectRootReconciliationOperations") + expect(schema).toContain("project_root_reconciliation_one_live_idx") + expect(PROJECT_ROOT_RECONCILIATION_STATES).toEqual(['running', 'complete']) + }) + + it('fences operation creation and completion against gaps, later commits, and hijack', () => { + expect(migration).toContain('forge.assert_project_root_journal_window_v1') + expect(migration).toContain('v_counter <> p_through_generation OR v_max <> p_through_generation OR v_count <> p_through_generation') + expect(migration).toContain('project-root operation identity cannot be hijacked') + expect(migration).toContain('project-root completion compare-and-set failed') + expect(migration).toContain('project-root generation already has an immutable outcome') + expect(migration).toContain('forge.materialize_project_root_ref_expansion_v1') + }) + + it('uses the dedicated login with only canonical helper state columns and fixed routines', () => { + expect(reconcileScript).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') + expect(reconcileScript).toContain("forge_project_root_reconciler") + expect(reconcileScript).toContain('reconcileFilesystemGrantsForProject') + expect(reconcileScript).toContain('hasBoundFilesystemAuthority') + expect(bootstrap).toContain("'forge_project_root_reconciler'") + expect(migration).toContain('GRANT UPDATE (status, error_message, updated_at) ON public.tasks TO forge_project_root_reconciler') + expect(migration).toContain('GRANT UPDATE (status, blocked_reason, metadata, updated_at) ON public.work_packages TO forge_project_root_reconciler') + expect(migration).toContain('public.project_root_reconciliation_operations') + expect(migration).toContain('REVOKE ALL ON FUNCTION forge.begin_project_root_reconciliation_v1') + for (const table of [ + 'project_root_reconciliation_operations', + 'project_root_reconciliation_checkpoints', + 'project_root_reconciliation_outcomes', + ]) expect(webCi).toContain(`'${table}'`) + }) + + it('keeps concurrent index DDL separate and strict cutover watermark-fenced', () => { + expect(indexScript).toContain('CREATE UNIQUE INDEX CONCURRENTLY') + expect(indexScript).toContain('FORGE_DATABASE_ADMIN_URL') + expect(reconcileScript).not.toContain('FORGE_DATABASE_ADMIN_URL') + expect(cutoverScript).toContain('--through --apply') + expect(cutoverScript).toContain('project_root_reconciliation_operations') + expect(cutoverScript).toContain('projects_root_ref_idx') + expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') + expect(webCi).toContain('Capture the post-drain root journal watermark') + }) +}) diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 21ebdbea..ace7ec79 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -41,6 +41,9 @@ BEGIN ) OR NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = 'forge_local_projection_archiver' AND rolcanlogin AND NOT rolinherit AND NOT rolsuper + ) OR NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles + WHERE rolname = 'forge_project_root_reconciler' AND rolcanlogin AND NOT rolinherit AND NOT rolsuper ) THEN RAISE EXCEPTION 'dedicated S4 logins must be bootstrapped before migration' USING ERRCODE = '42501'; @@ -360,75 +363,28 @@ CREATE TRIGGER projects_root_ref_renull_guard_v1 BEFORE UPDATE OF root_ref ON public.projects FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_ref_renull_v1(); --> statement-breakpoint +-- Kept as a protected compatibility tombstone for the C5 expansion window. +-- C6 never invokes it; durable operation rows below replace its singleton use. CREATE TABLE public.project_root_ref_reconciliation ( singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), last_project_id uuid, rows_updated bigint NOT NULL DEFAULT 0 CHECK (rows_updated >= 0), - state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','running','complete')), + state text NOT NULL DEFAULT 'superseded' CHECK (state IN ('superseded','complete')), updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp() ); INSERT INTO public.project_root_ref_reconciliation (singleton) VALUES (true); --> statement-breakpoint +-- Retained only so an upgraded operator receives an explicit refusal instead +-- of silently running the superseded singleton algorithm. CREATE OR REPLACE FUNCTION forge.reconcile_project_root_refs_v1(p_batch_size integer) RETURNS TABLE (batch_rows integer, remaining_nulls bigint, reconciliation_state text) LANGUAGE plpgsql SECURITY DEFINER -SET search_path = pg_catalog, public +SET search_path = pg_catalog AS $$ -DECLARE - v_checkpoint public.project_root_ref_reconciliation%ROWTYPE; - v_rows integer; - v_remaining bigint; - v_last_id uuid; BEGIN - IF p_batch_size NOT BETWEEN 1 AND 1000 THEN - RAISE EXCEPTION 'root-reference batch size must be between 1 and 1000' - USING ERRCODE = '22023'; - END IF; - - SELECT checkpoint.* INTO STRICT v_checkpoint - FROM public.project_root_ref_reconciliation checkpoint - WHERE checkpoint.singleton - FOR UPDATE; - - WITH candidates AS ( - SELECT project.id - FROM public.projects project - WHERE project.root_ref IS NULL - AND (v_checkpoint.last_project_id IS NULL OR project.id > v_checkpoint.last_project_id) - ORDER BY project.id - LIMIT p_batch_size - FOR UPDATE - ), populated AS ( - UPDATE public.projects project - SET root_ref = pg_catalog.gen_random_uuid() - FROM candidates - WHERE project.id = candidates.id - AND project.root_ref IS NULL - RETURNING project.id - ) - SELECT pg_catalog.count(*)::integer, - (pg_catalog.array_agg(id ORDER BY id DESC))[1] - INTO v_rows, v_last_id - FROM populated; - - SELECT pg_catalog.count(*) INTO v_remaining - FROM public.projects project - WHERE project.root_ref IS NULL; - - UPDATE public.project_root_ref_reconciliation checkpoint - SET last_project_id = CASE - WHEN v_remaining = 0 THEN checkpoint.last_project_id - WHEN v_rows > 0 THEN v_last_id - ELSE NULL - END, - rows_updated = checkpoint.rows_updated + v_rows, - state = CASE WHEN v_remaining = 0 THEN 'complete' ELSE 'running' END, - updated_at = pg_catalog.clock_timestamp() - WHERE checkpoint.singleton; - - RETURN QUERY SELECT v_rows, v_remaining, - CASE WHEN v_remaining = 0 THEN 'complete'::text ELSE 'running'::text END; + RAISE EXCEPTION 'singleton root-reference reconciliation is superseded; use project-roots:reconcile-expansion with an actor and watermark' + USING ERRCODE = '55000'; END; $$; --> statement-breakpoint @@ -511,6 +467,243 @@ CREATE TRIGGER project_root_change_journal_append_only_v1 BEFORE UPDATE OR DELETE ON public.project_root_change_journal FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_change_journal_mutation_v1(); --> statement-breakpoint +-- C6 keeps the expansion journal authoritative while a bounded external +-- reconciler applies the existing TypeScript S3 projection in the same +-- transaction. These rows deliberately contain identities and counters only. +CREATE TABLE public.project_root_reconciliation_operations ( + operation_id uuid PRIMARY KEY, + actor_id uuid NOT NULL, + through_generation bigint NOT NULL CHECK (through_generation >= 0), + last_processed_generation bigint NOT NULL DEFAULT 0 CHECK (last_processed_generation >= 0), + last_project_id uuid, + batch_count bigint NOT NULL DEFAULT 0 CHECK (batch_count >= 0), + cumulative_count bigint NOT NULL DEFAULT 0 CHECK (cumulative_count >= 0), + state text NOT NULL DEFAULT 'running' CHECK (state IN ('running','complete')), + created_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + completed_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + CONSTRAINT project_root_reconciliation_operation_progress_chk CHECK ( + last_processed_generation <= through_generation + AND cumulative_count = last_processed_generation + AND ((state = 'running' AND completed_at IS NULL) OR (state = 'complete' AND completed_at IS NOT NULL) + ) +); +CREATE UNIQUE INDEX project_root_reconciliation_one_live_idx + ON public.project_root_reconciliation_operations ((true)) WHERE state = 'running'; +--> statement-breakpoint +CREATE TABLE public.project_root_reconciliation_checkpoints ( + operation_id uuid NOT NULL REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, + checkpoint_generation bigint NOT NULL CHECK (checkpoint_generation >= 0), + actor_id uuid NOT NULL, + through_generation bigint NOT NULL CHECK (through_generation >= 0), + last_processed_generation bigint NOT NULL CHECK (last_processed_generation >= 0), + last_project_id uuid, + batch_count bigint NOT NULL CHECK (batch_count >= 0), + cumulative_count bigint NOT NULL CHECK (cumulative_count >= 0), + state text NOT NULL CHECK (state IN ('running','complete')), + checkpointed_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), + PRIMARY KEY (operation_id, checkpoint_generation), + CONSTRAINT project_root_reconciliation_checkpoint_shape_chk CHECK ( + last_processed_generation <= through_generation + AND cumulative_count = last_processed_generation + ) +); +CREATE TABLE public.project_root_reconciliation_outcomes ( + generation bigint PRIMARY KEY REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT, + operation_id uuid NOT NULL REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, + actor_id uuid NOT NULL, + project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE RESTRICT, + outcome text NOT NULL CHECK (outcome IN ('insert','root_update','archive')), + recorded_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp() +); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'project-root reconciliation history is append-only' USING ERRCODE = '55000'; +END; +$$; +CREATE TRIGGER project_root_reconciliation_checkpoints_append_only_v1 + BEFORE UPDATE OR DELETE ON public.project_root_reconciliation_checkpoints + FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1(); +CREATE TRIGGER project_root_reconciliation_outcomes_append_only_v1 + BEFORE UPDATE OR DELETE ON public.project_root_reconciliation_outcomes + FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1(); +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.assert_project_root_reconciler_v1() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog +AS $$ +DECLARE v_role pg_catalog.pg_roles%ROWTYPE; +BEGIN + IF session_user <> 'forge_project_root_reconciler' THEN + RAISE EXCEPTION 'project-root reconciliation requires its fixed login' USING ERRCODE = '42501'; + END IF; + SELECT * INTO STRICT v_role FROM pg_catalog.pg_roles WHERE rolname = session_user; + IF NOT v_role.rolcanlogin OR v_role.rolinherit OR v_role.rolsuper OR v_role.rolcreatedb + OR v_role.rolcreaterole OR v_role.rolreplication OR v_role.rolbypassrls THEN + RAISE EXCEPTION 'project-root reconciler role attributes are unsafe' USING ERRCODE = '42501'; + END IF; + IF EXISTS (SELECT 1 FROM pg_catalog.pg_auth_members m WHERE m.member = session_user::regrole OR m.roleid = session_user::regrole) THEN + RAISE EXCEPTION 'project-root reconciler must not have role memberships' USING ERRCODE = '42501'; + END IF; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.assert_project_root_journal_window_v1(p_through_generation bigint) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_counter bigint; v_max bigint; v_count bigint; +BEGIN + IF p_through_generation < 0 THEN RAISE EXCEPTION 'root journal watermark must be non-negative' USING ERRCODE = '22023'; END IF; + SELECT last_generation INTO STRICT v_counter FROM public.project_root_change_journal_counter WHERE singleton FOR UPDATE; + SELECT coalesce(max(generation), 0), count(*) INTO v_max, v_count FROM public.project_root_change_journal; + IF v_counter <> p_through_generation OR v_max <> p_through_generation OR v_count <> p_through_generation THEN + RAISE EXCEPTION 'root journal watermark is not contiguous and exact' USING ERRCODE = '55000'; + END IF; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.materialize_project_root_ref_expansion_v1(p_batch_size integer) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_rows integer; +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + IF p_batch_size NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION 'root-reference materialization batch must be 1 through 100' USING ERRCODE = '22023'; END IF; + WITH candidates AS ( + SELECT project.id FROM public.projects project WHERE project.root_ref IS NULL + ORDER BY project.id LIMIT p_batch_size FOR UPDATE + ), populated AS ( + UPDATE public.projects project SET root_ref = pg_catalog.gen_random_uuid() + FROM candidates WHERE project.id = candidates.id AND project.root_ref IS NULL + RETURNING project.id + ) SELECT count(*)::integer INTO v_rows FROM populated; + RETURN v_rows; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.begin_project_root_reconciliation_v1(p_operation_id uuid, p_actor_id uuid, p_through_generation bigint) +RETURNS TABLE(operation_id uuid, actor_id uuid, through_generation bigint, state text, last_processed_generation bigint) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + PERFORM forge.assert_project_root_journal_window_v1(p_through_generation); + IF p_operation_id IS NULL THEN + SELECT * INTO v_operation FROM public.project_root_reconciliation_operations + WHERE actor_id = p_actor_id AND through_generation = p_through_generation + ORDER BY created_at DESC LIMIT 1 FOR UPDATE; + ELSE + SELECT * INTO v_operation FROM public.project_root_reconciliation_operations + WHERE operation_id = p_operation_id FOR UPDATE; + END IF; + IF FOUND THEN + IF v_operation.actor_id <> p_actor_id OR v_operation.through_generation <> p_through_generation THEN + RAISE EXCEPTION 'project-root operation identity cannot be hijacked' USING ERRCODE = '42501'; + END IF; + ELSE + IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations WHERE state = 'running') THEN + RAISE EXCEPTION 'a project-root reconciliation operation is already live' USING ERRCODE = '55P03'; + END IF; + INSERT INTO public.project_root_reconciliation_operations(operation_id, actor_id, through_generation, state, completed_at) + VALUES (coalesce(p_operation_id, pg_catalog.gen_random_uuid()), p_actor_id, p_through_generation, + CASE WHEN p_through_generation = 0 THEN 'complete' ELSE 'running' END, + CASE WHEN p_through_generation = 0 THEN pg_catalog.clock_timestamp() ELSE NULL END) + RETURNING * INTO v_operation; + INSERT INTO public.project_root_reconciliation_checkpoints( + operation_id, checkpoint_generation, actor_id, through_generation, last_processed_generation, + last_project_id, batch_count, cumulative_count, state + ) VALUES (v_operation.operation_id, 0, v_operation.actor_id, v_operation.through_generation, 0, NULL, 0, 0, v_operation.state); + END IF; + RETURN QUERY SELECT v_operation.operation_id, v_operation.actor_id, v_operation.through_generation, v_operation.state, v_operation.last_processed_generation; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.claim_project_root_reconciliation_batch_v1(p_operation_id uuid, p_actor_id uuid, p_batch_size integer) +RETURNS TABLE(generation bigint, project_id uuid, outcome text, root_binding_revision bigint, grant_decision_revision bigint) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + IF p_batch_size NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION 'project-root reconciliation batch must be 1 through 100' USING ERRCODE = '22023'; END IF; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations + 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 + 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; + 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; +END; +$$; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid, p_outcome text) +RETURNS TABLE(state text, last_processed_generation bigint) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $$ +DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; v_journal public.project_root_change_journal%ROWTYPE; +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE 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; + 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; + 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 + 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 + 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, + updated_at = pg_catalog.clock_timestamp() + WHERE operation_id = p_operation_id RETURNING * INTO v_operation; + INSERT INTO public.project_root_reconciliation_checkpoints( + operation_id, checkpoint_generation, actor_id, through_generation, last_processed_generation, + last_project_id, batch_count, cumulative_count, state + ) VALUES (v_operation.operation_id, 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); + RETURN QUERY SELECT v_operation.state, v_operation.last_processed_generation; +END; +$$; +--> statement-breakpoint CREATE TABLE public.architect_plan_versions ( task_id uuid NOT NULL, plan_artifact_id uuid NOT NULL, @@ -7207,12 +7400,23 @@ REVOKE ALL ON public.architect_plan_versions, public.architect_plan_entries, public.filesystem_mcp_decision_nonce_claims, public.project_root_ref_reconciliation, public.project_root_change_journal_counter, - public.project_root_change_journal FROM PUBLIC; + public.project_root_change_journal, + public.project_root_reconciliation_operations, + public.project_root_reconciliation_checkpoints, + public.project_root_reconciliation_outcomes FROM PUBLIC; +REVOKE ALL ON public.project_root_change_journal FROM PUBLIC; REVOKE ALL ON FUNCTION forge.fill_project_root_ref_on_insert_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.guard_project_root_ref_renull_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.reconcile_project_root_refs_v1(integer) FROM PUBLIC; REVOKE ALL ON FUNCTION forge.append_project_root_change_journal_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.reject_project_root_change_journal_mutation_v1() FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.assert_project_root_reconciler_v1() FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.assert_project_root_journal_window_v1(bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.materialize_project_root_ref_expansion_v1(integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.begin_project_root_reconciliation_v1(uuid,uuid,bigint) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.claim_project_root_reconciliation_batch_v1(uuid,uuid,integer) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.complete_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid,text) FROM PUBLIC; +REVOKE ALL ON FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.s4_protected_paths_enabled_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.guard_s4_approval_gate_review_head_v1() FROM PUBLIC; REVOKE ALL ON FUNCTION forge.reject_s4_retained_mutation_v1() FROM PUBLIC; @@ -7318,6 +7522,25 @@ GRANT EXECUTE ON FUNCTION forge.apply_local_projection_overlimit_archive_v2(uuid GRANT EXECUTE ON FUNCTION forge.resume_local_projection_overlimit_archive_v2(uuid,uuid,text) TO forge_local_projection_archiver; GRANT EXECUTE ON FUNCTION forge.rollback_local_projection_overlimit_archive_v2(uuid,uuid,text) TO forge_local_projection_archiver; GRANT EXECUTE ON FUNCTION forge.cancel_local_projection_overlimit_archive_v2(uuid,uuid,text) TO forge_local_projection_archiver; +GRANT EXECUTE ON FUNCTION forge.begin_project_root_reconciliation_v1(uuid,uuid,bigint) TO forge_project_root_reconciler; +GRANT EXECUTE ON FUNCTION forge.claim_project_root_reconciliation_batch_v1(uuid,uuid,integer) TO forge_project_root_reconciler; +GRANT EXECUTE ON FUNCTION forge.complete_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid,text) TO forge_project_root_reconciler; +GRANT EXECUTE ON FUNCTION forge.materialize_project_root_ref_expansion_v1(integer) TO forge_project_root_reconciler; +-- Canonical TypeScript S3 reconciliation needs these exact ordinary state +-- columns. It receives no insert/delete nor any protected journal authority. +GRANT SELECT ON public.projects, public.tasks, public.work_packages, + public.filesystem_mcp_grant_approvals, public.filesystem_mcp_current_decision_pointers, + public.project_filesystem_grant_decisions, public.project_filesystem_current_decision_pointers, + public.work_package_local_projection_heads TO forge_project_root_reconciler; +-- PostgreSQL requires UPDATE privilege to acquire FOR UPDATE locks. These are +-- immutable/key columns only; the reconciler never writes them. +GRANT UPDATE (id) ON public.projects, public.filesystem_mcp_grant_approvals, + public.project_filesystem_grant_decisions TO forge_project_root_reconciler; +GRANT UPDATE (project_id) ON public.project_filesystem_current_decision_pointers TO forge_project_root_reconciler; +GRANT UPDATE (work_package_id) ON public.filesystem_mcp_current_decision_pointers TO forge_project_root_reconciler; +GRANT UPDATE (status, error_message, updated_at) ON public.tasks TO forge_project_root_reconciler; +GRANT UPDATE (status, blocked_reason, metadata, updated_at) ON public.work_packages TO forge_project_root_reconciler; +GRANT EXECUTE ON FUNCTION forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text) TO forge_project_root_reconciler; -- The bootstrap fence temporarily gives the incoming owner CREATE on the two -- containing schemas because PostgreSQL requires it for SET OWNER. The -- finalizer revokes both grants before it verifies the permanent boundary. @@ -7341,14 +7564,24 @@ ALTER TABLE public.local_effect_recovery_actions OWNER TO forge_s4_routines_owne ALTER TABLE public.local_projection_archive_operations OWNER TO forge_s4_routines_owner; ALTER TABLE public.local_projection_archive_operation_checkpoints OWNER TO forge_s4_routines_owner; ALTER TABLE public.filesystem_mcp_decision_nonce_claims OWNER TO forge_s4_routines_owner; -ALTER TABLE public.project_root_ref_reconciliation OWNER TO forge_s4_routines_owner; ALTER TABLE public.project_root_change_journal_counter OWNER TO forge_s4_routines_owner; ALTER TABLE public.project_root_change_journal OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_ref_reconciliation OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_reconciliation_operations OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_reconciliation_checkpoints OWNER TO forge_s4_routines_owner; +ALTER TABLE public.project_root_reconciliation_outcomes OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.fill_project_root_ref_on_insert_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.guard_project_root_ref_renull_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.reconcile_project_root_refs_v1(integer) OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.append_project_root_change_journal_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.reject_project_root_change_journal_mutation_v1() OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.assert_project_root_reconciler_v1() OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.assert_project_root_journal_window_v1(bigint) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.materialize_project_root_ref_expansion_v1(integer) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.begin_project_root_reconciliation_v1(uuid,uuid,bigint) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.claim_project_root_reconciliation_batch_v1(uuid,uuid,integer) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.complete_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid,text) OWNER TO forge_s4_routines_owner; +ALTER FUNCTION forge.reject_project_root_reconciliation_history_mutation_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.s4_protected_paths_enabled_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.guard_s4_approval_gate_review_head_v1() OWNER TO forge_s4_routines_owner; ALTER FUNCTION forge.reject_s4_retained_mutation_v1() OWNER TO forge_s4_routines_owner; diff --git a/web/db/schema.ts b/web/db/schema.ts index 7f403484..46c14e47 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -270,11 +270,12 @@ export const projects = pgTable('projects', { export type Project = InferSelectModel export type NewProject = InferInsertModel +/** C5 compatibility tombstone; C6 never uses this singleton. */ export const projectRootRefReconciliation = pgTable('project_root_ref_reconciliation', { singleton: boolean('singleton').primaryKey().default(true), lastProjectId: uuid('last_project_id'), rowsUpdated: bigint('rows_updated', { mode: 'bigint' }).notNull().default(sql`0`), - state: text('state').notNull().default('pending'), + state: text('state').notNull().default('superseded'), updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), }) @@ -303,6 +304,60 @@ export const projectRootChangeJournal = pgTable('project_root_change_journal', { check('project_root_change_journal_grant_decision_revision_chk', sql`${t.grantDecisionRevision} is null or ${t.grantDecisionRevision} > 0`), ]) +export const projectRootReconciliationOperations = pgTable('project_root_reconciliation_operations', { + operationId: uuid('operation_id').primaryKey(), + actorId: uuid('actor_id').notNull(), + throughGeneration: bigint('through_generation', { mode: 'bigint' }).notNull(), + lastProcessedGeneration: bigint('last_processed_generation', { mode: 'bigint' }).notNull().default(sql`0`), + lastProjectId: uuid('last_project_id'), + batchCount: bigint('batch_count', { mode: 'bigint' }).notNull().default(sql`0`), + cumulativeCount: bigint('cumulative_count', { mode: 'bigint' }).notNull().default(sql`0`), + state: text('state').notNull().default('running'), + createdAt: timestamp('created_at', tsOpts).defaultNow().notNull(), + completedAt: timestamp('completed_at', tsOpts), + updatedAt: timestamp('updated_at', tsOpts).defaultNow().notNull(), +}, (t) => [ + uniqueIndex('project_root_reconciliation_one_live_idx').on(sql`(true)`).where(sql`${t.state} = 'running'`), + check('project_root_reconciliation_operation_progress_chk', sql` + ${t.throughGeneration} >= 0 and ${t.lastProcessedGeneration} >= 0 + and ${t.lastProcessedGeneration} <= ${t.throughGeneration} + and ${t.cumulativeCount} = ${t.lastProcessedGeneration} + and ${t.state} in ('running','complete') + and ((${t.state} = 'running' and ${t.completedAt} is null) or (${t.state} = 'complete' and ${t.completedAt} is not null)) + `), +]) + +export const projectRootReconciliationCheckpoints = pgTable('project_root_reconciliation_checkpoints', { + operationId: uuid('operation_id').notNull().references(() => projectRootReconciliationOperations.operationId, { onDelete: 'restrict' }), + checkpointGeneration: bigint('checkpoint_generation', { mode: 'bigint' }).notNull(), + actorId: uuid('actor_id').notNull(), + throughGeneration: bigint('through_generation', { mode: 'bigint' }).notNull(), + lastProcessedGeneration: bigint('last_processed_generation', { mode: 'bigint' }).notNull(), + lastProjectId: uuid('last_project_id'), + batchCount: bigint('batch_count', { mode: 'bigint' }).notNull(), + cumulativeCount: bigint('cumulative_count', { mode: 'bigint' }).notNull(), + state: text('state').notNull(), + checkpointedAt: timestamp('checkpointed_at', tsOpts).defaultNow().notNull(), +}, (t) => [ + primaryKey({ columns: [t.operationId, t.checkpointGeneration] }), + check('project_root_reconciliation_checkpoint_shape_chk', sql` + ${t.checkpointGeneration} >= 0 and ${t.throughGeneration} >= 0 + and ${t.lastProcessedGeneration} >= 0 and ${t.lastProcessedGeneration} <= ${t.throughGeneration} + and ${t.cumulativeCount} = ${t.lastProcessedGeneration} and ${t.state} in ('running','complete') + `), +]) + +export const projectRootReconciliationOutcomes = pgTable('project_root_reconciliation_outcomes', { + generation: bigint('generation', { mode: 'bigint' }).primaryKey().references(() => projectRootChangeJournal.generation, { onDelete: 'restrict' }), + operationId: uuid('operation_id').notNull().references(() => projectRootReconciliationOperations.operationId, { onDelete: 'restrict' }), + actorId: uuid('actor_id').notNull(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'restrict' }), + outcome: text('outcome').notNull(), + recordedAt: timestamp('recorded_at', tsOpts).defaultNow().notNull(), +}, (t) => [ + check('project_root_reconciliation_outcome_kind_chk', sql`${t.outcome} in ('insert','root_update','archive')`), +]) + // --------------------------------------------------------------------------- // Epic 172 release authentication and transition substrate // diff --git a/web/lib/mcps/project-root-reconciliation.ts b/web/lib/mcps/project-root-reconciliation.ts new file mode 100644 index 00000000..ba554e06 --- /dev/null +++ b/web/lib/mcps/project-root-reconciliation.ts @@ -0,0 +1,81 @@ +import { PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES, type ProjectRootChangeJournalOutcome } from '@/lib/mcps/project-root-change-journal' + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const NON_NEGATIVE_DECIMAL = /^(?:0|[1-9][0-9]*)$/ + +export const PROJECT_ROOT_RECONCILIATION_STATES = Object.freeze(['running', 'complete'] as const) +export type ProjectRootReconciliationState = typeof PROJECT_ROOT_RECONCILIATION_STATES[number] + +export type ProjectRootReconciliationCommand = Readonly<{ + actorId: string + apply: boolean + throughGeneration: bigint +}> + +export type ClaimedProjectRootChange = Readonly<{ + generation: bigint + grantDecisionRevision: bigint | null + outcome: ProjectRootChangeJournalOutcome + projectId: string + rootBindingRevision: bigint | null +}> + +function parseUuid(value: string | undefined, name: string): string { + if (!value || !UUID.test(value)) throw new Error(`${name} must be a canonical UUID`) + return value +} + +function parseGeneration(value: string | undefined): bigint { + if (!value || !NON_NEGATIVE_DECIMAL.test(value)) { + throw new Error('--through must be a non-negative canonical generation') + } + return BigInt(value) +} + +/** The operator command is intentionally actionless until its literal --apply. */ +export function parseProjectRootReconciliationCommand(argv: readonly string[]): ProjectRootReconciliationCommand { + let actor: string | undefined + let through: string | undefined + let apply = false + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index] + if (token === '--apply') { + if (apply) throw new Error('--apply may appear only once') + apply = true + continue + } + if (token === '--actor') { + if (actor !== undefined) throw new Error('--actor may appear only once') + actor = argv[++index] + continue + } + if (token === '--through') { + if (through !== undefined) throw new Error('--through may appear only once') + through = argv[++index] + continue + } + throw new Error(`Unknown project-root reconciliation argument: ${token}`) + } + return Object.freeze({ actorId: parseUuid(actor, '--actor'), apply, throughGeneration: parseGeneration(through) }) +} + +export function parseClaimedProjectRootChange(value: Record): ClaimedProjectRootChange { + const generation = value.generation + const projectId = value.projectId + const outcome = value.outcome + const parseOptionalRevision = (candidate: unknown, name: string): bigint | null => { + if (candidate === null) return null + if (typeof candidate !== 'string' || !/^[1-9][0-9]*$/.test(candidate)) throw new Error(`${name} must be null or positive`) + return BigInt(candidate) + } + if (typeof generation !== 'string' || !/^[1-9][0-9]*$/.test(generation)) throw new Error('claimed generation must be positive') + if (typeof projectId !== 'string' || !UUID.test(projectId)) throw new Error('claimed projectId must be a UUID') + if (!PROJECT_ROOT_CHANGE_JOURNAL_OUTCOMES.includes(outcome as ProjectRootChangeJournalOutcome)) throw new Error('claimed outcome is invalid') + return Object.freeze({ + generation: BigInt(generation), + grantDecisionRevision: parseOptionalRevision(value.grantDecisionRevision, 'grantDecisionRevision'), + outcome: outcome as ProjectRootChangeJournalOutcome, + projectId, + rootBindingRevision: parseOptionalRevision(value.rootBindingRevision, 'rootBindingRevision'), + }) +} diff --git a/web/package.json b/web/package.json index 83a2811f..709d702d 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,8 @@ "protocol:inspect-local-projection-overlimit": "tsx scripts/inspect-local-projection-overlimit.ts", "protocol:archive-local-projection-overlimit": "tsx scripts/archive-local-projection-overlimit.ts", "session-credentials:reconcile": "tsx scripts/reconcile-session-credentials.ts", - "project-roots:reconcile-expansion": "bash scripts/ci/reconcile-migration-0027-root-refs.sh", + "project-roots:reconcile-expansion": "tsx scripts/reconcile-project-root-expansion.ts", + "project-roots:build-concurrent-index": "tsx scripts/build-project-root-ref-index.ts", "project-roots:strict-cutover": "bash scripts/ci/cutover-migration-0027-root-ref.sh", "test:migration-0027-upgrade": "bash scripts/ci/prove-migration-0027-upgrade.sh", "auth:reset-password": "tsx scripts/reset-password.ts", diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index dbac8bb4..1139b7df 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -10,6 +10,7 @@ const LOGIN_ROLES = [ 'forge_review_source_resolver', 'forge_s4_recovery_operator', 'forge_local_projection_archiver', + 'forge_project_root_reconciler', ] as const const OWNER = 'forge_s4_routines_owner' const PROTECTED_ROLES = [OWNER, ...LOGIN_ROLES] as const @@ -27,6 +28,9 @@ const OWNED_TABLES = [ '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', 's4_completion_handoffs', 's4_protected_review_sources', 's4_protected_review_source_reads', @@ -102,7 +106,8 @@ async function main(): Promise { forge_packet_issuer, forge_review_source_resolver, forge_s4_recovery_operator, - forge_local_projection_archiver + forge_local_projection_archiver, + forge_project_root_reconciler `) await admin`revoke create on schema forge from ${admin(OWNER)}` await admin`revoke create on schema public from ${admin(OWNER)}` @@ -200,7 +205,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) or membership.member = any(array[ '${OWNER}'::regrole, @@ -210,7 +216,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) <> 1 or ( select pg_catalog.count(*) @@ -258,7 +265,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) or membership.member = any(array[ '${OWNER}'::regrole, @@ -268,7 +276,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) <> 1 or ( select pg_catalog.count(*) @@ -344,6 +353,13 @@ async function main(): Promise { ,'reconcile_project_root_refs_v1' ,'append_project_root_change_journal_v1' ,'reject_project_root_change_journal_mutation_v1' + ,'assert_project_root_reconciler_v1' + ,'assert_project_root_journal_window_v1' + ,'materialize_project_root_ref_expansion_v1' + ,'begin_project_root_reconciliation_v1' + ,'claim_project_root_reconciliation_batch_v1' + ,'complete_project_root_reconciliation_generation_v1' + ,'reject_project_root_reconciliation_history_mutation_v1' ,'s4_protected_paths_enabled_v1' ,'guard_s4_approval_gate_review_head_v1' ,'bind_architect_replan_entry_v1' @@ -399,7 +415,7 @@ async function main(): Promise { ) acl where acl.grantee = 0 and acl.privilege_type = 'EXECUTE' ) - ) <> 63 then + ) <> 70 then raise exception 'The S4 routine owner or PUBLIC boundary is incomplete' using errcode = '42501'; end if; @@ -453,7 +469,8 @@ async function main(): Promise { 'forge_packet_issuer', 'forge_review_source_resolver', 'forge_s4_recovery_operator', - 'forge_local_projection_archiver' + 'forge_local_projection_archiver', + 'forge_project_root_reconciler' ]) role_name where not pg_catalog.has_schema_privilege(role_name, 'forge', 'usage') or pg_catalog.has_schema_privilege(role_name, 'forge', 'create') @@ -505,7 +522,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) or membership.member = any(array[ '${OWNER}'::regrole, @@ -515,7 +533,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) then raise exception 'A finalized S4 protected principal retains a membership edge' @@ -532,7 +551,8 @@ async function main(): Promise { 'forge_packet_issuer', 'forge_review_source_resolver', 'forge_s4_recovery_operator', - 'forge_local_projection_archiver' + 'forge_local_projection_archiver', + 'forge_project_root_reconciler' ]) and role.rolpassword is not null ) or exists ( @@ -545,7 +565,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) then raise exception 'A finalized S4 principal retains a password or role setting' @@ -560,7 +581,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) or exists ( select 1 from pg_catalog.pg_proc routine @@ -571,7 +593,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) or exists ( select 1 from pg_catalog.pg_namespace namespace_row @@ -582,7 +605,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) or exists ( select 1 from pg_catalog.pg_database database_row @@ -593,7 +617,8 @@ async function main(): Promise { 'forge_packet_issuer'::regrole, 'forge_review_source_resolver'::regrole, 'forge_s4_recovery_operator'::regrole, - 'forge_local_projection_archiver'::regrole + 'forge_local_projection_archiver'::regrole, + 'forge_project_root_reconciler'::regrole ]) ) then raise exception 'A dedicated S4 login owns a database object' diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts new file mode 100644 index 00000000..663edd95 --- /dev/null +++ b/web/scripts/build-project-root-ref-index.ts @@ -0,0 +1,30 @@ +import '../lib/load-env' +import postgres from 'postgres' + +async function main(): Promise { + if (process.argv.slice(2).join(' ') !== '--apply') { + throw new Error('Concurrent root-reference index creation is actionless without --apply.') + } + const adminUrl = process.env.FORGE_DATABASE_ADMIN_URL?.trim() + if (!adminUrl) throw new Error('FORGE_DATABASE_ADMIN_URL is required for the short-lived concurrent DDL step.') + const admin = postgres(adminUrl, { max: 1, onnotice: () => {} }) + try { + // 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.') + } finally { + await admin.end({ timeout: 5 }) + } +} + +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : 'Concurrent index build failed.') + process.exitCode = 1 +}) diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 45be5467..9fffbabd 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -2,36 +2,44 @@ set -euo pipefail : "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" -if [[ "${1:-}" != '--apply' ]]; then - echo 'Strict root-reference cutover is actionless without the explicit --apply flag.' >&2 +if [[ $# -eq 1 && "${1:-}" == '--apply' ]]; then + through="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command "SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton;")" +elif [[ "${1:-}" == '--through' && "${2:-}" =~ ^(0|[1-9][0-9]*)$ && "${3:-}" == '--apply' && $# -eq 3 ]]; then + through="$2" +else + echo 'Usage: npm run project-roots:strict-cutover -- --through --apply' >&2 exit 2 fi -psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 <<'SQL' +psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 < 'disabled' THEN - RAISE EXCEPTION 'strict root-reference cutover requires Step 0 to remain disabled' - USING ERRCODE = '55000'; + RAISE EXCEPTION 'strict root-reference cutover requires Step 0 to remain disabled' USING ERRCODE = '55000'; END IF; IF NOT EXISTS ( - SELECT 1 FROM public.project_root_ref_reconciliation - WHERE singleton AND state = 'complete' - ) OR EXISTS (SELECT 1 FROM public.projects WHERE root_ref IS NULL) THEN - RAISE EXCEPTION 'strict root-reference cutover requires completed reconciliation and a zero-null scan' - USING ERRCODE = '55000'; + SELECT 1 FROM public.project_root_reconciliation_operations operation + WHERE operation.through_generation = v_through AND operation.state = 'complete' + AND operation.last_processed_generation = v_through AND operation.cumulative_count = v_through + ) OR (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) <> v_through + OR (SELECT coalesce(max(generation), 0) FROM public.project_root_change_journal) <> v_through + OR (SELECT count(*) FROM public.project_root_change_journal) <> v_through + OR (SELECT count(*) FROM public.project_root_reconciliation_outcomes) <> v_through + OR EXISTS (SELECT 1 FROM public.projects WHERE root_ref IS NULL) THEN + RAISE EXCEPTION 'strict root-reference cutover requires exact completed watermark coverage' USING ERRCODE = '55000'; END IF; IF NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = 'public.projects'::pg_catalog.regclass - AND conname = 'projects_root_ref_not_null_proof' - ) THEN - ALTER TABLE public.projects - ADD CONSTRAINT projects_root_ref_not_null_proof - CHECK (root_ref IS NOT NULL) NOT VALID; + SELECT 1 FROM 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$; ALTER TABLE public.projects VALIDATE CONSTRAINT projects_root_ref_not_null_proof; @@ -39,4 +47,4 @@ ALTER TABLE public.projects ALTER COLUMN root_ref SET NOT NULL; COMMIT; SQL -echo 'Strict root-reference cutover completed; Step 0 and S4 activation state were not changed.' +echo "Strict root-reference cutover completed at watermark ${through}; Step 0 remains disabled." diff --git a/web/scripts/ci/reconcile-migration-0027-root-refs.sh b/web/scripts/ci/reconcile-migration-0027-root-refs.sh index 3d2e42c2..5e291035 100644 --- a/web/scripts/ci/reconcile-migration-0027-root-refs.sh +++ b/web/scripts/ci/reconcile-migration-0027-root-refs.sh @@ -1,43 +1,27 @@ #!/usr/bin/env bash set -euo pipefail -: "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" -batch_size="${FORGE_ROOT_REF_RECONCILE_BATCH_SIZE:-100}" -if [[ ! "${batch_size}" =~ ^[0-9]+$ ]] || (( batch_size < 1 || batch_size > 1000 )); then - echo 'FORGE_ROOT_REF_RECONCILE_BATCH_SIZE must be an integer from 1 to 1000.' >&2 - exit 1 -fi - -preflight="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 --command " - SELECT state FROM public.forge_epic_172_enablement_state WHERE singleton_id = 'epic-172'; -")" -if [[ "${preflight}" != 'disabled' ]]; then - echo "Root-reference reconciliation requires the existing Step 0 state disabled; got ${preflight}." >&2 - exit 1 -fi - -for ((attempt = 1; attempt <= 100000; attempt += 1)); do - result="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --field-separator '|' --set ON_ERROR_STOP=1 \ - --command "SELECT * FROM forge.reconcile_project_root_refs_v1(${batch_size});")" - IFS='|' read -r batch_rows remaining state <<<"${result}" - if [[ ! "${batch_rows}" =~ ^[0-9]+$ || ! "${remaining}" =~ ^[0-9]+$ ]]; then - echo "Unexpected reconciliation result: ${result}" >&2 +# Compatibility shim for the populated-upgrade proof. It uses the short-lived +# admin connection only to provision the disposable login and capture a +# watermark; the reconciliation process itself receives only the dedicated URL. +if [[ $# -eq 0 ]]; then + : "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null + authority="${FORGE_DATABASE_ADMIN_URL#*://}" + export FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" + while :; do + rows="$(psql "$FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command "SELECT forge.materialize_project_root_ref_expansion_v1(100);")" + [[ "$rows" =~ ^[0-9]+$ ]] || { echo 'Legacy root materialization returned an invalid row count.' >&2; exit 1; } + [[ "$rows" == '0' ]] && break + done + watermark="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton;")" + if [[ ! "$watermark" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo 'The root journal watermark is malformed.' >&2 exit 1 fi - echo "Root-reference batch ${attempt}: updated ${batch_rows}; remaining ${remaining}." - if [[ "${state}" == 'complete' && "${remaining}" == '0' ]]; then - break - fi - if (( attempt == 100000 )); then - echo 'Root-reference reconciliation exceeded its deterministic batch limit.' >&2 - exit 1 - fi -done - -zero_scan="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 \ - --command "SELECT count(*) FROM public.projects WHERE root_ref IS NULL;")" -if [[ "${zero_scan}" != '0' ]]; then - echo "Root-reference zero scan failed with ${zero_scan} null rows." >&2 - exit 1 + exec npx tsx scripts/reconcile-project-root-expansion.ts \ + --through "$watermark" --actor 11111111-1111-4111-8111-111111111111 --apply fi -echo 'Root-reference reconciliation completed with a zero-null scan.' +exec npx tsx scripts/reconcile-project-root-expansion.ts "$@" diff --git a/web/scripts/reconcile-project-root-expansion.ts b/web/scripts/reconcile-project-root-expansion.ts new file mode 100644 index 00000000..826a07d9 --- /dev/null +++ b/web/scripts/reconcile-project-root-expansion.ts @@ -0,0 +1,106 @@ +import '../lib/load-env' +import postgres from 'postgres' +import { eq, sql } from 'drizzle-orm' +import { parseClaimedProjectRootChange, parseProjectRootReconciliationCommand } from '@/lib/mcps/project-root-reconciliation' + +const usage = 'Usage: npm run project-roots:reconcile-expansion -- --through --actor --apply' + +function reconcilerUrl(): string { + const value = process.env.FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL?.trim() + if (!value) throw new Error('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL is required.') + const parsed = new URL(value) + if (decodeURIComponent(parsed.username) !== 'forge_project_root_reconciler') { + throw new Error('Project-root reconciliation requires the dedicated forge_project_root_reconciler login.') + } + return value +} + +async function main(): Promise { + const command = parseProjectRootReconciliationCommand(process.argv.slice(2)) + if (!command.apply) { + console.log(JSON.stringify({ mode: 'dry-run', through: command.throughGeneration.toString(), actor: command.actorId })) + return + } + const url = reconcilerUrl() + // The canonical S3 helper uses the repository's shared Drizzle transaction. + // Set it before the dynamic imports so every mutation stays on this dedicated + // login and the fixed routine completion remains in that same transaction. + process.env.DATABASE_URL = url + const [{ db, closeDb }, schema, reconciliation] = await Promise.all([ + import('@/db'), + import('@/db/schema'), + import('@/lib/mcps/filesystem-grant-reconciliation'), + ]) + const observer = postgres(url, { max: 1, onnotice: () => {} }) + try { + const [operation] = await observer<{ + operationId: string; state: string; lastProcessedGeneration: string; throughGeneration: string + }[]>` + select operation_id as "operationId", state, last_processed_generation::text as "lastProcessedGeneration", + through_generation::text as "throughGeneration" + from forge.begin_project_root_reconciliation_v1(null, ${command.actorId}::uuid, ${command.throughGeneration.toString()}::bigint) + ` + if (!operation) throw new Error('Project-root reconciliation operation was not created.') + if (operation.state === 'complete') { + console.log(JSON.stringify({ mode: 'complete-replay', operationId: operation.operationId, through: operation.throughGeneration })) + return + } + const batchSize = Number(process.env.FORGE_PROJECT_ROOT_RECONCILE_BATCH_SIZE ?? '25') + if (!Number.isInteger(batchSize) || batchSize < 1 || batchSize > 100) throw new Error('FORGE_ROOT_REF_RECONCILE_BATCH_SIZE must be 1 through 100.') + for (;;) { + const result = await db.transaction(async (tx) => { + await tx.execute(sql`set local lock_timeout = '5s'`) + await tx.execute(sql`set local statement_timeout = '30s'`) + const claimedRows = await tx.execute(sql` + select generation::text as generation, project_id as "projectId", outcome, + root_binding_revision::text as "rootBindingRevision", grant_decision_revision::text as "grantDecisionRevision" + from forge.claim_project_root_reconciliation_batch_v1( + ${operation.operationId}::uuid, ${command.actorId}::uuid, ${batchSize} + ) + `) + const claimed = (claimedRows as unknown as Array>).map(parseClaimedProjectRootChange) + if (claimed.length === 0) return { completed: true, processed: 0 } + for (const change of claimed) { + const [project] = await tx.select().from(schema.projects) + .where(eq(schema.projects.id, change.projectId)).for('update') + if (!project) throw new Error('Journal project disappeared before reconciliation.') + const hasBoundFilesystemAuthority = project.rootBindingRevision > BigInt(0) + || project.grantDecisionRevision > BigInt(0) + // A legacy root-ref materialization can only be a no-authority + // outcome while both authority revisions are the canonical zero. + if (change.outcome !== 'insert' && hasBoundFilesystemAuthority) { + await reconciliation.reconcileFilesystemGrantsForProject(tx, { + actorId: command.actorId, + grantDecisionRevision: project.grantDecisionRevision.toString(), + lockedProject: project, + nextMcpConfig: reconciliation.filesystemMcpConfigAfterRootRepoint({ + grantDecisionRevision: project.grantDecisionRevision, + mcpConfig: project.mcpConfig, + rootBindingRevision: project.rootBindingRevision, + }), + trigger: 'project_root_repoint', + }) + } + 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} + ) + `) + } + return { completed: false, processed: claimed.length } + }) + if (result.completed) break + console.log(JSON.stringify({ mode: 'applied-batch', operationId: operation.operationId, processed: result.processed })) + } + console.log(JSON.stringify({ mode: 'complete', operationId: operation.operationId, through: command.throughGeneration.toString() })) + } finally { + await observer.end({ timeout: 5 }) + await closeDb() + } +} + +void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : usage) + process.exitCode = 1 +}) From af33979b761ed0b7b0e5b15d3dd243009de7a43b Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:00:03 +0800 Subject: [PATCH 04/69] fix: close root reconciliation constraint --- web/db/migrations/0027_epic_172_s4_packet_context.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ace7ec79..af1fe171 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -485,7 +485,7 @@ CREATE TABLE public.project_root_reconciliation_operations ( CONSTRAINT project_root_reconciliation_operation_progress_chk CHECK ( last_processed_generation <= through_generation AND cumulative_count = last_processed_generation - AND ((state = 'running' AND completed_at IS NULL) OR (state = 'complete' AND completed_at IS NOT NULL) + AND ((state = 'running' AND completed_at IS NULL) OR (state = 'complete' AND completed_at IS NOT NULL)) ) ); CREATE UNIQUE INDEX project_root_reconciliation_one_live_idx From 89912541cc3415987a93c052b4f86b075d1d6c7c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:04:30 +0800 Subject: [PATCH 05/69] fix: grant reconciler projection-head lock access --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/db/migrations/0027_epic_172_s4_packet_context.sql | 4 ++++ web/scripts/bootstrap-epic-172-s4-roles.ts | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 921c57ab..823253cf 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -61,6 +61,8 @@ describe('project-root expansion reconciliation boundary', () => { expect(bootstrap).toContain("'forge_project_root_reconciler'") expect(migration).toContain('GRANT UPDATE (status, error_message, updated_at) ON public.tasks TO forge_project_root_reconciler') expect(migration).toContain('GRANT UPDATE (status, blocked_reason, metadata, updated_at) ON public.work_packages TO forge_project_root_reconciler') + expect(migration).toContain('GRANT UPDATE (id) ON public.work_package_local_projection_heads TO forge_project_root_reconciler') + expect(bootstrap).toContain('grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler') expect(migration).toContain('public.project_root_reconciliation_operations') expect(migration).toContain('REVOKE ALL ON FUNCTION forge.begin_project_root_reconciliation_v1') for (const table of [ 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 af1fe171..f0fb7900 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -7538,6 +7538,10 @@ GRANT UPDATE (id) ON public.projects, public.filesystem_mcp_grant_approvals, public.project_filesystem_grant_decisions TO forge_project_root_reconciler; GRANT UPDATE (project_id) ON public.project_filesystem_current_decision_pointers TO forge_project_root_reconciler; GRANT UPDATE (work_package_id) ON public.filesystem_mcp_current_decision_pointers TO forge_project_root_reconciler; +-- PostgreSQL also requires an UPDATE privilege for the canonical helper's +-- FOR UPDATE lock on the current projection-head rows. The immutable key is +-- sufficient; this login receives no mutable head-column privilege. +GRANT UPDATE (id) ON public.work_package_local_projection_heads TO forge_project_root_reconciler; GRANT UPDATE (status, error_message, updated_at) ON public.tasks TO forge_project_root_reconciler; GRANT UPDATE (status, blocked_reason, metadata, updated_at) ON public.work_packages TO forge_project_root_reconciler; GRANT EXECUTE ON FUNCTION forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text) TO forge_project_root_reconciler; diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 1139b7df..2dbd71fb 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -144,6 +144,10 @@ async function main(): Promise { await admin.unsafe(`grant usage on schema forge to ${LOGIN_ROLES.join(', ')}`) await admin`grant execute on function forge.read_epic_172_enablement_state_v1() to ${admin(OWNER)}` await admin`grant select, update on table public.work_package_local_projection_heads to ${admin(OWNER)}` + // The dedicated reconciler acquires FOR UPDATE locks through the + // canonical S3 helper. PostgreSQL requires an UPDATE privilege for + // that lock mode; grant only the immutable primary key column. + await admin`grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler` const [{ protectedOwnershipCount }] = await admin<{ protectedOwnershipCount: number }[]>` select (( select count(*) from pg_catalog.pg_class relation From 909634dae3505ed81b50d72a41b47df2466d1d6b Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:16:42 +0800 Subject: [PATCH 06/69] fix: suppress phase persistence for root reconciliation --- web/__tests__/project-root-reconciliation.test.ts | 8 ++++++++ web/lib/mcps/filesystem-grant-reconciliation.ts | 14 +++++++++++--- web/scripts/reconcile-project-root-expansion.ts | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 823253cf..2cbc6099 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -10,6 +10,7 @@ const migration = readFileSync(fileURLToPath(new URL('../db/migrations/0027_epic 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 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') @@ -72,6 +73,13 @@ describe('project-root expansion reconciliation boundary', () => { ]) expect(webCi).toContain(`'${table}'`) }) + 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') + expect(reconcileScript).not.toContain('--suppress') + }) + it('keeps concurrent index DDL separate and strict cutover watermark-fenced', () => { expect(indexScript).toContain('CREATE UNIQUE INDEX CONCURRENTLY') expect(indexScript).toContain('FORGE_DATABASE_ADMIN_URL') diff --git a/web/lib/mcps/filesystem-grant-reconciliation.ts b/web/lib/mcps/filesystem-grant-reconciliation.ts index 2d7c6c91..79122310 100644 --- a/web/lib/mcps/filesystem-grant-reconciliation.ts +++ b/web/lib/mcps/filesystem-grant-reconciliation.ts @@ -619,6 +619,8 @@ async function applyCanonicalProjection(input: { taskProjectionScopeById: ReadonlyMap transitionAuthorityByPackageId?: ReadonlyMap forcePackageIds?: ReadonlySet + /** Internal root-journal mode; never sourced from an operator request. */ + suppressPhasePersistence?: boolean tx: GrantTransaction }): Promise> { const recoveredTaskIds = new Set() @@ -641,9 +643,11 @@ async function applyCanonicalProjection(input: { if (!check.blocked) { if (!parsedMarker && !forcePersist) continue const grant = projectFilesystemGrantFromAuthority(input.projectAuthority) - const effective = grant - ? phasesWithEffective(pkg.metadata, projectFilesystemEffectivePhase(grant)) - : forcePersist ? currentPhases : undefined + const effective = input.suppressPhasePersistence + ? undefined + : grant + ? phasesWithEffective(pkg.metadata, projectFilesystemEffectivePhase(grant)) + : forcePersist ? currentPhases : undefined const recovering = Boolean(parsedMarker) const [updated] = await input.tx .update(workPackages) @@ -806,6 +810,7 @@ async function reconcileLockedProjectRows(input: { projectAuthority: ProjectFilesystemDecisionAuthority | null taskRows: readonly LockedTask[] transitionAuthorityByPackageId?: ReadonlyMap + suppressPhasePersistence?: boolean trigger: FilesystemGrantProjectReconciliationTrigger tx: GrantTransaction now: Date @@ -830,6 +835,7 @@ async function reconcileLockedProjectRows(input: { input.taskRows.map((task) => [task.id, task.localProjectionScopeState]), ), transitionAuthorityByPackageId: input.transitionAuthorityByPackageId, + suppressPhasePersistence: input.suppressPhasePersistence, tx: input.tx, }) for (const task of input.taskRows) { @@ -864,6 +870,7 @@ export async function reconcileFilesystemGrantsForProject( lockedProject: LockedProject nextMcpConfig: ProjectMcpConfig trigger: FilesystemGrantProjectReconciliationTrigger + suppressPhasePersistence?: boolean }, ): Promise { if (!input.actorId.trim()) throw new Error('Grant reconciliation requires an actor.') @@ -961,6 +968,7 @@ export async function reconcileFilesystemGrantsForProject( projectAuthority: currentProjectDecision ? projectDecisionAuthority(currentProjectDecision) : null, taskRows, transitionAuthorityByPackageId, + suppressPhasePersistence: input.suppressPhasePersistence, trigger: input.trigger, tx, }) diff --git a/web/scripts/reconcile-project-root-expansion.ts b/web/scripts/reconcile-project-root-expansion.ts index 826a07d9..fbc7cab5 100644 --- a/web/scripts/reconcile-project-root-expansion.ts +++ b/web/scripts/reconcile-project-root-expansion.ts @@ -78,6 +78,7 @@ async function main(): Promise { mcpConfig: project.mcpConfig, rootBindingRevision: project.rootBindingRevision, }), + suppressPhasePersistence: true, trigger: 'project_root_repoint', }) } From 8d9afd7e6c8411c8163e2a37f0391903478a321c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:19:29 +0800 Subject: [PATCH 07/69] fix: bootstrap root reconciler grants --- web/__tests__/project-root-reconciliation.test.ts | 8 +++++--- web/db/migrations/0027_epic_172_s4_packet_context.sql | 11 ----------- web/scripts/bootstrap-epic-172-s4-roles.ts | 6 ++++++ 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 2cbc6099..4cc0a45b 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -60,9 +60,11 @@ describe('project-root expansion reconciliation boundary', () => { expect(reconcileScript).toContain('reconcileFilesystemGrantsForProject') expect(reconcileScript).toContain('hasBoundFilesystemAuthority') expect(bootstrap).toContain("'forge_project_root_reconciler'") - expect(migration).toContain('GRANT UPDATE (status, error_message, updated_at) ON public.tasks TO forge_project_root_reconciler') - expect(migration).toContain('GRANT UPDATE (status, blocked_reason, metadata, updated_at) ON public.work_packages TO forge_project_root_reconciler') - expect(migration).toContain('GRANT UPDATE (id) ON public.work_package_local_projection_heads TO forge_project_root_reconciler') + expect(migration).not.toContain('GRANT SELECT ON public.projects, public.tasks, public.work_packages') + expect(migration).not.toContain('GRANT UPDATE (status, error_message, updated_at) ON public.tasks TO forge_project_root_reconciler') + expect(bootstrap).toContain('grant select on table public.projects, public.tasks, public.work_packages') + expect(bootstrap).toContain('grant update (status, error_message, updated_at) on table public.tasks to forge_project_root_reconciler') + expect(bootstrap).toContain('grant update (status, blocked_reason, metadata, updated_at) on table public.work_packages to forge_project_root_reconciler') expect(bootstrap).toContain('grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler') expect(migration).toContain('public.project_root_reconciliation_operations') expect(migration).toContain('REVOKE ALL ON FUNCTION forge.begin_project_root_reconciliation_v1') 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 f0fb7900..aa9845b3 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -7528,22 +7528,11 @@ GRANT EXECUTE ON FUNCTION forge.complete_project_root_reconciliation_generation_ GRANT EXECUTE ON FUNCTION forge.materialize_project_root_ref_expansion_v1(integer) TO forge_project_root_reconciler; -- Canonical TypeScript S3 reconciliation needs these exact ordinary state -- columns. It receives no insert/delete nor any protected journal authority. -GRANT SELECT ON public.projects, public.tasks, public.work_packages, - public.filesystem_mcp_grant_approvals, public.filesystem_mcp_current_decision_pointers, - public.project_filesystem_grant_decisions, public.project_filesystem_current_decision_pointers, - public.work_package_local_projection_heads TO forge_project_root_reconciler; -- PostgreSQL requires UPDATE privilege to acquire FOR UPDATE locks. These are -- immutable/key columns only; the reconciler never writes them. -GRANT UPDATE (id) ON public.projects, public.filesystem_mcp_grant_approvals, - public.project_filesystem_grant_decisions TO forge_project_root_reconciler; -GRANT UPDATE (project_id) ON public.project_filesystem_current_decision_pointers TO forge_project_root_reconciler; -GRANT UPDATE (work_package_id) ON public.filesystem_mcp_current_decision_pointers TO forge_project_root_reconciler; -- PostgreSQL also requires an UPDATE privilege for the canonical helper's -- FOR UPDATE lock on the current projection-head rows. The immutable key is -- sufficient; this login receives no mutable head-column privilege. -GRANT UPDATE (id) ON public.work_package_local_projection_heads TO forge_project_root_reconciler; -GRANT UPDATE (status, error_message, updated_at) ON public.tasks TO forge_project_root_reconciler; -GRANT UPDATE (status, blocked_reason, metadata, updated_at) ON public.work_packages TO forge_project_root_reconciler; GRANT EXECUTE ON FUNCTION forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text) TO forge_project_root_reconciler; -- The bootstrap fence temporarily gives the incoming owner CREATE on the two -- containing schemas because PostgreSQL requires it for SET OWNER. The diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 2dbd71fb..1bddf047 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -148,6 +148,12 @@ async function main(): Promise { // canonical S3 helper. PostgreSQL requires an UPDATE privilege for // that lock mode; grant only the immutable primary key column. await admin`grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler` + await admin`grant select on table public.projects, public.tasks, public.work_packages, public.filesystem_mcp_grant_approvals, public.filesystem_mcp_current_decision_pointers, public.project_filesystem_grant_decisions, public.project_filesystem_current_decision_pointers, public.work_package_local_projection_heads to forge_project_root_reconciler` + await admin`grant update (id) on table public.projects, public.filesystem_mcp_grant_approvals, public.project_filesystem_grant_decisions to forge_project_root_reconciler` + await admin`grant update (project_id) on table public.project_filesystem_current_decision_pointers to forge_project_root_reconciler` + await admin`grant update (work_package_id) on table public.filesystem_mcp_current_decision_pointers to forge_project_root_reconciler` + await admin`grant update (status, error_message, updated_at) on table public.tasks to forge_project_root_reconciler` + await admin`grant update (status, blocked_reason, metadata, updated_at) on table public.work_packages to forge_project_root_reconciler` const [{ protectedOwnershipCount }] = await admin<{ protectedOwnershipCount: number }[]>` select (( select count(*) from pg_catalog.pg_class relation From b64b9b4ba0076197b2df74c8294a03b0afa66a71 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:40:49 +0800 Subject: [PATCH 08/69] fix: remove reconciler lock-only update grants --- web/__tests__/project-root-reconciliation.test.ts | 5 ++++- web/scripts/bootstrap-epic-172-s4-roles.ts | 7 ------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 4cc0a45b..d777b2d2 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -65,7 +65,10 @@ describe('project-root expansion reconciliation boundary', () => { expect(bootstrap).toContain('grant select on table public.projects, public.tasks, public.work_packages') expect(bootstrap).toContain('grant update (status, error_message, updated_at) on table public.tasks to forge_project_root_reconciler') expect(bootstrap).toContain('grant update (status, blocked_reason, metadata, updated_at) on table public.work_packages to forge_project_root_reconciler') - expect(bootstrap).toContain('grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler') + expect(bootstrap).not.toContain('grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler') + expect(bootstrap).not.toContain('grant update (id) on table public.projects, public.filesystem_mcp_grant_approvals') + expect(bootstrap).not.toContain('grant update (project_id) on table public.project_filesystem_current_decision_pointers') + expect(bootstrap).not.toContain('grant update (work_package_id) on table public.filesystem_mcp_current_decision_pointers') expect(migration).toContain('public.project_root_reconciliation_operations') expect(migration).toContain('REVOKE ALL ON FUNCTION forge.begin_project_root_reconciliation_v1') for (const table of [ diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 1bddf047..efac77c3 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -144,14 +144,7 @@ async function main(): Promise { await admin.unsafe(`grant usage on schema forge to ${LOGIN_ROLES.join(', ')}`) await admin`grant execute on function forge.read_epic_172_enablement_state_v1() to ${admin(OWNER)}` await admin`grant select, update on table public.work_package_local_projection_heads to ${admin(OWNER)}` - // The dedicated reconciler acquires FOR UPDATE locks through the - // canonical S3 helper. PostgreSQL requires an UPDATE privilege for - // that lock mode; grant only the immutable primary key column. - await admin`grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler` await admin`grant select on table public.projects, public.tasks, public.work_packages, public.filesystem_mcp_grant_approvals, public.filesystem_mcp_current_decision_pointers, public.project_filesystem_grant_decisions, public.project_filesystem_current_decision_pointers, public.work_package_local_projection_heads to forge_project_root_reconciler` - await admin`grant update (id) on table public.projects, public.filesystem_mcp_grant_approvals, public.project_filesystem_grant_decisions to forge_project_root_reconciler` - await admin`grant update (project_id) on table public.project_filesystem_current_decision_pointers to forge_project_root_reconciler` - await admin`grant update (work_package_id) on table public.filesystem_mcp_current_decision_pointers to forge_project_root_reconciler` await admin`grant update (status, error_message, updated_at) on table public.tasks to forge_project_root_reconciler` await admin`grant update (status, blocked_reason, metadata, updated_at) on table public.work_packages to forge_project_root_reconciler` const [{ protectedOwnershipCount }] = await admin<{ protectedOwnershipCount: number }[]>` From 2c8b1df53e908e301223a5855fcb8f4a51d599b1 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:43:18 +0800 Subject: [PATCH 09/69] fix: bootstrap projection head routine access --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/db/migrations/0027_epic_172_s4_packet_context.sql | 1 - web/scripts/bootstrap-epic-172-s4-roles.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 4cc0a45b..dfc5ef36 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -65,6 +65,8 @@ describe('project-root expansion reconciliation boundary', () => { expect(bootstrap).toContain('grant select on table public.projects, public.tasks, public.work_packages') expect(bootstrap).toContain('grant update (status, error_message, updated_at) on table public.tasks to forge_project_root_reconciler') expect(bootstrap).toContain('grant update (status, blocked_reason, metadata, updated_at) on table public.work_packages to forge_project_root_reconciler') + expect(migration).not.toContain('GRANT EXECUTE ON FUNCTION forge.advance_local_projection_head_v1') + expect(bootstrap).toContain('grant execute on function forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text) to forge_project_root_reconciler') expect(bootstrap).toContain('grant update (id) on table public.work_package_local_projection_heads to forge_project_root_reconciler') expect(migration).toContain('public.project_root_reconciliation_operations') expect(migration).toContain('REVOKE ALL ON FUNCTION forge.begin_project_root_reconciliation_v1') 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 aa9845b3..fc5ad8b8 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -7533,7 +7533,6 @@ GRANT EXECUTE ON FUNCTION forge.materialize_project_root_ref_expansion_v1(intege -- PostgreSQL also requires an UPDATE privilege for the canonical helper's -- FOR UPDATE lock on the current projection-head rows. The immutable key is -- sufficient; this login receives no mutable head-column privilege. -GRANT EXECUTE ON FUNCTION forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text) TO forge_project_root_reconciler; -- The bootstrap fence temporarily gives the incoming owner CREATE on the two -- containing schemas because PostgreSQL requires it for SET OWNER. The -- finalizer revokes both grants before it verifies the permanent boundary. diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 1bddf047..31bc2349 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -154,6 +154,7 @@ async function main(): Promise { await admin`grant update (work_package_id) on table public.filesystem_mcp_current_decision_pointers to forge_project_root_reconciler` await admin`grant update (status, error_message, updated_at) on table public.tasks to forge_project_root_reconciler` await admin`grant update (status, blocked_reason, metadata, updated_at) on table public.work_packages to forge_project_root_reconciler` + await admin`grant execute on function forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text) to forge_project_root_reconciler` const [{ protectedOwnershipCount }] = await admin<{ protectedOwnershipCount: number }[]>` select (( select count(*) from pg_catalog.pg_class relation From 50bbe6e59b7b9ab6014d0f4c9acd6b39b4623c71 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:46:33 +0800 Subject: [PATCH 10/69] feat: bind root reconciliation write contexts --- .../project-root-reconciliation.test.ts | 7 +++ .../0027_epic_172_s4_packet_context.sql | 48 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 1deb4851..e22d7947 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -52,6 +52,13 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('project-root completion compare-and-set failed') expect(migration).toContain('project-root generation already has an immutable outcome') expect(migration).toContain('forge.materialize_project_root_ref_expansion_v1') + expect(migration).toContain('CREATE TABLE public.project_root_reconciliation_write_contexts') + expect(migration).toContain('backend_pid integer NOT NULL') + expect(migration).toContain('transaction_id bigint NOT NULL') + expect(migration).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(migration).toContain('project-root write context is absent or stale') + expect(migration).toContain('project_root_reconciliation_write_contexts_append_only_v1') + expect(migration).toContain('project-root write context is immutable outside fixed completion') }) it('uses the dedicated login with only canonical helper state columns and fixed routines', () => { 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 fc5ad8b8..2a7addb7 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -663,6 +663,51 @@ BEGIN 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, + 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), + 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) +); +CREATE OR REPLACE FUNCTION forge.enter_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid) +RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ +DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; v_journal public.project_root_change_journal%ROWTYPE; +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE operation_id=p_operation_id FOR UPDATE; + SELECT * INTO STRICT v_journal FROM public.project_root_change_journal WHERE generation=p_generation FOR UPDATE; + PERFORM 1 FROM public.projects WHERE id=p_project_id FOR UPDATE; + IF v_operation.actor_id <> p_actor_id OR v_operation.state <> 'running' OR p_generation <> v_operation.last_processed_generation + 1 OR p_generation > v_operation.through_generation OR v_journal.project_id <> p_project_id OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE generation=p_generation) THEN RAISE EXCEPTION 'project-root write context is not claimable' USING ERRCODE='42501'; END IF; + INSERT INTO public.project_root_reconciliation_write_contexts(operation_id,generation,actor_id,project_id,backend_pid,transaction_id) + VALUES(p_operation_id,p_generation,p_actor_id,p_project_id,pg_catalog.pg_backend_pid(),pg_catalog.txid_current()); +END; $$; +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 $$ +BEGIN + IF TG_OP = 'DELETE' OR NEW.operation_id IS DISTINCT FROM OLD.operation_id + OR NEW.generation IS DISTINCT FROM OLD.generation OR NEW.actor_id IS DISTINCT FROM OLD.actor_id + OR NEW.project_id IS DISTINCT FROM OLD.project_id OR NEW.backend_pid IS DISTINCT FROM OLD.backend_pid + OR NEW.transaction_id IS DISTINCT FROM OLD.transaction_id OR NEW.entered_at IS DISTINCT FROM OLD.entered_at + OR OLD.completed_at IS NOT NULL OR NEW.completed_at IS NULL THEN + RAISE EXCEPTION 'project-root write context is immutable outside fixed completion' USING ERRCODE='55000'; + END IF; + RETURN NEW; +END; $$; +CREATE TRIGGER project_root_reconciliation_write_contexts_append_only_v1 +BEFORE UPDATE OR DELETE ON public.project_root_reconciliation_write_contexts +FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_reconciliation_write_context_mutation_v1(); +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; +GRANT EXECUTE ON FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; 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 @@ -672,6 +717,7 @@ AS $$ DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; v_journal public.project_root_change_journal%ROWTYPE; BEGIN PERFORM forge.assert_project_root_reconciler_v1(); + IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context WHERE context.operation_id=p_operation_id AND context.generation=p_generation AND context.actor_id=p_actor_id AND context.project_id=p_project_id AND context.backend_pid=pg_catalog.pg_backend_pid() AND context.transaction_id=pg_catalog.txid_current() AND context.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root write context is absent or stale' USING ERRCODE='42501'; END IF; SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE 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; @@ -700,6 +746,8 @@ BEGIN ) 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; RETURN QUERY SELECT v_operation.state, v_operation.last_processed_generation; END; $$; From 66037a48d16fed3d98bc77b893fd8552b9f0b90a Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:47:29 +0800 Subject: [PATCH 11/69] fix: qualify root reconciliation operations --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ .../migrations/0027_epic_172_s4_packet_context.sql | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index e22d7947..1edf9337 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -49,6 +49,8 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('forge.assert_project_root_journal_window_v1') expect(migration).toContain('v_counter <> p_through_generation OR v_max <> p_through_generation OR v_count <> p_through_generation') expect(migration).toContain('project-root operation identity cannot be hijacked') + expect(migration).toContain('operation_row.actor_id = p_actor_id') + expect(migration).toContain('operation_row.through_generation = p_through_generation') expect(migration).toContain('project-root completion compare-and-set failed') expect(migration).toContain('project-root generation already has an immutable outcome') expect(migration).toContain('forge.materialize_project_root_ref_expansion_v1') diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 2a7addb7..79ad97a1 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -606,19 +606,19 @@ BEGIN PERFORM forge.assert_project_root_reconciler_v1(); PERFORM forge.assert_project_root_journal_window_v1(p_through_generation); IF p_operation_id IS NULL THEN - SELECT * INTO v_operation FROM public.project_root_reconciliation_operations - WHERE actor_id = p_actor_id AND through_generation = p_through_generation - ORDER BY created_at DESC LIMIT 1 FOR UPDATE; + SELECT * INTO v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.actor_id = p_actor_id AND operation_row.through_generation = p_through_generation + ORDER BY operation_row.created_at DESC LIMIT 1 FOR UPDATE; ELSE - SELECT * INTO v_operation FROM public.project_root_reconciliation_operations - WHERE operation_id = p_operation_id FOR UPDATE; + SELECT * INTO v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.operation_id = p_operation_id FOR UPDATE; END IF; IF FOUND THEN IF v_operation.actor_id <> p_actor_id OR v_operation.through_generation <> p_through_generation THEN RAISE EXCEPTION 'project-root operation identity cannot be hijacked' USING ERRCODE = '42501'; END IF; ELSE - IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations WHERE state = 'running') THEN + IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations AS operation_row WHERE operation_row.state = 'running') THEN RAISE EXCEPTION 'a project-root reconciliation operation is already live' USING ERRCODE = '55P03'; END IF; INSERT INTO public.project_root_reconciliation_operations(operation_id, actor_id, through_generation, state, completed_at) From 463256cdd5cbc7894300f314f0c7c8bbc70777c1 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:48:44 +0800 Subject: [PATCH 12/69] fix: require root context completion at commit --- .../project-root-reconciliation.test.ts | 3 +++ .../0027_epic_172_s4_packet_context.sql | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 1edf9337..4281a833 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -61,6 +61,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('project-root write context is absent or stale') expect(migration).toContain('project_root_reconciliation_write_contexts_append_only_v1') expect(migration).toContain('project-root write context is immutable outside fixed completion') + expect(migration).toContain('CREATE CONSTRAINT TRIGGER project_root_reconciliation_write_contexts_commit_v1') + expect(migration).toContain('DEFERRABLE INITIALLY DEFERRED') + expect(migration).toContain('project-root write context must complete before commit') }) it('uses the dedicated login with only canonical helper state columns and fixed routines', () => { 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 79ad97a1..539f32e1 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -705,6 +705,30 @@ END; $$; CREATE TRIGGER project_root_reconciliation_write_contexts_append_only_v1 BEFORE UPDATE OR DELETE ON public.project_root_reconciliation_write_contexts FOR EACH ROW EXECUTE FUNCTION forge.reject_project_root_reconciliation_write_context_mutation_v1(); +CREATE OR REPLACE FUNCTION forge.assert_project_root_reconciliation_write_context_committed_v1() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ +DECLARE v_context public.project_root_reconciliation_write_contexts%ROWTYPE; +BEGIN + SELECT * INTO STRICT v_context FROM public.project_root_reconciliation_write_contexts context_row + WHERE context_row.operation_id = NEW.operation_id AND context_row.generation = NEW.generation; + IF v_context.completed_at IS NULL OR NOT EXISTS ( + SELECT 1 FROM public.project_root_reconciliation_outcomes outcome_row + JOIN public.project_root_change_journal journal_row ON journal_row.generation = outcome_row.generation + JOIN public.project_root_reconciliation_operations operation_row ON operation_row.operation_id = outcome_row.operation_id + WHERE outcome_row.generation = v_context.generation AND outcome_row.operation_id = v_context.operation_id + AND outcome_row.actor_id = v_context.actor_id AND outcome_row.project_id = v_context.project_id + AND outcome_row.outcome = journal_row.outcome AND journal_row.project_id = v_context.project_id + AND operation_row.last_processed_generation >= v_context.generation + ) THEN + RAISE EXCEPTION 'project-root write context must complete before commit' USING ERRCODE = '55000'; + END IF; + RETURN NULL; +END; $$; +CREATE CONSTRAINT TRIGGER project_root_reconciliation_write_contexts_commit_v1 +AFTER INSERT OR UPDATE ON public.project_root_reconciliation_write_contexts +DEFERRABLE INITIALLY DEFERRED FOR EACH ROW +EXECUTE FUNCTION forge.assert_project_root_reconciliation_write_context_committed_v1(); +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; GRANT EXECUTE ON FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; From 30a4c482ba88bc99862f13ad2c0be0f48b1cad5c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:49:47 +0800 Subject: [PATCH 13/69] feat: lock root reconciliation authority --- web/__tests__/project-root-reconciliation.test.ts | 4 ++++ .../migrations/0027_epic_172_s4_packet_context.sql | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 4281a833..12bb6c57 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -64,6 +64,10 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('CREATE CONSTRAINT TRIGGER project_root_reconciliation_write_contexts_commit_v1') expect(migration).toContain('DEFERRABLE INITIALLY DEFERRED') expect(migration).toContain('project-root write context must complete before commit') + expect(migration).toContain('forge.lock_project_root_reconciliation_authority_v1') + expect(migration).toContain('approval_row.project_id=p_project_id ORDER BY approval_row.id FOR UPDATE') + expect(migration).toContain('decision_row.project_id=p_project_id ORDER BY decision_row.id FOR UPDATE') + expect(migration).toContain('project-root authority lock has no active write context') }) it('uses the dedicated login with only canonical helper state columns and fixed routines', () => { 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 539f32e1..f727fa0c 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -732,6 +732,18 @@ REVOKE ALL ON FUNCTION forge.assert_project_root_reconciliation_write_context_co 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; GRANT EXECUTE ON FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; +CREATE OR REPLACE FUNCTION forge.lock_project_root_reconciliation_authority_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid) +RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ +BEGIN + PERFORM forge.assert_project_root_reconciler_v1(); + IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context_row WHERE context_row.operation_id=p_operation_id AND context_row.actor_id=p_actor_id AND context_row.generation=p_generation AND context_row.project_id=p_project_id AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root authority lock has no active write context' USING ERRCODE='42501'; END IF; + PERFORM 1 FROM public.filesystem_mcp_grant_approvals approval_row WHERE approval_row.project_id=p_project_id ORDER BY approval_row.id FOR UPDATE; + PERFORM 1 FROM public.project_filesystem_grant_decisions decision_row WHERE decision_row.project_id=p_project_id ORDER BY decision_row.id FOR UPDATE; + PERFORM 1 FROM public.project_filesystem_current_decision_pointers pointer_row WHERE pointer_row.project_id=p_project_id FOR UPDATE; + PERFORM 1 FROM public.filesystem_mcp_current_decision_pointers pointer_row JOIN public.work_packages package_row ON package_row.id=pointer_row.work_package_id JOIN public.tasks task_row ON task_row.id=package_row.task_id WHERE task_row.project_id=p_project_id ORDER BY pointer_row.work_package_id FOR UPDATE; +END; $$; +REVOKE ALL ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid, p_outcome text) RETURNS TABLE(state text, last_processed_generation bigint) LANGUAGE plpgsql From 95c2b9785a38d034fbb76b1fe1fc59de9b195f53 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:52:46 +0800 Subject: [PATCH 14/69] fix: fence root context ownership before transfer --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/db/migrations/0027_epic_172_s4_packet_context.sql | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 12bb6c57..2e3b2b63 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -68,6 +68,8 @@ 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.indexOf('REVOKE ALL ON TABLE public.project_root_reconciliation_write_contexts FROM PUBLIC')).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') }) it('uses the dedicated login with only canonical helper state columns and fixed routines', () => { 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..cbdebc0d 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -688,8 +688,8 @@ 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; +ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner; CREATE OR REPLACE FUNCTION forge.reject_project_root_reconciliation_write_context_mutation_v1() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$ BEGIN @@ -731,6 +731,9 @@ EXECUTE FUNCTION forge.assert_project_root_reconciliation_write_context_committe 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; +ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) 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; 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 $$ @@ -743,6 +746,7 @@ BEGIN PERFORM 1 FROM public.filesystem_mcp_current_decision_pointers pointer_row JOIN public.work_packages package_row ON package_row.id=pointer_row.work_package_id JOIN public.tasks task_row ON task_row.id=package_row.task_id WHERE task_row.project_id=p_project_id ORDER BY pointer_row.work_package_id FOR UPDATE; END; $$; REVOKE ALL ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) FROM PUBLIC; +ALTER FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner; GRANT EXECUTE ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; CREATE OR REPLACE FUNCTION forge.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) From e54f498d4e40c6d0aa2f63bfbc9c0bb75a8e6f9c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:54:58 +0800 Subject: [PATCH 15/69] feat: enter root reconciliation write context --- web/__tests__/project-root-reconciliation.test.ts | 3 +++ web/lib/mcps/filesystem-grant-reconciliation.ts | 6 ++++++ web/scripts/reconcile-project-root-expansion.ts | 4 +++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 2e3b2b63..f1fc8085 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -103,6 +103,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(reconciliation).toContain('suppressPhasePersistence?: boolean') expect(reconciliation).toContain('input.suppressPhasePersistence ? undefined : grant') expect(reconcileScript).not.toContain('--suppress') + expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(reconcileScript).toContain('rootReconciliationContext:') + expect(reconciliation).toContain('forge.lock_project_root_reconciliation_authority_v1') }) it('keeps concurrent index DDL separate and strict cutover watermark-fenced', () => { diff --git a/web/lib/mcps/filesystem-grant-reconciliation.ts b/web/lib/mcps/filesystem-grant-reconciliation.ts index 79122310..19c6304a 100644 --- a/web/lib/mcps/filesystem-grant-reconciliation.ts +++ b/web/lib/mcps/filesystem-grant-reconciliation.ts @@ -871,6 +871,7 @@ export async function reconcileFilesystemGrantsForProject( nextMcpConfig: ProjectMcpConfig trigger: FilesystemGrantProjectReconciliationTrigger suppressPhasePersistence?: boolean + rootReconciliationContext?: { operationId: string; actorId: string; generation: string; projectId: string } }, ): Promise { if (!input.actorId.trim()) throw new Error('Grant reconciliation requires an actor.') @@ -903,6 +904,11 @@ export async function reconcileFilesystemGrantsForProject( .where(inArray(workPackages.taskId, taskRows.map((task) => task.id))) .orderBy(workPackages.id) .for('update') + if (input.rootReconciliationContext) { + const context = input.rootReconciliationContext + if (context.projectId !== input.lockedProject.id) throw httpError('Root reconciliation context project changed.', 409) + await tx.execute(sql`select forge.lock_project_root_reconciliation_authority_v1(${context.operationId}::uuid, ${context.actorId}::uuid, ${context.generation}::bigint, ${context.projectId}::uuid)`) + } const packageDecisionRows = await tx.select() .from(filesystemMcpGrantApprovals) .where(eq(filesystemMcpGrantApprovals.projectId, input.lockedProject.id)) diff --git a/web/scripts/reconcile-project-root-expansion.ts b/web/scripts/reconcile-project-root-expansion.ts index fbc7cab5..22ce4049 100644 --- a/web/scripts/reconcile-project-root-expansion.ts +++ b/web/scripts/reconcile-project-root-expansion.ts @@ -61,8 +61,9 @@ async function main(): Promise { const claimed = (claimedRows as unknown as Array>).map(parseClaimedProjectRootChange) if (claimed.length === 0) return { completed: true, processed: 0 } for (const change of claimed) { + await tx.execute(sql`select forge.enter_project_root_reconciliation_generation_v1(${operation.operationId}::uuid, ${command.actorId}::uuid, ${change.generation.toString()}::bigint, ${change.projectId}::uuid)`) const [project] = await tx.select().from(schema.projects) - .where(eq(schema.projects.id, change.projectId)).for('update') + .where(eq(schema.projects.id, change.projectId)) if (!project) throw new Error('Journal project disappeared before reconciliation.') const hasBoundFilesystemAuthority = project.rootBindingRevision > BigInt(0) || project.grantDecisionRevision > BigInt(0) @@ -79,6 +80,7 @@ async function main(): Promise { rootBindingRevision: project.rootBindingRevision, }), suppressPhasePersistence: true, + rootReconciliationContext: { operationId: operation.operationId, actorId: command.actorId, generation: change.generation.toString(), projectId: change.projectId }, trigger: 'project_root_repoint', }) } From 75dc9e53734672d8e0bce4fffd4c83d210ac1aca Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:56:18 +0800 Subject: [PATCH 16/69] fix: use prelocked root authority reads --- .../project-root-reconciliation.test.ts | 1 + .../mcps/filesystem-grant-reconciliation.ts | 21 ++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index f1fc8085..7e706b79 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -106,6 +106,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(reconcileScript).toContain('rootReconciliationContext:') expect(reconciliation).toContain('forge.lock_project_root_reconciliation_authority_v1') + expect(reconciliation).toContain('input.rootReconciliationContext ? packageDecisionQuery : packageDecisionQuery.for(\'update\')') }) it('keeps concurrent index DDL separate and strict cutover watermark-fenced', () => { diff --git a/web/lib/mcps/filesystem-grant-reconciliation.ts b/web/lib/mcps/filesystem-grant-reconciliation.ts index 19c6304a..2bf44e08 100644 --- a/web/lib/mcps/filesystem-grant-reconciliation.ts +++ b/web/lib/mcps/filesystem-grant-reconciliation.ts @@ -909,18 +909,18 @@ export async function reconcileFilesystemGrantsForProject( if (context.projectId !== input.lockedProject.id) throw httpError('Root reconciliation context project changed.', 409) await tx.execute(sql`select forge.lock_project_root_reconciliation_authority_v1(${context.operationId}::uuid, ${context.actorId}::uuid, ${context.generation}::bigint, ${context.projectId}::uuid)`) } - const packageDecisionRows = await tx.select() + const packageDecisionQuery = tx.select() .from(filesystemMcpGrantApprovals) .where(eq(filesystemMcpGrantApprovals.projectId, input.lockedProject.id)) .orderBy(filesystemMcpGrantApprovals.id) - .for('update') - const projectDecisionRows = await tx.select().from(projectFilesystemGrantDecisions) + const packageDecisionRows = await (input.rootReconciliationContext ? packageDecisionQuery : packageDecisionQuery.for('update')) + const projectDecisionQuery = tx.select().from(projectFilesystemGrantDecisions) .where(eq(projectFilesystemGrantDecisions.projectId, input.lockedProject.id)) .orderBy(projectFilesystemGrantDecisions.id) - .for('update') - const [projectPointer] = await tx.select().from(projectFilesystemCurrentDecisionPointers) + const projectDecisionRows = await (input.rootReconciliationContext ? projectDecisionQuery : projectDecisionQuery.for('update')) + const projectPointerQuery = tx.select().from(projectFilesystemCurrentDecisionPointers) .where(eq(projectFilesystemCurrentDecisionPointers.projectId, input.lockedProject.id)) - .for('update') + const [projectPointer] = await (input.rootReconciliationContext ? projectPointerQuery : projectPointerQuery.for('update')) if (!projectPointer) throw httpError('Project filesystem decision authority is not initialized.', 409) const currentProjectDecision = projectDecisionPointerParent(projectPointer, projectDecisionRows) if (currentProjectDecision === undefined) { @@ -928,14 +928,19 @@ export async function reconcileFilesystemGrantsForProject( } const pointerRows = packageRows.length === 0 ? [] - : await tx.select() + : await (input.rootReconciliationContext + ? tx.select() .from(filesystemMcpCurrentDecisionPointers) .where(inArray( filesystemMcpCurrentDecisionPointers.workPackageId, packageRows.map((pkg) => pkg.id), )) .orderBy(filesystemMcpCurrentDecisionPointers.workPackageId) - .for('update') + : tx.select() + .from(filesystemMcpCurrentDecisionPointers) + .where(inArray(filesystemMcpCurrentDecisionPointers.workPackageId, packageRows.map((pkg) => pkg.id))) + .orderBy(filesystemMcpCurrentDecisionPointers.workPackageId) + .for('update')) if (pointerRows.length !== packageRows.length) { throw httpError('Filesystem decision authority is not initialized for every package.', 409) } From 63c55d7e95f8a2c70b3ff5aaccb844cd9ac3a1ec Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:56:55 +0800 Subject: [PATCH 17/69] fix: transfer root context table after triggers --- web/__tests__/project-root-reconciliation.test.ts | 2 +- web/db/migrations/0027_epic_172_s4_packet_context.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 2e3b2b63..6df13d2c 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -68,7 +68,7 @@ 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.indexOf('REVOKE ALL ON TABLE public.project_root_reconciliation_write_contexts FROM PUBLIC')).toBeLessThan(migration.indexOf('ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner')) + expect(migration.indexOf('project_root_reconciliation_write_contexts_commit_v1')).toBeLessThan(migration.indexOf('ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner')) expect(migration).toContain('ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner') }) diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index cbdebc0d..d312a985 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -689,7 +689,6 @@ BEGIN VALUES(p_operation_id,p_generation,p_actor_id,p_project_id,pg_catalog.pg_backend_pid(),pg_catalog.txid_current()); END; $$; REVOKE ALL ON TABLE public.project_root_reconciliation_write_contexts FROM PUBLIC; -ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner; CREATE OR REPLACE FUNCTION forge.reject_project_root_reconciliation_write_context_mutation_v1() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$ BEGIN @@ -728,6 +727,7 @@ 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; From fe5442cd86525ccd747d402df88fda7494f7d9d9 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:58:56 +0800 Subject: [PATCH 18/69] feat: fence root reconciler task updates --- .../project-root-reconciliation.test.ts | 3 +++ .../0027_epic_172_s4_packet_context.sql | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 982d465b..0cf4a457 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -68,6 +68,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('approval_row.project_id=p_project_id ORDER BY approval_row.id FOR UPDATE') expect(migration).toContain('decision_row.project_id=p_project_id ORDER BY decision_row.id FOR UPDATE') expect(migration).toContain('project-root authority lock has no active write context') + expect(migration).toContain('project_root_reconciler_task_update_guard_v1') + expect(migration).toContain("OLD.status NOT IN ('running','failed') OR NEW.status <> 'approved'") + expect(migration).toContain('project-root task update has no active write context') expect(migration.indexOf('project_root_reconciliation_write_contexts_commit_v1')).toBeLessThan(migration.indexOf('ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner')) expect(migration).toContain('ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner') }) diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index d312a985..a1398fa2 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -748,6 +748,22 @@ END; $$; REVOKE ALL ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) FROM PUBLIC; ALTER FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner; GRANT EXECUTE ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; +CREATE OR REPLACE FUNCTION forge.guard_project_root_reconciler_task_update_v1() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ +BEGIN + IF session_user <> 'forge_project_root_reconciler' THEN RETURN NEW; END IF; + IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context_row WHERE context_row.project_id=OLD.project_id AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root task update has no active write context' USING ERRCODE='42501'; END IF; + IF OLD.status NOT IN ('running','failed') OR NEW.status <> 'approved' OR NEW.error_message IS NOT NULL + OR (to_jsonb(NEW) - ARRAY['status','error_message','updated_at']) IS DISTINCT FROM (to_jsonb(OLD) - ARRAY['status','error_message','updated_at']) THEN + RAISE EXCEPTION 'project-root task update is outside canonical convergence' USING ERRCODE='42501'; + END IF; + NEW.updated_at := pg_catalog.transaction_timestamp(); + RETURN NEW; +END; $$; +REVOKE ALL ON FUNCTION forge.guard_project_root_reconciler_task_update_v1() FROM PUBLIC; +CREATE TRIGGER project_root_reconciler_task_update_guard_v1 +BEFORE UPDATE ON public.tasks FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_reconciler_task_update_v1(); +ALTER FUNCTION forge.guard_project_root_reconciler_task_update_v1() OWNER TO forge_s4_routines_owner; CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid, p_outcome text) RETURNS TABLE(state text, last_processed_generation bigint) LANGUAGE plpgsql From ef62fc2d70ccdc8ff504051bab51df1820a22a5a Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:59:55 +0800 Subject: [PATCH 19/69] fix: grant root routines before ownership transfer --- web/__tests__/project-root-reconciliation.test.ts | 1 + web/db/migrations/0027_epic_172_s4_packet_context.sql | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 6df13d2c..752d379b 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -70,6 +70,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('project-root authority lock has no active write context') expect(migration.indexOf('project_root_reconciliation_write_contexts_commit_v1')).toBeLessThan(migration.indexOf('ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner')) expect(migration).toContain('ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner') + 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', () => { diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index d312a985..53f2d677 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -733,8 +733,8 @@ REVOKE ALL ON FUNCTION forge.reject_project_root_reconciliation_write_context_mu 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; -ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) 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 @@ -746,8 +746,8 @@ BEGIN PERFORM 1 FROM public.filesystem_mcp_current_decision_pointers pointer_row JOIN public.work_packages package_row ON package_row.id=pointer_row.work_package_id JOIN public.tasks task_row ON task_row.id=package_row.task_id WHERE task_row.project_id=p_project_id ORDER BY pointer_row.work_package_id FOR UPDATE; END; $$; REVOKE ALL ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) FROM PUBLIC; -ALTER FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner; GRANT EXECUTE ON FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) TO forge_project_root_reconciler; +ALTER FUNCTION forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner; CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid, p_outcome text) RETURNS TABLE(state text, last_processed_generation bigint) LANGUAGE plpgsql From d2805a5e79abf9d76795c423b808f6121ad4900a Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:05:16 +0800 Subject: [PATCH 20/69] feat: extract filesystem grant block validator --- web/__tests__/project-root-reconciliation.test.ts | 6 ++++++ .../migrations/0026_epic_172_s3_grant_lifecycle.sql | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 7a754260..4a49de10 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -7,6 +7,7 @@ import { } from '@/lib/mcps/project-root-reconciliation' const migration = readFileSync(fileURLToPath(new URL('../db/migrations/0027_epic_172_s4_packet_context.sql', import.meta.url)), 'utf8') +const s3Migration = readFileSync(fileURLToPath(new URL('../db/migrations/0026_epic_172_s3_grant_lifecycle.sql', import.meta.url)), 'utf8') const schema = readFileSync(fileURLToPath(new URL('../db/schema.ts', import.meta.url)), 'utf8') const bootstrap = readFileSync(fileURLToPath(new URL('../scripts/bootstrap-epic-172-s4-roles.ts', import.meta.url)), 'utf8') const reconcileScript = readFileSync(fileURLToPath(new URL('../scripts/reconcile-project-root-expansion.ts', import.meta.url)), 'utf8') @@ -16,6 +17,11 @@ const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover- const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') describe('project-root expansion reconciliation boundary', () => { + it('defines the pure reusable canonical filesystem grant-block validator', () => { + expect(s3Migration).toContain('CREATE FUNCTION forge_is_canonical_filesystem_grant_block_v2(p_block jsonb)') + expect(s3Migration).toContain('RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE') + expect(s3Migration).toContain("'project_grant_removed','project_grant_narrowed','project_root_repoint'") + }) it('parses only the literal actor/watermark/apply command and keeps dry-run actionless', () => { const actor = '123e4567-e89b-42d3-a456-426614174000' expect(parseProjectRootReconciliationCommand(['--through', '0', '--actor', actor])).toEqual({ diff --git a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql index b979d78d..44b8b5fa 100644 --- a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql +++ b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql @@ -411,6 +411,19 @@ BEFORE UPDATE OR DELETE ON "project_filesystem_grant_decisions" FOR EACH ROW EXECUTE FUNCTION "forge_reject_project_filesystem_decision_mutation"(); -- SQL mirrors the closed TypeScript S3 hold-state union. Older marker-shaped +-- Pure reusable validator; the table constraint below preserves the legacy +-- status/absence semantics while this predicate owns the marker grammar. +CREATE FUNCTION forge_is_canonical_filesystem_grant_block_v2(p_block jsonb) +RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + SELECT (jsonb_typeof(p_block)='object' + AND p_block ?& ARRAY['schemaVersion','kind','source','taskDisposition','autoRetryable','terminalFailure','requirementKeys','requestedCapabilities','recoveryAction','blockFingerprint','blockedAt','holdKind','grantPhase','grantConsumed','grantDecisionRevision','revocationReason'] + AND p_block - ARRAY['schemaVersion','kind','source','taskDisposition','autoRetryable','terminalFailure','requirementKeys','requestedCapabilities','recoveryAction','blockFingerprint','blockedAt','holdKind','grantPhase','grantConsumed','grantDecisionRevision','revocationReason']='{}'::jsonb + AND p_block->'schemaVersion'='2'::jsonb AND p_block->>'kind'='filesystem_grant' AND p_block->>'source'='filesystem-grant-approval' AND p_block->>'taskDisposition'='operator_hold' AND p_block->'autoRetryable'='false'::jsonb AND p_block->'terminalFailure'='false'::jsonb + AND forge_is_canonical_bounded_string_set(p_block->'requirementKeys',256,240) AND forge_is_canonical_bounded_string_set(p_block->'requestedCapabilities',3,240) AND forge_is_canonical_filesystem_capability_set(p_block->'requestedCapabilities') + AND p_block->>'recoveryAction'='approve_project_filesystem_context' AND (p_block->>'blockFingerprint') ~ '^sha256:[0-9a-f]{64}$' AND forge_is_canonical_utc_timestamp(p_block->>'blockedAt') + AND ((p_block->>'holdKind'='approval_required' AND p_block->>'grantPhase' IN ('none','proposed','not_issued') AND p_block->'grantConsumed'='false'::jsonb AND p_block->'grantDecisionRevision'='null'::jsonb AND p_block->'revocationReason'='null'::jsonb) OR (p_block->>'holdKind'='denied_required' AND p_block->>'grantPhase'='denied' AND p_block->'grantConsumed'='false'::jsonb AND (p_block->'grantDecisionRevision'='null'::jsonb OR (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$') AND p_block->'revocationReason'='null'::jsonb) OR (p_block->>'holdKind'='revoked_required' AND p_block->>'grantPhase'='revoked' AND p_block->'grantConsumed'='false'::jsonb AND (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' AND p_block->>'revocationReason' IN ('project_grant_removed','project_grant_narrowed','project_root_repoint')) OR (p_block->>'holdKind'='consumed_once' AND p_block->>'grantPhase'='approved' AND p_block->'grantConsumed'='true'::jsonb AND (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' AND p_block->'revocationReason'='null'::jsonb)) + ) IS TRUE +$$; -- JSON remains retained as data but is not S3 recovery authority; every -- version-2 writer is constrained to exact S3 keys, bounds, and blocked status. ALTER TABLE "work_packages" From 4426077572e510319cd1783b55066880fab03f49 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:09:14 +0800 Subject: [PATCH 21/69] fix: qualify root reconciliation SQL references --- .../project-root-reconciliation.test.ts | 8 ++++ .../0027_epic_172_s4_packet_context.sql | 44 +++++++++---------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 4a49de10..3417e79d 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -59,6 +59,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('operation_row.through_generation = p_through_generation') expect(migration).toContain('project-root completion compare-and-set failed') expect(migration).toContain('project-root generation already has an immutable outcome') + const claimRoutine = migration.slice( + migration.indexOf('CREATE OR REPLACE FUNCTION forge.claim_project_root_reconciliation_batch_v1'), + migration.indexOf('CREATE TABLE public.project_root_reconciliation_write_contexts'), + ) + expect(claimRoutine).toContain('coalesce(max(journal_row.generation), 0)') + expect(claimRoutine).toContain('FROM public.project_root_change_journal AS journal_row') + expect(claimRoutine).not.toContain('coalesce(max(generation), 0)') + expect(claimRoutine).toContain('SELECT journal_row.generation, journal_row.project_id, journal_row.outcome, journal_row.root_binding_revision, journal_row.grant_decision_revision') expect(migration).toContain('forge.materialize_project_root_ref_expansion_v1') expect(migration).toContain('CREATE TABLE public.project_root_reconciliation_write_contexts') expect(migration).toContain('backend_pid integer NOT NULL') diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 46dedd93..5f2c6ac3 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -645,21 +645,21 @@ DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; BEGIN PERFORM forge.assert_project_root_reconciler_v1(); IF p_batch_size NOT BETWEEN 1 AND 100 THEN RAISE EXCEPTION 'project-root reconciliation batch must be 1 through 100' USING ERRCODE = '22023'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations - WHERE operation_id = p_operation_id; - IF (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) <> v_operation.through_generation - OR (SELECT coalesce(max(generation), 0) FROM public.project_root_change_journal) <> v_operation.through_generation - OR (SELECT count(*) FROM public.project_root_change_journal) <> v_operation.through_generation THEN + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.operation_id = p_operation_id; + IF (SELECT counter_row.last_generation FROM public.project_root_change_journal_counter AS counter_row WHERE counter_row.singleton) <> v_operation.through_generation + OR (SELECT coalesce(max(journal_row.generation), 0) FROM public.project_root_change_journal AS journal_row) <> v_operation.through_generation + OR (SELECT count(*) FROM public.project_root_change_journal AS journal_row) <> v_operation.through_generation THEN RAISE EXCEPTION 'project-root journal changed before batch claim' USING ERRCODE = '55000'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations - WHERE operation_id = p_operation_id FOR UPDATE; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row + WHERE operation_row.operation_id = p_operation_id FOR UPDATE; IF v_operation.actor_id <> p_actor_id OR v_operation.state <> 'running' THEN RAISE EXCEPTION 'project-root operation is not claimable by this actor' USING ERRCODE = '42501'; END IF; RETURN QUERY - SELECT journal.generation, journal.project_id, journal.outcome, journal.root_binding_revision, journal.grant_decision_revision - FROM public.project_root_change_journal journal - WHERE journal.generation > v_operation.last_processed_generation AND journal.generation <= v_operation.through_generation - ORDER BY journal.generation LIMIT p_batch_size FOR UPDATE; + SELECT journal_row.generation, journal_row.project_id, journal_row.outcome, journal_row.root_binding_revision, journal_row.grant_decision_revision + FROM public.project_root_change_journal AS journal_row + WHERE journal_row.generation > v_operation.last_processed_generation AND journal_row.generation <= v_operation.through_generation + ORDER BY journal_row.generation LIMIT p_batch_size FOR UPDATE; END; $$; --> statement-breakpoint @@ -774,36 +774,36 @@ DECLARE v_operation public.project_root_reconciliation_operations%ROWTYPE; v_jou BEGIN PERFORM forge.assert_project_root_reconciler_v1(); IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context WHERE context.operation_id=p_operation_id AND context.generation=p_generation AND context.actor_id=p_actor_id AND context.project_id=p_project_id AND context.backend_pid=pg_catalog.pg_backend_pid() AND context.transaction_id=pg_catalog.txid_current() AND context.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root write context is absent or stale' USING ERRCODE='42501'; END IF; - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE operation_id = p_operation_id; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row WHERE operation_row.operation_id = p_operation_id; PERFORM forge.assert_project_root_journal_window_v1(v_operation.through_generation); - SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations WHERE operation_id = p_operation_id FOR UPDATE; + SELECT * INTO STRICT v_operation FROM public.project_root_reconciliation_operations AS operation_row WHERE operation_row.operation_id = p_operation_id FOR UPDATE; IF v_operation.actor_id <> p_actor_id OR v_operation.state <> 'running' OR p_generation <> v_operation.last_processed_generation + 1 THEN RAISE EXCEPTION 'project-root completion compare-and-set failed' USING ERRCODE = '40001'; END IF; - SELECT * INTO STRICT v_journal FROM public.project_root_change_journal WHERE generation = p_generation FOR UPDATE; + SELECT * INTO STRICT v_journal FROM public.project_root_change_journal AS journal_row WHERE journal_row.generation = p_generation FOR UPDATE; IF v_journal.project_id <> p_project_id OR v_journal.outcome <> p_outcome OR p_generation > v_operation.through_generation THEN RAISE EXCEPTION 'project-root journal outcome changed or is incoherent' USING ERRCODE = '22023'; END IF; - IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE generation = p_generation) THEN + IF EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes AS outcome_row WHERE outcome_row.generation = p_generation) THEN RAISE EXCEPTION 'project-root generation already has an immutable outcome' USING ERRCODE = '23505'; END IF; INSERT INTO public.project_root_reconciliation_outcomes(generation, operation_id, actor_id, project_id, outcome) VALUES (p_generation, p_operation_id, p_actor_id, p_project_id, p_outcome); - UPDATE public.project_root_reconciliation_operations + UPDATE public.project_root_reconciliation_operations AS operation_row SET last_processed_generation = p_generation, last_project_id = p_project_id, - batch_count = batch_count + 1, cumulative_count = cumulative_count + 1, - state = CASE WHEN p_generation = through_generation THEN 'complete' ELSE 'running' END, - completed_at = CASE WHEN p_generation = through_generation THEN pg_catalog.clock_timestamp() ELSE NULL END, + batch_count = operation_row.batch_count + 1, cumulative_count = operation_row.cumulative_count + 1, + state = CASE WHEN p_generation = operation_row.through_generation THEN 'complete' ELSE 'running' END, + completed_at = CASE WHEN p_generation = operation_row.through_generation THEN pg_catalog.clock_timestamp() ELSE NULL END, updated_at = pg_catalog.clock_timestamp() - WHERE operation_id = p_operation_id RETURNING * INTO v_operation; + WHERE operation_row.operation_id = p_operation_id RETURNING operation_row.* INTO v_operation; INSERT INTO public.project_root_reconciliation_checkpoints( operation_id, checkpoint_generation, actor_id, through_generation, last_processed_generation, last_project_id, batch_count, cumulative_count, state ) VALUES (v_operation.operation_id, v_operation.last_processed_generation, v_operation.actor_id, v_operation.through_generation, v_operation.last_processed_generation, v_operation.last_project_id, v_operation.batch_count, v_operation.cumulative_count, v_operation.state); - UPDATE public.project_root_reconciliation_write_contexts SET completed_at=pg_catalog.clock_timestamp() - WHERE operation_id=p_operation_id AND generation=p_generation AND backend_pid=pg_catalog.pg_backend_pid() AND transaction_id=pg_catalog.txid_current() AND completed_at IS NULL; + UPDATE public.project_root_reconciliation_write_contexts AS context_row SET completed_at=pg_catalog.clock_timestamp() + WHERE context_row.operation_id=p_operation_id AND context_row.generation=p_generation AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL; RETURN QUERY SELECT v_operation.state, v_operation.last_processed_generation; END; $$; From 71d398e834efadc16f69712951646d48b649c076 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:11:15 +0800 Subject: [PATCH 22/69] refactor: use filesystem grant block validator --- .../0026_epic_172_s3_grant_lifecycle.sql | 69 +------------------ 1 file changed, 1 insertion(+), 68 deletions(-) diff --git a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql index 44b8b5fa..824c5050 100644 --- a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql +++ b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql @@ -431,74 +431,7 @@ ALTER TABLE "work_packages" CHECK ( NOT ("metadata" ? 'mcpGrantBlock') OR COALESCE("metadata"->'mcpGrantBlock'->>'schemaVersion', '') <> '2' - OR ( - ( - jsonb_typeof("metadata"->'mcpGrantBlock') = 'object' - AND "status" = 'blocked' - AND ("metadata"->'mcpGrantBlock') ?& ARRAY[ - 'schemaVersion','kind','source','taskDisposition','autoRetryable', - 'terminalFailure','requirementKeys','requestedCapabilities', - 'recoveryAction','blockFingerprint','blockedAt','holdKind', - 'grantPhase','grantConsumed','grantDecisionRevision','revocationReason' - ] - AND ("metadata"->'mcpGrantBlock') - ARRAY[ - 'schemaVersion','kind','source','taskDisposition','autoRetryable', - 'terminalFailure','requirementKeys','requestedCapabilities', - 'recoveryAction','blockFingerprint','blockedAt','holdKind', - 'grantPhase','grantConsumed','grantDecisionRevision','revocationReason' - ] = '{}'::jsonb - AND "metadata"->'mcpGrantBlock'->'schemaVersion' = '2'::jsonb - AND "metadata"->'mcpGrantBlock'->>'kind' = 'filesystem_grant' - AND "metadata"->'mcpGrantBlock'->>'source' = 'filesystem-grant-approval' - AND "metadata"->'mcpGrantBlock'->>'taskDisposition' = 'operator_hold' - AND "metadata"->'mcpGrantBlock'->'autoRetryable' = 'false'::jsonb - AND "metadata"->'mcpGrantBlock'->'terminalFailure' = 'false'::jsonb - AND forge_is_canonical_bounded_string_set( - "metadata"->'mcpGrantBlock'->'requirementKeys', 256, 240 - ) - AND forge_is_canonical_bounded_string_set( - "metadata"->'mcpGrantBlock'->'requestedCapabilities', 3, 240 - ) - AND forge_is_canonical_filesystem_capability_set( - "metadata"->'mcpGrantBlock'->'requestedCapabilities' - ) - AND "metadata"->'mcpGrantBlock'->>'recoveryAction' = 'approve_project_filesystem_context' - AND ("metadata"->'mcpGrantBlock'->>'blockFingerprint') ~ '^sha256:[0-9a-f]{64}$' - AND forge_is_canonical_utc_timestamp("metadata"->'mcpGrantBlock'->>'blockedAt') - AND ( - ( - "metadata"->'mcpGrantBlock'->>'holdKind' = 'approval_required' - AND "metadata"->'mcpGrantBlock'->>'grantPhase' IN ('none','proposed','not_issued') - AND "metadata"->'mcpGrantBlock'->'grantConsumed' = 'false'::jsonb - AND "metadata"->'mcpGrantBlock'->'grantDecisionRevision' = 'null'::jsonb - AND "metadata"->'mcpGrantBlock'->'revocationReason' = 'null'::jsonb - ) OR ( - "metadata"->'mcpGrantBlock'->>'holdKind' = 'denied_required' - AND "metadata"->'mcpGrantBlock'->>'grantPhase' = 'denied' - AND "metadata"->'mcpGrantBlock'->'grantConsumed' = 'false'::jsonb - AND ( - "metadata"->'mcpGrantBlock'->'grantDecisionRevision' = 'null'::jsonb - OR ("metadata"->'mcpGrantBlock'->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' - ) - AND "metadata"->'mcpGrantBlock'->'revocationReason' = 'null'::jsonb - ) OR ( - "metadata"->'mcpGrantBlock'->>'holdKind' = 'revoked_required' - AND "metadata"->'mcpGrantBlock'->>'grantPhase' = 'revoked' - AND "metadata"->'mcpGrantBlock'->'grantConsumed' = 'false'::jsonb - AND ("metadata"->'mcpGrantBlock'->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' - AND "metadata"->'mcpGrantBlock'->>'revocationReason' IN ( - 'project_grant_removed','project_grant_narrowed','project_root_repoint' - ) - ) OR ( - "metadata"->'mcpGrantBlock'->>'holdKind' = 'consumed_once' - AND "metadata"->'mcpGrantBlock'->>'grantPhase' = 'approved' - AND "metadata"->'mcpGrantBlock'->'grantConsumed' = 'true'::jsonb - AND ("metadata"->'mcpGrantBlock'->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' - AND "metadata"->'mcpGrantBlock'->'revocationReason' = 'null'::jsonb - ) - ) - ) IS TRUE - ) + OR ("status" = 'blocked' AND forge_is_canonical_filesystem_grant_block_v2("metadata"->'mcpGrantBlock') IS TRUE) ); --> statement-breakpoint -- The release ledger is owned by a separate NOLOGIN role. The administrator- From 6259c7933c27a541804a2564d52251156230d8cc Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:12:53 +0800 Subject: [PATCH 23/69] feat: fence root reconciler package updates --- .../project-root-reconciliation.test.ts | 3 +++ .../0027_epic_172_s4_packet_context.sql | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 3417e79d..ebd7dcb0 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -85,6 +85,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(migration).toContain('project_root_reconciler_task_update_guard_v1') expect(migration).toContain("OLD.status NOT IN ('running','failed') OR NEW.status <> 'approved'") expect(migration).toContain('project-root task update has no active write context') + expect(migration).toContain('project_root_reconciler_package_update_guard_v1') + expect(migration).toContain('forge_is_canonical_filesystem_grant_block_v2(v_new_marker)') + expect(migration).toContain('project-root package marker removal is not canonical') expect(migration.indexOf('project_root_reconciliation_write_contexts_commit_v1')).toBeLessThan(migration.indexOf('ALTER TABLE public.project_root_reconciliation_write_contexts OWNER TO forge_s4_routines_owner')) expect(migration).toContain('ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid) OWNER TO forge_s4_routines_owner') expect(migration.indexOf('GRANT EXECUTE ON FUNCTION forge.enter_project_root_reconciliation_generation_v1')).toBeLessThan(migration.indexOf('ALTER FUNCTION forge.enter_project_root_reconciliation_generation_v1')) diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 5f2c6ac3..1e31885d 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -764,6 +764,23 @@ REVOKE ALL ON FUNCTION forge.guard_project_root_reconciler_task_update_v1() FROM CREATE TRIGGER project_root_reconciler_task_update_guard_v1 BEFORE UPDATE ON public.tasks FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_reconciler_task_update_v1(); ALTER FUNCTION forge.guard_project_root_reconciler_task_update_v1() OWNER TO forge_s4_routines_owner; +CREATE OR REPLACE FUNCTION forge.guard_project_root_reconciler_package_update_v1() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ +DECLARE v_old_marker jsonb := OLD.metadata->'mcpGrantBlock'; v_new_marker jsonb := NEW.metadata->'mcpGrantBlock'; +BEGIN + IF session_user <> 'forge_project_root_reconciler' THEN RETURN NEW; END IF; + IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context_row JOIN public.tasks task_row ON task_row.project_id=context_row.project_id WHERE task_row.id=OLD.task_id AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root package update has no active write context' USING ERRCODE='42501'; END IF; + IF (to_jsonb(NEW)-ARRAY['status','blocked_reason','metadata','updated_at']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','blocked_reason','metadata','updated_at']) OR (NEW.metadata-'mcpGrantBlock') IS DISTINCT FROM (OLD.metadata-'mcpGrantBlock') THEN RAISE EXCEPTION 'project-root package update changed protected fields' USING ERRCODE='42501'; END IF; + IF v_new_marker IS NOT NULL THEN + IF NOT forge_is_canonical_filesystem_grant_block_v2(v_new_marker) OR (v_old_marker IS NOT NULL AND NOT forge_is_canonical_filesystem_grant_block_v2(v_old_marker)) OR NEW.status <> 'blocked' OR NEW.blocked_reason <> 'Filesystem context requires an operator decision before execution.' THEN RAISE EXCEPTION 'project-root package marker is not canonical' USING ERRCODE='42501'; END IF; + ELSIF v_old_marker IS NOT NULL THEN + IF NOT forge_is_canonical_filesystem_grant_block_v2(v_old_marker) OR OLD.status NOT IN ('blocked','failed') OR NEW.status <> 'ready' OR NEW.blocked_reason IS NOT NULL THEN RAISE EXCEPTION 'project-root package marker removal is not canonical' USING ERRCODE='42501'; END IF; + ELSE RAISE EXCEPTION 'project-root package update must change a canonical marker' USING ERRCODE='42501'; END IF; + NEW.updated_at := pg_catalog.transaction_timestamp(); RETURN NEW; +END; $$; +REVOKE ALL ON FUNCTION forge.guard_project_root_reconciler_package_update_v1() FROM PUBLIC; +CREATE TRIGGER project_root_reconciler_package_update_guard_v1 BEFORE UPDATE ON public.work_packages FOR EACH ROW EXECUTE FUNCTION forge.guard_project_root_reconciler_package_update_v1(); +ALTER FUNCTION forge.guard_project_root_reconciler_package_update_v1() OWNER TO forge_s4_routines_owner; CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid, p_outcome text) RETURNS TABLE(state text, last_processed_generation bigint) LANGUAGE plpgsql From 5238c2ae838d33f75d34906c198ed188abf1e182 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:17:01 +0800 Subject: [PATCH 24/69] fix: qualify filesystem grant validators --- web/__tests__/project-root-reconciliation.test.ts | 7 +++++++ web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql | 8 ++++---- web/db/migrations/0027_epic_172_s4_packet_context.sql | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ebd7dcb0..e859d966 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -21,6 +21,13 @@ describe('project-root expansion reconciliation boundary', () => { expect(s3Migration).toContain('CREATE FUNCTION forge_is_canonical_filesystem_grant_block_v2(p_block jsonb)') expect(s3Migration).toContain('RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE') expect(s3Migration).toContain("'project_grant_removed','project_grant_narrowed','project_root_repoint'") + expect(s3Migration).toContain('public.forge_is_canonical_bounded_string_set(p_block->\'requirementKeys\',256,240)') + expect(s3Migration).toContain('public.forge_is_canonical_filesystem_capability_set(p_block->\'requestedCapabilities\')') + expect(s3Migration).toContain("public.forge_is_canonical_utc_timestamp(p_block->>'blockedAt')") + expect(s3Migration).toContain('CHECK (public.forge_is_canonical_filesystem_capability_set("capabilities"))') + expect(s3Migration).toContain('public.forge_is_canonical_filesystem_grant_block_v2("metadata"->\'mcpGrantBlock\')') + expect(migration).toContain('public.forge_is_canonical_filesystem_grant_block_v2(v_new_marker)') + expect(migration).toContain('public.forge_is_canonical_filesystem_grant_block_v2(v_old_marker)') }) it('parses only the literal actor/watermark/apply command and keeps dry-run actionless', () => { const actor = '123e4567-e89b-42d3-a456-426614174000' diff --git a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql index 824c5050..6caa27b3 100644 --- a/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql +++ b/web/db/migrations/0026_epic_172_s3_grant_lifecycle.sql @@ -288,7 +288,7 @@ CREATE TABLE "project_filesystem_grant_decisions" ( CONSTRAINT "project_filesystem_grant_decisions_fingerprint_check" CHECK ("decision_fingerprint" ~ '^sha256:[0-9a-f]{64}$'), CONSTRAINT "project_filesystem_grant_decisions_capabilities_check" - CHECK (forge_is_canonical_filesystem_capability_set("capabilities")), + CHECK (public.forge_is_canonical_filesystem_capability_set("capabilities")), CONSTRAINT "project_filesystem_grant_decisions_prior_tuple_check" CHECK ( ( @@ -419,8 +419,8 @@ RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ AND p_block ?& ARRAY['schemaVersion','kind','source','taskDisposition','autoRetryable','terminalFailure','requirementKeys','requestedCapabilities','recoveryAction','blockFingerprint','blockedAt','holdKind','grantPhase','grantConsumed','grantDecisionRevision','revocationReason'] AND p_block - ARRAY['schemaVersion','kind','source','taskDisposition','autoRetryable','terminalFailure','requirementKeys','requestedCapabilities','recoveryAction','blockFingerprint','blockedAt','holdKind','grantPhase','grantConsumed','grantDecisionRevision','revocationReason']='{}'::jsonb AND p_block->'schemaVersion'='2'::jsonb AND p_block->>'kind'='filesystem_grant' AND p_block->>'source'='filesystem-grant-approval' AND p_block->>'taskDisposition'='operator_hold' AND p_block->'autoRetryable'='false'::jsonb AND p_block->'terminalFailure'='false'::jsonb - AND forge_is_canonical_bounded_string_set(p_block->'requirementKeys',256,240) AND forge_is_canonical_bounded_string_set(p_block->'requestedCapabilities',3,240) AND forge_is_canonical_filesystem_capability_set(p_block->'requestedCapabilities') - AND p_block->>'recoveryAction'='approve_project_filesystem_context' AND (p_block->>'blockFingerprint') ~ '^sha256:[0-9a-f]{64}$' AND forge_is_canonical_utc_timestamp(p_block->>'blockedAt') + AND public.forge_is_canonical_bounded_string_set(p_block->'requirementKeys',256,240) AND public.forge_is_canonical_bounded_string_set(p_block->'requestedCapabilities',3,240) AND public.forge_is_canonical_filesystem_capability_set(p_block->'requestedCapabilities') + AND p_block->>'recoveryAction'='approve_project_filesystem_context' AND (p_block->>'blockFingerprint') ~ '^sha256:[0-9a-f]{64}$' AND public.forge_is_canonical_utc_timestamp(p_block->>'blockedAt') AND ((p_block->>'holdKind'='approval_required' AND p_block->>'grantPhase' IN ('none','proposed','not_issued') AND p_block->'grantConsumed'='false'::jsonb AND p_block->'grantDecisionRevision'='null'::jsonb AND p_block->'revocationReason'='null'::jsonb) OR (p_block->>'holdKind'='denied_required' AND p_block->>'grantPhase'='denied' AND p_block->'grantConsumed'='false'::jsonb AND (p_block->'grantDecisionRevision'='null'::jsonb OR (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$') AND p_block->'revocationReason'='null'::jsonb) OR (p_block->>'holdKind'='revoked_required' AND p_block->>'grantPhase'='revoked' AND p_block->'grantConsumed'='false'::jsonb AND (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' AND p_block->>'revocationReason' IN ('project_grant_removed','project_grant_narrowed','project_root_repoint')) OR (p_block->>'holdKind'='consumed_once' AND p_block->>'grantPhase'='approved' AND p_block->'grantConsumed'='true'::jsonb AND (p_block->>'grantDecisionRevision') ~ '^[1-9][0-9]*$' AND p_block->'revocationReason'='null'::jsonb)) ) IS TRUE $$; @@ -431,7 +431,7 @@ ALTER TABLE "work_packages" CHECK ( NOT ("metadata" ? 'mcpGrantBlock') OR COALESCE("metadata"->'mcpGrantBlock'->>'schemaVersion', '') <> '2' - OR ("status" = 'blocked' AND forge_is_canonical_filesystem_grant_block_v2("metadata"->'mcpGrantBlock') IS TRUE) + OR ("status" = 'blocked' AND public.forge_is_canonical_filesystem_grant_block_v2("metadata"->'mcpGrantBlock') IS TRUE) ); --> statement-breakpoint -- The release ledger is owned by a separate NOLOGIN role. The administrator- diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 1e31885d..84d88e5a 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -772,9 +772,9 @@ BEGIN IF NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts context_row JOIN public.tasks task_row ON task_row.project_id=context_row.project_id WHERE task_row.id=OLD.task_id AND context_row.backend_pid=pg_catalog.pg_backend_pid() AND context_row.transaction_id=pg_catalog.txid_current() AND context_row.completed_at IS NULL) THEN RAISE EXCEPTION 'project-root package update has no active write context' USING ERRCODE='42501'; END IF; IF (to_jsonb(NEW)-ARRAY['status','blocked_reason','metadata','updated_at']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','blocked_reason','metadata','updated_at']) OR (NEW.metadata-'mcpGrantBlock') IS DISTINCT FROM (OLD.metadata-'mcpGrantBlock') THEN RAISE EXCEPTION 'project-root package update changed protected fields' USING ERRCODE='42501'; END IF; IF v_new_marker IS NOT NULL THEN - IF NOT forge_is_canonical_filesystem_grant_block_v2(v_new_marker) OR (v_old_marker IS NOT NULL AND NOT forge_is_canonical_filesystem_grant_block_v2(v_old_marker)) OR NEW.status <> 'blocked' OR NEW.blocked_reason <> 'Filesystem context requires an operator decision before execution.' THEN RAISE EXCEPTION 'project-root package marker is not canonical' USING ERRCODE='42501'; END IF; + IF NOT public.forge_is_canonical_filesystem_grant_block_v2(v_new_marker) OR (v_old_marker IS NOT NULL AND NOT public.forge_is_canonical_filesystem_grant_block_v2(v_old_marker)) OR NEW.status <> 'blocked' OR NEW.blocked_reason <> 'Filesystem context requires an operator decision before execution.' THEN RAISE EXCEPTION 'project-root package marker is not canonical' USING ERRCODE='42501'; END IF; ELSIF v_old_marker IS NOT NULL THEN - IF NOT forge_is_canonical_filesystem_grant_block_v2(v_old_marker) OR OLD.status NOT IN ('blocked','failed') OR NEW.status <> 'ready' OR NEW.blocked_reason IS NOT NULL THEN RAISE EXCEPTION 'project-root package marker removal is not canonical' USING ERRCODE='42501'; END IF; + IF NOT public.forge_is_canonical_filesystem_grant_block_v2(v_old_marker) OR OLD.status NOT IN ('blocked','failed') OR NEW.status <> 'ready' OR NEW.blocked_reason IS NOT NULL THEN RAISE EXCEPTION 'project-root package marker removal is not canonical' USING ERRCODE='42501'; END IF; ELSE RAISE EXCEPTION 'project-root package update must change a canonical marker' USING ERRCODE='42501'; END IF; NEW.updated_at := pg_catalog.transaction_timestamp(); RETURN NEW; END; $$; From c6cbc72ac00c42c158b91bc12da8a3d6fb6fbe5e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:21:51 +0800 Subject: [PATCH 25/69] fix: stop root reconciliation after completion --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/scripts/reconcile-project-root-expansion.ts | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index e859d966..aab66c7f 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -134,6 +134,8 @@ describe('project-root expansion reconciliation boundary', () => { expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(reconcileScript).toContain('rootReconciliationContext:') expect(reconciliation).toContain('forge.lock_project_root_reconciliation_authority_v1') + expect(reconcileScript).toContain("completion.state === 'complete'") + expect(reconcileScript).toContain('Project-root completion returned an invalid state.') expect(reconciliation).toContain('input.rootReconciliationContext ? packageDecisionQuery : packageDecisionQuery.for(\'update\')') }) diff --git a/web/scripts/reconcile-project-root-expansion.ts b/web/scripts/reconcile-project-root-expansion.ts index 22ce4049..92c52a98 100644 --- a/web/scripts/reconcile-project-root-expansion.ts +++ b/web/scripts/reconcile-project-root-expansion.ts @@ -84,12 +84,18 @@ async function main(): Promise { trigger: 'project_root_repoint', }) } - await tx.execute(sql` + const completionRows = await tx.execute(sql` select * from forge.complete_project_root_reconciliation_generation_v1( ${operation.operationId}::uuid, ${command.actorId}::uuid, ${change.generation.toString()}::bigint, ${change.projectId}::uuid, ${change.outcome} ) `) + const completion = (completionRows as unknown as Array<{ state?: unknown; last_processed_generation?: unknown }>)[0] + if (!completion || (completion.state !== 'running' && completion.state !== 'complete') + || String(completion.last_processed_generation) !== change.generation.toString()) { + throw new Error('Project-root completion returned an invalid state.') + } + if (completion.state === 'complete') return { completed: true, processed: claimed.length } } return { completed: false, processed: claimed.length } }) From f25388b288676af5716d8317a131bc9e4d923573 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:24:52 +0800 Subject: [PATCH 26/69] test: make phase suppression assertion semantic --- web/__tests__/project-root-reconciliation.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index aab66c7f..7d27e859 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -129,7 +129,10 @@ describe('project-root expansion reconciliation boundary', () => { it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') - expect(reconciliation).toContain('input.suppressPhasePersistence ? undefined : grant') + const normalizedReconciliation = reconciliation.replace(/\s+/g, ' ') + expect(normalizedReconciliation).toMatch( + /const effective = input\.suppressPhasePersistence \? undefined : grant \? phasesWithEffective\(pkg\.metadata, projectFilesystemEffectivePhase\(grant\)\) : forcePersist \? currentPhases : undefined/, + ) expect(reconcileScript).not.toContain('--suppress') expect(reconcileScript).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(reconcileScript).toContain('rootReconciliationContext:') From f928d1c81fe67011621cdd60eab4cef12c06bce5 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:32:08 +0800 Subject: [PATCH 27/69] fix: escape cutover SQL dollar quotes --- web/__tests__/project-root-reconciliation.test.ts | 3 +++ web/scripts/ci/cutover-migration-0027-root-ref.sh | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 7d27e859..f8892ddf 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -147,6 +147,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(indexScript).toContain('FORGE_DATABASE_ADMIN_URL') expect(reconcileScript).not.toContain('FORGE_DATABASE_ADMIN_URL') expect(cutoverScript).toContain('--through --apply') + expect(cutoverScript).toContain('DO \\$cutover\\$') + expect(cutoverScript).toContain('\\$cutover\\$;') + expect(cutoverScript).toContain('DECLARE v_through bigint := ${through}::bigint;') expect(cutoverScript).toContain('project_root_reconciliation_operations') expect(cutoverScript).toContain('projects_root_ref_idx') expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 9fffbabd..7ca81b3e 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -14,7 +14,7 @@ fi psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 < 'disabled' THEN @@ -41,7 +41,7 @@ BEGIN -- Compatibility marker only; C6 authority is the exact operation/outcome set above. UPDATE public.project_root_ref_reconciliation SET state = 'complete', updated_at = pg_catalog.clock_timestamp() WHERE singleton; END; -$cutover$; +\$cutover\$; ALTER TABLE public.projects VALIDATE CONSTRAINT projects_root_ref_not_null_proof; ALTER TABLE public.projects ALTER COLUMN root_ref SET NOT NULL; COMMIT; From 22f0bf5909c55b04233e79f884160bee0b1aa7dd Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:41:55 +0800 Subject: [PATCH 28/69] fix: restore root index cutover proof sequence --- web/__tests__/project-root-reconciliation.test.ts | 9 +++++++++ web/scripts/ci/cutover-migration-0027-root-ref.sh | 2 +- web/scripts/ci/prove-migration-0027-upgrade.sh | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index f8892ddf..f9fb862c 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -13,6 +13,7 @@ const bootstrap = readFileSync(fileURLToPath(new URL('../scripts/bootstrap-epic- const reconcileScript = readFileSync(fileURLToPath(new URL('../scripts/reconcile-project-root-expansion.ts', import.meta.url)), 'utf8') const reconciliation = readFileSync(fileURLToPath(new URL('../lib/mcps/filesystem-grant-reconciliation.ts', import.meta.url)), 'utf8') const indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project-root-ref-index.ts', import.meta.url)), 'utf8') +const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-upgrade.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -152,6 +153,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(cutoverScript).toContain('DECLARE v_through bigint := ${through}::bigint;') expect(cutoverScript).toContain('project_root_reconciliation_operations') expect(cutoverScript).toContain('projects_root_ref_idx') + expect(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") + expect(cutoverScript).toContain('strict root-reference cutover requires a valid concurrent projects(root_ref) index') + expect(upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh')).toBeLessThan( + upgradeProof.indexOf('npm run project-roots:build-concurrent-index -- --apply'), + ) + expect(upgradeProof.indexOf('npm run project-roots:build-concurrent-index -- --apply')).toBeLessThan( + upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply'), + ) expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') expect(webCi).toContain('Capture the post-drain root journal watermark') }) diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 7ca81b3e..6caa585e 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -33,7 +33,7 @@ BEGIN END IF; IF NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_index index_row - WHERE index_row.indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass AND index_row.indisvalid + WHERE index_row.indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND index_row.indisvalid ) THEN RAISE EXCEPTION 'strict root-reference cutover requires a valid concurrent projects(root_ref) index' USING ERRCODE = '55000'; END IF; IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint WHERE conrelid = 'public.projects'::regclass AND conname = 'projects_root_ref_not_null_proof') THEN ALTER TABLE public.projects ADD CONSTRAINT projects_root_ref_not_null_proof CHECK (root_ref IS NOT NULL) NOT VALID; diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 55d6a39b..6ae4d17d 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -56,6 +56,7 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != fi bash scripts/ci/reconcile-migration-0027-root-refs.sh +npm run project-roots:build-concurrent-index -- --apply bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-cutover-assertions.sql From 6e7614ecc1b888c06e8e74eda156f5b57c459ba2 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:48:50 +0800 Subject: [PATCH 29/69] fix: inventory root write contexts --- .github/workflows/web-ci.yml | 4 ++-- web/__tests__/project-root-reconciliation.test.ts | 1 + web/scripts/bootstrap-epic-172-s4-roles.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index fd82c3b3..c86ddb6e 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -303,7 +303,7 @@ jobs: 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', 'project_root_change_journal_counter', 'project_root_change_journal', 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', - 'project_root_reconciliation_outcomes' + 'project_root_reconciliation_outcomes', 'project_root_reconciliation_write_contexts' ] LOOP FOREACH table_privilege IN ARRAY ARRAY[ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER' @@ -371,7 +371,7 @@ jobs: 'filesystem_mcp_decision_nonce_claims', 'project_root_ref_reconciliation', 'project_root_change_journal_counter', 'project_root_change_journal', 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', - 'project_root_reconciliation_outcomes' + 'project_root_reconciliation_outcomes', 'project_root_reconciliation_write_contexts' ]; projection_tables constant text[] := ARRAY[ 'forge_epic_172_s3_release_state', 'work_package_local_projection_sources', diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index f9fb862c..ebbdfc60 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -44,6 +44,7 @@ describe('project-root expansion reconciliation boundary', () => { 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', 'project_root_reconciliation_outcomes', + 'project_root_reconciliation_write_contexts', ]) expect(migration).toContain(`CREATE TABLE public.${table}`) expect(migration).toContain('generation bigint PRIMARY KEY REFERENCES public.project_root_change_journal(generation)') expect(migration).toContain('project_root_reconciliation_checkpoints_append_only_v1') diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 4fdddb2b..5a4a539b 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -31,6 +31,7 @@ const OWNED_TABLES = [ 'project_root_reconciliation_operations', 'project_root_reconciliation_checkpoints', 'project_root_reconciliation_outcomes', + 'project_root_reconciliation_write_contexts', 's4_completion_handoffs', 's4_protected_review_sources', 's4_protected_review_source_reads', From 82a721067076f7ec3b8ee7a2e355e49bfdf45895 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:36:44 +0800 Subject: [PATCH 30/69] feat: mirror root write contexts in schema --- .../project-root-reconciliation.test.ts | 2 ++ web/db/schema.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ebbdfc60..c835be5e 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -56,6 +56,8 @@ describe('project-root expansion reconciliation boundary', () => { ) expect(reconciliationSchema).not.toMatch(/(?:local_path|root_ref|reason|jsonb)/i) expect(schema).toContain("export const projectRootReconciliationOperations") + expect(schema).toContain("export const projectRootReconciliationWriteContexts") + expect(schema).toContain("project_root_reconciliation_write_context_generation_unique") expect(schema).toContain("project_root_reconciliation_one_live_idx") expect(PROJECT_ROOT_RECONCILIATION_STATES).toEqual(['running', 'complete']) }) diff --git a/web/db/schema.ts b/web/db/schema.ts index 46c14e47..d0b0918d 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1902,6 +1902,23 @@ export const s4ProtectedReviewSources = pgTable('s4_protected_review_sources', { createdAt: timestamp('created_at', tsOpts).defaultNow().notNull(), }) +export const projectRootReconciliationWriteContexts = pgTable('project_root_reconciliation_write_contexts', { + operationId: uuid('operation_id').notNull().references(() => projectRootReconciliationOperations.operationId, { onDelete: 'restrict' }), + generation: bigint('generation', { mode: 'bigint' }).notNull().references(() => projectRootChangeJournal.generation, { onDelete: 'restrict' }), + actorId: uuid('actor_id').notNull(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'restrict' }), + backendPid: integer('backend_pid').notNull(), + transactionId: bigint('transaction_id', { mode: 'bigint' }).notNull(), + enteredAt: timestamp('entered_at', tsOpts).defaultNow().notNull(), + completedAt: timestamp('completed_at', tsOpts), +}, (t) => [ + primaryKey({ columns: [t.operationId, t.generation] }), + unique('project_root_reconciliation_write_context_generation_unique').on(t.generation), + check('project_root_reconciliation_write_context_backend_pid_chk', sql`${t.backendPid} > 0`), + check('project_root_reconciliation_write_context_transaction_id_chk', sql`${t.transactionId} > 0`), + check('project_root_reconciliation_write_context_shape_chk', sql`${t.completedAt} is null or ${t.completedAt} >= ${t.enteredAt}`), +]) + export const s4ProtectedReviewSourceReads = pgTable( 's4_protected_review_source_reads', { From 8a17609ebe61b7cc3f58a0b4a39da5df69a34eef Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:38:30 +0800 Subject: [PATCH 31/69] fix: recover root index builds safely --- web/scripts/build-project-root-ref-index.ts | 25 ++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts index 663edd95..7a13e11d 100644 --- a/web/scripts/build-project-root-ref-index.ts +++ b/web/scripts/build-project-root-ref-index.ts @@ -9,17 +9,26 @@ async function main(): Promise { if (!adminUrl) throw new Error('FORGE_DATABASE_ADMIN_URL is required for the short-lived concurrent DDL step.') const admin = postgres(adminUrl, { max: 1, onnotice: () => {} }) try { + await admin`select pg_catalog.pg_advisory_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))` + const inspect = async () => admin<{ exists: boolean; canonical: boolean; valid: boolean; ready: boolean }[]>` + select true as exists, idx.indisvalid as valid, idx.indisready as ready, + (idx.indisunique and idx.indisvalid and idx.indisready and idx.indnkeyatts=1 and idx.indnatts=1 + and pg_catalog.pg_get_indexdef(idx.indexrelid) = 'CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects USING btree (root_ref) WHERE (root_ref IS NOT NULL)') as canonical + from pg_catalog.pg_index idx where idx.indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass + ` + let [index] = await inspect() + if (index && !index.canonical && index.valid && index.ready) throw new Error('projects_root_ref_idx exists with a noncanonical definition.') + if (index && (!index.valid || !index.ready)) { + await admin.unsafe('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') + index = undefined + } // PostgreSQL rejects CONCURRENTLY inside a transaction. Keep this isolated // from reconciliation, whose dedicated login never receives this URL. - await admin.unsafe( - 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL', - ) - const [index] = await admin<{ valid: boolean }[]>` - select indisvalid as valid from pg_catalog.pg_index - where indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass - ` - if (!index?.valid) throw new Error('Concurrent projects(root_ref) index is not valid.') + if (!index) await admin.unsafe('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL') + ;[index] = await inspect() + if (!index?.canonical) throw new Error('Concurrent projects(root_ref) index is not exact and valid.') } finally { + await admin`select pg_catalog.pg_advisory_unlock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))`.catch(() => undefined) await admin.end({ timeout: 5 }) } } From e9d173e6f9c359cea37eefb9525a06775796fdb5 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:40:04 +0800 Subject: [PATCH 32/69] fix: require exact root index at cutover --- web/scripts/ci/cutover-migration-0027-root-ref.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 6caa585e..14e60d88 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -14,6 +14,7 @@ fi psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 01:42:51 +0800 Subject: [PATCH 33/69] test: prove root index recovery lifecycle --- ...ove-migration-0027-root-index-lifecycle.sh | 21 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 3 +++ 2 files changed, 24 insertions(+) create mode 100644 web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh diff --git a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh new file mode 100644 index 00000000..2d21385d --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +# The fixture deliberately makes the production concurrent unique build fail. +# PostgreSQL retains the same-name invalid index, giving the builder's recovery +# path a deterministic, non-timing-dependent input. +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 <<'SQL' +INSERT INTO public.projects (id, name, submitted_by, root_ref) +SELECT '27000000-0000-4000-8000-000000000050', 'Duplicate root index A', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; +INSERT INTO public.projects (id, name, submitted_by, root_ref) +SELECT '27000000-0000-4000-8000-000000000060', 'Duplicate root index B', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; +SQL +if npm run project-roots:build-concurrent-index -- --apply; then + echo 'Duplicate roots unexpectedly allowed the unique concurrent index build.' >&2; exit 1 +fi +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = 'public.projects_root_ref_idx'::regclass AND NOT indisvalid" >/dev/null +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "UPDATE public.projects SET root_ref = '27000000-0000-4000-8000-0000000000bb'::uuid WHERE id = '27000000-0000-4000-8000-000000000060'" +bash scripts/ci/reconcile-migration-0027-root-refs.sh +npm run project-roots:build-concurrent-index -- --apply +npm run project-roots:build-concurrent-index -- --apply diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 6ae4d17d..44bff453 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -56,6 +56,9 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != fi bash scripts/ci/reconcile-migration-0027-root-refs.sh +if [[ "${FORGE_ROOT_INDEX_LIFECYCLE_PROOF:-0}" == '1' ]]; then + bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh +fi npm run project-roots:build-concurrent-index -- --apply bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ From 3321a1f30e087b4e937f9a4408ce1187f906e208 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:47:13 +0800 Subject: [PATCH 34/69] fix: classify missing root index safely --- web/scripts/build-project-root-ref-index.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts index 7a13e11d..0f366260 100644 --- a/web/scripts/build-project-root-ref-index.ts +++ b/web/scripts/build-project-root-ref-index.ts @@ -11,20 +11,22 @@ async function main(): Promise { try { await admin`select pg_catalog.pg_advisory_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))` const inspect = async () => admin<{ exists: boolean; canonical: boolean; valid: boolean; ready: boolean }[]>` - select true as exists, idx.indisvalid as valid, idx.indisready as ready, - (idx.indisunique and idx.indisvalid and idx.indisready and idx.indnkeyatts=1 and idx.indnatts=1 - and pg_catalog.pg_get_indexdef(idx.indexrelid) = 'CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects USING btree (root_ref) WHERE (root_ref IS NOT NULL)') as canonical - from pg_catalog.pg_index idx where idx.indexrelid = 'public.projects_root_ref_idx'::pg_catalog.regclass + with candidate as (select pg_catalog.to_regclass('public.projects_root_ref_idx') as index_oid) + select (candidate.index_oid is not null) as exists, + coalesce(idx.indisvalid, false) as valid, coalesce(idx.indisready, false) as ready, + coalesce(idx.indisunique and idx.indisvalid and idx.indisready and idx.indnkeyatts=1 and idx.indnatts=1 + and pg_catalog.pg_get_indexdef(idx.indexrelid) = 'CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects USING btree (root_ref) WHERE (root_ref IS NOT NULL)', false) as canonical + from candidate left join pg_catalog.pg_index idx on idx.indexrelid = candidate.index_oid ` let [index] = await inspect() - if (index && !index.canonical && index.valid && index.ready) throw new Error('projects_root_ref_idx exists with a noncanonical definition.') - if (index && (!index.valid || !index.ready)) { + if (index?.exists && !index.canonical && index.valid && index.ready) throw new Error('projects_root_ref_idx exists with a noncanonical definition.') + if (index?.exists && (!index.valid || !index.ready)) { await admin.unsafe('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') index = undefined } // PostgreSQL rejects CONCURRENTLY inside a transaction. Keep this isolated // from reconciliation, whose dedicated login never receives this URL. - if (!index) await admin.unsafe('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL') + if (!index?.exists) await admin.unsafe('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(root_ref) WHERE root_ref IS NOT NULL') ;[index] = await inspect() if (!index?.canonical) throw new Error('Concurrent projects(root_ref) index is not exact and valid.') } finally { From 70384662f85a4c867b302d2106af731559fefd2c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:50:36 +0800 Subject: [PATCH 35/69] test: require root index lifecycle proof --- .../project-root-reconciliation.test.ts | 13 +++- ...ove-migration-0027-root-index-lifecycle.sh | 78 ++++++++++++++++++- .../ci/prove-migration-0027-upgrade.sh | 5 +- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index c835be5e..90ffa9bb 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -14,6 +14,7 @@ const reconcileScript = readFileSync(fileURLToPath(new URL('../scripts/reconcile const reconciliation = readFileSync(fileURLToPath(new URL('../lib/mcps/filesystem-grant-reconciliation.ts', import.meta.url)), 'utf8') const indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project-root-ref-index.ts', import.meta.url)), 'utf8') const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-upgrade.sh', import.meta.url)), 'utf8') +const indexLifecycleProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-index-lifecycle.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -159,11 +160,19 @@ describe('project-root expansion reconciliation boundary', () => { expect(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") expect(cutoverScript).toContain('strict root-reference cutover requires a valid concurrent projects(root_ref) index') expect(upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh')).toBeLessThan( - upgradeProof.indexOf('npm run project-roots:build-concurrent-index -- --apply'), + upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), ) - expect(upgradeProof.indexOf('npm run project-roots:build-concurrent-index -- --apply')).toBeLessThan( + expect(upgradeProof).not.toContain('FORGE_ROOT_INDEX_LIFECYCLE_PROOF') + expect(upgradeProof.match(/bash scripts\/ci\/prove-migration-0027-root-index-lifecycle\.sh/g)).toHaveLength(1) + expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh')).toBeLessThan( upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply'), ) + expect(indexLifecycleProof).toContain("expect_failure \"$canonical_index_refusal\"") + expect(indexLifecycleProof).toContain('assert_cutover_not_started') + expect(indexLifecycleProof).toContain('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL') + expect(indexLifecycleProof).toContain('projects_root_ref_idx exists with a noncanonical definition.') + expect(indexLifecycleProof).toContain('could not create unique index "projects_root_ref_idx"') + expect(indexLifecycleProof).toContain('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') expect(webCi).toContain('Capture the post-drain root journal watermark') }) diff --git a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh index 2d21385d..9b964fab 100644 --- a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -1,7 +1,72 @@ #!/usr/bin/env bash set -euo pipefail + : "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" +canonical_index_refusal='strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index' + +expect_failure() { + local expected_message="$1" + shift + local output + output="$(mktemp)" + if "$@" >"$output" 2>&1; then + cat "$output" >&2 + rm -f "$output" + echo "Expected command to fail: $*" >&2 + exit 1 + fi + if ! rg --fixed-strings --quiet "$expected_message" "$output"; then + cat "$output" >&2 + rm -f "$output" + echo "Expected failure message was absent: $expected_message" >&2 + exit 1 + fi + rm -f "$output" +} + +assert_cutover_not_started() { + local root_not_null proof_constraint + root_not_null="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT attnotnull FROM pg_catalog.pg_attribute WHERE attrelid = 'public.projects'::regclass AND attname = 'root_ref' AND NOT attisdropped")" + proof_constraint="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT count(*) FROM pg_catalog.pg_constraint WHERE conrelid = 'public.projects'::regclass AND conname = 'projects_root_ref_not_null_proof'")" + if [[ "$root_not_null" != 'f' || "$proof_constraint" != '0' ]]; then + echo 'Canonical-index refusal altered strict-cutover schema state.' >&2 + exit 1 + fi +} + +# Reconciliation has completed, so the missing-index refusal must happen before +# any concurrent DDL. This proves cutover itself is the boundary, not the builder. +expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply +assert_cutover_not_started + +# A valid but structurally wrong same-name index is never disposable production +# state. The production builder and cutover both refuse it without replacement. +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + 'CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL' +wrong_index_before="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ + "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" +if [[ -z "$wrong_index_before" ]]; then + echo 'Failed to create the deterministic wrong root-reference index.' >&2 + exit 1 +fi +expect_failure 'projects_root_ref_idx exists with a noncanonical definition.' \ + npm run project-roots:build-concurrent-index -- --apply +expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply +assert_cutover_not_started +wrong_index_after="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ + "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" +if [[ "$wrong_index_after" != "$wrong_index_before" ]]; then + echo 'The valid wrong root-reference index was replaced or changed.' >&2 + exit 1 +fi +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + 'DROP INDEX CONCURRENTLY public.projects_root_ref_idx' + # The fixture deliberately makes the production concurrent unique build fail. # PostgreSQL retains the same-name invalid index, giving the builder's recovery # path a deterministic, non-timing-dependent input. @@ -11,10 +76,17 @@ SELECT '27000000-0000-4000-8000-000000000050', 'Duplicate root index A', id, '27 INSERT INTO public.projects (id, name, submitted_by, root_ref) SELECT '27000000-0000-4000-8000-000000000060', 'Duplicate root index B', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; SQL -if npm run project-roots:build-concurrent-index -- --apply; then - echo 'Duplicate roots unexpectedly allowed the unique concurrent index build.' >&2; exit 1 +expect_failure 'could not create unique index "projects_root_ref_idx"' \ + npm run project-roots:build-concurrent-index -- --apply +invalid_index="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND (NOT indisvalid OR NOT indisready))")" +if [[ "$invalid_index" != 't' ]]; then + echo 'The failed concurrent build did not retain the expected invalid index.' >&2 + exit 1 fi -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = 'public.projects_root_ref_idx'::regclass AND NOT indisvalid" >/dev/null +expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply +assert_cutover_not_started psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "UPDATE public.projects SET root_ref = '27000000-0000-4000-8000-0000000000bb'::uuid WHERE id = '27000000-0000-4000-8000-000000000060'" bash scripts/ci/reconcile-migration-0027-root-refs.sh npm run project-roots:build-concurrent-index -- --apply diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 44bff453..60824b0f 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -56,10 +56,7 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != fi bash scripts/ci/reconcile-migration-0027-root-refs.sh -if [[ "${FORGE_ROOT_INDEX_LIFECYCLE_PROOF:-0}" == '1' ]]; then - bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh -fi -npm run project-roots:build-concurrent-index -- --apply +bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-cutover-assertions.sql From 560600f72f68e3023af6fdb3beec43c1ab72cc1c Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:53:40 +0800 Subject: [PATCH 36/69] test: seed root authority project fixture --- .../project-root-reconciliation.test.ts | 12 ++ ...on-0027-root-authority-project-fixture.sql | 106 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 web/scripts/ci/sql/migration-0027-root-authority-project-fixture.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 90ffa9bb..bf8185ea 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -15,6 +15,7 @@ const reconciliation = readFileSync(fileURLToPath(new URL('../lib/mcps/filesyste const indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project-root-ref-index.ts', import.meta.url)), 'utf8') const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-upgrade.sh', import.meta.url)), 'utf8') const indexLifecycleProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-index-lifecycle.sh', import.meta.url)), 'utf8') +const rootAuthorityProjectFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-project-fixture.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -131,6 +132,17 @@ describe('project-root expansion reconciliation boundary', () => { ]) expect(webCi).toContain(`'${table}'`) }) + it('keeps the admin-only root-authority project fixture self-validating and incomplete', () => { + expect(rootAuthorityProjectFixture).toContain('root authority fixture must not run as the reconciler login') + expect(rootAuthorityProjectFixture).toContain("'27000000-0000-4000-8000-000000000700'") + expect(rootAuthorityProjectFixture).toContain("'27000000-0000-4000-8000-000000000702'") + expect(rootAuthorityProjectFixture).toContain('"requiredMcps":["filesystem","github"]') + expect(rootAuthorityProjectFixture).toContain('project_filesystem_current_decision_pointers') + expect(rootAuthorityProjectFixture).toContain("'^sha256:[0-9a-f]{64}$'") + expect(rootAuthorityProjectFixture).toContain('duplicate or ambiguous current authority') + expect(rootAuthorityProjectFixture).not.toContain('reconcile-project-root-expansion.ts') + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/sql/migration-0027-root-authority-project-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-project-fixture.sql new file mode 100644 index 00000000..f200846b --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-authority-project-fixture.sql @@ -0,0 +1,106 @@ +\set ON_ERROR_STOP on + +-- Admin-only disposable-fixture arrangement for ROOT-R2A. This is deliberately +-- not a production authority path and remains incomplete until R2A1b/R2A2 add +-- packages and invoke the dedicated reconciler command. +DO $fixture$ +BEGIN + IF current_user = 'forge_project_root_reconciler' THEN + RAISE EXCEPTION 'root authority fixture must not run as the reconciler login'; + END IF; +END; +$fixture$; + +INSERT INTO public.projects ( + id, name, submitted_by, root_ref, root_binding_revision, + grant_decision_revision, mcp_config +) VALUES ( + '27000000-0000-4000-8000-000000000700', + 'Root authority reconciliation fixture', + '27000000-0000-4000-8000-000000000001', + '27000000-0000-4000-8000-000000000701', + 1, + 1, + '{"profile":"default","requiredMcps":["filesystem","github"],"overrides":{}}'::jsonb +); + +INSERT INTO public.project_filesystem_grant_decisions ( + id, project_id, decision, capabilities, grant_decision_revision, + root_binding_revision, decision_fingerprint, decision_generation, + decided_by, decided_at +) VALUES ( + '27000000-0000-4000-8000-000000000702', + '27000000-0000-4000-8000-000000000700', + 'approved', + '["filesystem.project.read"]'::jsonb, + 1, + 1, + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 1, + '27000000-0000-4000-8000-000000000001', + '2026-07-28T00:00:00.000Z'::timestamptz +); + +UPDATE public.project_filesystem_current_decision_pointers +SET + current_decision_id = '27000000-0000-4000-8000-000000000702', + current_decision_project_id = '27000000-0000-4000-8000-000000000700', + current_decision_revision = 1, + current_root_binding_revision = 1, + current_decision_fingerprint = + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + current_decision_generation = 1, + pointer_generation = 1 +WHERE project_id = '27000000-0000-4000-8000-000000000700'; + +DO $assertions$ +DECLARE + project_row public.projects%ROWTYPE; + decision_row public.project_filesystem_grant_decisions%ROWTYPE; + pointer_row public.project_filesystem_current_decision_pointers%ROWTYPE; +BEGIN + SELECT * INTO STRICT project_row + FROM public.projects + WHERE id = '27000000-0000-4000-8000-000000000700'; + SELECT * INTO STRICT decision_row + FROM public.project_filesystem_grant_decisions + WHERE id = '27000000-0000-4000-8000-000000000702'; + SELECT * INTO STRICT pointer_row + FROM public.project_filesystem_current_decision_pointers + WHERE project_id = project_row.id; + + IF project_row.submitted_by <> '27000000-0000-4000-8000-000000000001'::uuid + OR project_row.root_ref IS NULL + OR project_row.root_binding_revision <= 0 + OR project_row.grant_decision_revision <= 0 + OR project_row.mcp_config <> '{"profile":"default","requiredMcps":["filesystem","github"],"overrides":{}}'::jsonb + OR decision_row.project_id <> project_row.id + OR decision_row.decision <> 'approved' + OR decision_row.capabilities <> '["filesystem.project.read"]'::jsonb + OR decision_row.grant_decision_revision <> project_row.grant_decision_revision + OR decision_row.root_binding_revision <> project_row.root_binding_revision + OR decision_row.decision_generation <> 1 + OR decision_row.decision_fingerprint !~ '^sha256:[0-9a-f]{64}$' + OR pointer_row.current_decision_id <> decision_row.id + OR pointer_row.current_decision_project_id <> project_row.id + OR pointer_row.current_decision_revision <> decision_row.grant_decision_revision + OR pointer_row.current_root_binding_revision <> decision_row.root_binding_revision + OR pointer_row.current_decision_generation <> decision_row.decision_generation + OR pointer_row.current_decision_fingerprint <> decision_row.decision_fingerprint + OR pointer_row.current_decision_fingerprint !~ '^sha256:[0-9a-f]{64}$' + OR pointer_row.pointer_generation <> decision_row.decision_generation + THEN + RAISE EXCEPTION 'root authority project fixture has invalid project decision or current pointer parentage'; + END IF; + + IF (SELECT count(*) FROM public.project_filesystem_grant_decisions + WHERE project_id = project_row.id) <> 1 + OR (SELECT count(*) FROM public.project_filesystem_current_decision_pointers + WHERE project_id = project_row.id) <> 1 + OR (SELECT count(*) FROM public.project_filesystem_current_decision_pointers + WHERE project_id = project_row.id AND current_decision_id IS NOT NULL) <> 1 + THEN + RAISE EXCEPTION 'root authority project fixture has duplicate or ambiguous current authority'; + END IF; +END; +$assertions$; From 1742838e37457cfa7264c8920d64a0cc82271518 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:54:54 +0800 Subject: [PATCH 37/69] test: use portable index proof matcher --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index bf8185ea..e341f0e5 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -180,6 +180,8 @@ describe('project-root expansion reconciliation boundary', () => { upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply'), ) expect(indexLifecycleProof).toContain("expect_failure \"$canonical_index_refusal\"") + expect(indexLifecycleProof).toContain('grep -F -- "$expected_message" "$output" >/dev/null') + expect(indexLifecycleProof).not.toContain('rg --fixed-strings') expect(indexLifecycleProof).toContain('assert_cutover_not_started') expect(indexLifecycleProof).toContain('CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL') expect(indexLifecycleProof).toContain('projects_root_ref_idx exists with a noncanonical definition.') diff --git a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh index 9b964fab..f058288f 100644 --- a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -16,7 +16,7 @@ expect_failure() { echo "Expected command to fail: $*" >&2 exit 1 fi - if ! rg --fixed-strings --quiet "$expected_message" "$output"; then + if ! grep -F -- "$expected_message" "$output" >/dev/null; then cat "$output" >&2 rm -f "$output" echo "Expected failure message was absent: $expected_message" >&2 From c2cf6b2975929cf0dd08c34e04ace312a8135a48 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:59:13 +0800 Subject: [PATCH 38/69] test: seed root authority package fixture --- .../project-root-reconciliation.test.ts | 15 ++ ...on-0027-root-authority-package-fixture.sql | 200 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index e341f0e5..62f51ebd 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -16,6 +16,7 @@ const indexScript = readFileSync(fileURLToPath(new URL('../scripts/build-project const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-upgrade.sh', import.meta.url)), 'utf8') const indexLifecycleProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-index-lifecycle.sh', import.meta.url)), 'utf8') const rootAuthorityProjectFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-project-fixture.sql', import.meta.url)), 'utf8') +const rootAuthorityPackageFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-package-fixture.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -143,6 +144,20 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityProjectFixture).not.toContain('reconcile-project-root-expansion.ts') }) + it('keeps the admin-only root-authority package fixture bounded to seed state', () => { + expect(rootAuthorityPackageFixture).toContain('requires the exact R2A1a project authority') + expect(rootAuthorityPackageFixture).toContain("'27000000-0000-4000-8000-000000000711'") + expect(rootAuthorityPackageFixture).toContain("'27000000-0000-4000-8000-000000000712'") + expect(rootAuthorityPackageFixture).toContain("'27000000-0000-4000-8000-000000000721'") + expect(rootAuthorityPackageFixture).toContain('public.forge_is_canonical_filesystem_grant_block_v2') + expect(rootAuthorityPackageFixture).toContain('mcpGrantPhases') + expect(rootAuthorityPackageFixture).toContain("metadata - 'mcpGrantBlock'") + expect(rootAuthorityPackageFixture).toContain('count(DISTINCT metadata->\'mcpGrantBlock\'->>\'blockFingerprint\')') + expect(rootAuthorityPackageFixture).toContain("head_kind = 'operator_hold'") + expect(rootAuthorityPackageFixture).toContain('invalid projection heads or sources') + expect(rootAuthorityPackageFixture).not.toContain('reconcile-project-root-expansion.ts') + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql new file mode 100644 index 00000000..b6f91a8e --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -0,0 +1,200 @@ +\set ON_ERROR_STOP on + +-- Admin-only fixture arrangement for ROOT-R2A. It depends on the exact +-- project authority seeded by migration-0027-root-authority-project-fixture.sql +-- and intentionally does not invoke reconciliation until R2A2. +DO $fixture$ +BEGIN + IF current_user = 'forge_project_root_reconciler' THEN + RAISE EXCEPTION 'root authority package fixture must not run as the reconciler login'; + END IF; + IF NOT EXISTS ( + SELECT 1 + FROM public.projects project_row + JOIN public.project_filesystem_grant_decisions decision_row + ON decision_row.id = '27000000-0000-4000-8000-000000000702'::uuid + AND decision_row.project_id = project_row.id + JOIN public.project_filesystem_current_decision_pointers pointer_row + ON pointer_row.project_id = project_row.id + AND pointer_row.current_decision_id = decision_row.id + WHERE project_row.id = '27000000-0000-4000-8000-000000000700'::uuid + AND project_row.root_binding_revision = 1 + AND project_row.grant_decision_revision = 1 + ) THEN + RAISE EXCEPTION 'root authority package fixture requires the exact R2A1a project authority'; + END IF; +END; +$fixture$; + +INSERT INTO public.tasks ( + id, project_id, submitted_by, title, prompt, status, local_projection_scope_state +) VALUES + ( + '27000000-0000-4000-8000-000000000710', + '27000000-0000-4000-8000-000000000700', + '27000000-0000-4000-8000-000000000001', + 'Root authority running convergence', + 'Fixture task for marker addition and replacement.', + 'running', + 'active' + ), + ( + '27000000-0000-4000-8000-000000000720', + '27000000-0000-4000-8000-000000000700', + '27000000-0000-4000-8000-000000000001', + 'Root authority legacy failed convergence', + 'Fixture task for marker removal and task recovery.', + 'failed', + 'active' + ); + +INSERT INTO public.work_packages ( + id, task_id, assigned_role, title, summary, status, sequence, + mcp_requirements, blocked_reason, metadata +) VALUES + ( + '27000000-0000-4000-8000-000000000711', + '27000000-0000-4000-8000-000000000710', + 'backend', + 'Root authority marker addition', + 'A ready package that later receives the canonical root hold.', + 'ready', + 1, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + NULL, + '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb + ), + ( + '27000000-0000-4000-8000-000000000712', + '27000000-0000-4000-8000-000000000710', + 'backend', + 'Root authority marker replacement', + 'A valid prior marker that later refreshes under the root change.', + 'blocked', + 2, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + 'Filesystem context requires an operator decision before execution.', + '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-replacement"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","blockedAt":"2026-07-28T00:00:01.000Z","holdKind":"approval_required","grantPhase":"none","grantConsumed":false,"grantDecisionRevision":null,"revocationReason":null}}'::jsonb + ), + ( + '27000000-0000-4000-8000-000000000721', + '27000000-0000-4000-8000-000000000720', + 'qa', + 'Root authority marker removal', + 'A valid prior marker that later clears through recovery.', + 'blocked', + 1, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + 'Filesystem context requires an operator decision before execution.', + '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-removal"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","blockedAt":"2026-07-28T00:00:02.000Z","holdKind":"consumed_once","grantPhase":"approved","grantConsumed":true,"grantDecisionRevision":"1","revocationReason":null}}'::jsonb + ); + +DO $assertions$ +DECLARE + expected_packages uuid[] := ARRAY[ + '27000000-0000-4000-8000-000000000711'::uuid, + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + ]; +BEGIN + IF (SELECT count(*) FROM public.tasks + WHERE id = ANY (ARRAY[ + '27000000-0000-4000-8000-000000000710'::uuid, + '27000000-0000-4000-8000-000000000720'::uuid + ]) AND project_id = '27000000-0000-4000-8000-000000000700'::uuid + AND local_projection_scope_state = 'active') <> 2 + OR NOT EXISTS (SELECT 1 FROM public.tasks WHERE id = '27000000-0000-4000-8000-000000000710'::uuid AND status = 'running') + OR NOT EXISTS (SELECT 1 FROM public.tasks WHERE id = '27000000-0000-4000-8000-000000000720'::uuid AND status = 'failed') + OR (SELECT count(*) FROM public.work_packages package_row + JOIN public.tasks task_row ON task_row.id = package_row.task_id + WHERE package_row.id = ANY (expected_packages) + AND task_row.project_id = '27000000-0000-4000-8000-000000000700'::uuid) <> 3 + THEN + RAISE EXCEPTION 'root authority package fixture has invalid task or project ownership'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id = '27000000-0000-4000-8000-000000000711'::uuid + AND status = 'ready' AND NOT (metadata ? 'mcpGrantBlock') + ) OR EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id = ANY (ARRAY[ + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + ]) + AND (status <> 'blocked' + OR public.forge_is_canonical_filesystem_grant_block_v2(metadata->'mcpGrantBlock') IS NOT TRUE) + ) OR EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id = ANY (expected_packages) + AND (metadata->'mcpGrantPhases' IS NULL OR metadata->'fixturePeer' IS NULL) + ) OR NOT EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id = '27000000-0000-4000-8000-000000000711'::uuid + AND metadata = '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb + ) OR EXISTS ( + SELECT 1 FROM public.work_packages + WHERE id IN ( + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + ) + AND metadata - 'mcpGrantBlock' NOT IN ( + '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}}}'::jsonb, + '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}}}'::jsonb + ) + ) OR (SELECT count(DISTINCT metadata->'mcpGrantBlock'->>'blockFingerprint') + FROM public.work_packages + WHERE id IN ( + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + )) <> 2 + OR (SELECT count(DISTINCT metadata->'mcpGrantBlock'->>'blockedAt') + FROM public.work_packages + WHERE id IN ( + '27000000-0000-4000-8000-000000000712'::uuid, + '27000000-0000-4000-8000-000000000721'::uuid + )) <> 2 + ) + THEN + RAISE EXCEPTION 'root authority package fixture has invalid marker or baseline metadata'; + END IF; + + IF (SELECT count(*) FROM public.filesystem_mcp_current_decision_pointers pointer_row + JOIN public.work_packages package_row ON package_row.id = pointer_row.work_package_id + WHERE package_row.id = ANY (expected_packages) + AND pointer_row.task_id = package_row.task_id + AND pointer_row.current_decision_id IS NULL + AND pointer_row.current_decision_task_id IS NULL + AND pointer_row.current_decision_work_package_id IS NULL + AND pointer_row.current_decision_revision IS NULL + AND pointer_row.current_decision_fingerprint IS NULL + AND pointer_row.pointer_version = 0 + AND pointer_row.pointer_fingerprint = 'empty:' || package_row.id::text) <> 3 + OR (SELECT count(*) FROM public.filesystem_mcp_current_decision_pointers + WHERE work_package_id = ANY (expected_packages)) <> 3 + THEN + RAISE EXCEPTION 'root authority package fixture has invalid or ambiguous package pointers'; + END IF; + + IF (SELECT count(*) FROM public.work_package_local_projection_heads head + JOIN public.work_packages package_row ON package_row.id = head.work_package_id + WHERE package_row.id = ANY (expected_packages)) <> 24 + OR (SELECT count(*) FROM public.work_package_local_projection_heads head + JOIN public.work_packages package_row ON package_row.id = head.work_package_id + WHERE package_row.id = ANY (expected_packages) + AND head.head_kind = 'operator_hold' + AND head.head_index = 5 + AND head.head_revision = 0 + AND head.current_source_id IS NULL + AND head.compare_and_set_fingerprint = head.head_fingerprint + AND head.head_fingerprint = 'head:v1:' || package_row.task_id::text || ':' || package_row.id::text || ':operator_hold:5') <> 3 + OR EXISTS ( + SELECT 1 FROM public.work_package_local_projection_sources + WHERE work_package_id = ANY (expected_packages) + ) + THEN + RAISE EXCEPTION 'root authority package fixture has invalid projection heads or sources'; + END IF; +END; +$assertions$; From bd7039226591f854cbc61bc3a5a55354aaffd6e9 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:01:07 +0800 Subject: [PATCH 39/69] fix: check root index before coverage --- .../project-root-reconciliation.test.ts | 8 +++++++ .../ci/cutover-migration-0027-root-ref.sh | 22 +++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 62f51ebd..12091e47 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -186,6 +186,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(cutoverScript).toContain('projects_root_ref_idx') expect(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") expect(cutoverScript).toContain('strict root-reference cutover requires a valid concurrent projects(root_ref) index') + const advisoryLock = cutoverScript.indexOf("pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))") + const indexGuard = cutoverScript.indexOf('strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index') + const coverageGuard = cutoverScript.indexOf('strict root-reference cutover requires exact completed watermark coverage') + const proofConstraint = cutoverScript.indexOf('ALTER TABLE public.projects ADD CONSTRAINT projects_root_ref_not_null_proof') + expect(advisoryLock).toBeGreaterThanOrEqual(0) + expect(advisoryLock).toBeLessThan(indexGuard) + expect(indexGuard).toBeLessThan(coverageGuard) + expect(coverageGuard).toBeLessThan(proofConstraint) expect(upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh')).toBeLessThan( upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), ) diff --git a/web/scripts/ci/cutover-migration-0027-root-ref.sh b/web/scripts/ci/cutover-migration-0027-root-ref.sh index 14e60d88..9cf02314 100644 --- a/web/scripts/ci/cutover-migration-0027-root-ref.sh +++ b/web/scripts/ci/cutover-migration-0027-root-ref.sh @@ -21,17 +21,6 @@ BEGIN IF (SELECT state FROM public.forge_epic_172_enablement_state WHERE singleton_id = 'epic-172') <> 'disabled' THEN RAISE EXCEPTION 'strict root-reference cutover requires Step 0 to remain disabled' USING ERRCODE = '55000'; END IF; - IF NOT EXISTS ( - SELECT 1 FROM public.project_root_reconciliation_operations operation - WHERE operation.through_generation = v_through AND operation.state = 'complete' - AND operation.last_processed_generation = v_through AND operation.cumulative_count = v_through - ) OR (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) <> v_through - OR (SELECT coalesce(max(generation), 0) FROM public.project_root_change_journal) <> v_through - OR (SELECT count(*) FROM public.project_root_change_journal) <> v_through - OR (SELECT count(*) FROM public.project_root_reconciliation_outcomes) <> v_through - OR EXISTS (SELECT 1 FROM public.projects WHERE root_ref IS NULL) THEN - RAISE EXCEPTION 'strict root-reference cutover requires exact completed watermark coverage' USING ERRCODE = '55000'; - END IF; IF NOT EXISTS ( SELECT 1 FROM pg_catalog.pg_index index_row JOIN pg_catalog.pg_class index_class ON index_class.oid = index_row.indexrelid @@ -43,6 +32,17 @@ BEGIN AND index_row.indexprs IS NULL AND index_row.indkey[0] = attribute_row.attnum AND pg_catalog.pg_get_expr(index_row.indpred, index_row.indrelid) = '(root_ref IS NOT NULL)' ) THEN RAISE EXCEPTION 'strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index' USING ERRCODE = '55000'; END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.project_root_reconciliation_operations operation + WHERE operation.through_generation = v_through AND operation.state = 'complete' + AND operation.last_processed_generation = v_through AND operation.cumulative_count = v_through + ) OR (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) <> v_through + OR (SELECT coalesce(max(generation), 0) FROM public.project_root_change_journal) <> v_through + OR (SELECT count(*) FROM public.project_root_change_journal) <> v_through + OR (SELECT count(*) FROM public.project_root_reconciliation_outcomes) <> v_through + OR EXISTS (SELECT 1 FROM public.projects WHERE root_ref IS NULL) THEN + RAISE EXCEPTION 'strict root-reference cutover requires exact completed watermark coverage' USING ERRCODE = '55000'; + END IF; IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_constraint WHERE conrelid = 'public.projects'::regclass AND conname = 'projects_root_ref_not_null_proof') THEN ALTER TABLE public.projects ADD CONSTRAINT projects_root_ref_not_null_proof CHECK (root_ref IS NOT NULL) NOT VALID; END IF; From 76c425fbdd4b01dbe3c6b5a3b67ae6d9c1e6f1f0 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:12:47 +0800 Subject: [PATCH 40/69] test: prove root authority reconciliation --- .../project-root-reconciliation.test.ts | 36 ++++- ...tion-0027-root-authority-reconciliation.sh | 54 +++++++ .../ci/prove-migration-0027-upgrade.sh | 4 +- ...on-0027-root-authority-package-fixture.sql | 4 +- ...ot-authority-reconciliation-assertions.sql | 136 ++++++++++++++++++ 5 files changed, 228 insertions(+), 6 deletions(-) create mode 100755 web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh create mode 100644 web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 12091e47..9cd3503b 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -17,6 +17,8 @@ const upgradeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-mig const indexLifecycleProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-index-lifecycle.sh', import.meta.url)), 'utf8') const rootAuthorityProjectFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-project-fixture.sql', import.meta.url)), 'utf8') const rootAuthorityPackageFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-package-fixture.sql', import.meta.url)), 'utf8') +const rootAuthorityProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-authority-reconciliation.sh', import.meta.url)), 'utf8') +const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -133,7 +135,7 @@ describe('project-root expansion reconciliation boundary', () => { ]) expect(webCi).toContain(`'${table}'`) }) - it('keeps the admin-only root-authority project fixture self-validating and incomplete', () => { + it('keeps the admin-only root-authority project fixture self-validating', () => { expect(rootAuthorityProjectFixture).toContain('root authority fixture must not run as the reconciler login') expect(rootAuthorityProjectFixture).toContain("'27000000-0000-4000-8000-000000000700'") expect(rootAuthorityProjectFixture).toContain("'27000000-0000-4000-8000-000000000702'") @@ -156,6 +158,34 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityPackageFixture).toContain("head_kind = 'operator_hold'") expect(rootAuthorityPackageFixture).toContain('invalid projection heads or sources') expect(rootAuthorityPackageFixture).not.toContain('reconcile-project-root-expansion.ts') + expect(rootAuthorityPackageFixture).toContain('filesystem.project.search') + }) + + it('runs the bound-authority fixture through the real dedicated reconciler once', () => { + expect(rootAuthorityProof).toContain('migration-0027-root-authority-project-fixture.sql') + expect(rootAuthorityProof).toContain('migration-0027-root-authority-package-fixture.sql') + expect(rootAuthorityProof).toContain("UPDATE public.projects SET root_ref") + expect(rootAuthorityProof).toContain("outcome = 'root_update'") + expect(rootAuthorityProof).toContain("SELECT session_user") + expect(rootAuthorityProof).toContain('forge_project_root_reconciler') + expect(rootAuthorityProof.match(/bash scripts\/ci\/reconcile-migration-0027-root-refs\.sh/g)).toHaveLength(1) + expect(rootAuthorityProof).toContain('migration-0027-root-authority-reconciliation-assertions.sql') + expect(upgradeProof).toContain('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh') + expect(upgradeProof).not.toContain('bash scripts/ci/reconcile-migration-0027-root-refs.sh') + expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh')).toBeLessThan( + upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), + ) + }) + + it('asserts canonical package/task/head effects without direct protected authority mutation', () => { + expect(rootAuthorityAssertions).toContain('project_root_reconciliation_write_contexts') + expect(rootAuthorityAssertions).toContain("source_row.contribution->>'transition'") + expect(rootAuthorityAssertions).toContain("WHEN v_add THEN 'hold' WHEN v_refresh THEN 'refresh' WHEN v_recover THEN 'recovery'") + expect(rootAuthorityAssertions).toContain('forge_is_canonical_filesystem_grant_block_v2') + expect(rootAuthorityAssertions).toContain("metadata - 'mcpGrantBlock'") + expect(rootAuthorityAssertions).toContain('mcpGrantPhases') + expect(rootAuthorityAssertions).toContain('root authority reconciliation mutated protected decision or pointer authority directly') + expect(rootAuthorityAssertions).toContain('root authority task convergence did not recover running and failed tasks') }) it('selects phase suppression only in the internal root-journal caller', () => { @@ -185,7 +215,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(cutoverScript).toContain('project_root_reconciliation_operations') expect(cutoverScript).toContain('projects_root_ref_idx') expect(cutoverScript).toContain("pg_catalog.to_regclass('public.projects_root_ref_idx')") - expect(cutoverScript).toContain('strict root-reference cutover requires a valid concurrent projects(root_ref) index') + expect(cutoverScript).toContain('strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index') const advisoryLock = cutoverScript.indexOf("pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))") const indexGuard = cutoverScript.indexOf('strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index') const coverageGuard = cutoverScript.indexOf('strict root-reference cutover requires exact completed watermark coverage') @@ -194,7 +224,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(advisoryLock).toBeLessThan(indexGuard) expect(indexGuard).toBeLessThan(coverageGuard) expect(coverageGuard).toBeLessThan(proofConstraint) - expect(upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh')).toBeLessThan( + expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh')).toBeLessThan( upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), ) expect(upgradeProof).not.toContain('FORGE_ROOT_INDEX_LIFECYCLE_PROOF') diff --git a/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh new file mode 100755 index 00000000..6d431435 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +project_id='27000000-0000-4000-8000-000000000700' +new_root_ref='27000000-0000-4000-8000-000000000703' +actor_id='11111111-1111-4111-8111-111111111111' + +# These fixtures are deliberately arranged by the disposable administrator. +# The only reconciliation execution below is the existing dedicated-login shim. +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --file scripts/ci/sql/migration-0027-root-authority-project-fixture.sql +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --file scripts/ci/sql/migration-0027-root-authority-package-fixture.sql + +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "UPDATE public.projects SET root_ref = '${new_root_ref}'::uuid WHERE id = '${project_id}'::uuid" + +authority_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT generation FROM public.project_root_change_journal WHERE project_id = '${project_id}'::uuid AND outcome = 'root_update' ORDER BY generation DESC LIMIT 1")" +if [[ ! "$authority_generation" =~ ^[1-9][0-9]*$ ]]; then + echo 'The authority fixture did not create an exact root-update journal generation.' >&2 + exit 1 +fi + +# This is a direct proof that the same URL used by the compatibility shim is +# the fixed, least-privilege reconciler login. The shim itself owns the sole +# post-drain watermark capture and production CLI invocation. +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +reconciler_url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +if [[ "$(psql "$reconciler_url" --no-align --tuples-only --set ON_ERROR_STOP=1 --command 'SELECT session_user')" != 'forge_project_root_reconciler' ]]; then + echo 'The root-authority proof did not obtain the dedicated reconciler login.' >&2 + exit 1 +fi + +# Exactly one operation is created: the existing shim first drains any legacy +# null roots, captures its exact watermark, then runs the production CLI. +bash scripts/ci/reconcile-migration-0027-root-refs.sh + +through_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT through_generation FROM public.project_root_reconciliation_operations WHERE actor_id = '${actor_id}'::uuid ORDER BY created_at DESC LIMIT 1")" +if [[ ! "$through_generation" =~ ^[1-9][0-9]*$ ]] || (( authority_generation > through_generation )); then + echo 'The completed reconciliation operation does not cover the authority root-update generation.' >&2 + exit 1 +fi + +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --set authority_generation="$authority_generation" \ + --set through_generation="$through_generation" \ + --set actor_id="$actor_id" \ + --file scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 60824b0f..44048a59 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -55,7 +55,9 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != exit 1 fi -bash scripts/ci/reconcile-migration-0027-root-refs.sh +# The authority proof arranges its protected graph under the disposable admin, +# then invokes the existing reconciler shim once through its dedicated login. +bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql index b6f91a8e..67b8df8f 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -60,7 +60,7 @@ INSERT INTO public.work_packages ( 'A ready package that later receives the canonical root hold.', 'ready', 1, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, NULL, '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb ), @@ -72,7 +72,7 @@ INSERT INTO public.work_packages ( 'A valid prior marker that later refreshes under the root change.', 'blocked', 2, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, 'Filesystem context requires an operator decision before execution.', '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-replacement"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","blockedAt":"2026-07-28T00:00:01.000Z","holdKind":"approval_required","grantPhase":"none","grantConsumed":false,"grantDecisionRevision":null,"revocationReason":null}}'::jsonb ), diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql new file mode 100644 index 00000000..3be7e528 --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -0,0 +1,136 @@ +\set ON_ERROR_STOP on + +DO $assertions$ +DECLARE + v_project_id uuid := '27000000-0000-4000-8000-000000000700'::uuid; + v_task_running uuid := '27000000-0000-4000-8000-000000000710'::uuid; + v_task_failed uuid := '27000000-0000-4000-8000-000000000720'::uuid; + v_add uuid := '27000000-0000-4000-8000-000000000711'::uuid; + v_refresh uuid := '27000000-0000-4000-8000-000000000712'::uuid; + v_recover uuid := '27000000-0000-4000-8000-000000000721'::uuid; + v_actor uuid := :'actor_id'::uuid; + v_authority_generation bigint := :'authority_generation'::bigint; + v_through_generation bigint := :'through_generation'::bigint; +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM public.project_root_change_journal journal_row + WHERE journal_row.generation = v_authority_generation + AND journal_row.project_id = v_project_id + AND journal_row.outcome = 'root_update' + ) THEN + RAISE EXCEPTION 'root authority proof lost its exact root-update journal generation'; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM public.project_root_reconciliation_operations operation_row + JOIN public.project_root_reconciliation_outcomes outcome_row + ON outcome_row.operation_id = operation_row.operation_id + AND outcome_row.generation = v_authority_generation + AND outcome_row.actor_id = v_actor + AND outcome_row.project_id = v_project_id + AND outcome_row.outcome = 'root_update' + JOIN public.project_root_reconciliation_write_contexts context_row + ON context_row.operation_id = operation_row.operation_id + AND context_row.generation = v_authority_generation + AND context_row.actor_id = v_actor + AND context_row.project_id = v_project_id + AND context_row.completed_at IS NOT NULL + WHERE operation_row.actor_id = v_actor + AND operation_row.through_generation = v_through_generation + AND operation_row.last_processed_generation = v_through_generation + AND operation_row.cumulative_count = v_through_generation + AND operation_row.state = 'complete' + AND operation_row.completed_at IS NOT NULL + ) THEN + RAISE EXCEPTION 'root authority generation was not completed by the dedicated operation/context lifecycle'; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.work_packages package_row + WHERE package_row.id = v_add + AND ( + package_row.status <> 'blocked' + OR package_row.blocked_reason <> 'Filesystem context requires an operator decision before execution.' + OR public.forge_is_canonical_filesystem_grant_block_v2(package_row.metadata->'mcpGrantBlock') IS NOT TRUE + OR package_row.metadata - 'mcpGrantBlock' IS DISTINCT FROM + '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb + OR package_row.metadata->'mcpGrantBlock'->'requestedCapabilities' <> '["filesystem.project.read","filesystem.project.search"]'::jsonb + ) + ) OR EXISTS ( + SELECT 1 FROM public.work_packages package_row + WHERE package_row.id = v_refresh + AND ( + package_row.status <> 'blocked' + OR package_row.blocked_reason <> 'Filesystem context requires an operator decision before execution.' + OR public.forge_is_canonical_filesystem_grant_block_v2(package_row.metadata->'mcpGrantBlock') IS NOT TRUE + OR package_row.metadata - 'mcpGrantBlock' IS DISTINCT FROM + '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}}}'::jsonb + OR package_row.metadata->'mcpGrantBlock'->'requestedCapabilities' <> '["filesystem.project.read","filesystem.project.search"]'::jsonb + ) + ) OR EXISTS ( + SELECT 1 FROM public.work_packages package_row + WHERE package_row.id = v_recover + AND ( + package_row.status <> 'ready' + OR package_row.blocked_reason IS NOT NULL + OR package_row.metadata ? 'mcpGrantBlock' + OR package_row.metadata IS DISTINCT FROM + '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}}}'::jsonb + ) + ) THEN + RAISE EXCEPTION 'root authority package marker, phase, or peer metadata convergence is not canonical'; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.tasks task_row + WHERE (task_row.id = v_task_running OR task_row.id = v_task_failed) + AND (task_row.status <> 'approved' OR task_row.error_message IS NOT NULL) + ) THEN + RAISE EXCEPTION 'root authority task convergence did not recover running and failed tasks'; + END IF; + + IF (SELECT count(*) FROM public.work_package_local_projection_heads head + JOIN public.work_package_local_projection_sources source_row + ON source_row.id = head.current_source_id + AND source_row.task_id = head.current_source_task_id + AND source_row.work_package_id = head.current_source_work_package_id + AND source_row.source_kind = head.current_source_kind + AND source_row.source_revision = head.current_source_revision + AND source_row.source_fingerprint = head.current_source_fingerprint + WHERE head.work_package_id IN (v_add, v_refresh, v_recover) + AND head.head_kind = 'operator_hold' + AND head.head_revision = 1 + AND head.current_source_kind = 'operator_hold' + AND head.current_source_revision = 1 + AND head.current_source_fingerprint ~ '^sha256:[0-9a-f]{64}$' + AND head.compare_and_set_fingerprint ~ '^sha256:[0-9a-f]{64}$' + AND source_row.contribution->>'transition' = CASE head.work_package_id + WHEN v_add THEN 'hold' WHEN v_refresh THEN 'refresh' WHEN v_recover THEN 'recovery' END + AND source_row.contribution->>'operatorHold' = CASE head.work_package_id + WHEN v_recover THEN 'false' ELSE 'true' END + ) <> 3 THEN + RAISE EXCEPTION 'root authority projection-head compare-and-set effects are incomplete'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM public.project_filesystem_current_decision_pointers pointer_row + WHERE pointer_row.project_id = v_project_id + AND pointer_row.current_decision_id = '27000000-0000-4000-8000-000000000702'::uuid + AND pointer_row.current_decision_project_id = v_project_id + AND pointer_row.current_decision_revision = 1 + AND pointer_row.current_root_binding_revision = 1 + AND pointer_row.current_decision_fingerprint = + 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + AND pointer_row.pointer_generation = 1 + ) OR (SELECT count(*) FROM public.project_filesystem_grant_decisions WHERE project_id = v_project_id) <> 1 + OR EXISTS ( + SELECT 1 FROM public.filesystem_mcp_current_decision_pointers pointer_row + WHERE pointer_row.work_package_id IN (v_add, v_refresh, v_recover) + AND (pointer_row.current_decision_id IS NOT NULL OR pointer_row.pointer_version <> 0) + ) THEN + RAISE EXCEPTION 'root authority reconciliation mutated protected decision or pointer authority directly'; + END IF; +END; +$assertions$; From f462f60c12e920b22e33441a1c36b745ff9223c7 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:20:35 +0800 Subject: [PATCH 41/69] test: serialize root proof reconciliation --- .../project-root-reconciliation.test.ts | 45 ++++--- ...tion-0027-root-authority-reconciliation.sh | 93 ++++++++------- ...ove-migration-0027-root-index-lifecycle.sh | 111 +++++++++++------- .../ci/prove-migration-0027-upgrade.sh | 11 +- ...ot-authority-reconciliation-assertions.sql | 21 +++- 5 files changed, 171 insertions(+), 110 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 9cd3503b..91b07f53 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -161,20 +161,17 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityPackageFixture).toContain('filesystem.project.search') }) - it('runs the bound-authority fixture through the real dedicated reconciler once', () => { + it('splits bound-authority fixture preparation from post-drain assertions', () => { expect(rootAuthorityProof).toContain('migration-0027-root-authority-project-fixture.sql') expect(rootAuthorityProof).toContain('migration-0027-root-authority-package-fixture.sql') expect(rootAuthorityProof).toContain("UPDATE public.projects SET root_ref") expect(rootAuthorityProof).toContain("outcome = 'root_update'") - expect(rootAuthorityProof).toContain("SELECT session_user") - expect(rootAuthorityProof).toContain('forge_project_root_reconciler') - expect(rootAuthorityProof.match(/bash scripts\/ci\/reconcile-migration-0027-root-refs\.sh/g)).toHaveLength(1) + expect(rootAuthorityProof).toContain('--prepare|--assert') + expect(rootAuthorityProof).toContain('case "$phase" in') + expect(rootAuthorityProof).not.toContain('reconcile-migration-0027-root-refs.sh') expect(rootAuthorityProof).toContain('migration-0027-root-authority-reconciliation-assertions.sql') - expect(upgradeProof).toContain('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh') - expect(upgradeProof).not.toContain('bash scripts/ci/reconcile-migration-0027-root-refs.sh') - expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh')).toBeLessThan( - upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), - ) + expect(upgradeProof).toContain('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare') + expect(upgradeProof).toContain('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert') }) it('asserts canonical package/task/head effects without direct protected authority mutation', () => { @@ -186,6 +183,7 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityAssertions).toContain('mcpGrantPhases') expect(rootAuthorityAssertions).toContain('root authority reconciliation mutated protected decision or pointer authority directly') expect(rootAuthorityAssertions).toContain('root authority task convergence did not recover running and failed tasks') + expect(rootAuthorityAssertions).toContain('the single root reconciliation operation does not cover every prepared journal generation') }) it('selects phase suppression only in the internal root-journal caller', () => { @@ -224,14 +222,21 @@ describe('project-root expansion reconciliation boundary', () => { expect(advisoryLock).toBeLessThan(indexGuard) expect(indexGuard).toBeLessThan(coverageGuard) expect(coverageGuard).toBeLessThan(proofConstraint) - expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh')).toBeLessThan( - upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh'), - ) + const authorityPrepare = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare') + const indexPrepare = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare') + const reconcile = upgradeProof.indexOf('bash scripts/ci/reconcile-migration-0027-root-refs.sh') + const authorityAssert = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert') + const indexRecover = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --recover') + const cutover = upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply') + expect(authorityPrepare).toBeGreaterThanOrEqual(0) + expect(authorityPrepare).toBeLessThan(indexPrepare) + expect(indexPrepare).toBeLessThan(reconcile) + expect(reconcile).toBeLessThan(authorityAssert) + expect(authorityAssert).toBeLessThan(indexRecover) + expect(indexRecover).toBeLessThan(cutover) + expect(upgradeProof.match(/bash scripts\/ci\/reconcile-migration-0027-root-refs\.sh/g)).toHaveLength(1) expect(upgradeProof).not.toContain('FORGE_ROOT_INDEX_LIFECYCLE_PROOF') - expect(upgradeProof.match(/bash scripts\/ci\/prove-migration-0027-root-index-lifecycle\.sh/g)).toHaveLength(1) - expect(upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh')).toBeLessThan( - upgradeProof.indexOf('bash scripts/ci/cutover-migration-0027-root-ref.sh --apply'), - ) + expect(upgradeProof.match(/bash scripts\/ci\/prove-migration-0027-root-index-lifecycle\.sh/g)).toHaveLength(2) expect(indexLifecycleProof).toContain("expect_failure \"$canonical_index_refusal\"") expect(indexLifecycleProof).toContain('grep -F -- "$expected_message" "$output" >/dev/null') expect(indexLifecycleProof).not.toContain('rg --fixed-strings') @@ -240,6 +245,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(indexLifecycleProof).toContain('projects_root_ref_idx exists with a noncanonical definition.') expect(indexLifecycleProof).toContain('could not create unique index "projects_root_ref_idx"') expect(indexLifecycleProof).toContain('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') + expect(indexLifecycleProof).toContain('--prepare|--recover') + expect(indexLifecycleProof).not.toContain('reconcile-migration-0027-root-refs.sh') + const authorityAssertBody = rootAuthorityProof.slice(rootAuthorityProof.indexOf(' --assert)')) + const indexRecoverBody = indexLifecycleProof.slice(indexLifecycleProof.indexOf(' --recover)')) + expect(authorityAssertBody).not.toContain('UPDATE public.projects') + expect(authorityAssertBody).not.toContain('INSERT INTO public.projects') + expect(indexRecoverBody).not.toContain('UPDATE public.projects') + expect(indexRecoverBody).not.toContain('INSERT INTO public.projects') expect(webCi).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') expect(webCi).toContain('Capture the post-drain root journal watermark') }) diff --git a/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh index 6d431435..97e85113 100755 --- a/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh +++ b/web/scripts/ci/prove-migration-0027-root-authority-reconciliation.sh @@ -1,54 +1,57 @@ #!/usr/bin/env bash set -euo pipefail -: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" - project_id='27000000-0000-4000-8000-000000000700' new_root_ref='27000000-0000-4000-8000-000000000703' actor_id='11111111-1111-4111-8111-111111111111' -# These fixtures are deliberately arranged by the disposable administrator. -# The only reconciliation execution below is the existing dedicated-login shim. -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ - --file scripts/ci/sql/migration-0027-root-authority-project-fixture.sql -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ - --file scripts/ci/sql/migration-0027-root-authority-package-fixture.sql - -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ - "UPDATE public.projects SET root_ref = '${new_root_ref}'::uuid WHERE id = '${project_id}'::uuid" - -authority_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ - "SELECT generation FROM public.project_root_change_journal WHERE project_id = '${project_id}'::uuid AND outcome = 'root_update' ORDER BY generation DESC LIMIT 1")" -if [[ ! "$authority_generation" =~ ^[1-9][0-9]*$ ]]; then - echo 'The authority fixture did not create an exact root-update journal generation.' >&2 - exit 1 -fi +usage() { + echo 'Usage: prove-migration-0027-root-authority-reconciliation.sh --prepare|--assert' >&2 + exit 2 +} -# This is a direct proof that the same URL used by the compatibility shim is -# the fixed, least-privilege reconciler login. The shim itself owns the sole -# post-drain watermark capture and production CLI invocation. -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ - "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null -authority="${FORGE_DATABASE_ADMIN_URL#*://}" -reconciler_url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" -if [[ "$(psql "$reconciler_url" --no-align --tuples-only --set ON_ERROR_STOP=1 --command 'SELECT session_user')" != 'forge_project_root_reconciler' ]]; then - echo 'The root-authority proof did not obtain the dedicated reconciler login.' >&2 - exit 1 -fi - -# Exactly one operation is created: the existing shim first drains any legacy -# null roots, captures its exact watermark, then runs the production CLI. -bash scripts/ci/reconcile-migration-0027-root-refs.sh - -through_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ - "SELECT through_generation FROM public.project_root_reconciliation_operations WHERE actor_id = '${actor_id}'::uuid ORDER BY created_at DESC LIMIT 1")" -if [[ ! "$through_generation" =~ ^[1-9][0-9]*$ ]] || (( authority_generation > through_generation )); then - echo 'The completed reconciliation operation does not cover the authority root-update generation.' >&2 - exit 1 -fi +phase="${1:-}" +case "$phase" in + --prepare|--assert) [[ $# -eq 1 ]] || usage ;; + *) usage ;; +esac +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ - --set authority_generation="$authority_generation" \ - --set through_generation="$through_generation" \ - --set actor_id="$actor_id" \ - --file scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +case "$phase" in + --prepare) + # These fixtures are deliberately arranged by the disposable administrator. + # Preparation writes journal rows only; the upgrade orchestrator owns the + # single post-drain reconciliation operation for every prepared fixture. + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --file scripts/ci/sql/migration-0027-root-authority-project-fixture.sql + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --file scripts/ci/sql/migration-0027-root-authority-package-fixture.sql + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "UPDATE public.projects SET root_ref = '${new_root_ref}'::uuid WHERE id = '${project_id}'::uuid" + authority_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT generation FROM public.project_root_change_journal WHERE project_id = '${project_id}'::uuid AND outcome = 'root_update' ORDER BY generation DESC LIMIT 1")" + if [[ ! "$authority_generation" =~ ^[1-9][0-9]*$ ]]; then + echo 'The authority fixture did not create an exact root-update journal generation.' >&2 + exit 1 + fi + ;; + --assert) + authority_generation="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --set ON_ERROR_STOP=1 --command \ + "SELECT generation FROM public.project_root_change_journal WHERE project_id = '${project_id}'::uuid AND outcome = 'root_update' ORDER BY generation DESC LIMIT 1")" + operation_row="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --set ON_ERROR_STOP=1 --command \ + "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id = '${actor_id}'::uuid ORDER BY created_at DESC LIMIT 1")" + operation_id="${operation_row%%|*}" + through_generation="${operation_row#*|}" + if [[ ! "$authority_generation" =~ ^[1-9][0-9]*$ ]] || [[ ! "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || [[ ! "$through_generation" =~ ^[1-9][0-9]*$ ]] || (( authority_generation > through_generation )); then + echo 'The completed reconciliation operation does not cover the authority root-update generation.' >&2 + exit 1 + fi + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 \ + --set authority_generation="$authority_generation" \ + --set through_generation="$through_generation" \ + --set operation_id="$operation_id" \ + --set actor_id="$actor_id" \ + --file scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql + ;; + *) usage ;; +esac diff --git a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh index f058288f..7de70061 100644 --- a/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh +++ b/web/scripts/ci/prove-migration-0027-root-index-lifecycle.sh @@ -1,8 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" - canonical_index_refusal='strict root-reference cutover requires the exact canonical concurrent projects(root_ref) index' expect_failure() { @@ -37,57 +35,82 @@ assert_cutover_not_started() { fi } -# Reconciliation has completed, so the missing-index refusal must happen before -# any concurrent DDL. This proves cutover itself is the boundary, not the builder. -expect_failure "$canonical_index_refusal" \ - bash scripts/ci/cutover-migration-0027-root-ref.sh --apply -assert_cutover_not_started +usage() { + echo 'Usage: prove-migration-0027-root-index-lifecycle.sh --prepare|--recover' >&2 + exit 2 +} + +phase="${1:-}" +case "$phase" in + --prepare|--recover) [[ $# -eq 1 ]] || usage ;; + *) usage ;; +esac +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +case "$phase" in + --prepare) + # The index guard runs before watermark coverage, so this remains an exact + # cutover refusal even though preparation has not reconciled journal rows. + expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply + assert_cutover_not_started # A valid but structurally wrong same-name index is never disposable production # state. The production builder and cutover both refuse it without replacement. -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ - 'CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL' -wrong_index_before="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ - "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" -if [[ -z "$wrong_index_before" ]]; then - echo 'Failed to create the deterministic wrong root-reference index.' >&2 - exit 1 -fi -expect_failure 'projects_root_ref_idx exists with a noncanonical definition.' \ - npm run project-roots:build-concurrent-index -- --apply -expect_failure "$canonical_index_refusal" \ - bash scripts/ci/cutover-migration-0027-root-ref.sh --apply -assert_cutover_not_started -wrong_index_after="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ - "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" -if [[ "$wrong_index_after" != "$wrong_index_before" ]]; then - echo 'The valid wrong root-reference index was replaced or changed.' >&2 - exit 1 -fi -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ - 'DROP INDEX CONCURRENTLY public.projects_root_ref_idx' + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + 'CREATE UNIQUE INDEX CONCURRENTLY projects_root_ref_idx ON public.projects(id) WHERE root_ref IS NOT NULL' + wrong_index_before="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ + "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" + if [[ -z "$wrong_index_before" ]]; then + echo 'Failed to create the deterministic wrong root-reference index.' >&2 + exit 1 + fi + expect_failure 'projects_root_ref_idx exists with a noncanonical definition.' \ + npm run project-roots:build-concurrent-index -- --apply + expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply + assert_cutover_not_started + wrong_index_after="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --field-separator='|' --command \ + "SELECT indexrelid::oid, pg_catalog.pg_get_indexdef(indexrelid) FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx')")" + if [[ "$wrong_index_after" != "$wrong_index_before" ]]; then + echo 'The valid wrong root-reference index was replaced or changed.' >&2 + exit 1 + fi + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + 'DROP INDEX CONCURRENTLY public.projects_root_ref_idx' # The fixture deliberately makes the production concurrent unique build fail. # PostgreSQL retains the same-name invalid index, giving the builder's recovery # path a deterministic, non-timing-dependent input. -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 <<'SQL' + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 <<'SQL' INSERT INTO public.projects (id, name, submitted_by, root_ref) SELECT '27000000-0000-4000-8000-000000000050', 'Duplicate root index A', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; INSERT INTO public.projects (id, name, submitted_by, root_ref) SELECT '27000000-0000-4000-8000-000000000060', 'Duplicate root index B', id, '27000000-0000-4000-8000-0000000000aa'::uuid FROM public.users LIMIT 1; SQL -expect_failure 'could not create unique index "projects_root_ref_idx"' \ - npm run project-roots:build-concurrent-index -- --apply -invalid_index="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ - "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND (NOT indisvalid OR NOT indisready))")" -if [[ "$invalid_index" != 't' ]]; then - echo 'The failed concurrent build did not retain the expected invalid index.' >&2 - exit 1 -fi -expect_failure "$canonical_index_refusal" \ - bash scripts/ci/cutover-migration-0027-root-ref.sh --apply -assert_cutover_not_started -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "UPDATE public.projects SET root_ref = '27000000-0000-4000-8000-0000000000bb'::uuid WHERE id = '27000000-0000-4000-8000-000000000060'" -bash scripts/ci/reconcile-migration-0027-root-refs.sh -npm run project-roots:build-concurrent-index -- --apply -npm run project-roots:build-concurrent-index -- --apply + expect_failure 'could not create unique index "projects_root_ref_idx"' \ + npm run project-roots:build-concurrent-index -- --apply + invalid_index="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND (NOT indisvalid OR NOT indisready))")" + if [[ "$invalid_index" != 't' ]]; then + echo 'The failed concurrent build did not retain the expected invalid index.' >&2 + exit 1 + fi + expect_failure "$canonical_index_refusal" \ + bash scripts/ci/cutover-migration-0027-root-ref.sh --apply + assert_cutover_not_started + psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command \ + "UPDATE public.projects SET root_ref = '27000000-0000-4000-8000-0000000000bb'::uuid WHERE id = '27000000-0000-4000-8000-000000000060'" + ;; + --recover) + invalid_index="$(psql "$FORGE_DATABASE_ADMIN_URL" --no-align --tuples-only --command \ + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_index WHERE indexrelid = pg_catalog.to_regclass('public.projects_root_ref_idx') AND (NOT indisvalid OR NOT indisready))")" + if [[ "$invalid_index" != 't' ]]; then + echo 'The prepared invalid root-reference index was unexpectedly absent or valid.' >&2 + exit 1 + fi + npm run project-roots:build-concurrent-index -- --apply + npm run project-roots:build-concurrent-index -- --apply + ;; + *) usage ;; +esac diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 44048a59..8571717d 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -55,10 +55,13 @@ if [[ "$(redis-cli -u "${REDIS_URL}" exists 'session:orphan-migration-0027')" != exit 1 fi -# The authority proof arranges its protected graph under the disposable admin, -# then invokes the existing reconciler shim once through its dedicated login. -bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh -bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh +# Prepare every journal-producing fixture before the only post-drain reconcile. +# Assertions and index recovery are read/DDL-only after that completed operation. +bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare +bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare +bash scripts/ci/reconcile-migration-0027-root-refs.sh +bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert +bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --recover bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-cutover-assertions.sql diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql index 3be7e528..d0143d93 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -9,6 +9,7 @@ DECLARE v_refresh uuid := '27000000-0000-4000-8000-000000000712'::uuid; v_recover uuid := '27000000-0000-4000-8000-000000000721'::uuid; v_actor uuid := :'actor_id'::uuid; + v_operation_id uuid := :'operation_id'::uuid; v_authority_generation bigint := :'authority_generation'::bigint; v_through_generation bigint := :'through_generation'::bigint; BEGIN @@ -37,7 +38,8 @@ BEGIN AND context_row.actor_id = v_actor AND context_row.project_id = v_project_id AND context_row.completed_at IS NOT NULL - WHERE operation_row.actor_id = v_actor + WHERE operation_row.operation_id = v_operation_id + AND operation_row.actor_id = v_actor AND operation_row.through_generation = v_through_generation AND operation_row.last_processed_generation = v_through_generation AND operation_row.cumulative_count = v_through_generation @@ -47,6 +49,23 @@ BEGIN RAISE EXCEPTION 'root authority generation was not completed by the dedicated operation/context lifecycle'; END IF; + IF NOT EXISTS ( + SELECT 1 FROM public.project_root_reconciliation_operations operation_row + WHERE operation_row.operation_id = v_operation_id + AND operation_row.through_generation = (SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton) + AND operation_row.last_processed_generation = operation_row.through_generation + AND operation_row.cumulative_count = operation_row.through_generation + AND operation_row.state = 'complete' + ) OR EXISTS ( + SELECT 1 FROM public.project_root_change_journal journal_row + LEFT JOIN public.project_root_reconciliation_outcomes outcome_row + ON outcome_row.generation = journal_row.generation + AND outcome_row.operation_id = v_operation_id + WHERE outcome_row.generation IS NULL + ) THEN + RAISE EXCEPTION 'the single root reconciliation operation does not cover every prepared journal generation'; + END IF; + IF EXISTS ( SELECT 1 FROM public.work_packages package_row WHERE package_row.id = v_add From bdc3b493d4e5fc287de9abdb52392cbb9c03f08e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:22:32 +0800 Subject: [PATCH 42/69] fix: type root index classifier state --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ web/scripts/build-project-root-ref-index.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 91b07f53..ac903424 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -204,6 +204,8 @@ describe('project-root expansion reconciliation boundary', () => { it('keeps concurrent index DDL separate and strict cutover watermark-fenced', () => { expect(indexScript).toContain('CREATE UNIQUE INDEX CONCURRENTLY') + expect(indexScript).toContain('type IndexState =') + expect(indexScript).toContain('let index: IndexState | undefined = (await inspect())[0]') expect(indexScript).toContain('FORGE_DATABASE_ADMIN_URL') expect(reconcileScript).not.toContain('FORGE_DATABASE_ADMIN_URL') expect(cutoverScript).toContain('--through --apply') diff --git a/web/scripts/build-project-root-ref-index.ts b/web/scripts/build-project-root-ref-index.ts index 0f366260..7d61eef3 100644 --- a/web/scripts/build-project-root-ref-index.ts +++ b/web/scripts/build-project-root-ref-index.ts @@ -1,6 +1,13 @@ import '../lib/load-env' import postgres from 'postgres' +type IndexState = { + exists: boolean + canonical: boolean + valid: boolean + ready: boolean +} + async function main(): Promise { if (process.argv.slice(2).join(' ') !== '--apply') { throw new Error('Concurrent root-reference index creation is actionless without --apply.') @@ -10,7 +17,7 @@ async function main(): Promise { const admin = postgres(adminUrl, { max: 1, onnotice: () => {} }) try { await admin`select pg_catalog.pg_advisory_lock(pg_catalog.hashtext('forge:projects_root_ref_idx:v1'))` - const inspect = async () => admin<{ exists: boolean; canonical: boolean; valid: boolean; ready: boolean }[]>` + const inspect = async () => admin` with candidate as (select pg_catalog.to_regclass('public.projects_root_ref_idx') as index_oid) select (candidate.index_oid is not null) as exists, coalesce(idx.indisvalid, false) as valid, coalesce(idx.indisready, false) as ready, @@ -18,7 +25,7 @@ async function main(): Promise { and pg_catalog.pg_get_indexdef(idx.indexrelid) = 'CREATE UNIQUE INDEX projects_root_ref_idx ON public.projects USING btree (root_ref) WHERE (root_ref IS NOT NULL)', false) as canonical from candidate left join pg_catalog.pg_index idx on idx.indexrelid = candidate.index_oid ` - let [index] = await inspect() + let index: IndexState | undefined = (await inspect())[0] if (index?.exists && !index.canonical && index.valid && index.ready) throw new Error('projects_root_ref_idx exists with a noncanonical definition.') if (index?.exists && (!index.valid || !index.ready)) { await admin.unsafe('DROP INDEX CONCURRENTLY public.projects_root_ref_idx') From 368d28d7cb81bdac54350387ff2afa5b3415b1e9 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:26:28 +0800 Subject: [PATCH 43/69] test: prove root reconciliation rollback --- .../project-root-reconciliation.test.ts | 14 ++++++++ ...ation-0027-root-reconciliation-negative.sh | 36 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 3 ++ ...oot-reconciliation-negative-assertions.sql | 12 +++++++ 4 files changed, 65 insertions(+) create mode 100755 web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh create mode 100644 web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ac903424..ea6c7c0a 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -19,6 +19,7 @@ const rootAuthorityProjectFixture = readFileSync(fileURLToPath(new URL('../scrip const rootAuthorityPackageFixture = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-package-fixture.sql', import.meta.url)), 'utf8') const rootAuthorityProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-authority-reconciliation.sh', import.meta.url)), 'utf8') const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql', import.meta.url)), 'utf8') +const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -186,6 +187,19 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityAssertions).toContain('the single root reconciliation operation does not cover every prepared journal generation') }) + it('keeps the dedicated pre-reconciliation rollback proof bounded and resumable', () => { + expect(negativeProof).toContain('forge.begin_project_root_reconciliation_v1') + expect(negativeProof).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(negativeProof).toContain('SELECT 1/0') + expect(negativeProof).toContain('project-root operation identity cannot be hijacked') + expect(negativeProof).toContain('project-root authority lock has no active write context') + expect(negativeProof).toContain('migration-0027-root-reconciliation-negative-assertions.sql') + expect(negativeProof).not.toContain('reconcile-migration-0027-root-refs.sh') + expect(upgradeProof.indexOf('prove-migration-0027-root-reconciliation-negative.sh')).toBeLessThan( + upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh'), + ) + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh new file mode 100755 index 00000000..ffbdd59a --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +actor='11111111-1111-4111-8111-111111111111' +task='27000000-0000-4000-8000-000000000730' +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +while :; do + rows="$(psql "$url" -At --set ON_ERROR_STOP=1 --command 'SELECT forge.materialize_project_root_ref_expansion_v1(100)')" + [[ "$rows" =~ ^[0-9]+$ ]] || { echo 'root materialization returned an invalid row count' >&2; exit 1; } + [[ "$rows" == 0 ]] && break +done +through="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton')" +gen_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command 'SELECT generation, project_id FROM public.project_root_change_journal WHERE generation=1')" +generation="${gen_project%%|*}"; project="${gen_project#*|}" +[[ "$generation" == 1 && "$project" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ ]] || { echo 'negative proof requires journal generation one and a positive watermark' >&2; exit 1; } +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "INSERT INTO public.tasks(id,project_id,submitted_by,title,prompt,status) SELECT '${task}'::uuid, '${project}'::uuid, submitted_by, 'Root rollback proof', 'rollback only', 'running' FROM public.projects WHERE id='${project}'::uuid" +operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, state, last_processed_generation FROM forge.begin_project_root_reconciliation_v1(NULL,'${actor}'::uuid,${through}::bigint)")" +operation_id="${operation%%|*}" +[[ "$operation" == *'|running|0' && "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof did not create the exact live operation' >&2; exit 1; } + +expect_failure() { local text="$1"; shift; local output; output="$(mktemp)"; if "$@" >"$output" 2>&1; then cat "$output"; unlink "$output"; exit 1; fi; grep -F -- "$text" "$output" >/dev/null || { cat "$output"; unlink "$output"; exit 1; }; unlink "$output"; } +expect_failure 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" +expect_failure 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" +expect_failure 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" +expect_failure 'project-root authority lock has no active write context' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.lock_project_root_reconciliation_authority_v1('${operation_id}'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" +expect_failure 'rollback sentinel' psql "$url" --set ON_ERROR_STOP=1 < 'running') + OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id=:'operation_id'::uuid AND generation=:'generation'::bigint) + OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE operation_id=:'operation_id'::uuid AND generation=:'generation'::bigint) + OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations WHERE operation_id=:'operation_id'::uuid AND state='running' AND last_processed_generation=0) + OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_checkpoints WHERE operation_id=:'operation_id'::uuid AND checkpoint_generation=0 AND last_processed_generation=0) THEN + RAISE EXCEPTION 'negative context rollback advanced protected reconciliation state'; + END IF; +END; +$proof$; From 62423f571dc82d3700454c47f19c2b64701abe1e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:29:59 +0800 Subject: [PATCH 44/69] fix: parse root authority package fixture --- .../ci/sql/migration-0027-root-authority-package-fixture.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql index 67b8df8f..e1d0607c 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -155,7 +155,6 @@ BEGIN '27000000-0000-4000-8000-000000000712'::uuid, '27000000-0000-4000-8000-000000000721'::uuid )) <> 2 - ) THEN RAISE EXCEPTION 'root authority package fixture has invalid marker or baseline metadata'; END IF; From 36bf6d7c199462dd0ca247f81c5819df3ef4cb0f Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:32:26 +0800 Subject: [PATCH 45/69] test: prove root reconciliation replay --- .../project-root-reconciliation.test.ts | 12 +++++++ ...gration-0027-root-reconciliation-replay.sh | 35 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + 3 files changed, 48 insertions(+) create mode 100755 web/scripts/ci/prove-migration-0027-root-reconciliation-replay.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ea6c7c0a..58c7b4b2 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -20,6 +20,7 @@ const rootAuthorityPackageFixture = readFileSync(fileURLToPath(new URL('../scrip const rootAuthorityProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-authority-reconciliation.sh', import.meta.url)), 'utf8') const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql', import.meta.url)), 'utf8') const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') +const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -200,6 +201,17 @@ describe('project-root expansion reconciliation boundary', () => { ) }) + it('replays the completed reconciliation through the exact dedicated CLI without mutation', () => { + expect(replayProof).toContain('"mode":"complete-replay"') + expect(replayProof).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') + expect(replayProof).toContain('project_root_reconciliation_write_contexts') + expect(replayProof).toContain('work_package_local_projection_heads') + expect(replayProof).toContain('Completed root reconciliation replay mutated protected state.') + expect(upgradeProof.indexOf('prove-migration-0027-root-reconciliation-replay.sh')).toBeGreaterThan( + upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh'), + ) + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-replay.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-replay.sh new file mode 100755 index 00000000..56558992 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-replay.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" + +actor='11111111-1111-4111-8111-111111111111' +operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" +operation_id="${operation%%|*}" +through="${operation#*|}" +[[ "$operation_id" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ ]] || { echo 'Completed root reconciliation operation is unavailable for replay proof.' >&2; exit 1; } + +snapshot() { + psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command " + WITH protected_rows AS ( + SELECT 'operation:' || to_jsonb(o)::text AS line FROM public.project_root_reconciliation_operations o WHERE o.operation_id='${operation_id}'::uuid + UNION ALL SELECT 'checkpoint:' || to_jsonb(c)::text FROM public.project_root_reconciliation_checkpoints c WHERE c.operation_id='${operation_id}'::uuid + UNION ALL SELECT 'context:' || to_jsonb(c)::text FROM public.project_root_reconciliation_write_contexts c WHERE c.operation_id='${operation_id}'::uuid + UNION ALL SELECT 'outcome:' || to_jsonb(o)::text FROM public.project_root_reconciliation_outcomes o WHERE o.operation_id='${operation_id}'::uuid + UNION ALL SELECT 'head:' || to_jsonb(h)::text FROM public.work_package_local_projection_heads h JOIN public.tasks t ON t.id=h.task_id WHERE t.project_id='27000000-0000-4000-8000-000000000700'::uuid + UNION ALL SELECT 'pointer:' || to_jsonb(p)::text FROM public.project_filesystem_current_decision_pointers p WHERE p.project_id='27000000-0000-4000-8000-000000000700'::uuid + UNION ALL SELECT 'package-pointer:' || to_jsonb(p)::text FROM public.filesystem_mcp_current_decision_pointers p JOIN public.tasks t ON t.id=p.task_id WHERE t.project_id='27000000-0000-4000-8000-000000000700'::uuid + UNION ALL SELECT 'task:' || to_jsonb(t)::text FROM public.tasks t WHERE t.project_id='27000000-0000-4000-8000-000000000700'::uuid + UNION ALL SELECT 'package:' || to_jsonb(p)::text FROM public.work_packages p JOIN public.tasks t ON t.id=p.task_id WHERE t.project_id='27000000-0000-4000-8000-000000000700'::uuid + ) SELECT count(*)::text || '|' || md5(coalesce(string_agg(line, E'\\n' ORDER BY line), '')) FROM protected_rows" +} + +before="$(snapshot)" +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +output="$(FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL="$url" npx tsx scripts/reconcile-project-root-expansion.ts --through "$through" --actor "$actor" --apply)" +grep -F -- '"mode":"complete-replay"' <<<"$output" >/dev/null || { echo "Unexpected root reconciliation replay result: $output" >&2; exit 1; } +after="$(snapshot)" +if [[ "$after" != "$before" ]]; then + echo 'Completed root reconciliation replay mutated protected state.' >&2 + exit 1 +fi diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 4b8daa39..755a87df 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -64,6 +64,7 @@ bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh bash scripts/ci/reconcile-migration-0027-root-refs.sh bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert +bash scripts/ci/prove-migration-0027-root-reconciliation-replay.sh bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --recover bash scripts/ci/cutover-migration-0027-root-ref.sh --apply psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ From da51a83a7323925f15d18600aa98f3f8c1b8c836 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:34:36 +0800 Subject: [PATCH 46/69] test: prove root reconciler privileges --- .../project-root-reconciliation.test.ts | 9 ++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + ...-root-reconciler-privileges-assertions.sql | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 58c7b4b2..ba6926db 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -21,6 +21,7 @@ const rootAuthorityProof = readFileSync(fileURLToPath(new URL('../scripts/ci/pro const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql', import.meta.url)), 'utf8') const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') +const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -212,6 +213,14 @@ describe('project-root expansion reconciliation boundary', () => { ) }) + it('keeps the dedicated reconciler privilege proof catalog-driven and mandatory', () => { + expect(privilegeProof).toContain('has_table_privilege') + expect(privilegeProof).toContain('has_column_privilege') + expect(privilegeProof).toContain('has_function_privilege') + expect(privilegeProof).toContain('project_root_reconciliation_write_contexts') + expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 755a87df..7de0100a 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -59,6 +59,7 @@ fi # Assertions and index recovery are read/DDL-only after that completed operation. bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare +psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql # This creates (but never advances) the exact live operation. The shim resumes # it below after its now-empty materialization pass. bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql new file mode 100644 index 00000000..08627feb --- /dev/null +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -0,0 +1,21 @@ +\set ON_ERROR_STOP on +DO $proof$ +DECLARE r pg_catalog.pg_roles%ROWTYPE; +BEGIN + SELECT * INTO STRICT r FROM pg_catalog.pg_roles WHERE rolname='forge_project_root_reconciler'; + IF NOT r.rolcanlogin OR r.rolinherit OR r.rolsuper OR r.rolcreaterole OR r.rolcreatedb OR r.rolreplication OR r.rolbypassrls + OR EXISTS (SELECT 1 FROM pg_catalog.pg_auth_members WHERE member='forge_project_root_reconciler'::regrole OR roleid='forge_project_root_reconciler'::regrole) THEN + RAISE EXCEPTION 'root reconciler role attributes or memberships exceed the closed contract'; + END IF; + IF NOT has_table_privilege('forge_project_root_reconciler','public.projects','SELECT') + OR NOT has_table_privilege('forge_project_root_reconciler','public.tasks','SELECT') + OR NOT has_column_privilege('forge_project_root_reconciler','public.tasks','status','UPDATE') + OR NOT has_column_privilege('forge_project_root_reconciler','public.work_packages','metadata','UPDATE') + OR has_table_privilege('forge_project_root_reconciler','public.tasks','INSERT, DELETE, TRUNCATE, UPDATE') + OR has_table_privilege('forge_project_root_reconciler','public.project_root_reconciliation_write_contexts','SELECT, INSERT, UPDATE, DELETE') + OR has_function_privilege('forge_project_root_reconciler','forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)','EXECUTE') IS NOT TRUE + OR has_function_privilege('forge_project_root_reconciler','forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid)','EXECUTE') IS NOT TRUE + OR has_function_privilege('forge_app_test','forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)','EXECUTE') + THEN RAISE EXCEPTION 'root reconciler catalog privileges do not match the closed runtime contract'; END IF; +END; +$proof$; From 9c59003d535f9186758d977e9959f616ab8b52c2 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:36:44 +0800 Subject: [PATCH 47/69] fix: assert root rollback error --- .../ci/prove-migration-0027-root-reconciliation-negative.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index ffbdd59a..7b10a34d 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -26,7 +26,9 @@ expect_failure 'project-root operation identity cannot be hijacked' psql "$url" expect_failure 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" expect_failure 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" expect_failure 'project-root authority lock has no active write context' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.lock_project_root_reconciliation_authority_v1('${operation_id}'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" -expect_failure 'rollback sentinel' psql "$url" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 02:39:04 +0800 Subject: [PATCH 48/69] test: prove stale root contexts --- .../project-root-reconciliation.test.ts | 11 +++++++++ ...prove-migration-0027-root-stale-context.sh | 24 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + 3 files changed, 36 insertions(+) create mode 100755 web/scripts/ci/prove-migration-0027-root-stale-context.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index ba6926db..5a8d5e8d 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -22,6 +22,7 @@ const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/c const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') +const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -221,6 +222,16 @@ describe('project-root expansion reconciliation boundary', () => { expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') }) + it('rejects stale and fabricated root reconciliation context reuse before the CLI resumes', () => { + expect(staleContextProof).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(staleContextProof).toContain('forge.complete_project_root_reconciliation_generation_v1') + expect(staleContextProof).toContain("set_config('forge.project_root_context','forged',true)") + expect(staleContextProof).toContain('project-root write context is absent or stale') + expect(upgradeProof.indexOf('prove-migration-0027-root-stale-context.sh')).toBeLessThan( + upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh'), + ) + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-stale-context.sh b/web/scripts/ci/prove-migration-0027-root-stale-context.sh new file mode 100755 index 00000000..9121a172 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-stale-context.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" +actor='11111111-1111-4111-8111-111111111111'; task='27000000-0000-4000-8000-000000000730' +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, last_project_id FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" +operation_id="${operation%%|*}" +project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT project_id FROM public.project_root_change_journal WHERE generation=1')" +[[ "$operation_id" =~ ^[0-9a-f-]{36}$ && "$project" =~ ^[0-9a-f-]{36}$ ]] || { echo 'stale context proof lacks the prepared operation' >&2; exit 1; } +expect_failure() { local text="$1"; shift; local out; out="$(mktemp)"; if "$@" >"$out" 2>&1; then cat "$out"; unlink "$out"; exit 1; fi; grep -F -- "$text" "$out" >/dev/null || { cat "$out"; unlink "$out"; exit 1; }; unlink "$out"; } +# A valid context permits the fenced task transition only in this transaction; +# its deliberate abort makes the subsequent backend/session attempts stale. +expect_failure 'division by zero' psql "$url" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 02:41:10 +0800 Subject: [PATCH 49/69] fix: bind root negative assertion inputs --- .../project-root-reconciliation.test.ts | 8 +++++++ ...oot-reconciliation-negative-assertions.sql | 21 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 5a8d5e8d..bf0c8567 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -23,6 +23,7 @@ const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-mi const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') +const negativeAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -232,6 +233,13 @@ describe('project-root expansion reconciliation boundary', () => { ) }) + it('binds negative assertion inputs outside the dollar-quoted PL/pgSQL body', () => { + expect(negativeAssertions).toContain('CREATE TEMP TABLE root_negative_assertion_inputs') + expect(negativeAssertions).toContain("VALUES (:'operation_id'::uuid, :'task_id'::uuid, :'generation'::bigint)") + expect(negativeAssertions).toContain('FROM root_negative_assertion_inputs') + expect(negativeAssertions).not.toContain("id=:'task_id'") + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql index 7a2a9408..8a1e220c 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql @@ -1,11 +1,22 @@ \set ON_ERROR_STOP on +CREATE TEMP TABLE root_negative_assertion_inputs ( + operation_id uuid NOT NULL, + task_id uuid NOT NULL, + generation bigint NOT NULL CHECK (generation > 0) +) ON COMMIT DROP; +INSERT INTO root_negative_assertion_inputs (operation_id, task_id, generation) +VALUES (:'operation_id'::uuid, :'task_id'::uuid, :'generation'::bigint); + DO $proof$ +DECLARE v_operation_id uuid; v_task_id uuid; v_generation bigint; BEGIN - IF EXISTS (SELECT 1 FROM public.tasks WHERE id=:'task_id'::uuid AND status <> 'running') - OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id=:'operation_id'::uuid AND generation=:'generation'::bigint) - OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE operation_id=:'operation_id'::uuid AND generation=:'generation'::bigint) - OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations WHERE operation_id=:'operation_id'::uuid AND state='running' AND last_processed_generation=0) - OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_checkpoints WHERE operation_id=:'operation_id'::uuid AND checkpoint_generation=0 AND last_processed_generation=0) THEN + SELECT operation_id, task_id, generation INTO STRICT v_operation_id, v_task_id, v_generation + FROM root_negative_assertion_inputs; + IF EXISTS (SELECT 1 FROM public.tasks WHERE id=v_task_id AND status <> 'running') + OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id=v_operation_id AND generation=v_generation) + OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_outcomes WHERE operation_id=v_operation_id AND generation=v_generation) + OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_operations WHERE operation_id=v_operation_id AND state='running' AND last_processed_generation=0) + OR NOT EXISTS (SELECT 1 FROM public.project_root_reconciliation_checkpoints WHERE operation_id=v_operation_id AND checkpoint_generation=0 AND last_processed_generation=0) THEN RAISE EXCEPTION 'negative context rollback advanced protected reconciliation state'; END IF; END; From a855c8a1c12fafbc265a28876d74198630904367 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:43:52 +0800 Subject: [PATCH 50/69] test: prove root reconciliation contention --- .../project-root-reconciliation.test.ts | 9 ++++++ .../prove-migration-0027-root-contention.sh | 30 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + 3 files changed, 40 insertions(+) create mode 100755 web/scripts/ci/prove-migration-0027-root-contention.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index bf0c8567..3318818d 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -24,6 +24,7 @@ const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migr const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') const negativeAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql', import.meta.url)), 'utf8') +const contentionProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-contention.sh', import.meta.url)), 'utf8') const cutoverScript = readFileSync(fileURLToPath(new URL('../scripts/ci/cutover-migration-0027-root-ref.sh', import.meta.url)), 'utf8') const webCi = readFileSync(fileURLToPath(new URL('../../.github/workflows/web-ci.yml', import.meta.url)), 'utf8') @@ -240,6 +241,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(negativeAssertions).not.toContain("id=:'task_id'") }) + it('uses two dedicated sessions and bounded lock-timeout contention before the CLI resumes', () => { + expect(contentionProof).toContain('forge.claim_project_root_reconciliation_batch_v1') + expect(contentionProof).toContain('forge.enter_project_root_reconciliation_generation_v1') + expect(contentionProof).toContain("lock_timeout='250ms'") + expect(contentionProof).toContain('canceling statement due to lock timeout') + expect(upgradeProof.indexOf('prove-migration-0027-root-contention.sh')).toBeLessThan(upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh')) + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-contention.sh b/web/scripts/ci/prove-migration-0027-root-contention.sh new file mode 100755 index 00000000..3c54b1c8 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-contention.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" +actor='11111111-1111-4111-8111-111111111111' +authority="${FORGE_DATABASE_ADMIN_URL#*://}" +url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" +operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" +op="${operation%%|*}"; through="${operation#*|}" +project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT project_id FROM public.project_root_change_journal WHERE generation=1')" +[[ "$op" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ && "$project" =~ ^[0-9a-f-]{36}$ ]] || { echo 'contention proof lacks a live operation' >&2; exit 1; } +barrier="$(mktemp -d)"; ready="$barrier/ready"; release="$barrier/release"; winner_out="$barrier/winner"; loser_out="$barrier/loser" +cleanup() { [[ -n "${winner_pid:-}" ]] && kill "$winner_pid" 2>/dev/null || true; rm -rf "$barrier"; } +trap cleanup EXIT +( psql "$url" --set ON_ERROR_STOP=1 <"$winner_out" 2>&1 & winner_pid=$! +for _ in $(seq 1 100); do [[ -f "$ready" ]] && break; sleep 0.05; done +[[ -f "$ready" ]] || { cat "$winner_out" >&2; exit 1; } +if psql "$url" --set ON_ERROR_STOP=1 --command "BEGIN; SET LOCAL lock_timeout='250ms'; SET LOCAL statement_timeout='1s'; SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${actor}'::uuid,1); COMMIT;" >"$loser_out" 2>&1; then cat "$loser_out" >&2; exit 1; fi +grep -F -- 'canceling statement due to lock timeout' "$loser_out" >/dev/null || { cat "$loser_out" >&2; exit 1; } +touch "$release"; wait "$winner_pid"; unset winner_pid +psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "DO \$\$ BEGIN IF (SELECT count(*) FROM public.project_root_reconciliation_outcomes WHERE operation_id='${op}'::uuid AND generation=1) <> 1 OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id='${op}'::uuid AND generation=1 AND completed_at IS NULL) THEN RAISE EXCEPTION 'contention proof left an ambiguous reconciliation lineage'; END IF; END \$\$;" diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index 5fc7106d..f665b647 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -64,6 +64,7 @@ psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file scripts/ci/sql/m # it below after its now-empty materialization pass. bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh bash scripts/ci/prove-migration-0027-root-stale-context.sh +bash scripts/ci/prove-migration-0027-root-contention.sh bash scripts/ci/reconcile-migration-0027-root-refs.sh bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --assert bash scripts/ci/prove-migration-0027-root-reconciliation-replay.sh From 758c579bb4e2a569ffc2c0451399342a429f2e3e Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:46:17 +0800 Subject: [PATCH 51/69] test: keep negative reconciliation inputs transactional --- web/__tests__/project-root-reconciliation.test.ts | 2 ++ .../migration-0027-root-reconciliation-negative-assertions.sql | 2 ++ 2 files changed, 4 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 3318818d..16fec0b6 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -235,9 +235,11 @@ describe('project-root expansion reconciliation boundary', () => { }) it('binds negative assertion inputs outside the dollar-quoted PL/pgSQL body', () => { + expect(negativeAssertions).toContain('BEGIN;') expect(negativeAssertions).toContain('CREATE TEMP TABLE root_negative_assertion_inputs') expect(negativeAssertions).toContain("VALUES (:'operation_id'::uuid, :'task_id'::uuid, :'generation'::bigint)") expect(negativeAssertions).toContain('FROM root_negative_assertion_inputs') + expect(negativeAssertions).toContain('COMMIT;') expect(negativeAssertions).not.toContain("id=:'task_id'") }) diff --git a/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql index 8a1e220c..1a541d26 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql @@ -1,4 +1,5 @@ \set ON_ERROR_STOP on +BEGIN; CREATE TEMP TABLE root_negative_assertion_inputs ( operation_id uuid NOT NULL, task_id uuid NOT NULL, @@ -21,3 +22,4 @@ BEGIN END IF; END; $proof$; +COMMIT; From ec91143b7d654ab1361c20a0cfc017f5da3733ad Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:53:13 +0800 Subject: [PATCH 52/69] test: diagnose root proof psql failures --- .../project-root-reconciliation.test.ts | 8 +++++++ .../prove-migration-0027-root-contention.sh | 23 +++++++++++-------- ...ation-0027-root-reconciliation-negative.sh | 2 +- ...prove-migration-0027-root-stale-context.sh | 2 +- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 16fec0b6..1b5137b8 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -248,9 +248,17 @@ describe('project-root expansion reconciliation boundary', () => { expect(contentionProof).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(contentionProof).toContain("lock_timeout='250ms'") expect(contentionProof).toContain('canceling statement due to lock timeout') + expect(contentionProof).toContain("[[ \"$loser_status\" == 3 ]]") + expect(contentionProof).toContain("'${outcome}'") + expect(contentionProof).not.toContain('FROM public.project_root_change_journal WHERE generation=1));') expect(upgradeProof.indexOf('prove-migration-0027-root-contention.sh')).toBeLessThan(upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh')) }) + it('requires every deliberate negative psql probe to return SQL-error exit status three', () => { + expect(negativeProof).toContain('expected psql exit 3') + expect(staleContextProof).toContain('expected psql exit 3') + }) + it('selects phase suppression only in the internal root-journal caller', () => { expect(reconcileScript).toContain('suppressPhasePersistence: true') expect(reconciliation).toContain('suppressPhasePersistence?: boolean') diff --git a/web/scripts/ci/prove-migration-0027-root-contention.sh b/web/scripts/ci/prove-migration-0027-root-contention.sh index 3c54b1c8..d6af3bd3 100755 --- a/web/scripts/ci/prove-migration-0027-root-contention.sh +++ b/web/scripts/ci/prove-migration-0027-root-contention.sh @@ -4,10 +4,13 @@ set -euo pipefail actor='11111111-1111-4111-8111-111111111111' authority="${FORGE_DATABASE_ADMIN_URL#*://}" url="postgresql://forge_project_root_reconciler:forge_project_root_reconciler_test@${authority#*@}" -operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" +fail_phase() { local phase="$1" output="$2"; echo "root contention proof failed during ${phase}" >&2; [[ -f "$output" ]] && cat "$output" >&2; exit 1; } +run_success() { local phase="$1"; shift; local output; output="$(mktemp)"; if ! "$@" >"$output" 2>&1; then fail_phase "$phase" "$output"; fi; cat "$output"; unlink "$output"; } +operation="$(run_success 'load live operation' psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, through_generation FROM public.project_root_reconciliation_operations WHERE actor_id='${actor}'::uuid ORDER BY created_at DESC LIMIT 1")" op="${operation%%|*}"; through="${operation#*|}" -project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT project_id FROM public.project_root_change_journal WHERE generation=1')" -[[ "$op" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ && "$project" =~ ^[0-9a-f-]{36}$ ]] || { echo 'contention proof lacks a live operation' >&2; exit 1; } +generation_row="$(run_success 'load generation-one journal outcome' psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command 'SELECT project_id, outcome FROM public.project_root_change_journal WHERE generation=1')" +project="${generation_row%%|*}"; outcome="${generation_row#*|}" +[[ "$op" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ && "$project" =~ ^[0-9a-f-]{36}$ && "$outcome" =~ ^(insert|root_update|archive)$ ]] || { echo 'contention proof lacks a live operation or closed journal outcome' >&2; exit 1; } barrier="$(mktemp -d)"; ready="$barrier/ready"; release="$barrier/release"; winner_out="$barrier/winner"; loser_out="$barrier/loser" cleanup() { [[ -n "${winner_pid:-}" ]] && kill "$winner_pid" 2>/dev/null || true; rm -rf "$barrier"; } trap cleanup EXIT @@ -18,13 +21,15 @@ SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${ SELECT forge.enter_project_root_reconciliation_generation_v1('${op}'::uuid,'${actor}'::uuid,1,'${project}'::uuid); \! touch '${ready}' \! while [ ! -f '${release}' ]; do sleep 0.05; done -SELECT * FROM forge.complete_project_root_reconciliation_generation_v1('${op}'::uuid,'${actor}'::uuid,1,'${project}'::uuid,(SELECT outcome FROM public.project_root_change_journal WHERE generation=1)); +SELECT * FROM forge.complete_project_root_reconciliation_generation_v1('${op}'::uuid,'${actor}'::uuid,1,'${project}'::uuid,'${outcome}'); COMMIT; SQL ) >"$winner_out" 2>&1 & winner_pid=$! for _ in $(seq 1 100); do [[ -f "$ready" ]] && break; sleep 0.05; done -[[ -f "$ready" ]] || { cat "$winner_out" >&2; exit 1; } -if psql "$url" --set ON_ERROR_STOP=1 --command "BEGIN; SET LOCAL lock_timeout='250ms'; SET LOCAL statement_timeout='1s'; SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${actor}'::uuid,1); COMMIT;" >"$loser_out" 2>&1; then cat "$loser_out" >&2; exit 1; fi -grep -F -- 'canceling statement due to lock timeout' "$loser_out" >/dev/null || { cat "$loser_out" >&2; exit 1; } -touch "$release"; wait "$winner_pid"; unset winner_pid -psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "DO \$\$ BEGIN IF (SELECT count(*) FROM public.project_root_reconciliation_outcomes WHERE operation_id='${op}'::uuid AND generation=1) <> 1 OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id='${op}'::uuid AND generation=1 AND completed_at IS NULL) THEN RAISE EXCEPTION 'contention proof left an ambiguous reconciliation lineage'; END IF; END \$\$;" +[[ -f "$ready" ]] || fail_phase 'wait for winner context barrier' "$winner_out" +if psql "$url" --set ON_ERROR_STOP=1 --command "BEGIN; SET LOCAL lock_timeout='250ms'; SET LOCAL statement_timeout='1s'; SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${actor}'::uuid,1); COMMIT;" >"$loser_out" 2>&1; then fail_phase 'require loser lock-timeout rejection' "$loser_out"; else loser_status=$?; fi +[[ "$loser_status" == 3 ]] || { echo "root contention proof expected loser psql exit 3, got ${loser_status}" >&2; fail_phase 'require loser lock-timeout rejection' "$loser_out"; } +grep -F -- 'canceling statement due to lock timeout' "$loser_out" >/dev/null || fail_phase 'require loser lock-timeout rejection' "$loser_out" +touch "$release" +if wait "$winner_pid"; then unset winner_pid; else winner_status=$?; echo "root contention proof winner psql exited ${winner_status}" >&2; fail_phase 'complete winner reconciliation' "$winner_out"; fi +run_success 'assert one completed contention lineage' psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "DO \$\$ BEGIN IF (SELECT count(*) FROM public.project_root_reconciliation_outcomes WHERE operation_id='${op}'::uuid AND generation=1) <> 1 OR EXISTS (SELECT 1 FROM public.project_root_reconciliation_write_contexts WHERE operation_id='${op}'::uuid AND generation=1 AND completed_at IS NULL) THEN RAISE EXCEPTION 'contention proof left an ambiguous reconciliation lineage'; END IF; END \$\$;" >/dev/null diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index 7b10a34d..3223adf2 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -21,7 +21,7 @@ operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --comma operation_id="${operation%%|*}" [[ "$operation" == *'|running|0' && "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof did not create the exact live operation' >&2; exit 1; } -expect_failure() { local text="$1"; shift; local output; output="$(mktemp)"; if "$@" >"$output" 2>&1; then cat "$output"; unlink "$output"; exit 1; fi; grep -F -- "$text" "$output" >/dev/null || { cat "$output"; unlink "$output"; exit 1; }; unlink "$output"; } +expect_failure() { local text="$1"; shift; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == 3 ]] || { echo "negative reconciliation proof expected psql exit 3 for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } expect_failure 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" expect_failure 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" expect_failure 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" diff --git a/web/scripts/ci/prove-migration-0027-root-stale-context.sh b/web/scripts/ci/prove-migration-0027-root-stale-context.sh index 9121a172..8d163d06 100755 --- a/web/scripts/ci/prove-migration-0027-root-stale-context.sh +++ b/web/scripts/ci/prove-migration-0027-root-stale-context.sh @@ -8,7 +8,7 @@ operation="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON operation_id="${operation%%|*}" project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT project_id FROM public.project_root_change_journal WHERE generation=1')" [[ "$operation_id" =~ ^[0-9a-f-]{36}$ && "$project" =~ ^[0-9a-f-]{36}$ ]] || { echo 'stale context proof lacks the prepared operation' >&2; exit 1; } -expect_failure() { local text="$1"; shift; local out; out="$(mktemp)"; if "$@" >"$out" 2>&1; then cat "$out"; unlink "$out"; exit 1; fi; grep -F -- "$text" "$out" >/dev/null || { cat "$out"; unlink "$out"; exit 1; }; unlink "$out"; } +expect_failure() { local text="$1"; shift; local out status; out="$(mktemp)"; if "$@" >"$out" 2>&1; then echo "stale reconciliation proof expected psql failure: ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; else status=$?; fi; [[ "$status" == 3 ]] || { echo "stale reconciliation proof expected psql exit 3 for ${text}, got ${status}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; grep -F -- "$text" "$out" >/dev/null || { echo "stale reconciliation proof received the wrong psql error for ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; unlink "$out"; } # A valid context permits the fenced task transition only in this transaction; # its deliberate abort makes the subsequent backend/session attempts stale. expect_failure 'division by zero' psql "$url" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 02:59:32 +0800 Subject: [PATCH 53/69] test: distinguish root proof psql statuses --- web/__tests__/project-root-reconciliation.test.ts | 10 ++++++---- .../ci/prove-migration-0027-root-contention.sh | 2 +- ...ve-migration-0027-root-reconciliation-negative.sh | 12 ++++++------ .../ci/prove-migration-0027-root-stale-context.sh | 10 +++++----- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 1b5137b8..36de0005 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -248,15 +248,17 @@ describe('project-root expansion reconciliation boundary', () => { expect(contentionProof).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(contentionProof).toContain("lock_timeout='250ms'") expect(contentionProof).toContain('canceling statement due to lock timeout') - expect(contentionProof).toContain("[[ \"$loser_status\" == 3 ]]") + expect(contentionProof).toContain("[[ \"$loser_status\" == 1 ]]") expect(contentionProof).toContain("'${outcome}'") expect(contentionProof).not.toContain('FROM public.project_root_change_journal WHERE generation=1));') expect(upgradeProof.indexOf('prove-migration-0027-root-contention.sh')).toBeLessThan(upgradeProof.indexOf('reconcile-migration-0027-root-refs.sh')) }) - it('requires every deliberate negative psql probe to return SQL-error exit status three', () => { - expect(negativeProof).toContain('expected psql exit 3') - expect(staleContextProof).toContain('expected psql exit 3') + it('requires invocation-specific psql exit statuses for command and script rejections', () => { + expect(negativeProof).toContain("expect_failure 1 'project-root operation identity cannot be hijacked'") + expect(negativeProof).toContain("expect_failure 3 'division by zero'") + expect(staleContextProof).toContain("expect_failure 1 'project-root task update has no active write context'") + expect(staleContextProof).toContain("expect_failure 3 'division by zero'") }) it('selects phase suppression only in the internal root-journal caller', () => { diff --git a/web/scripts/ci/prove-migration-0027-root-contention.sh b/web/scripts/ci/prove-migration-0027-root-contention.sh index d6af3bd3..40399096 100755 --- a/web/scripts/ci/prove-migration-0027-root-contention.sh +++ b/web/scripts/ci/prove-migration-0027-root-contention.sh @@ -28,7 +28,7 @@ SQL for _ in $(seq 1 100); do [[ -f "$ready" ]] && break; sleep 0.05; done [[ -f "$ready" ]] || fail_phase 'wait for winner context barrier' "$winner_out" if psql "$url" --set ON_ERROR_STOP=1 --command "BEGIN; SET LOCAL lock_timeout='250ms'; SET LOCAL statement_timeout='1s'; SELECT * FROM forge.claim_project_root_reconciliation_batch_v1('${op}'::uuid,'${actor}'::uuid,1); COMMIT;" >"$loser_out" 2>&1; then fail_phase 'require loser lock-timeout rejection' "$loser_out"; else loser_status=$?; fi -[[ "$loser_status" == 3 ]] || { echo "root contention proof expected loser psql exit 3, got ${loser_status}" >&2; fail_phase 'require loser lock-timeout rejection' "$loser_out"; } +[[ "$loser_status" == 1 ]] || { echo "root contention proof expected command-mode loser psql exit 1, got ${loser_status}" >&2; fail_phase 'require loser lock-timeout rejection' "$loser_out"; } grep -F -- 'canceling statement due to lock timeout' "$loser_out" >/dev/null || fail_phase 'require loser lock-timeout rejection' "$loser_out" touch "$release" if wait "$winner_pid"; then unset winner_pid; else winner_status=$?; echo "root contention proof winner psql exited ${winner_status}" >&2; fail_phase 'complete winner reconciliation' "$winner_out"; fi diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index 3223adf2..e0490ef1 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -21,14 +21,14 @@ operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --comma operation_id="${operation%%|*}" [[ "$operation" == *'|running|0' && "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof did not create the exact live operation' >&2; exit 1; } -expect_failure() { local text="$1"; shift; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == 3 ]] || { echo "negative reconciliation proof expected psql exit 3 for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } -expect_failure 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" -expect_failure 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" -expect_failure 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" -expect_failure 'project-root authority lock has no active write context' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.lock_project_root_reconciliation_authority_v1('${operation_id}'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" +expect_failure() { local expected_status="$1" text="$2"; shift 2; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == "$expected_status" ]] || { echo "negative reconciliation proof expected psql exit ${expected_status} for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } +expect_failure 1 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" +expect_failure 1 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" +expect_failure 1 'project-root write context is not claimable' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,2,'${project}'::uuid)" +expect_failure 1 'project-root authority lock has no active write context' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.lock_project_root_reconciliation_authority_v1('${operation_id}'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" # psql must stop on this exact server error. The following admin psql command # is a fresh session and proves the aborted transaction left no durable state. -expect_failure 'division by zero' psql "$url" --set ON_ERROR_STOP=1 <&2; exit 1; } -expect_failure() { local text="$1"; shift; local out status; out="$(mktemp)"; if "$@" >"$out" 2>&1; then echo "stale reconciliation proof expected psql failure: ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; else status=$?; fi; [[ "$status" == 3 ]] || { echo "stale reconciliation proof expected psql exit 3 for ${text}, got ${status}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; grep -F -- "$text" "$out" >/dev/null || { echo "stale reconciliation proof received the wrong psql error for ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; unlink "$out"; } +expect_failure() { local expected_status="$1" text="$2"; shift 2; local out status; out="$(mktemp)"; if "$@" >"$out" 2>&1; then echo "stale reconciliation proof expected psql failure: ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; else status=$?; fi; [[ "$status" == "$expected_status" ]] || { echo "stale reconciliation proof expected psql exit ${expected_status} for ${text}, got ${status}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; grep -F -- "$text" "$out" >/dev/null || { echo "stale reconciliation proof received the wrong psql error for ${text}" >&2; cat "$out" >&2; unlink "$out"; exit 1; }; unlink "$out"; } # A valid context permits the fenced task transition only in this transaction; # its deliberate abort makes the subsequent backend/session attempts stale. -expect_failure 'division by zero' psql "$url" --set ON_ERROR_STOP=1 < Date: Tue, 28 Jul 2026 03:03:41 +0800 Subject: [PATCH 54/69] test: bind root authority assertion inputs --- .../project-root-reconciliation.test.ts | 6 ++++++ ...ot-authority-reconciliation-assertions.sql | 21 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 36de0005..e4e69ff0 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -181,6 +181,12 @@ describe('project-root expansion reconciliation boundary', () => { }) it('asserts canonical package/task/head effects without direct protected authority mutation', () => { + expect(rootAuthorityAssertions).toContain('BEGIN;') + expect(rootAuthorityAssertions).toContain('CREATE TEMP TABLE root_authority_assertion_inputs') + expect(rootAuthorityAssertions).toContain("VALUES (:'actor_id'::uuid, :'operation_id'::uuid, :'authority_generation'::bigint, :'through_generation'::bigint)") + expect(rootAuthorityAssertions).toContain('FROM root_authority_assertion_inputs') + expect(rootAuthorityAssertions).toContain('COMMIT;') + expect(rootAuthorityAssertions).not.toContain("v_actor uuid := :'actor_id'::uuid") expect(rootAuthorityAssertions).toContain('project_root_reconciliation_write_contexts') expect(rootAuthorityAssertions).toContain("source_row.contribution->>'transition'") expect(rootAuthorityAssertions).toContain("WHEN v_add THEN 'hold' WHEN v_refresh THEN 'refresh' WHEN v_recover THEN 'recovery'") diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql index d0143d93..e681441b 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -1,4 +1,13 @@ \set ON_ERROR_STOP on +BEGIN; +CREATE TEMP TABLE root_authority_assertion_inputs ( + actor_id uuid NOT NULL, + operation_id uuid NOT NULL, + authority_generation bigint NOT NULL CHECK (authority_generation > 0), + through_generation bigint NOT NULL CHECK (through_generation > 0) +) ON COMMIT DROP; +INSERT INTO root_authority_assertion_inputs (actor_id, operation_id, authority_generation, through_generation) +VALUES (:'actor_id'::uuid, :'operation_id'::uuid, :'authority_generation'::bigint, :'through_generation'::bigint); DO $assertions$ DECLARE @@ -8,11 +17,14 @@ DECLARE v_add uuid := '27000000-0000-4000-8000-000000000711'::uuid; v_refresh uuid := '27000000-0000-4000-8000-000000000712'::uuid; v_recover uuid := '27000000-0000-4000-8000-000000000721'::uuid; - v_actor uuid := :'actor_id'::uuid; - v_operation_id uuid := :'operation_id'::uuid; - v_authority_generation bigint := :'authority_generation'::bigint; - v_through_generation bigint := :'through_generation'::bigint; + v_actor uuid; + v_operation_id uuid; + v_authority_generation bigint; + v_through_generation bigint; BEGIN + SELECT actor_id, operation_id, authority_generation, through_generation + INTO STRICT v_actor, v_operation_id, v_authority_generation, v_through_generation + FROM root_authority_assertion_inputs; IF NOT EXISTS ( SELECT 1 FROM public.project_root_change_journal journal_row @@ -153,3 +165,4 @@ BEGIN END IF; END; $assertions$; +COMMIT; From b6d92aa5a5463d5dcf91feca831e6a68252764ee Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:09:24 +0800 Subject: [PATCH 55/69] test: align root authority recovery fixture --- .../project-root-reconciliation.test.ts | 6 +++ ...on-0027-root-authority-package-fixture.sql | 5 +- ...ot-authority-reconciliation-assertions.sql | 49 +++++++++++++++++-- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index e4e69ff0..6ed48ca6 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -196,6 +196,12 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityAssertions).toContain('root authority reconciliation mutated protected decision or pointer authority directly') expect(rootAuthorityAssertions).toContain('root authority task convergence did not recover running and failed tasks') expect(rootAuthorityAssertions).toContain('the single root reconciliation operation does not cover every prepared journal generation') + expect(rootAuthorityAssertions).toContain('root authority addition package convergence is not canonical') + expect(rootAuthorityAssertions).toContain('root authority refresh package convergence is not canonical') + expect(rootAuthorityAssertions).toContain('root authority recovery package convergence is not canonical') + expect(rootAuthorityAssertions).toContain("'phases', package_row.metadata->'mcpGrantPhases'") + expect(rootAuthorityAssertions).toContain("'errorMessage', task_row.error_message") + expect(rootAuthorityPackageFixture).toContain("'[]'::jsonb") }) it('keeps the dedicated pre-reconciliation rollback proof bounded and resumable', () => { diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql index e1d0607c..961f13b0 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -84,7 +84,10 @@ INSERT INTO public.work_packages ( 'A valid prior marker that later clears through recovery.', 'blocked', 1, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read"]}]'::jsonb, + -- A prior filesystem marker can only recover after the package no longer + -- has a blocking filesystem requirement. A root repoint revokes every + -- still-required filesystem capability and therefore refreshes its hold. + '[]'::jsonb, 'Filesystem context requires an operator decision before execution.', '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-removal"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","blockedAt":"2026-07-28T00:00:02.000Z","holdKind":"consumed_once","grantPhase":"approved","grantConsumed":true,"grantDecisionRevision":"1","revocationReason":null}}'::jsonb ); diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql index e681441b..80266f06 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -21,6 +21,8 @@ DECLARE v_operation_id uuid; v_authority_generation bigint; v_through_generation bigint; + v_package_actual jsonb; + v_task_actual jsonb; BEGIN SELECT actor_id, operation_id, authority_generation, through_generation INTO STRICT v_actor, v_operation_id, v_authority_generation, v_through_generation @@ -89,7 +91,19 @@ BEGIN '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb OR package_row.metadata->'mcpGrantBlock'->'requestedCapabilities' <> '["filesystem.project.read","filesystem.project.search"]'::jsonb ) - ) OR EXISTS ( + ) THEN + SELECT jsonb_build_object( + 'blockedReason', package_row.blocked_reason, + 'marker', package_row.metadata->'mcpGrantBlock', + 'peer', package_row.metadata->'fixturePeer', + 'phases', package_row.metadata->'mcpGrantPhases', + 'status', package_row.status + ) INTO STRICT v_package_actual + FROM public.work_packages package_row WHERE package_row.id = v_add; + RAISE EXCEPTION 'root authority addition package convergence is not canonical: %', v_package_actual; + END IF; + + IF EXISTS ( SELECT 1 FROM public.work_packages package_row WHERE package_row.id = v_refresh AND ( @@ -100,7 +114,19 @@ BEGIN '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}}}'::jsonb OR package_row.metadata->'mcpGrantBlock'->'requestedCapabilities' <> '["filesystem.project.read","filesystem.project.search"]'::jsonb ) - ) OR EXISTS ( + ) THEN + SELECT jsonb_build_object( + 'blockedReason', package_row.blocked_reason, + 'marker', package_row.metadata->'mcpGrantBlock', + 'peer', package_row.metadata->'fixturePeer', + 'phases', package_row.metadata->'mcpGrantPhases', + 'status', package_row.status + ) INTO STRICT v_package_actual + FROM public.work_packages package_row WHERE package_row.id = v_refresh; + RAISE EXCEPTION 'root authority refresh package convergence is not canonical: %', v_package_actual; + END IF; + + IF EXISTS ( SELECT 1 FROM public.work_packages package_row WHERE package_row.id = v_recover AND ( @@ -111,7 +137,15 @@ BEGIN '{"fixturePeer":{"case":"removal","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-remove","revision":"1"}}}'::jsonb ) ) THEN - RAISE EXCEPTION 'root authority package marker, phase, or peer metadata convergence is not canonical'; + SELECT jsonb_build_object( + 'blockedReason', package_row.blocked_reason, + 'marker', package_row.metadata->'mcpGrantBlock', + 'peer', package_row.metadata->'fixturePeer', + 'phases', package_row.metadata->'mcpGrantPhases', + 'status', package_row.status + ) INTO STRICT v_package_actual + FROM public.work_packages package_row WHERE package_row.id = v_recover; + RAISE EXCEPTION 'root authority recovery package convergence is not canonical: %', v_package_actual; END IF; IF EXISTS ( @@ -119,7 +153,14 @@ BEGIN WHERE (task_row.id = v_task_running OR task_row.id = v_task_failed) AND (task_row.status <> 'approved' OR task_row.error_message IS NOT NULL) ) THEN - RAISE EXCEPTION 'root authority task convergence did not recover running and failed tasks'; + SELECT jsonb_agg(jsonb_build_object( + 'errorMessage', task_row.error_message, + 'id', task_row.id, + 'status', task_row.status + ) ORDER BY task_row.id) INTO STRICT v_task_actual + FROM public.tasks task_row + WHERE task_row.id = v_task_running OR task_row.id = v_task_failed; + RAISE EXCEPTION 'root authority task convergence did not recover running and failed tasks: %', v_task_actual; END IF; IF (SELECT count(*) FROM public.work_package_local_projection_heads head From f4673300b45e3cb65877535a7b8577e297f22139 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:14:47 +0800 Subject: [PATCH 56/69] test: make root authority additions admissible --- web/__tests__/project-root-reconciliation.test.ts | 4 ++++ .../ci/sql/migration-0027-root-authority-package-fixture.sql | 4 ++-- ...igration-0027-root-authority-reconciliation-assertions.sql | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 6ed48ca6..0a1c6da3 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -200,8 +200,12 @@ describe('project-root expansion reconciliation boundary', () => { expect(rootAuthorityAssertions).toContain('root authority refresh package convergence is not canonical') expect(rootAuthorityAssertions).toContain('root authority recovery package convergence is not canonical') expect(rootAuthorityAssertions).toContain("'phases', package_row.metadata->'mcpGrantPhases'") + expect(rootAuthorityAssertions).toContain("'requirements', package_row.mcp_requirements") expect(rootAuthorityAssertions).toContain("'errorMessage', task_row.error_message") expect(rootAuthorityPackageFixture).toContain("'[]'::jsonb") + expect(rootAuthorityPackageFixture).toContain('"requirement":"required"') + expect(rootAuthorityPackageFixture).toContain('"assignedRole":"backend"') + expect(rootAuthorityPackageFixture).toContain('"fallback":{"action":"block","message":""}') }) it('keeps the dedicated pre-reconciliation rollback proof bounded and resumable', () => { diff --git a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql index 961f13b0..25fe9c15 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-package-fixture.sql @@ -60,7 +60,7 @@ INSERT INTO public.work_packages ( 'A ready package that later receives the canonical root hold.', 'ready', 1, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, + '[{"mcpId":"filesystem","requirement":"required","assignedRole":"backend","fallback":{"action":"block","message":""},"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, NULL, '{"fixturePeer":{"case":"addition","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-add","revision":"1"}}}'::jsonb ), @@ -72,7 +72,7 @@ INSERT INTO public.work_packages ( 'A valid prior marker that later refreshes under the root change.', 'blocked', 2, - '[{"mcpId":"filesystem","required":true,"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, + '[{"mcpId":"filesystem","requirement":"required","assignedRole":"backend","fallback":{"action":"block","message":""},"capabilities":["filesystem.project.read","filesystem.project.search"]}]'::jsonb, 'Filesystem context requires an operator decision before execution.', '{"fixturePeer":{"case":"replacement","retain":true},"mcpGrantPhases":{"filesystem":{"phase":"preexisting-replace","revision":"1"}},"mcpGrantBlock":{"schemaVersion":2,"kind":"filesystem_grant","source":"filesystem-grant-approval","taskDisposition":"operator_hold","autoRetryable":false,"terminalFailure":false,"requirementKeys":["requirement:root-replacement"],"requestedCapabilities":["filesystem.project.read"],"recoveryAction":"approve_project_filesystem_context","blockFingerprint":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","blockedAt":"2026-07-28T00:00:01.000Z","holdKind":"approval_required","grantPhase":"none","grantConsumed":false,"grantDecisionRevision":null,"revocationReason":null}}'::jsonb ), diff --git a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql index 80266f06..077d533b 100644 --- a/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-authority-reconciliation-assertions.sql @@ -97,6 +97,7 @@ BEGIN 'marker', package_row.metadata->'mcpGrantBlock', 'peer', package_row.metadata->'fixturePeer', 'phases', package_row.metadata->'mcpGrantPhases', + 'requirements', package_row.mcp_requirements, 'status', package_row.status ) INTO STRICT v_package_actual FROM public.work_packages package_row WHERE package_row.id = v_add; @@ -120,6 +121,7 @@ BEGIN 'marker', package_row.metadata->'mcpGrantBlock', 'peer', package_row.metadata->'fixturePeer', 'phases', package_row.metadata->'mcpGrantPhases', + 'requirements', package_row.mcp_requirements, 'status', package_row.status ) INTO STRICT v_package_actual FROM public.work_packages package_row WHERE package_row.id = v_refresh; @@ -142,6 +144,7 @@ BEGIN 'marker', package_row.metadata->'mcpGrantBlock', 'peer', package_row.metadata->'fixturePeer', 'phases', package_row.metadata->'mcpGrantPhases', + 'requirements', package_row.mcp_requirements, 'status', package_row.status ) INTO STRICT v_package_actual FROM public.work_packages package_row WHERE package_row.id = v_recover; From 07072ae12ec3703284c3e7d6f0375e47f6812bb7 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:32:45 +0800 Subject: [PATCH 57/69] test: close root reconciler privilege allowlist --- .../project-root-reconciliation.test.ts | 8 + ...-root-reconciler-privileges-assertions.sql | 193 ++++++++++++++++-- 2 files changed, 186 insertions(+), 15 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 0a1c6da3..611ff8ff 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -233,9 +233,17 @@ describe('project-root expansion reconciliation boundary', () => { }) it('keeps the dedicated reconciler privilege proof catalog-driven and mandatory', () => { + expect(privilegeProof).toContain('root_reconciler_expected_privileges') + expect(privilegeProof).toContain('root_reconciler_actual_privileges') expect(privilegeProof).toContain('has_table_privilege') expect(privilegeProof).toContain('has_column_privilege') expect(privilegeProof).toContain('has_function_privilege') + expect(privilegeProof).toContain('has_sequence_privilege') + expect(privilegeProof).toContain('has_schema_privilege') + expect(privilegeProof).toContain('has_database_privilege') + expect(privilegeProof).toContain('pg_auth_members') + expect(privilegeProof).toContain('pg_get_function_identity_arguments') + expect(privilegeProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeProof).toContain('project_root_reconciliation_write_contexts') expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') }) diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index 08627feb..e2d8f69e 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -1,21 +1,184 @@ \set ON_ERROR_STOP on +BEGIN; + +CREATE TEMP TABLE root_reconciler_expected_privileges ( + category text NOT NULL, + entry text NOT NULL, + PRIMARY KEY (category, entry) +) ON COMMIT DROP; +CREATE TEMP TABLE root_reconciler_actual_privileges ( + category text NOT NULL, + entry text NOT NULL, + PRIMARY KEY (category, entry) +) ON COMMIT DROP; + +INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES + ('relation', 'public.projects:SELECT'), + ('relation', 'public.tasks:SELECT'), + ('relation', 'public.work_packages:SELECT'), + ('relation', 'public.filesystem_mcp_grant_approvals:SELECT'), + ('relation', 'public.filesystem_mcp_current_decision_pointers:SELECT'), + ('relation', 'public.project_filesystem_grant_decisions:SELECT'), + ('relation', 'public.project_filesystem_current_decision_pointers:SELECT'), + ('relation', 'public.work_package_local_projection_heads:SELECT'), + ('column_update', 'public.tasks.status'), + ('column_update', 'public.tasks.error_message'), + ('column_update', 'public.tasks.updated_at'), + ('column_update', 'public.work_packages.status'), + ('column_update', 'public.work_packages.blocked_reason'), + ('column_update', 'public.work_packages.metadata'), + ('column_update', 'public.work_packages.updated_at'), + ('routine', 'forge.advance_local_projection_head_v1(uuid,uuid,text,uuid,bigint,text,jsonb,bigint,text,text)'), + ('routine', 'forge.begin_project_root_reconciliation_v1(uuid,uuid,bigint)'), + ('routine', 'forge.claim_project_root_reconciliation_batch_v1(uuid,uuid,integer)'), + ('routine', 'forge.complete_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid,text)'), + ('routine', 'forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)'), + ('routine', 'forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid)'), + ('routine', 'forge.materialize_project_root_ref_expansion_v1(integer)'), + ('schema', 'public:USAGE'), + ('schema', 'forge:USAGE'), + ('database', 'CONNECT'), + ('database', 'TEMPORARY'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'relation', namespace_row.nspname || '.' || relation.relname || ':' || privilege.privilege +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +CROSS JOIN (VALUES ('SELECT'), ('INSERT'), ('UPDATE'), ('DELETE'), ('TRUNCATE'), ('REFERENCES'), ('TRIGGER'), ('MAINTAIN')) AS privilege(privilege) +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege); +-- The scan intentionally includes protected relations such as +-- public.project_root_reconciliation_write_contexts: absent from the allowlist +-- means every effective direct relation privilege is rejected. + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'column_update', namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = relation.oid +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND attribute_row.attnum > 0 + AND NOT attribute_row.attisdropped + AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, 'UPDATE'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.pg_get_function_identity_arguments(routine.oid), ', ', ',') || ')' +FROM pg_catalog.pg_proc routine +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace +WHERE namespace_row.nspname IN ('public', 'forge') + AND routine.prokind IN ('f', 'p') + AND pg_catalog.has_function_privilege('forge_project_root_reconciler', routine.oid, 'EXECUTE'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'schema', namespace_row.nspname || ':' || privilege.privilege +FROM pg_catalog.pg_namespace namespace_row +CROSS JOIN (VALUES ('USAGE'), ('CREATE')) AS privilege(privilege) +WHERE namespace_row.nspname IN ('public', 'forge') + AND pg_catalog.has_schema_privilege('forge_project_root_reconciler', namespace_row.oid, privilege.privilege); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'database', privilege.privilege +FROM pg_catalog.pg_database database_row +CROSS JOIN (VALUES ('CONNECT'), ('TEMPORARY'), ('CREATE')) AS privilege(privilege) +WHERE database_row.datname = pg_catalog.current_database() + AND pg_catalog.has_database_privilege('forge_project_root_reconciler', database_row.oid, privilege.privilege); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'sequence', namespace_row.nspname || '.' || sequence_row.relname || ':' || privilege.privilege +FROM pg_catalog.pg_class sequence_row +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = sequence_row.relnamespace +CROSS JOIN (VALUES ('USAGE'), ('SELECT'), ('UPDATE')) AS privilege(privilege) +WHERE namespace_row.nspname IN ('public', 'forge') + AND sequence_row.relkind = 'S' + AND pg_catalog.has_sequence_privilege('forge_project_root_reconciler', sequence_row.oid, privilege.privilege); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'ownership', 'database:' || database_row.datname +FROM pg_catalog.pg_database database_row +WHERE database_row.datname = pg_catalog.current_database() + AND database_row.datdba = 'forge_project_root_reconciler'::regrole +UNION ALL +SELECT 'ownership', 'schema:' || namespace_row.nspname +FROM pg_catalog.pg_namespace namespace_row +WHERE namespace_row.nspname IN ('public', 'forge') + AND namespace_row.nspowner = 'forge_project_root_reconciler'::regrole +UNION ALL +SELECT 'ownership', 'relation:' || namespace_row.nspname || '.' || relation.relname +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relowner = 'forge_project_root_reconciler'::regrole +UNION ALL +SELECT 'ownership', 'routine:' || namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.pg_get_function_identity_arguments(routine.oid), ', ', ',') || ')' +FROM pg_catalog.pg_proc routine +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace +WHERE namespace_row.nspname IN ('public', 'forge') + AND routine.proowner = 'forge_project_root_reconciler'::regrole; + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'membership', granted.rolname || '<-' || member.rolname || ':admin=' || membership.admin_option || ':inherit=' || membership.inherit_option || ':set=' || membership.set_option +FROM pg_catalog.pg_auth_members membership +JOIN pg_catalog.pg_roles granted ON granted.oid = membership.roleid +JOIN pg_catalog.pg_roles member ON member.oid = membership.member +WHERE membership.member = 'forge_project_root_reconciler'::regrole + OR membership.roleid = 'forge_project_root_reconciler'::regrole; + DO $proof$ -DECLARE r pg_catalog.pg_roles%ROWTYPE; +DECLARE + role_row pg_catalog.pg_roles%ROWTYPE; + diff jsonb; BEGIN - SELECT * INTO STRICT r FROM pg_catalog.pg_roles WHERE rolname='forge_project_root_reconciler'; - IF NOT r.rolcanlogin OR r.rolinherit OR r.rolsuper OR r.rolcreaterole OR r.rolcreatedb OR r.rolreplication OR r.rolbypassrls - OR EXISTS (SELECT 1 FROM pg_catalog.pg_auth_members WHERE member='forge_project_root_reconciler'::regrole OR roleid='forge_project_root_reconciler'::regrole) THEN - RAISE EXCEPTION 'root reconciler role attributes or memberships exceed the closed contract'; + SELECT * INTO STRICT role_row + FROM pg_catalog.pg_roles + WHERE rolname = 'forge_project_root_reconciler'; + IF NOT role_row.rolcanlogin OR role_row.rolinherit OR role_row.rolsuper OR role_row.rolcreaterole + OR role_row.rolcreatedb OR role_row.rolreplication OR role_row.rolbypassrls THEN + RAISE EXCEPTION 'root reconciler role attributes exceed the closed contract: %', + jsonb_build_object('canlogin', role_row.rolcanlogin, 'inherit', role_row.rolinherit, + 'superuser', role_row.rolsuper, 'createrole', role_row.rolcreaterole, + 'createdb', role_row.rolcreatedb, 'replication', role_row.rolreplication, + 'bypassrls', role_row.rolbypassrls); + END IF; + + SELECT coalesce(jsonb_agg(jsonb_build_object( + 'category', category, + 'missing', missing, + 'unexpected', unexpected + ) ORDER BY category), '[]'::jsonb) + INTO diff + FROM ( + SELECT categories.category, + coalesce((SELECT jsonb_agg(entry ORDER BY entry) FROM ( + SELECT expected.entry + FROM root_reconciler_expected_privileges expected + WHERE expected.category = categories.category + EXCEPT + SELECT actual.entry + FROM root_reconciler_actual_privileges actual + WHERE actual.category = categories.category + ) missing_rows), '[]'::jsonb) AS missing, + coalesce((SELECT jsonb_agg(entry ORDER BY entry) FROM ( + SELECT actual.entry + FROM root_reconciler_actual_privileges actual + WHERE actual.category = categories.category + EXCEPT + SELECT expected.entry + FROM root_reconciler_expected_privileges expected + WHERE expected.category = categories.category + ) unexpected_rows), '[]'::jsonb) AS unexpected + FROM ( + SELECT category FROM root_reconciler_expected_privileges + UNION + SELECT category FROM root_reconciler_actual_privileges + ) categories + ) category_diffs + WHERE missing <> '[]'::jsonb OR unexpected <> '[]'::jsonb; + + IF diff <> '[]'::jsonb THEN + RAISE EXCEPTION 'root reconciler effective privilege allowlist mismatch: %', diff; END IF; - IF NOT has_table_privilege('forge_project_root_reconciler','public.projects','SELECT') - OR NOT has_table_privilege('forge_project_root_reconciler','public.tasks','SELECT') - OR NOT has_column_privilege('forge_project_root_reconciler','public.tasks','status','UPDATE') - OR NOT has_column_privilege('forge_project_root_reconciler','public.work_packages','metadata','UPDATE') - OR has_table_privilege('forge_project_root_reconciler','public.tasks','INSERT, DELETE, TRUNCATE, UPDATE') - OR has_table_privilege('forge_project_root_reconciler','public.project_root_reconciliation_write_contexts','SELECT, INSERT, UPDATE, DELETE') - OR has_function_privilege('forge_project_root_reconciler','forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)','EXECUTE') IS NOT TRUE - OR has_function_privilege('forge_project_root_reconciler','forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid)','EXECUTE') IS NOT TRUE - OR has_function_privilege('forge_app_test','forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)','EXECUTE') - THEN RAISE EXCEPTION 'root reconciler catalog privileges do not match the closed runtime contract'; END IF; END; $proof$; +COMMIT; From 0484f0198e1cf5fbf0837799ff848c703ad25f20 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:36:43 +0800 Subject: [PATCH 58/69] test: prove root reconciler privilege allowlist mutations --- .../project-root-reconciliation.test.ts | 15 ++++ ...027-root-reconciler-privilege-mutations.sh | 73 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + ...-root-reconciler-privileges-assertions.sql | 9 ++- 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 611ff8ff..7b9a957e 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -22,6 +22,7 @@ const rootAuthorityAssertions = readFileSync(fileURLToPath(new URL('../scripts/c const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-negative.sh', import.meta.url)), 'utf8') const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') +const privilegeMutationProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh', import.meta.url)), 'utf8') const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') const negativeAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql', import.meta.url)), 'utf8') const contentionProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-contention.sh', import.meta.url)), 'utf8') @@ -248,6 +249,20 @@ describe('project-root expansion reconciliation boundary', () => { expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') }) + it('proves the exact closed privilege allowlist rejects direct, column, and PUBLIC-effective mutations', () => { + expect(privilegeMutationProof).toContain('GRANT INSERT ON TABLE public.projects TO forge_project_root_reconciler;') + expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') + expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') + expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') + expect(privilegeMutationProof).toContain('public.projects:INSERT') + expect(privilegeMutationProof).toContain('public.tasks.title') + expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') + expect(privilegeMutationProof).toContain('\\i ${assertion_file}') + expect(privilegeMutationProof).not.toContain('has_table_privilege') + expect(privilegeMutationProof).toContain('fresh clean allowlist assertion') + expect(upgradeProof).toContain('prove-migration-0027-root-reconciler-privilege-mutations.sh') + }) + it('rejects stale and fabricated root reconciliation context reuse before the CLI resumes', () => { expect(staleContextProof).toContain('forge.enter_project_root_reconciliation_generation_v1') expect(staleContextProof).toContain('forge.complete_project_root_reconciliation_generation_v1') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh new file mode 100644 index 00000000..a2e231b5 --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +assertion_file="${script_dir}/sql/migration-0027-root-reconciler-privileges-assertions.sql" +proof_tmpdir="$(mktemp -d)" + +cleanup() { + rm -rf "${proof_tmpdir}" +} +trap cleanup EXIT + +fail_with_output() { + local phase="$1" + local output_file="$2" + echo "Root reconciler privilege mutation proof failed during ${phase}." >&2 + cat "${output_file}" >&2 + exit 1 +} + +expect_allowlist_rejection() { + local phase="$1" + local expected_entry="$2" + local grant_sql="$3" + local output_file="${proof_tmpdir}/${phase}.log" + local status=0 + + set +e + psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 >"${output_file}" 2>&1 </dev/null; then + fail_with_output "${phase}: exact allowlist rejection was absent" "${output_file}" + fi + if ! grep -F -- "${expected_entry}" "${output_file}" >/dev/null; then + fail_with_output "${phase}: expected unexpected-set entry ${expected_entry} was absent" "${output_file}" + fi +} + +# Every mutation is uncommitted. The assertion failure closes that psql session, +# which rolls the transaction back before the following fresh-session probe. +expect_allowlist_rejection \ + 'relation privilege mutation' \ + 'public.projects:INSERT' \ + 'GRANT INSERT ON TABLE public.projects TO forge_project_root_reconciler;' + +expect_allowlist_rejection \ + 'column update privilege mutation' \ + 'public.tasks.title' \ + 'GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;' + +# This intentionally exercises an effective privilege inherited from PUBLIC, +# rather than only a direct grant to the dedicated login. +expect_allowlist_rejection \ + 'public routine execute mutation' \ + 'forge.read_epic_172_enablement_state_v1()' \ + 'GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;' + +clean_output="${proof_tmpdir}/clean.log" +if ! psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file "${assertion_file}" >"${clean_output}" 2>&1; then + fail_with_output 'fresh clean allowlist assertion' "${clean_output}" +fi diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index f665b647..c104a0e9 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -60,6 +60,7 @@ fi bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +bash scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh # This creates (but never advances) the exact live operation. The shim resumes # it below after its now-empty materialization pass. bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index e2d8f69e..eec6f067 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -1,16 +1,15 @@ \set ON_ERROR_STOP on -BEGIN; CREATE TEMP TABLE root_reconciler_expected_privileges ( category text NOT NULL, entry text NOT NULL, PRIMARY KEY (category, entry) -) ON COMMIT DROP; +); CREATE TEMP TABLE root_reconciler_actual_privileges ( category text NOT NULL, entry text NOT NULL, PRIMARY KEY (category, entry) -) ON COMMIT DROP; +); INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES ('relation', 'public.projects:SELECT'), @@ -181,4 +180,6 @@ BEGIN END IF; END; $proof$; -COMMIT; + +DROP TABLE root_reconciler_actual_privileges; +DROP TABLE root_reconciler_expected_privileges; From 52d798a62c9ae4e1307af919575addcb084283ce Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:38:06 +0800 Subject: [PATCH 59/69] fix: gate root reconciler maintain privilege by server version --- web/__tests__/project-root-reconciliation.test.ts | 3 +++ ...ration-0027-root-reconciler-privileges-assertions.sql | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 7b9a957e..98b22327 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -237,6 +237,9 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeProof).toContain('root_reconciler_expected_privileges') expect(privilegeProof).toContain('root_reconciler_actual_privileges') expect(privilegeProof).toContain('has_table_privilege') + expect(privilegeProof).toContain("unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER']::text[])") + expect(privilegeProof).toContain("SELECT 'MAINTAIN'") + expect(privilegeProof).toContain("current_setting('server_version_num')::integer >= 170000") expect(privilegeProof).toContain('has_column_privilege') expect(privilegeProof).toContain('has_function_privilege') expect(privilegeProof).toContain('has_sequence_privilege') diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index eec6f067..d943caca 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -43,7 +43,14 @@ INSERT INTO root_reconciler_actual_privileges (category, entry) SELECT 'relation', namespace_row.nspname || '.' || relation.relname || ':' || privilege.privilege FROM pg_catalog.pg_class relation JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace -CROSS JOIN (VALUES ('SELECT'), ('INSERT'), ('UPDATE'), ('DELETE'), ('TRUNCATE'), ('REFERENCES'), ('TRIGGER'), ('MAINTAIN')) AS privilege(privilege) +CROSS JOIN ( + SELECT privilege + FROM unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER']::text[]) AS supported(privilege) + UNION ALL + -- MAINTAIN is a PostgreSQL 17 relation privilege; PostgreSQL 16 rejects it. + SELECT 'MAINTAIN' + WHERE pg_catalog.current_setting('server_version_num')::integer >= 170000 +) AS privilege WHERE namespace_row.nspname IN ('public', 'forge') AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') AND pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege); From 2ed915c1aba772058b1b50b1807d46b99e124ccf Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:41:44 +0800 Subject: [PATCH 60/69] test: isolate root reconciliation binding rejections --- .../project-root-reconciliation.test.ts | 8 +++ ...ation-0027-root-reconciliation-negative.sh | 51 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 98b22327..1e8d4b2d 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -215,6 +215,14 @@ describe('project-root expansion reconciliation boundary', () => { expect(negativeProof).toContain('SELECT 1/0') expect(negativeProof).toContain('project-root operation identity cannot be hijacked') expect(negativeProof).toContain('project-root authority lock has no active write context') + expect(negativeProof).toContain("'correct-next-generation wrong-project binding'") + expect(negativeProof).toContain("'wrong-generation correct-journal-project binding'") + expect(negativeProof).toContain("WHERE project_id <> '${project}'::uuid") + expect(negativeProof).toContain('WHERE generation > ${generation} AND generation <= ${through}') + expect(negativeProof).toContain('snapshot_reconciliation_state') + expect(negativeProof).toContain("'runtime_audits'") + expect(negativeProof).toContain("'projection_heads'") + expect(negativeProof).toContain("'packages'") expect(negativeProof).toContain('migration-0027-root-reconciliation-negative-assertions.sql') expect(negativeProof).not.toContain('reconcile-migration-0027-root-refs.sh') expect(upgradeProof.indexOf('prove-migration-0027-root-reconciliation-negative.sh')).toBeLessThan( diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index e0490ef1..5b643d78 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -21,6 +21,57 @@ operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --comma operation_id="${operation%%|*}" [[ "$operation" == *'|running|0' && "$operation_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof did not create the exact live operation' >&2; exit 1; } +snapshot_reconciliation_state() { + psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command " + WITH snapshots AS ( + SELECT 'operations' AS category, coalesce(jsonb_agg(to_jsonb(operation_row) ORDER BY operation_row.operation_id)::text, '[]') AS payload FROM public.project_root_reconciliation_operations operation_row + UNION ALL SELECT 'checkpoints', coalesce(jsonb_agg(to_jsonb(checkpoint_row) ORDER BY checkpoint_row.operation_id, checkpoint_row.checkpoint_generation)::text, '[]') FROM public.project_root_reconciliation_checkpoints checkpoint_row + UNION ALL SELECT 'outcomes', coalesce(jsonb_agg(to_jsonb(outcome_row) ORDER BY outcome_row.generation)::text, '[]') FROM public.project_root_reconciliation_outcomes outcome_row + UNION ALL SELECT 'contexts', coalesce(jsonb_agg(to_jsonb(context_row) ORDER BY context_row.operation_id, context_row.generation)::text, '[]') FROM public.project_root_reconciliation_write_contexts context_row + UNION ALL SELECT 'journal', coalesce(jsonb_agg(to_jsonb(journal_row) ORDER BY journal_row.generation)::text, '[]') FROM public.project_root_change_journal journal_row + UNION ALL SELECT 'runtime_audits', coalesce(jsonb_agg(to_jsonb(audit_row) ORDER BY audit_row.id)::text, '[]') FROM public.filesystem_mcp_runtime_audits audit_row + UNION ALL SELECT 'project_pointers', coalesce(jsonb_agg(to_jsonb(pointer_row) ORDER BY pointer_row.project_id)::text, '[]') FROM public.project_filesystem_current_decision_pointers pointer_row + UNION ALL SELECT 'package_pointers', coalesce(jsonb_agg(to_jsonb(pointer_row) ORDER BY pointer_row.work_package_id)::text, '[]') FROM public.filesystem_mcp_current_decision_pointers pointer_row + UNION ALL SELECT 'projection_heads', coalesce(jsonb_agg(to_jsonb(head_row) ORDER BY head_row.id)::text, '[]') FROM public.work_package_local_projection_heads head_row + UNION ALL SELECT 'tasks', coalesce(jsonb_agg(to_jsonb(task_row) ORDER BY task_row.id)::text, '[]') FROM public.tasks task_row + UNION ALL SELECT 'packages', coalesce(jsonb_agg(to_jsonb(package_row) ORDER BY package_row.id)::text, '[]') FROM public.work_packages package_row + ) + SELECT md5(string_agg(category || ':' || payload, E'\\n' ORDER BY category)) FROM snapshots" +} + +expect_isolated_rejection() { + local phase="$1" expected_text="$2" generation_input="$3" project_input="$4" output status before after + before="$(snapshot_reconciliation_state)" + output="$(mktemp)" + if psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,${generation_input}::bigint,'${project_input}'::uuid)" >"$output" 2>&1; then + echo "negative reconciliation proof ${phase} unexpectedly succeeded" >&2 + cat "$output" >&2 + unlink "$output" + exit 1 + else + status=$? + fi + if [[ "$status" != 1 ]] || ! grep -F -- "$expected_text" "$output" >/dev/null; then + echo "negative reconciliation proof ${phase} received an unexpected psql status/error (status=${status})" >&2 + cat "$output" >&2 + unlink "$output" + exit 1 + fi + unlink "$output" + after="$(snapshot_reconciliation_state)" + [[ "$before" == "$after" && "$after" =~ ^[0-9a-f]{32}$ ]] || { echo "negative reconciliation proof ${phase} changed protected reconciliation state" >&2; exit 1; } +} + +wrong_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command "SELECT project_id FROM public.project_root_change_journal WHERE project_id <> '${project}'::uuid ORDER BY generation LIMIT 1")" +[[ "$wrong_project" =~ ^[0-9a-f-]{36}$ && "$wrong_project" != "$project" ]] || { echo 'negative proof lacks a distinct journal project for project-binding isolation' >&2; exit 1; } +expect_isolated_rejection 'correct-next-generation wrong-project binding' 'project-root write context is not claimable' "$generation" "$wrong_project" + +wrong_generation_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT generation, project_id FROM public.project_root_change_journal WHERE generation > ${generation} AND generation <= ${through} ORDER BY generation LIMIT 1")" +wrong_generation="${wrong_generation_project%%|*}" +wrong_generation_project_id="${wrong_generation_project#*|}" +[[ "$wrong_generation" =~ ^[1-9][0-9]*$ && "$wrong_generation" != "$generation" && "$wrong_generation_project_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof lacks a later journal generation for generation-sequence isolation' >&2; exit 1; } +expect_isolated_rejection 'wrong-generation correct-journal-project binding' 'project-root write context is not claimable' "$wrong_generation" "$wrong_generation_project_id" + expect_failure() { local expected_status="$1" text="$2"; shift 2; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == "$expected_status" ]] || { echo "negative reconciliation proof expected psql exit ${expected_status} for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } expect_failure 1 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" expect_failure 1 'query returned no rows' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('33333333-3333-4333-8333-333333333333'::uuid,'${actor}'::uuid,1,'${project}'::uuid)" From 30b0fab82a30770590c3ccc5b89122171a5850f3 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:49:00 +0800 Subject: [PATCH 61/69] fix: close root reconciler routine allowlist --- .../project-root-reconciliation.test.ts | 21 ++++++++- web/scripts/bootstrap-epic-172-s4-roles.ts | 34 ++++++++++++++ ...ration-0027-ordinary-app-trigger-writes.sh | 46 +++++++++++++++++++ .../ci/prove-migration-0027-upgrade.sh | 1 + ...-root-reconciler-privileges-assertions.sql | 9 +++- 5 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 1e8d4b2d..f2b65bf9 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -23,6 +23,7 @@ const negativeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-mi const replayProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciliation-replay.sh', import.meta.url)), 'utf8') const privilegeProof = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql', import.meta.url)), 'utf8') const privilegeMutationProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh', import.meta.url)), 'utf8') +const ordinaryAppTriggerProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh', import.meta.url)), 'utf8') const staleContextProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-stale-context.sh', import.meta.url)), 'utf8') const negativeAssertions = readFileSync(fileURLToPath(new URL('../scripts/ci/sql/migration-0027-root-reconciliation-negative-assertions.sql', import.meta.url)), 'utf8') const contentionProof = readFileSync(fileURLToPath(new URL('../scripts/ci/prove-migration-0027-root-contention.sh', import.meta.url)), 'utf8') @@ -254,7 +255,25 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeProof).toContain('has_schema_privilege') expect(privilegeProof).toContain('has_database_privilege') expect(privilegeProof).toContain('pg_auth_members') - expect(privilegeProof).toContain('pg_get_function_identity_arguments') + expect(privilegeProof).toContain('oidvectortypes(routine.proargtypes)') + expect(privilegeProof).toContain('public.forge_epic_172_s3_release_state:SELECT') + expect(privilegeProof).toContain('public.forge_is_canonical_bounded_string_set(jsonb,integer,integer)') + expect(privilegeProof).toContain('public.forge_is_canonical_filesystem_capability_set(jsonb)') + expect(privilegeProof).toContain('public.forge_is_canonical_filesystem_grant_block_v2(jsonb)') + expect(privilegeProof).toContain('public.forge_is_canonical_utc_timestamp(text)') + expect(privilegeProof).toContain('pure, immutable input validators') + expect(bootstrap).toContain('public.forge_preallocate_filesystem_decision_pointer()') + expect(bootstrap).toContain('public.forge_preallocate_project_filesystem_decision_pointer()') + expect(bootstrap).toContain('public.forge_reject_filesystem_grant_history_mutation()') + expect(bootstrap).toContain('public.forge_reject_project_filesystem_decision_mutation()') + expect(bootstrap).toContain('These six routines are trigger-only') + expect(bootstrap).toContain('publicTriggerExecuteCount') + expect(bootstrap).toContain('Trigger-only S3 helpers retain an unsafe PUBLIC EXECUTE grant.') + expect(ordinaryAppTriggerProof).toContain('postgresql://forge_app_test:forge_app_test@') + expect(ordinaryAppTriggerProof).toContain('public.project_filesystem_current_decision_pointers') + expect(ordinaryAppTriggerProof).toContain('public.filesystem_mcp_current_decision_pointers') + expect(ordinaryAppTriggerProof).toContain('work_package_local_projection_heads') + expect(upgradeProof).toContain('prove-migration-0027-ordinary-app-trigger-writes.sh') expect(privilegeProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeProof).toContain('project_root_reconciliation_write_contexts') expect(upgradeProof).toContain('migration-0027-root-reconciler-privileges-assertions.sql') diff --git a/web/scripts/bootstrap-epic-172-s4-roles.ts b/web/scripts/bootstrap-epic-172-s4-roles.ts index 5a4a539b..5f2f32a6 100644 --- a/web/scripts/bootstrap-epic-172-s4-roles.ts +++ b/web/scripts/bootstrap-epic-172-s4-roles.ts @@ -113,6 +113,40 @@ async function main(): Promise { await admin`revoke create on schema forge from ${admin(OWNER)}` await admin`revoke create on schema public from ${admin(OWNER)}` await admin`grant execute on function forge.read_epic_172_enablement_state_v1() to ${admin(OWNER)}` + // These six routines are trigger-only: migrations create their triggers + // before this bootstrap runs, and the owning trigger tables invoke them. + // No runtime login needs direct EXECUTE, so remove PostgreSQL's default + // PUBLIC function grant without opening a reconciler capability. + await admin.unsafe(` + revoke execute on function + public.forge_epic_172_reject_mutation_v1(), + public.forge_epic_172_reject_project_hard_delete_v1(), + public.forge_preallocate_filesystem_decision_pointer(), + public.forge_preallocate_project_filesystem_decision_pointer(), + public.forge_reject_filesystem_grant_history_mutation(), + public.forge_reject_project_filesystem_decision_mutation() + from public + `) + const [{ publicTriggerExecuteCount }] = await admin<{ publicTriggerExecuteCount: number }[]>` + select count(*)::integer as "publicTriggerExecuteCount" + from pg_catalog.pg_proc routine + cross join lateral pg_catalog.aclexplode( + coalesce(routine.proacl, pg_catalog.acldefault('f', routine.proowner)) + ) acl + where routine.oid = any(array[ + 'public.forge_epic_172_reject_mutation_v1()'::pg_catalog.regprocedure, + 'public.forge_epic_172_reject_project_hard_delete_v1()'::pg_catalog.regprocedure, + 'public.forge_preallocate_filesystem_decision_pointer()'::pg_catalog.regprocedure, + 'public.forge_preallocate_project_filesystem_decision_pointer()'::pg_catalog.regprocedure, + 'public.forge_reject_filesystem_grant_history_mutation()'::pg_catalog.regprocedure, + 'public.forge_reject_project_filesystem_decision_mutation()'::pg_catalog.regprocedure + ]) + and acl.grantee = 0 + and acl.privilege_type = 'EXECUTE' + ` + if (publicTriggerExecuteCount !== 0) { + throw new Error('Trigger-only S3 helpers retain an unsafe PUBLIC EXECUTE grant.') + } // S4 claims and archive transitions take row locks on the bounded S3 // current-head set. PostgreSQL requires UPDATE privilege for SELECT ... // FOR UPDATE even though these routines never modify the head rows. diff --git a/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh new file mode 100644 index 00000000..537a8ade --- /dev/null +++ b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${FORGE_MIGRATION_0027_DATABASE_URL:?Set the disposable PostgreSQL 0027 upgrade database URL.}" +: "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" + +authority="${FORGE_MIGRATION_0027_DATABASE_URL#*://}" +app_url="postgresql://forge_app_test:forge_app_test@${authority#*@}" +project_id='27000000-0000-4000-8000-0000000007a1' +task_id='27000000-0000-4000-8000-0000000007a2' +package_id='27000000-0000-4000-8000-0000000007a3' + +psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 <<'SQL' +GRANT CONNECT ON DATABASE forge_migration_0027_upgrade_test TO forge_app_test; +GRANT USAGE ON SCHEMA public TO forge_app_test; +GRANT INSERT ON TABLE public.projects, public.tasks, public.work_packages, + public.project_filesystem_current_decision_pointers, + public.filesystem_mcp_current_decision_pointers + TO forge_app_test; +SQL + +psql "${app_url}" --set ON_ERROR_STOP=1 \ + --set project_id="${project_id}" \ + --set task_id="${task_id}" \ + --set package_id="${package_id}" <<'SQL' +INSERT INTO public.projects (id, name) +VALUES (:'project_id'::uuid, 'Ordinary application trigger proof'); +INSERT INTO public.tasks (id, project_id, title, prompt) +VALUES (:'task_id'::uuid, :'project_id'::uuid, 'Ordinary application task', 'trigger proof'); +INSERT INTO public.work_packages (id, task_id, assigned_role, title, summary, status, sequence) +VALUES (:'package_id'::uuid, :'task_id'::uuid, 'backend', 'Ordinary application package', 'trigger proof', 'pending', 1); +SQL + +trigger_state="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 \ + --set project_id="${project_id}" \ + --set package_id="${package_id}" --command " + SELECT EXISTS ( + SELECT 1 FROM public.project_filesystem_current_decision_pointers + WHERE project_id = :'project_id'::uuid + ) AND EXISTS ( + SELECT 1 FROM public.filesystem_mcp_current_decision_pointers + WHERE work_package_id = :'package_id'::uuid + ) AND (SELECT count(*) FROM public.work_package_local_projection_heads + WHERE work_package_id = :'package_id'::uuid) = 8 + ")" +[[ "${trigger_state}" == 't' ]] || { echo 'ordinary application writes did not fire the S3 pointer and projection-head triggers' >&2; exit 1; } diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index c104a0e9..da3efe94 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -32,6 +32,7 @@ psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-expansion-assertions.sql psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 \ --file scripts/ci/sql/migration-0027-archive-assertions.sql +bash scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh echo 'Reconciling the legacy Redis session with its exact absolute expiry.' npm run session-credentials:reconcile diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index d943caca..a06e44ba 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -20,6 +20,7 @@ INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES ('relation', 'public.project_filesystem_grant_decisions:SELECT'), ('relation', 'public.project_filesystem_current_decision_pointers:SELECT'), ('relation', 'public.work_package_local_projection_heads:SELECT'), + ('relation', 'public.forge_epic_172_s3_release_state:SELECT'), ('column_update', 'public.tasks.status'), ('column_update', 'public.tasks.error_message'), ('column_update', 'public.tasks.updated_at'), @@ -34,6 +35,12 @@ INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES ('routine', 'forge.enter_project_root_reconciliation_generation_v1(uuid,uuid,bigint,uuid)'), ('routine', 'forge.lock_project_root_reconciliation_authority_v1(uuid,uuid,bigint,uuid)'), ('routine', 'forge.materialize_project_root_ref_expansion_v1(integer)'), + -- These four functions are pure, immutable input validators. PUBLIC + -- execution is intentional and does not read or mutate reconciliation state. + ('routine', 'public.forge_is_canonical_bounded_string_set(jsonb,integer,integer)'), + ('routine', 'public.forge_is_canonical_filesystem_capability_set(jsonb)'), + ('routine', 'public.forge_is_canonical_filesystem_grant_block_v2(jsonb)'), + ('routine', 'public.forge_is_canonical_utc_timestamp(text)'), ('schema', 'public:USAGE'), ('schema', 'forge:USAGE'), ('database', 'CONNECT'), @@ -70,7 +77,7 @@ WHERE namespace_row.nspname IN ('public', 'forge') AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, 'UPDATE'); INSERT INTO root_reconciler_actual_privileges (category, entry) -SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.pg_get_function_identity_arguments(routine.oid), ', ', ',') || ')' +SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.oidvectortypes(routine.proargtypes), ', ', ',') || ')' FROM pg_catalog.pg_proc routine JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace WHERE namespace_row.nspname IN ('public', 'forge') From 392290e9b39d73bdcf9c00f2e6d1f06e1e0f8f26 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:55:27 +0800 Subject: [PATCH 62/69] test: prove root reconciler actor boundaries --- .../project-root-reconciliation.test.ts | 21 +++++++ ...ation-0027-root-reconciliation-negative.sh | 56 ++++++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index f2b65bf9..19d324af 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -218,6 +218,11 @@ describe('project-root expansion reconciliation boundary', () => { expect(negativeProof).toContain('project-root authority lock has no active write context') expect(negativeProof).toContain("'correct-next-generation wrong-project binding'") expect(negativeProof).toContain("'wrong-generation correct-journal-project binding'") + expect(negativeProof).toContain("'valid-entry wrong-actor ownership'") + expect(negativeProof).toContain("'composite completion wrong-actor context identity'") + expect(negativeProof).toContain("actor_b='44444444-4444-4444-8444-444444444444'") + expect(negativeProof).toContain('project-root write context is absent or stale') + expect(negativeProof).toContain('leave a context row behind') expect(negativeProof).toContain("WHERE project_id <> '${project}'::uuid") expect(negativeProof).toContain('WHERE generation > ${generation} AND generation <= ${through}') expect(negativeProof).toContain('snapshot_reconciliation_state') @@ -231,6 +236,22 @@ describe('project-root expansion reconciliation boundary', () => { ) }) + it('keeps completion context identity actor-bound before the non-disclosing operation CAS', () => { + const completionRoutine = migration.slice( + migration.indexOf('CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1'), + migration.indexOf( + 'CREATE OR REPLACE FUNCTION', + migration.indexOf('CREATE OR REPLACE FUNCTION forge.complete_project_root_reconciliation_generation_v1') + 1, + ), + ) + // Actor is intentionally part of the active-context lookup. Moving it to a + // later actor-specific check would disclose another actor's live context. + expect(completionRoutine).toContain('context.operation_id=p_operation_id AND context.generation=p_generation AND context.actor_id=p_actor_id AND context.project_id=p_project_id AND context.backend_pid=pg_catalog.pg_backend_pid() AND context.transaction_id=pg_catalog.txid_current() AND context.completed_at IS NULL') + expect(completionRoutine.indexOf('context.actor_id=p_actor_id')).toBeLessThan( + completionRoutine.indexOf('v_operation.actor_id <> p_actor_id'), + ) + }) + it('replays the completed reconciliation through the exact dedicated CLI without mutation', () => { expect(replayProof).toContain('"mode":"complete-replay"') expect(replayProof).toContain('FORGE_PROJECT_ROOT_RECONCILER_DATABASE_URL') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh index 5b643d78..b9e4d9c9 100755 --- a/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciliation-negative.sh @@ -3,6 +3,7 @@ set -euo pipefail : "${FORGE_DATABASE_ADMIN_URL:?Set the disposable administrator URL.}" actor='11111111-1111-4111-8111-111111111111' +actor_b='44444444-4444-4444-8444-444444444444' task='27000000-0000-4000-8000-000000000730' psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "ALTER ROLE forge_project_root_reconciler PASSWORD 'forge_project_root_reconciler_test';" >/dev/null authority="${FORGE_DATABASE_ADMIN_URL#*://}" @@ -13,9 +14,12 @@ while :; do [[ "$rows" == 0 ]] && break done through="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command 'SELECT last_generation FROM public.project_root_change_journal_counter WHERE singleton')" -gen_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command 'SELECT generation, project_id FROM public.project_root_change_journal WHERE generation=1')" -generation="${gen_project%%|*}"; project="${gen_project#*|}" -[[ "$generation" == 1 && "$project" =~ ^[0-9a-f-]{36}$ && "$through" =~ ^[1-9][0-9]*$ ]] || { echo 'negative proof requires journal generation one and a positive watermark' >&2; exit 1; } +gen_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command 'SELECT generation, project_id, outcome FROM public.project_root_change_journal WHERE generation=1')" +generation="${gen_project%%|*}" +project_outcome="${gen_project#*|}" +project="${project_outcome%%|*}" +outcome="${project_outcome#*|}" +[[ "$generation" == 1 && "$project" =~ ^[0-9a-f-]{36}$ && "$outcome" =~ ^(insert|root_update|archive)$ && "$through" =~ ^[1-9][0-9]*$ ]] || { echo 'negative proof requires journal generation one and a positive watermark' >&2; exit 1; } psql "$FORGE_DATABASE_ADMIN_URL" --set ON_ERROR_STOP=1 --command "INSERT INTO public.tasks(id,project_id,submitted_by,title,prompt,status) SELECT '${task}'::uuid, '${project}'::uuid, submitted_by, 'Root rollback proof', 'rollback only', 'running' FROM public.projects WHERE id='${project}'::uuid" operation="$(psql "$url" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT operation_id, state, last_processed_generation FROM forge.begin_project_root_reconciliation_v1(NULL,'${actor}'::uuid,${through}::bigint)")" operation_id="${operation%%|*}" @@ -40,10 +44,10 @@ snapshot_reconciliation_state() { } expect_isolated_rejection() { - local phase="$1" expected_text="$2" generation_input="$3" project_input="$4" output status before after + local phase="$1" expected_text="$2" actor_input="$3" generation_input="$4" project_input="$5" output status before after before="$(snapshot_reconciliation_state)" output="$(mktemp)" - if psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor}'::uuid,${generation_input}::bigint,'${project_input}'::uuid)" >"$output" 2>&1; then + if psql "$url" --set ON_ERROR_STOP=1 --command "SELECT forge.enter_project_root_reconciliation_generation_v1('${operation_id}'::uuid,'${actor_input}'::uuid,${generation_input}::bigint,'${project_input}'::uuid)" >"$output" 2>&1; then echo "negative reconciliation proof ${phase} unexpectedly succeeded" >&2 cat "$output" >&2 unlink "$output" @@ -64,13 +68,51 @@ expect_isolated_rejection() { wrong_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --set ON_ERROR_STOP=1 --command "SELECT project_id FROM public.project_root_change_journal WHERE project_id <> '${project}'::uuid ORDER BY generation LIMIT 1")" [[ "$wrong_project" =~ ^[0-9a-f-]{36}$ && "$wrong_project" != "$project" ]] || { echo 'negative proof lacks a distinct journal project for project-binding isolation' >&2; exit 1; } -expect_isolated_rejection 'correct-next-generation wrong-project binding' 'project-root write context is not claimable' "$generation" "$wrong_project" +expect_isolated_rejection 'correct-next-generation wrong-project binding' 'project-root write context is not claimable' "$actor" "$generation" "$wrong_project" wrong_generation_project="$(psql "$FORGE_DATABASE_ADMIN_URL" -At --field-separator='|' --set ON_ERROR_STOP=1 --command "SELECT generation, project_id FROM public.project_root_change_journal WHERE generation > ${generation} AND generation <= ${through} ORDER BY generation LIMIT 1")" wrong_generation="${wrong_generation_project%%|*}" wrong_generation_project_id="${wrong_generation_project#*|}" [[ "$wrong_generation" =~ ^[1-9][0-9]*$ && "$wrong_generation" != "$generation" && "$wrong_generation_project_id" =~ ^[0-9a-f-]{36}$ ]] || { echo 'negative proof lacks a later journal generation for generation-sequence isolation' >&2; exit 1; } -expect_isolated_rejection 'wrong-generation correct-journal-project binding' 'project-root write context is not claimable' "$wrong_generation" "$wrong_generation_project_id" +expect_isolated_rejection 'wrong-generation correct-journal-project binding' 'project-root write context is not claimable' "$actor" "$wrong_generation" "$wrong_generation_project_id" + +# Every entry input below is valid except the logical actor: actor B cannot +# claim actor A's operation or leave a context row behind. +expect_isolated_rejection 'valid-entry wrong-actor ownership' 'project-root write context is not claimable' "$actor_b" "$generation" "$project" + +expect_composite_completion_context_rejection() { + local phase='composite completion wrong-actor context identity' output status before after + before="$(snapshot_reconciliation_state)" + output="$(mktemp)" + # The completion routine deliberately includes actor_id in its active-context + # lookup before the later operation CAS. Returning the generic stale-context + # error here avoids disclosing actor A's live context to actor B. + if psql "$url" --set ON_ERROR_STOP=1 >"$output" 2>&1 <&2 + cat "$output" >&2 + unlink "$output" + exit 1 + else + status=$? + fi + if [[ "$status" != 3 ]] || ! grep -F -- 'project-root write context is absent or stale' "$output" >/dev/null; then + echo "negative reconciliation proof ${phase} received an unexpected psql status/error (status=${status})" >&2 + cat "$output" >&2 + unlink "$output" + exit 1 + fi + unlink "$output" + after="$(snapshot_reconciliation_state)" + [[ "$before" == "$after" && "$after" =~ ^[0-9a-f]{32}$ ]] || { echo "negative reconciliation proof ${phase} changed protected reconciliation state or left an orphan context" >&2; exit 1; } +} + +expect_composite_completion_context_rejection expect_failure() { local expected_status="$1" text="$2"; shift 2; local output status; output="$(mktemp)"; if "$@" >"$output" 2>&1; then echo "negative reconciliation proof expected psql failure: ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; else status=$?; fi; [[ "$status" == "$expected_status" ]] || { echo "negative reconciliation proof expected psql exit ${expected_status} for ${text}, got ${status}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; grep -F -- "$text" "$output" >/dev/null || { echo "negative reconciliation proof received the wrong psql error for ${text}" >&2; cat "$output" >&2; unlink "$output"; exit 1; }; unlink "$output"; } expect_failure 1 'project-root operation identity cannot be hijacked' psql "$url" --set ON_ERROR_STOP=1 --command "SELECT * FROM forge.begin_project_root_reconciliation_v1('${operation_id}'::uuid,'22222222-2222-4222-8222-222222222222'::uuid,${through}::bigint)" From 87bf113a3f066195d912b9b82998555781d4e62d Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:57:10 +0800 Subject: [PATCH 63/69] test: fix ordinary app trigger proof inputs --- .../project-root-reconciliation.test.ts | 5 +++++ ...ration-0027-ordinary-app-trigger-writes.sh | 21 ++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 19d324af..8c61152b 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -291,6 +291,11 @@ describe('project-root expansion reconciliation boundary', () => { expect(bootstrap).toContain('publicTriggerExecuteCount') expect(bootstrap).toContain('Trigger-only S3 helpers retain an unsafe PUBLIC EXECUTE grant.') expect(ordinaryAppTriggerProof).toContain('postgresql://forge_app_test:forge_app_test@') + expect(ordinaryAppTriggerProof).toContain("--set project_id=\"${project_id}\"") + expect(ordinaryAppTriggerProof).toContain("--set package_id=\"${package_id}\" <<'SQL'") + expect(ordinaryAppTriggerProof).toContain("WHERE project_id = :'project_id'::uuid") + expect(ordinaryAppTriggerProof).toContain("WHERE work_package_id = :'package_id'::uuid") + expect(ordinaryAppTriggerProof).not.toContain('--set package_id="${package_id}" --command') expect(ordinaryAppTriggerProof).toContain('public.project_filesystem_current_decision_pointers') expect(ordinaryAppTriggerProof).toContain('public.filesystem_mcp_current_decision_pointers') expect(ordinaryAppTriggerProof).toContain('work_package_local_projection_heads') diff --git a/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh index 537a8ade..107db52b 100644 --- a/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh +++ b/web/scripts/ci/prove-migration-0027-ordinary-app-trigger-writes.sh @@ -33,14 +33,15 @@ SQL trigger_state="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 \ --set project_id="${project_id}" \ - --set package_id="${package_id}" --command " - SELECT EXISTS ( - SELECT 1 FROM public.project_filesystem_current_decision_pointers - WHERE project_id = :'project_id'::uuid - ) AND EXISTS ( - SELECT 1 FROM public.filesystem_mcp_current_decision_pointers - WHERE work_package_id = :'package_id'::uuid - ) AND (SELECT count(*) FROM public.work_package_local_projection_heads - WHERE work_package_id = :'package_id'::uuid) = 8 - ")" + --set package_id="${package_id}" <<'SQL' +SELECT EXISTS ( + SELECT 1 FROM public.project_filesystem_current_decision_pointers + WHERE project_id = :'project_id'::uuid +) AND EXISTS ( + SELECT 1 FROM public.filesystem_mcp_current_decision_pointers + WHERE work_package_id = :'package_id'::uuid +) AND (SELECT count(*) FROM public.work_package_local_projection_heads + WHERE work_package_id = :'package_id'::uuid) = 8; +SQL +)" [[ "${trigger_state}" == 't' ]] || { echo 'ordinary application writes did not fire the S3 pointer and projection-head triggers' >&2; exit 1; } From d22ad9d1d9f7ef43ce5b5583b12b6f24377507b1 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:01:44 +0800 Subject: [PATCH 64/69] fix: align root write context schema metadata --- .../project-root-reconciliation.test.ts | 32 +++++++++++++++++++ .../0027_epic_172_s4_packet_context.sql | 24 +++++++++----- web/db/schema.ts | 27 +++++++++++++--- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 8c61152b..b2839ada 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -75,6 +75,38 @@ describe('project-root expansion reconciliation boundary', () => { expect(PROJECT_ROOT_RECONCILIATION_STATES).toEqual(['running', 'complete']) }) + it('keeps protected write-context physical metadata aligned with Drizzle', () => { + const writeContextDdl = migration.slice( + migration.indexOf('CREATE TABLE public.project_root_reconciliation_write_contexts'), + migration.indexOf('CREATE OR REPLACE FUNCTION forge.enter_project_root_reconciliation_generation_v1'), + ) + const writeContextSchema = schema.slice( + schema.indexOf("export const projectRootReconciliationWriteContexts"), + schema.indexOf('export const s4ProtectedReviewSourceReads'), + ) + expect(writeContextDdl).toContain('DEFAULT pg_catalog.clock_timestamp()') + expect(writeContextSchema).toContain('default(sql`pg_catalog.clock_timestamp()`)') + for (const constraint of [ + 'project_root_reconciliation_write_contexts_pkey', + 'project_root_reconciliation_write_context_generation_unique', + 'project_root_reconciliation_write_context_backend_pid_chk', + 'project_root_reconciliation_write_context_transaction_id_chk', + 'project_root_reconciliation_write_context_shape_chk', + 'project_root_reconciliation_write_contexts_operation_id_fkey', + 'project_root_reconciliation_write_contexts_generation_fkey', + 'project_root_reconciliation_write_contexts_project_id_fkey', + ]) { + expect(writeContextDdl).toContain(constraint) + expect(writeContextSchema).toContain(constraint) + } + expect(writeContextDdl).toContain('FOREIGN KEY (operation_id) REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT') + expect(writeContextDdl).toContain('FOREIGN KEY (generation) REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT') + expect(writeContextDdl).toContain('FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE RESTRICT') + expect(writeContextSchema).toContain("foreignColumns: [projectRootReconciliationOperations.operationId]") + expect(writeContextSchema).toContain('foreignColumns: [projectRootChangeJournal.generation]') + expect(writeContextSchema).toContain('foreignColumns: [projects.id]') + }) + it('fences operation creation and completion against gaps, later commits, and hijack', () => { expect(migration).toContain('forge.assert_project_root_journal_window_v1') expect(migration).toContain('v_counter <> p_through_generation OR v_max <> p_through_generation OR v_count <> p_through_generation') diff --git a/web/db/migrations/0027_epic_172_s4_packet_context.sql b/web/db/migrations/0027_epic_172_s4_packet_context.sql index 84d88e5a..546cce36 100644 --- a/web/db/migrations/0027_epic_172_s4_packet_context.sql +++ b/web/db/migrations/0027_epic_172_s4_packet_context.sql @@ -664,17 +664,25 @@ END; $$; --> statement-breakpoint CREATE TABLE public.project_root_reconciliation_write_contexts ( - operation_id uuid NOT NULL REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, - generation bigint NOT NULL REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT, + operation_id uuid NOT NULL, + generation bigint NOT NULL, actor_id uuid NOT NULL, - project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE RESTRICT, - backend_pid integer NOT NULL CHECK (backend_pid > 0), - transaction_id bigint NOT NULL CHECK (transaction_id > 0), + project_id uuid NOT NULL, + backend_pid integer NOT NULL CONSTRAINT project_root_reconciliation_write_context_backend_pid_chk CHECK (backend_pid > 0), + transaction_id bigint NOT NULL CONSTRAINT project_root_reconciliation_write_context_transaction_id_chk CHECK (transaction_id > 0), + -- Record wall-clock entry time; a long root reconciliation transaction must + -- not collapse every context timestamp to its transaction start. entered_at timestamptz NOT NULL DEFAULT pg_catalog.clock_timestamp(), completed_at timestamptz, - PRIMARY KEY (operation_id, generation), - UNIQUE (generation), - CONSTRAINT project_root_reconciliation_write_context_shape_chk CHECK ((completed_at IS NULL) OR completed_at >= entered_at) + CONSTRAINT project_root_reconciliation_write_contexts_pkey PRIMARY KEY (operation_id, generation), + CONSTRAINT project_root_reconciliation_write_context_generation_unique UNIQUE (generation), + CONSTRAINT project_root_reconciliation_write_context_shape_chk CHECK ((completed_at IS NULL) OR completed_at >= entered_at), + CONSTRAINT project_root_reconciliation_write_contexts_operation_id_fkey + FOREIGN KEY (operation_id) REFERENCES public.project_root_reconciliation_operations(operation_id) ON DELETE RESTRICT, + CONSTRAINT project_root_reconciliation_write_contexts_generation_fkey + FOREIGN KEY (generation) REFERENCES public.project_root_change_journal(generation) ON DELETE RESTRICT, + CONSTRAINT project_root_reconciliation_write_contexts_project_id_fkey + FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE RESTRICT ); CREATE OR REPLACE FUNCTION forge.enter_project_root_reconciliation_generation_v1(p_operation_id uuid, p_actor_id uuid, p_generation bigint, p_project_id uuid) RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$ diff --git a/web/db/schema.ts b/web/db/schema.ts index d0b0918d..3953170c 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -1903,20 +1903,37 @@ export const s4ProtectedReviewSources = pgTable('s4_protected_review_sources', { }) export const projectRootReconciliationWriteContexts = pgTable('project_root_reconciliation_write_contexts', { - operationId: uuid('operation_id').notNull().references(() => projectRootReconciliationOperations.operationId, { onDelete: 'restrict' }), - generation: bigint('generation', { mode: 'bigint' }).notNull().references(() => projectRootChangeJournal.generation, { onDelete: 'restrict' }), + operationId: uuid('operation_id').notNull(), + generation: bigint('generation', { mode: 'bigint' }).notNull(), actorId: uuid('actor_id').notNull(), - projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'restrict' }), + projectId: uuid('project_id').notNull(), backendPid: integer('backend_pid').notNull(), transactionId: bigint('transaction_id', { mode: 'bigint' }).notNull(), - enteredAt: timestamp('entered_at', tsOpts).defaultNow().notNull(), + // Match the protected write-context DDL: this is wall-clock entry time, + // rather than PostgreSQL's transaction-start timestamp. + enteredAt: timestamp('entered_at', tsOpts).default(sql`pg_catalog.clock_timestamp()`).notNull(), completedAt: timestamp('completed_at', tsOpts), }, (t) => [ - primaryKey({ columns: [t.operationId, t.generation] }), + primaryKey({ name: 'project_root_reconciliation_write_contexts_pkey', columns: [t.operationId, t.generation] }), unique('project_root_reconciliation_write_context_generation_unique').on(t.generation), check('project_root_reconciliation_write_context_backend_pid_chk', sql`${t.backendPid} > 0`), check('project_root_reconciliation_write_context_transaction_id_chk', sql`${t.transactionId} > 0`), check('project_root_reconciliation_write_context_shape_chk', sql`${t.completedAt} is null or ${t.completedAt} >= ${t.enteredAt}`), + foreignKey({ + name: 'project_root_reconciliation_write_contexts_operation_id_fkey', + columns: [t.operationId], + foreignColumns: [projectRootReconciliationOperations.operationId], + }).onDelete('restrict'), + foreignKey({ + name: 'project_root_reconciliation_write_contexts_generation_fkey', + columns: [t.generation], + foreignColumns: [projectRootChangeJournal.generation], + }).onDelete('restrict'), + foreignKey({ + name: 'project_root_reconciliation_write_contexts_project_id_fkey', + columns: [t.projectId], + foreignColumns: [projects.id], + }).onDelete('restrict'), ]) export const s4ProtectedReviewSourceReads = pgTable( From 737e75cf6826e33e8e740f7aa827d3b62d719771 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:14:58 +0800 Subject: [PATCH 65/69] test: close root reconciler column privileges --- .../project-root-reconciliation.test.ts | 9 +++++++ ...027-root-reconciler-privilege-mutations.sh | 5 ++++ ...-root-reconciler-privileges-assertions.sql | 27 +++++++++++++++++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index b2839ada..2f6fc1c7 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -303,6 +303,13 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeProof).toContain("SELECT 'MAINTAIN'") expect(privilegeProof).toContain("current_setting('server_version_num')::integer >= 170000") expect(privilegeProof).toContain('has_column_privilege') + expect(privilegeProof).toContain("VALUES ('SELECT'), ('INSERT'), ('UPDATE'), ('REFERENCES')") + expect(privilegeProof).toContain("'column_' || lower(privilege.privilege)") + expect(privilegeProof).toContain("NOT pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege)") + expect(privilegeProof).toContain("('column_select')") + expect(privilegeProof).toContain("('column_insert')") + expect(privilegeProof).toContain("('column_update')") + expect(privilegeProof).toContain("('column_references')") expect(privilegeProof).toContain('has_function_privilege') expect(privilegeProof).toContain('has_sequence_privilege') expect(privilegeProof).toContain('has_schema_privilege') @@ -340,10 +347,12 @@ describe('project-root expansion reconciliation boundary', () => { it('proves the exact closed privilege allowlist rejects direct, column, and PUBLIC-effective mutations', () => { expect(privilegeMutationProof).toContain('GRANT INSERT ON TABLE public.projects TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') + expect(privilegeMutationProof).toContain('GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeMutationProof).toContain('public.projects:INSERT') expect(privilegeMutationProof).toContain('public.tasks.title') + expect(privilegeMutationProof).toContain('public.project_root_reconciliation_write_contexts.actor_id') expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') expect(privilegeMutationProof).toContain('\\i ${assertion_file}') expect(privilegeMutationProof).not.toContain('has_table_privilege') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index a2e231b5..390765bb 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -60,6 +60,11 @@ expect_allowlist_rejection \ 'public.tasks.title' \ 'GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;' +expect_allowlist_rejection \ + 'column select privilege mutation' \ + 'public.project_root_reconciliation_write_contexts.actor_id' \ + 'GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;' + # This intentionally exercises an effective privilege inherited from PUBLIC, # rather than only a direct grant to the dedicated login. expect_allowlist_rejection \ diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index a06e44ba..e0cdea61 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -10,6 +10,22 @@ CREATE TEMP TABLE root_reconciler_actual_privileges ( entry text NOT NULL, PRIMARY KEY (category, entry) ); +CREATE TEMP TABLE root_reconciler_privilege_categories ( + category text PRIMARY KEY +); + +INSERT INTO root_reconciler_privilege_categories (category) VALUES + ('relation'), + ('column_select'), + ('column_insert'), + ('column_update'), + ('column_references'), + ('routine'), + ('schema'), + ('database'), + ('sequence'), + ('ownership'), + ('membership'); INSERT INTO root_reconciler_expected_privileges (category, entry) VALUES ('relation', 'public.projects:SELECT'), @@ -65,16 +81,20 @@ WHERE namespace_row.nspname IN ('public', 'forge') -- public.project_root_reconciliation_write_contexts: absent from the allowlist -- means every effective direct relation privilege is rejected. +-- Record only column-specific effective grants. A table-level privilege of the +-- same class applies to every column and is already represented above. INSERT INTO root_reconciler_actual_privileges (category, entry) -SELECT 'column_update', namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname +SELECT 'column_' || lower(privilege.privilege), namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname FROM pg_catalog.pg_class relation JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = relation.oid +CROSS JOIN (VALUES ('SELECT'), ('INSERT'), ('UPDATE'), ('REFERENCES')) AS privilege(privilege) WHERE namespace_row.nspname IN ('public', 'forge') AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') AND attribute_row.attnum > 0 AND NOT attribute_row.attisdropped - AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, 'UPDATE'); + AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, privilege.privilege) + AND NOT pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege); INSERT INTO root_reconciler_actual_privileges (category, entry) SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.oidvectortypes(routine.proargtypes), ', ', ',') || ')' @@ -182,6 +202,8 @@ BEGIN WHERE expected.category = categories.category ) unexpected_rows), '[]'::jsonb) AS unexpected FROM ( + SELECT category FROM root_reconciler_privilege_categories + UNION SELECT category FROM root_reconciler_expected_privileges UNION SELECT category FROM root_reconciler_actual_privileges @@ -197,3 +219,4 @@ $proof$; DROP TABLE root_reconciler_actual_privileges; DROP TABLE root_reconciler_expected_privileges; +DROP TABLE root_reconciler_privilege_categories; From c7288810f74cda1080102c5484dbbeb703b52d69 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:20:22 +0800 Subject: [PATCH 66/69] test: close root reconciler grant options --- .../project-root-reconciliation.test.ts | 19 ++++ ...027-root-reconciler-privilege-mutations.sh | 37 +++++++ ...-root-reconciler-privileges-assertions.sql | 102 ++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 2f6fc1c7..36e37c3c 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -310,6 +310,22 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeProof).toContain("('column_insert')") expect(privilegeProof).toContain("('column_update')") expect(privilegeProof).toContain("('column_references')") + expect(privilegeProof).toContain("('grant_option_relation')") + expect(privilegeProof).toContain("('grant_option_column')") + expect(privilegeProof).toContain("('grant_option_routine')") + expect(privilegeProof).toContain("('grant_option_schema')") + expect(privilegeProof).toContain("('grant_option_database')") + expect(privilegeProof).toContain("('grant_option_sequence')") + expect(privilegeProof).toContain('pg_catalog.aclexplode') + expect(privilegeProof).toContain("pg_catalog.acldefault('r', relation.relowner)") + expect(privilegeProof).toContain("pg_catalog.acldefault('c', relation.relowner)") + expect(privilegeProof).toContain("pg_catalog.acldefault('f', routine.proowner)") + expect(privilegeProof).toContain("pg_catalog.acldefault('n', namespace_row.nspowner)") + expect(privilegeProof).toContain("pg_catalog.acldefault('d', database_row.datdba)") + expect(privilegeProof).toContain("pg_catalog.acldefault('s', sequence_row.relowner)") + expect(privilegeProof).toContain('acl_row.is_grantable') + expect(privilegeProof).toContain("CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC'") + expect(privilegeProof).toContain('direct/PUBLIC ACL rows are the complete effective grant-option surface') expect(privilegeProof).toContain('has_function_privilege') expect(privilegeProof).toContain('has_sequence_privilege') expect(privilegeProof).toContain('has_schema_privilege') @@ -348,15 +364,18 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeMutationProof).toContain('GRANT INSERT ON TABLE public.projects TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;') + expect(privilegeMutationProof).toContain('GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;') expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeMutationProof).toContain('public.projects:INSERT') expect(privilegeMutationProof).toContain('public.tasks.title') expect(privilegeMutationProof).toContain('public.project_root_reconciliation_write_contexts.actor_id') + expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${grantor}:grantee=forge_project_root_reconciler') expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') expect(privilegeMutationProof).toContain('\\i ${assertion_file}') expect(privilegeMutationProof).not.toContain('has_table_privilege') expect(privilegeMutationProof).toContain('fresh clean allowlist assertion') + expect(privilegeMutationProof).toContain('snapshot_application_acls') expect(upgradeProof).toContain('prove-migration-0027-root-reconciler-privilege-mutations.sh') }) diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index 390765bb..638e5848 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -20,6 +20,31 @@ fail_with_output() { exit 1 } +snapshot_application_acls() { + psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 --command " + WITH acl_rows AS ( + SELECT 'relation:' || namespace_row.nspname || '.' || relation.relname || ':' || coalesce(relation.relacl::text, '') AS entry + FROM pg_catalog.pg_class relation JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace + WHERE namespace_row.nspname IN ('public', 'forge') + UNION ALL + SELECT 'column:' || namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname || ':' || coalesce(attribute_row.attacl::text, '') + FROM pg_catalog.pg_class relation JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace + JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = relation.oid + WHERE namespace_row.nspname IN ('public', 'forge') AND attribute_row.attnum > 0 AND NOT attribute_row.attisdropped + UNION ALL + SELECT 'routine:' || namespace_row.nspname || '.' || routine.proname || '(' || pg_catalog.oidvectortypes(routine.proargtypes) || '):' || coalesce(routine.proacl::text, '') + FROM pg_catalog.pg_proc routine JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace + WHERE namespace_row.nspname IN ('public', 'forge') + UNION ALL + SELECT 'schema:' || namespace_row.nspname || ':' || coalesce(namespace_row.nspacl::text, '') + FROM pg_catalog.pg_namespace namespace_row WHERE namespace_row.nspname IN ('public', 'forge') + UNION ALL + SELECT 'database:' || database_row.datname || ':' || coalesce(database_row.datacl::text, '') + FROM pg_catalog.pg_database database_row WHERE database_row.datname = pg_catalog.current_database() + ) + SELECT md5(coalesce(string_agg(entry, E'\\n' ORDER BY entry), '')) FROM acl_rows" +} + expect_allowlist_rejection() { local phase="$1" local expected_entry="$2" @@ -50,6 +75,9 @@ SQL # Every mutation is uncommitted. The assertion failure closes that psql session, # which rolls the transaction back before the following fresh-session probe. +baseline_acl="$(snapshot_application_acls)" +[[ "${baseline_acl}" =~ ^[0-9a-f]{32}$ ]] || { echo 'root reconciler privilege mutation proof could not fingerprint baseline ACLs' >&2; exit 1; } + expect_allowlist_rejection \ 'relation privilege mutation' \ 'public.projects:INSERT' \ @@ -65,6 +93,13 @@ expect_allowlist_rejection \ 'public.project_root_reconciliation_write_contexts.actor_id' \ 'GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;' +grantor="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 --command 'SELECT current_user')" +[[ "${grantor}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo 'root reconciler privilege mutation proof received an unsafe grantor identity' >&2; exit 1; } +expect_allowlist_rejection \ + 'relation grant option mutation' \ + "public.projects:SELECT:grantor=${grantor}:grantee=forge_project_root_reconciler" \ + 'GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;' + # This intentionally exercises an effective privilege inherited from PUBLIC, # rather than only a direct grant to the dedicated login. expect_allowlist_rejection \ @@ -76,3 +111,5 @@ clean_output="${proof_tmpdir}/clean.log" if ! psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file "${assertion_file}" >"${clean_output}" 2>&1; then fail_with_output 'fresh clean allowlist assertion' "${clean_output}" fi + +[[ "$(snapshot_application_acls)" == "${baseline_acl}" ]] || { echo 'root reconciler privilege mutation proof left application ACL bytes changed after rollback' >&2; exit 1; } diff --git a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql index e0cdea61..ed8dd5cc 100644 --- a/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +++ b/web/scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql @@ -20,6 +20,12 @@ INSERT INTO root_reconciler_privilege_categories (category) VALUES ('column_insert'), ('column_update'), ('column_references'), + ('grant_option_relation'), + ('grant_option_column'), + ('grant_option_routine'), + ('grant_option_schema'), + ('grant_option_database'), + ('grant_option_sequence'), ('routine'), ('schema'), ('database'), @@ -96,6 +102,102 @@ WHERE namespace_row.nspname IN ('public', 'forge') AND pg_catalog.has_column_privilege('forge_project_root_reconciler', relation.oid, attribute_row.attnum, privilege.privilege) AND NOT pg_catalog.has_table_privilege('forge_project_root_reconciler', relation.oid, privilege.privilege); +-- Grant options are delegation authority. ACL rows identify their source; the +-- role has no memberships and owns no application objects (checked below), so +-- direct/PUBLIC ACL rows are the complete effective grant-option surface. +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_relation', + namespace_row.nspname || '.' || relation.relname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(relation.relacl, pg_catalog.acldefault('r', relation.relowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND ( + acl_row.privilege_type IN ('SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER') + OR (acl_row.privilege_type = 'MAINTAIN' + AND pg_catalog.current_setting('server_version_num')::integer >= 170000) + ); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_column', + namespace_row.nspname || '.' || relation.relname || '.' || attribute_row.attname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_class relation +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = relation.relnamespace +JOIN pg_catalog.pg_attribute attribute_row ON attribute_row.attrelid = relation.oid +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(attribute_row.attacl, pg_catalog.acldefault('c', relation.relowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND relation.relkind IN ('r', 'p', 'v', 'm', 'f') + AND attribute_row.attnum > 0 + AND NOT attribute_row.attisdropped + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type IN ('SELECT', 'INSERT', 'UPDATE', 'REFERENCES'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_routine', + namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.oidvectortypes(routine.proargtypes), ', ', ',') || '):EXECUTE' || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_proc routine +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = routine.pronamespace +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(routine.proacl, pg_catalog.acldefault('f', routine.proowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND routine.prokind IN ('f', 'p') + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type = 'EXECUTE'; + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_schema', + namespace_row.nspname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_namespace namespace_row +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(namespace_row.nspacl, pg_catalog.acldefault('n', namespace_row.nspowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type IN ('USAGE', 'CREATE'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_database', + database_row.datname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_database database_row +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(database_row.datacl, pg_catalog.acldefault('d', database_row.datdba))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE database_row.datname = pg_catalog.current_database() + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type IN ('CONNECT', 'CREATE', 'TEMPORARY'); + +INSERT INTO root_reconciler_actual_privileges (category, entry) +SELECT 'grant_option_sequence', + namespace_row.nspname || '.' || sequence_row.relname || ':' || acl_row.privilege_type || + ':grantor=' || pg_catalog.pg_get_userbyid(acl_row.grantor) || + ':grantee=' || CASE WHEN acl_row.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(acl_row.grantee) END +FROM pg_catalog.pg_class sequence_row +JOIN pg_catalog.pg_namespace namespace_row ON namespace_row.oid = sequence_row.relnamespace +CROSS JOIN LATERAL pg_catalog.aclexplode(coalesce(sequence_row.relacl, pg_catalog.acldefault('s', sequence_row.relowner))) + AS acl_row(grantor, grantee, privilege_type, is_grantable) +WHERE namespace_row.nspname IN ('public', 'forge') + AND sequence_row.relkind = 'S' + AND acl_row.grantee IN (0, 'forge_project_root_reconciler'::regrole) + AND acl_row.is_grantable + AND acl_row.privilege_type IN ('USAGE', 'SELECT', 'UPDATE'); + INSERT INTO root_reconciler_actual_privileges (category, entry) SELECT 'routine', namespace_row.nspname || '.' || routine.proname || '(' || replace(pg_catalog.oidvectortypes(routine.proargtypes), ', ', ',') || ')' FROM pg_catalog.pg_proc routine From 15751a686a4636edcdcdbe5dee0732b282601879 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:24:59 +0800 Subject: [PATCH 67/69] test: bind root grantor to mutation session --- .../project-root-reconciliation.test.ts | 6 ++- ...027-root-reconciler-privilege-mutations.sh | 51 ++++++++++++++++--- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 36e37c3c..1c01b90e 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -365,12 +365,16 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;') + expect(privilegeMutationProof).toContain('SELECT session_user AS mutation_session_user, current_user AS mutation_current_user \\gset') + expect(privilegeMutationProof).toContain('ROOT_GRANT_OPTION_MUTATION_IDENTITY session=:mutation_session_user current=:mutation_current_user') + expect(privilegeMutationProof).toContain('PostgreSQL records ACL grantors as current_user') + expect(privilegeMutationProof).not.toContain("grantor=\"$(psql") expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeMutationProof).toContain('public.projects:INSERT') expect(privilegeMutationProof).toContain('public.tasks.title') expect(privilegeMutationProof).toContain('public.project_root_reconciliation_write_contexts.actor_id') - expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${grantor}:grantee=forge_project_root_reconciler') + expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${mutation_current_user}:grantee=forge_project_root_reconciler') expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') expect(privilegeMutationProof).toContain('\\i ${assertion_file}') expect(privilegeMutationProof).not.toContain('has_table_privilege') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index 638e5848..8ae597d1 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -73,6 +73,50 @@ SQL fi } +expect_grant_option_rejection() { + local phase='relation grant option mutation' + local output_file="${proof_tmpdir}/${phase}.log" + local status=0 identity_line mutation_session_user mutation_current_user expected_entry + + set +e + psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 >"${output_file}" 2>&1 <&2 + fi + expected_entry="public.projects:SELECT:grantor=${mutation_current_user}:grantee=forge_project_root_reconciler" + + if [[ "${status}" -ne 3 ]]; then + fail_with_output "${phase}: expected psql script rejection status 3, received ${status}" "${output_file}" + fi + if ! grep -F -- 'root reconciler effective privilege allowlist mismatch' "${output_file}" >/dev/null; then + fail_with_output "${phase}: exact allowlist rejection was absent" "${output_file}" + fi + if ! grep -F -- "${expected_entry}" "${output_file}" >/dev/null; then + fail_with_output "${phase}: expected exact unexpected-set entry ${expected_entry} was absent" "${output_file}" + fi +} + # Every mutation is uncommitted. The assertion failure closes that psql session, # which rolls the transaction back before the following fresh-session probe. baseline_acl="$(snapshot_application_acls)" @@ -93,12 +137,7 @@ expect_allowlist_rejection \ 'public.project_root_reconciliation_write_contexts.actor_id' \ 'GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;' -grantor="$(psql "${FORGE_DATABASE_ADMIN_URL}" --no-align --tuples-only --set ON_ERROR_STOP=1 --command 'SELECT current_user')" -[[ "${grantor}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || { echo 'root reconciler privilege mutation proof received an unsafe grantor identity' >&2; exit 1; } -expect_allowlist_rejection \ - 'relation grant option mutation' \ - "public.projects:SELECT:grantor=${grantor}:grantee=forge_project_root_reconciler" \ - 'GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;' +expect_grant_option_rejection # This intentionally exercises an effective privilege inherited from PUBLIC, # rather than only a direct grant to the dedicated login. From f6a85435ce044185922aed0f230acd0f25537386 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:30:21 +0800 Subject: [PATCH 68/69] test: use root owner for grant option mutation --- .../project-root-reconciliation.test.ts | 11 +++-- ...027-root-reconciler-privilege-mutations.sh | 40 ++++++++++++------- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 1c01b90e..2434f49a 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -365,16 +365,19 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeMutationProof).toContain('GRANT UPDATE (title) ON TABLE public.tasks TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT (actor_id) ON TABLE public.project_root_reconciliation_write_contexts TO forge_project_root_reconciler;') expect(privilegeMutationProof).toContain('GRANT SELECT ON TABLE public.projects TO forge_project_root_reconciler WITH GRANT OPTION;') - expect(privilegeMutationProof).toContain('SELECT session_user AS mutation_session_user, current_user AS mutation_current_user \\gset') - expect(privilegeMutationProof).toContain('ROOT_GRANT_OPTION_MUTATION_IDENTITY session=:mutation_session_user current=:mutation_current_user') - expect(privilegeMutationProof).toContain('PostgreSQL records ACL grantors as current_user') + expect(privilegeMutationProof).toContain('pg_catalog.pg_get_userbyid(relation.relowner) AS mutation_owner_role') + expect(privilegeMutationProof).toContain("pg_catalog.pg_has_role(session_user, pg_catalog.pg_get_userbyid(relation.relowner), 'SET')") + expect(privilegeMutationProof).toContain('SET LOCAL ROLE :"mutation_owner_role";') + expect(privilegeMutationProof).toContain('RESET ROLE;') + expect(privilegeMutationProof).toContain('ROOT_GRANT_OPTION_MUTATION_IDENTITY owner=:mutation_owner_role session=:mutation_session_user current=:mutation_current_user can_set=:mutation_can_set_owner') + expect(privilegeMutationProof).toContain('PostgreSQL records the ACL grantor as the owner role that issued GRANT') expect(privilegeMutationProof).not.toContain("grantor=\"$(psql") expect(privilegeMutationProof).toContain('GRANT EXECUTE ON FUNCTION forge.read_epic_172_enablement_state_v1() TO PUBLIC;') expect(privilegeMutationProof).toContain('root reconciler effective privilege allowlist mismatch') expect(privilegeMutationProof).toContain('public.projects:INSERT') expect(privilegeMutationProof).toContain('public.tasks.title') expect(privilegeMutationProof).toContain('public.project_root_reconciliation_write_contexts.actor_id') - expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${mutation_current_user}:grantee=forge_project_root_reconciler') + expect(privilegeMutationProof).toContain('public.projects:SELECT:grantor=${mutation_owner_role}:grantee=forge_project_root_reconciler') expect(privilegeMutationProof).toContain('forge.read_epic_172_enablement_state_v1()') expect(privilegeMutationProof).toContain('\\i ${assertion_file}') expect(privilegeMutationProof).not.toContain('has_table_privilege') diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index 8ae597d1..dfdafb2a 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -76,35 +76,45 @@ SQL expect_grant_option_rejection() { local phase='relation grant option mutation' local output_file="${proof_tmpdir}/${phase}.log" - local status=0 identity_line mutation_session_user mutation_current_user expected_entry + local status=0 identity_line mutation_owner_role mutation_session_user mutation_current_user mutation_can_set_owner expected_entry set +e psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 >"${output_file}" 2>&1 <&2 - fi - expected_entry="public.projects:SELECT:grantor=${mutation_current_user}:grantee=forge_project_root_reconciler" + [[ "${mutation_can_set_owner}" == t && "${mutation_current_user}" == "${mutation_owner_role}" ]] || { + fail_with_output "${phase}: mutation session could not assume the catalog-derived owner role" "${output_file}" + } + # PostgreSQL records the ACL grantor as the owner role that issued GRANT. + # session_user remains in the proof output so inherited authority is visible. + expected_entry="public.projects:SELECT:grantor=${mutation_owner_role}:grantee=forge_project_root_reconciler" if [[ "${status}" -ne 3 ]]; then fail_with_output "${phase}: expected psql script rejection status 3, received ${status}" "${output_file}" From 445a66a1c01cae6c8940a92ce8d1a83254b06263 Mon Sep 17 00:00:00 2001 From: Joncallim <64296013+Joncallim@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:40:31 +0800 Subject: [PATCH 69/69] test: run root privilege mutations in upgrade proof --- web/__tests__/project-root-reconciliation.test.ts | 12 ++++++++++++ ...ation-0027-root-reconciler-privilege-mutations.sh | 10 ++++++++++ web/scripts/ci/prove-migration-0027-upgrade.sh | 2 ++ 3 files changed, 24 insertions(+) diff --git a/web/__tests__/project-root-reconciliation.test.ts b/web/__tests__/project-root-reconciliation.test.ts index 2434f49a..c018f624 100644 --- a/web/__tests__/project-root-reconciliation.test.ts +++ b/web/__tests__/project-root-reconciliation.test.ts @@ -383,7 +383,19 @@ describe('project-root expansion reconciliation boundary', () => { expect(privilegeMutationProof).not.toContain('has_table_privilege') expect(privilegeMutationProof).toContain('fresh clean allowlist assertion') expect(privilegeMutationProof).toContain('snapshot_application_acls') + expect(privilegeMutationProof).toContain("echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_START'") + expect(privilegeMutationProof).toContain('ROOT_GRANT_OPTION_MUTATION_IDENTITY owner=') + expect(privilegeMutationProof).toContain('ROOT_RECONCILER_PRIVILEGE_MUTATION_REJECTION phase=${phase} entry=${expected_entry}') + expect(privilegeMutationProof).toContain("echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_SUCCESS'") expect(upgradeProof).toContain('prove-migration-0027-root-reconciler-privilege-mutations.sh') + const mutationStart = upgradeProof.indexOf("echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_PHASE_START'") + const mutationInvocation = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh') + const mutationSuccess = upgradeProof.indexOf("echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_PHASE_SUCCESS'") + const negativeProofStart = upgradeProof.indexOf('bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh') + expect(mutationStart).toBeGreaterThanOrEqual(0) + expect(mutationStart).toBeLessThan(mutationInvocation) + expect(mutationInvocation).toBeLessThan(mutationSuccess) + expect(mutationSuccess).toBeLessThan(negativeProofStart) }) it('rejects stale and fabricated root reconciliation context reuse before the CLI resumes', () => { diff --git a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh index dfdafb2a..1c6ab9ee 100644 --- a/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +++ b/web/scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh @@ -3,6 +3,8 @@ set -euo pipefail : "${FORGE_DATABASE_ADMIN_URL:?Set the short-lived PostgreSQL administrator URL.}" +echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_START' + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" assertion_file="${script_dir}/sql/migration-0027-root-reconciler-privileges-assertions.sql" proof_tmpdir="$(mktemp -d)" @@ -71,6 +73,9 @@ SQL if ! grep -F -- "${expected_entry}" "${output_file}" >/dev/null; then fail_with_output "${phase}: expected unexpected-set entry ${expected_entry} was absent" "${output_file}" fi + grep -F -- 'root reconciler effective privilege allowlist mismatch' "${output_file}" + grep -F -- "${expected_entry}" "${output_file}" + echo "ROOT_RECONCILER_PRIVILEGE_MUTATION_REJECTION phase=${phase} entry=${expected_entry}" } expect_grant_option_rejection() { @@ -125,6 +130,10 @@ SQL if ! grep -F -- "${expected_entry}" "${output_file}" >/dev/null; then fail_with_output "${phase}: expected exact unexpected-set entry ${expected_entry} was absent" "${output_file}" fi + printf '%s\n' "${identity_line}" + grep -F -- 'root reconciler effective privilege allowlist mismatch' "${output_file}" + grep -F -- "${expected_entry}" "${output_file}" + echo "ROOT_RECONCILER_PRIVILEGE_MUTATION_REJECTION phase=${phase} entry=${expected_entry}" } # Every mutation is uncommitted. The assertion failure closes that psql session, @@ -162,3 +171,4 @@ if ! psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file "${assertio fi [[ "$(snapshot_application_acls)" == "${baseline_acl}" ]] || { echo 'root reconciler privilege mutation proof left application ACL bytes changed after rollback' >&2; exit 1; } +echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_SUCCESS' diff --git a/web/scripts/ci/prove-migration-0027-upgrade.sh b/web/scripts/ci/prove-migration-0027-upgrade.sh index da3efe94..34a4a6da 100644 --- a/web/scripts/ci/prove-migration-0027-upgrade.sh +++ b/web/scripts/ci/prove-migration-0027-upgrade.sh @@ -61,7 +61,9 @@ fi bash scripts/ci/prove-migration-0027-root-authority-reconciliation.sh --prepare bash scripts/ci/prove-migration-0027-root-index-lifecycle.sh --prepare psql "${FORGE_DATABASE_ADMIN_URL}" --set ON_ERROR_STOP=1 --file scripts/ci/sql/migration-0027-root-reconciler-privileges-assertions.sql +echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_PHASE_START' bash scripts/ci/prove-migration-0027-root-reconciler-privilege-mutations.sh +echo 'ROOT_RECONCILER_PRIVILEGE_MUTATIONS_PHASE_SUCCESS' # This creates (but never advances) the exact live operation. The shim resumes # it below after its now-empty materialization pass. bash scripts/ci/prove-migration-0027-root-reconciliation-negative.sh