diff --git a/apps/planning-service/migrations/0005_task_completion_chronology.sql b/apps/planning-service/migrations/0005_task_completion_chronology.sql new file mode 100644 index 000000000..819f58ca1 --- /dev/null +++ b/apps/planning-service/migrations/0005_task_completion_chronology.sql @@ -0,0 +1,10 @@ +ALTER TABLE planning.tasks + ADD CONSTRAINT tasks_completion_state_check + CHECK ( + (status = 'todo' AND completed_at IS NULL) + OR ( + status = 'done' + AND completed_at IS NOT NULL + AND completed_at >= created_at + ) + ) NOT VALID; diff --git a/apps/planning-service/migrations/0006_validate_task_completion_chronology.sql b/apps/planning-service/migrations/0006_validate_task_completion_chronology.sql new file mode 100644 index 000000000..0a460c10d --- /dev/null +++ b/apps/planning-service/migrations/0006_validate_task_completion_chronology.sql @@ -0,0 +1,2 @@ +ALTER TABLE planning.tasks + VALIDATE CONSTRAINT tasks_completion_state_check; diff --git a/apps/planning-service/migrations/README.md b/apps/planning-service/migrations/README.md index 396bdafe3..29733f15a 100644 --- a/apps/planning-service/migrations/README.md +++ b/apps/planning-service/migrations/README.md @@ -4,6 +4,12 @@ Apply SQL files in lexical order to the PostgreSQL database owned by the Plannin - `0001_initial_planning.sql` creates tenant-safe Goal → Project → Task tables. Parent-child foreign keys include `workspace_id`, preventing a child record from referencing a parent in another workspace. - `0002_durable_repository_contract.sql` enforces UUIDv4 identifiers, adds the composite task ownership key used by durable adapters, and replaces descending indexes with deterministic creation-order indexes. +- `0003_durable_today_sync.sql` creates the durable Today aggregate and idempotency records used to make Planning-owned Today synchronization replay-safe. +- `0004_data_rights_erasure_receipts.sql` creates durable Planning-owned erasure receipts so data-rights completion can be evidenced without retaining erased subject data. +- `0005_task_completion_chronology.sql` stages the task completion-state invariant with `NOT VALID`. New and changed rows must already satisfy `todo ⇒ completed_at IS NULL` and `done ⇒ completed_at >= created_at`, while PostgreSQL avoids the initial historical-table validation scan during the constraint-add step. +- `0006_validate_task_completion_chronology.sql` validates the staged completion constraint against historical rows in a separate migration boundary. Deployment must fail closed on any historical violation; do not shift application traffic until this migration succeeds. + +Keep `0005` and `0006` as separate lexical migration boundaries. Do not wrap the pair in one transaction: staged addition is intentionally separated from the historical validation scan so the stronger validation lock is not held across unrelated migration work. ## Runtime configuration @@ -13,4 +19,4 @@ The application does not apply migrations during startup. Deployment automation ## Rollback -Migrations are forward-only in automated environments. For an operator-approved rollback of `0002`, drop the three `*_creation_idx` indexes, recreate the indexes from `0001`, drop `tasks_id_workspace_unique`, and drop the `*_uuid_v4` check constraints. Roll back `0001` only after exporting service-owned data because it removes the Planning schema. +Migrations are forward-only in automated environments. For an operator-approved rollback of the `0005`/`0006` completion-chronology pair, drop `planning.tasks.tasks_completion_state_check` only after confirming that removing the durable invariant is an acceptable data-integrity regression; `0006` creates no separate database object to undo. For an operator-approved rollback of `0002`, drop the three `*_creation_idx` indexes, recreate the indexes from `0001`, drop `tasks_id_workspace_unique`, and drop the `*_uuid_v4` check constraints. Roll back `0001` only after exporting service-owned data because it removes the Planning schema. diff --git a/apps/planning-service/src/postgres-planning-repository.integration.test.ts b/apps/planning-service/src/postgres-planning-repository.integration.test.ts index 786a4769d..08e2686f5 100644 --- a/apps/planning-service/src/postgres-planning-repository.integration.test.ts +++ b/apps/planning-service/src/postgres-planning-repository.integration.test.ts @@ -37,6 +37,9 @@ async function applyMigrations(pool: Pool): Promise { '0001_initial_planning.sql', '0002_durable_repository_contract.sql', '0003_durable_today_sync.sql', + '0004_data_rights_erasure_receipts.sql', + '0005_task_completion_chronology.sql', + '0006_validate_task_completion_chronology.sql', ]) { const sql = await readFile( resolve(__dirname, '../migrations', migration), diff --git a/apps/planning-service/src/task-completion-chronology-migration.test.ts b/apps/planning-service/src/task-completion-chronology-migration.test.ts new file mode 100644 index 000000000..5c3f099b1 --- /dev/null +++ b/apps/planning-service/src/task-completion-chronology-migration.test.ts @@ -0,0 +1,122 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Pool } from 'pg'; +import { describe, expect, it } from 'vitest'; + +const stagingMigrationPath = resolve( + __dirname, + '../migrations/0005_task_completion_chronology.sql', +); +const validationMigrationPath = resolve( + __dirname, + '../migrations/0006_validate_task_completion_chronology.sql', +); + +async function readMigration(path: string): Promise { + return await readFile(path, 'utf8'); +} + +function normalizeSql(source: string): string { + return source.replace(/\s+/g, ' ').trim(); +} + +describe('Planning task completion chronology migration', () => { + it('stages the coherent completion-state constraint before scanning historical rows', async () => { + const migration = normalizeSql(await readMigration(stagingMigrationPath)); + + expect(migration).toContain('tasks_completion_state_check'); + expect(migration).toContain("status = 'todo' AND completed_at IS NULL"); + expect(migration).toContain("status = 'done' AND completed_at IS NOT NULL"); + expect(migration).toContain('completed_at >= created_at'); + expect(migration).toContain('NOT VALID'); + expect(migration).not.toContain('VALIDATE CONSTRAINT'); + }); + + it('validates the staged constraint in a later migration boundary', async () => { + const migration = normalizeSql(await readMigration(validationMigrationPath)); + + expect(migration).toContain( + 'VALIDATE CONSTRAINT tasks_completion_state_check', + ); + expect(migration).not.toContain('ADD CONSTRAINT'); + }); + + const databaseUrl = process.env.PLANNING_DATABASE_URL; + const databaseIt = databaseUrl ? it : it.skip; + + databaseIt( + 'rejects contradictory new task states and finishes with validated historical chronology', + async () => { + const pool = new Pool({ connectionString: databaseUrl }); + + try { + await pool.query( + 'DROP SCHEMA IF EXISTS planning_task_completion_chronology_test CASCADE', + ); + await pool.query( + 'CREATE SCHEMA planning_task_completion_chronology_test', + ); + await pool.query( + "CREATE TABLE planning_task_completion_chronology_test.tasks (status text NOT NULL CHECK (status IN ('todo', 'done')), created_at timestamptz NOT NULL, completed_at timestamptz)", + ); + + const stagingMigration = ( + await readMigration(stagingMigrationPath) + ).replaceAll( + 'planning.tasks', + 'planning_task_completion_chronology_test.tasks', + ); + await pool.query(stagingMigration); + + await expect( + pool.query( + "INSERT INTO planning_task_completion_chronology_test.tasks (status, created_at, completed_at) VALUES ('todo', $1, NULL)", + ['2026-09-10T10:00:00.000Z'], + ), + ).resolves.toBeDefined(); + await expect( + pool.query( + "INSERT INTO planning_task_completion_chronology_test.tasks (status, created_at, completed_at) VALUES ('done', $1, $2)", + ['2026-09-10T10:00:00.000Z', '2026-09-10T10:05:00.000Z'], + ), + ).resolves.toBeDefined(); + await expect( + pool.query( + "INSERT INTO planning_task_completion_chronology_test.tasks (status, created_at, completed_at) VALUES ('todo', $1, $2)", + ['2026-09-10T10:00:00.000Z', '2026-09-10T10:05:00.000Z'], + ), + ).rejects.toMatchObject({ code: '23514' }); + await expect( + pool.query( + "INSERT INTO planning_task_completion_chronology_test.tasks (status, created_at, completed_at) VALUES ('done', $1, NULL)", + ['2026-09-10T10:00:00.000Z'], + ), + ).rejects.toMatchObject({ code: '23514' }); + await expect( + pool.query( + "INSERT INTO planning_task_completion_chronology_test.tasks (status, created_at, completed_at) VALUES ('done', $1, $2)", + ['2026-09-10T10:05:00.000Z', '2026-09-10T10:00:00.000Z'], + ), + ).rejects.toMatchObject({ code: '23514' }); + + const validationMigration = ( + await readMigration(validationMigrationPath) + ).replaceAll( + 'planning.tasks', + 'planning_task_completion_chronology_test.tasks', + ); + await pool.query(validationMigration); + const validationState = await pool.query<{ convalidated: boolean }>( + "SELECT convalidated FROM pg_constraint WHERE conname = 'tasks_completion_state_check' AND conrelid = 'planning_task_completion_chronology_test.tasks'::regclass", + ); + + expect(validationState.rows).toEqual([{ convalidated: true }]); + } finally { + await pool.query( + 'DROP SCHEMA IF EXISTS planning_task_completion_chronology_test CASCADE', + ); + await pool.end(); + } + }, + ); +}); diff --git a/apps/planning-service/tests/postgres-data-rights.integration.test.ts b/apps/planning-service/tests/postgres-data-rights.integration.test.ts index 7a876a2eb..eb616c9ac 100644 --- a/apps/planning-service/tests/postgres-data-rights.integration.test.ts +++ b/apps/planning-service/tests/postgres-data-rights.integration.test.ts @@ -23,7 +23,9 @@ const CONFLICTING_REQUEST_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; function requireDatabaseUrl(): string { if (!DATABASE_URL) { - throw new Error('PLANNING_DATABASE_URL is required for PostgreSQL integration tests'); + throw new Error( + 'PLANNING_DATABASE_URL is required for PostgreSQL integration tests', + ); } return DATABASE_URL; } @@ -62,6 +64,8 @@ async function applyPlanningMigrations(pool: Pool): Promise { '0002_durable_repository_contract.sql', '0003_durable_today_sync.sql', '0004_data_rights_erasure_receipts.sql', + '0005_task_completion_chronology.sql', + '0006_validate_task_completion_chronology.sql', ]) { const sql = await readFile( resolve(__dirname, '../migrations', migrationFile), diff --git a/apps/planning-service/tests/postgres-today-lock-order.integration.test.ts b/apps/planning-service/tests/postgres-today-lock-order.integration.test.ts index 4e7f70534..f3eadbbcd 100644 --- a/apps/planning-service/tests/postgres-today-lock-order.integration.test.ts +++ b/apps/planning-service/tests/postgres-today-lock-order.integration.test.ts @@ -14,7 +14,9 @@ const describeWithDatabase = DATABASE_URL ? describe : describe.skip; function requireDatabaseUrl(): string { if (!DATABASE_URL) { - throw new Error('PLANNING_DATABASE_URL is required for PostgreSQL integration tests'); + throw new Error( + 'PLANNING_DATABASE_URL is required for PostgreSQL integration tests', + ); } return DATABASE_URL; } @@ -30,6 +32,9 @@ async function applyPlanningMigrations(pool: Pool): Promise { '0001_initial_planning.sql', '0002_durable_repository_contract.sql', '0003_durable_today_sync.sql', + '0004_data_rights_erasure_receipts.sql', + '0005_task_completion_chronology.sql', + '0006_validate_task_completion_chronology.sql', ]) { const sql = await readFile( resolve(__dirname, '../migrations', migrationFile), @@ -106,7 +111,9 @@ describeWithDatabase('PostgreSQL Today lock ordering', () => { async () => await runtime?.close(), async () => await migrationPool?.end(), async () => - await adminPool.query('DROP DATABASE IF EXISTS life_os_today_lock_test'), + await adminPool.query( + 'DROP DATABASE IF EXISTS life_os_today_lock_test', + ), async () => await adminPool.end(), ]; for (const cleanup of cleanups) {