Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fe6e6a7
test(planning): require durable task completion chronology
seonghobae Sep 10, 2026
a880b96
ci(planning): prove task chronology migration RED
seonghobae Sep 10, 2026
86d6714
feat(planning): enforce task completion chronology
seonghobae Sep 10, 2026
01b5d40
test(planning): verify completion chronology in PostgreSQL
seonghobae Sep 10, 2026
2657463
ci(planning): exercise chronology constraint in PostgreSQL
seonghobae Sep 10, 2026
856af24
test(planning): make completion migration assertion formatting-agnostic
seonghobae Sep 10, 2026
4f462b0
chore(planning): retire completion chronology canary
seonghobae Sep 10, 2026
655088b
test(planning): remove dynamic SQL from chronology contract
seonghobae Sep 10, 2026
164ae03
test(planning): require staged completion constraint validation
seonghobae Sep 10, 2026
4b76a04
test(planning): run staged chronology reality canary
seonghobae Sep 10, 2026
45b7c59
fix(planning): stage task completion chronology constraint
seonghobae Sep 10, 2026
a858ded
fix(planning): validate staged task completion chronology
seonghobae Sep 10, 2026
543d156
docs(planning): document staged chronology migration boundary
seonghobae Sep 10, 2026
1de871e
chore(planning): retire staged chronology canary
seonghobae Sep 10, 2026
28312d5
ci(planning): repair stale PostgreSQL migration fixtures
seonghobae Sep 11, 2026
9ab372e
ci(planning): execute PostgreSQL fixture chain repair
seonghobae Sep 11, 2026
b327e01
test(planning): keep PostgreSQL fixtures on current migrations
github-actions[bot] Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE planning.tasks
VALIDATE CONSTRAINT tasks_completion_state_check;
8 changes: 7 additions & 1 deletion apps/planning-service/migrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ async function applyMigrations(pool: Pool): Promise<void> {
'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),
Expand Down
122 changes: 122 additions & 0 deletions apps/planning-service/src/task-completion-chronology-migration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"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(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"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(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"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(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"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(
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
"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();
}
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -62,6 +64,8 @@ async function applyPlanningMigrations(pool: Pool): Promise<void> {
'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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -30,6 +32,9 @@ async function applyPlanningMigrations(pool: Pool): Promise<void> {
'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),
Expand Down Expand Up @@ -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) {
Expand Down