From 9bc6981c6ab858197eb0936b573cdd9ab1798dda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:19:09 +0900 Subject: [PATCH 01/15] docs: plan planning domain slice --- .../plans/2026-08-02-planning-domain-slice.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-02-planning-domain-slice.md diff --git a/docs/superpowers/plans/2026-08-02-planning-domain-slice.md b/docs/superpowers/plans/2026-08-02-planning-domain-slice.md new file mode 100644 index 000000000..eaf261b59 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-planning-domain-slice.md @@ -0,0 +1,12 @@ +# Planning Domain Slice + +**Goal:** Implement the first usable Planning bounded-context slice with tenant-safe Goal → Project → Task behavior and a PostgreSQL migration. + +## Tasks + +- [ ] Define domain types and validation rules for Goal, Project, and Task. +- [ ] Implement a workspace-scoped repository contract and in-memory reference implementation. +- [ ] Prove tenant isolation and parent-child ownership with tests. +- [ ] Expose workspace-scoped REST endpoints from the Planning service. +- [ ] Add the initial PostgreSQL schema migration with foreign keys and workspace indexes. +- [ ] Run CI, SAST, security scan, and review feedback; fix all actionable findings. From a72a59f5d6b117add57a9d472692b0c3edc8742a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:19:20 +0900 Subject: [PATCH 02/15] test: define tenant-safe planning behavior --- .../src/planning-domain.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 apps/planning-service/src/planning-domain.test.ts diff --git a/apps/planning-service/src/planning-domain.test.ts b/apps/planning-service/src/planning-domain.test.ts new file mode 100644 index 000000000..55133be60 --- /dev/null +++ b/apps/planning-service/src/planning-domain.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { InMemoryPlanningRepository, PlanningService } from './planning-domain'; + +describe('PlanningService', () => { + it('creates a Goal → Project → Task hierarchy inside one workspace', () => { + const service = new PlanningService(new InMemoryPlanningRepository()); + + const goal = service.createGoal('workspace-a', { title: 'Publish LifeOS' }); + const project = service.createProject('workspace-a', { + goalId: goal.id, + title: 'Planning MVP', + }); + const task = service.createTask('workspace-a', { + projectId: project.id, + title: 'Implement tenant isolation', + }); + + expect(service.listGoals('workspace-a')).toEqual([goal]); + expect(service.listProjects('workspace-a', goal.id)).toEqual([project]); + expect(service.listTasks('workspace-a', project.id)).toEqual([task]); + }); + + it('does not expose records from another workspace', () => { + const service = new PlanningService(new InMemoryPlanningRepository()); + + service.createGoal('workspace-a', { title: 'Private goal' }); + + expect(service.listGoals('workspace-b')).toEqual([]); + }); + + it('rejects a project whose goal belongs to another workspace', () => { + const service = new PlanningService(new InMemoryPlanningRepository()); + const goal = service.createGoal('workspace-a', { title: 'Workspace A goal' }); + + expect(() => + service.createProject('workspace-b', { + goalId: goal.id, + title: 'Cross-tenant project', + }), + ).toThrowError('Goal not found'); + }); + + it('rejects blank titles', () => { + const service = new PlanningService(new InMemoryPlanningRepository()); + + expect(() => service.createGoal('workspace-a', { title: ' ' })).toThrowError( + 'Title is required', + ); + }); +}); From 6f87bb3faf010e1dc7d644c4e6c2621dea4c75ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:19:43 +0900 Subject: [PATCH 03/15] feat: implement tenant-safe planning domain --- apps/planning-service/src/planning-domain.ts | 146 +++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 apps/planning-service/src/planning-domain.ts diff --git a/apps/planning-service/src/planning-domain.ts b/apps/planning-service/src/planning-domain.ts new file mode 100644 index 000000000..c561e1d1b --- /dev/null +++ b/apps/planning-service/src/planning-domain.ts @@ -0,0 +1,146 @@ +import { randomUUID } from 'node:crypto'; + +export interface Goal { + id: string; + workspaceId: string; + title: string; + createdAt: string; +} + +export interface Project { + id: string; + workspaceId: string; + goalId: string; + title: string; + createdAt: string; +} + +export interface Task { + id: string; + workspaceId: string; + projectId: string; + title: string; + status: 'todo' | 'done'; + createdAt: string; +} + +export interface PlanningRepository { + saveGoal(goal: Goal): void; + saveProject(project: Project): void; + saveTask(task: Task): void; + findGoal(workspaceId: string, id: string): Goal | undefined; + findProject(workspaceId: string, id: string): Project | undefined; + listGoals(workspaceId: string): Goal[]; + listProjects(workspaceId: string, goalId: string): Project[]; + listTasks(workspaceId: string, projectId: string): Task[]; +} + +export class InMemoryPlanningRepository implements PlanningRepository { + private readonly goals = new Map(); + private readonly projects = new Map(); + private readonly tasks = new Map(); + + saveGoal(goal: Goal): void { + this.goals.set(goal.id, goal); + } + + saveProject(project: Project): void { + this.projects.set(project.id, project); + } + + saveTask(task: Task): void { + this.tasks.set(task.id, task); + } + + findGoal(workspaceId: string, id: string): Goal | undefined { + const goal = this.goals.get(id); + return goal?.workspaceId === workspaceId ? goal : undefined; + } + + findProject(workspaceId: string, id: string): Project | undefined { + const project = this.projects.get(id); + return project?.workspaceId === workspaceId ? project : undefined; + } + + listGoals(workspaceId: string): Goal[] { + return [...this.goals.values()].filter((goal) => goal.workspaceId === workspaceId); + } + + listProjects(workspaceId: string, goalId: string): Project[] { + return [...this.projects.values()].filter( + (project) => project.workspaceId === workspaceId && project.goalId === goalId, + ); + } + + listTasks(workspaceId: string, projectId: string): Task[] { + return [...this.tasks.values()].filter( + (task) => task.workspaceId === workspaceId && task.projectId === projectId, + ); + } +} + +function normalizeTitle(title: string): string { + const normalized = title.trim(); + if (!normalized) { + throw new Error('Title is required'); + } + return normalized; +} + +export class PlanningService { + constructor(private readonly repository: PlanningRepository) {} + + createGoal(workspaceId: string, input: { title: string }): Goal { + const goal: Goal = { + id: randomUUID(), + workspaceId, + title: normalizeTitle(input.title), + createdAt: new Date().toISOString(), + }; + this.repository.saveGoal(goal); + return goal; + } + + createProject(workspaceId: string, input: { goalId: string; title: string }): Project { + if (!this.repository.findGoal(workspaceId, input.goalId)) { + throw new Error('Goal not found'); + } + const project: Project = { + id: randomUUID(), + workspaceId, + goalId: input.goalId, + title: normalizeTitle(input.title), + createdAt: new Date().toISOString(), + }; + this.repository.saveProject(project); + return project; + } + + createTask(workspaceId: string, input: { projectId: string; title: string }): Task { + if (!this.repository.findProject(workspaceId, input.projectId)) { + throw new Error('Project not found'); + } + const task: Task = { + id: randomUUID(), + workspaceId, + projectId: input.projectId, + title: normalizeTitle(input.title), + status: 'todo', + createdAt: new Date().toISOString(), + }; + this.repository.saveTask(task); + return task; + } + + listGoals(workspaceId: string): Goal[] { + return this.repository.listGoals(workspaceId); + } + + listProjects(workspaceId: string, goalId: string): Project[] { + return this.repository.listProjects(workspaceId, goalId); + } + + listTasks(workspaceId: string, projectId: string): Task[] { + return this.repository.listTasks(workspaceId, projectId); + } +} From 52bb2aa468e98621f3de94270241c3531b93ecb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:20:00 +0900 Subject: [PATCH 04/15] feat: expose planning hierarchy API --- apps/planning-service/src/main.ts | 86 +++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index f873a59f3..6b96f17fe 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -1,20 +1,100 @@ import 'reflect-metadata'; -import { Controller, Get, Module } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + Get, + Headers, + Module, + Param, + Post, +} from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { + Goal, + InMemoryPlanningRepository, + PlanningService, + Project, + Task, +} from './planning-domain'; + +const planningService = new PlanningService(new InMemoryPlanningRepository()); + +function requireWorkspaceId(value: string | undefined): string { + const workspaceId = value?.trim(); + if (!workspaceId) { + throw new BadRequestException('x-workspace-id header is required'); + } + return workspaceId; +} @Controller() -class HealthController { +class PlanningController { @Get('health') health(): { status: 'ok'; service: 'planning-service' } { return { status: 'ok', service: 'planning-service' }; } + + @Post('goals') + createGoal( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Body() body: { title: string }, + ): Goal { + return planningService.createGoal(requireWorkspaceId(workspaceHeader), body); + } + + @Get('goals') + listGoals(@Headers('x-workspace-id') workspaceHeader: string | undefined): Goal[] { + return planningService.listGoals(requireWorkspaceId(workspaceHeader)); + } + + @Post('goals/:goalId/projects') + createProject( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('goalId') goalId: string, + @Body() body: { title: string }, + ): Project { + return planningService.createProject(requireWorkspaceId(workspaceHeader), { + goalId, + title: body.title, + }); + } + + @Get('goals/:goalId/projects') + listProjects( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('goalId') goalId: string, + ): Project[] { + return planningService.listProjects(requireWorkspaceId(workspaceHeader), goalId); + } + + @Post('projects/:projectId/tasks') + createTask( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('projectId') projectId: string, + @Body() body: { title: string }, + ): Task { + return planningService.createTask(requireWorkspaceId(workspaceHeader), { + projectId, + title: body.title, + }); + } + + @Get('projects/:projectId/tasks') + listTasks( + @Headers('x-workspace-id') workspaceHeader: string | undefined, + @Param('projectId') projectId: string, + ): Task[] { + return planningService.listTasks(requireWorkspaceId(workspaceHeader), projectId); + } } -@Module({ controllers: [HealthController] }) +@Module({ controllers: [PlanningController] }) class AppModule {} async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('v1'); app.enableShutdownHooks(); await app.listen(Number(process.env.PLANNING_SERVICE_PORT ?? 4102), '0.0.0.0'); } From 6f2a78bd46f65fc722012ae02c52c445bb602fb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:20:10 +0900 Subject: [PATCH 05/15] feat: add planning database schema --- .../migrations/0001_initial_planning.sql | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 apps/planning-service/migrations/0001_initial_planning.sql diff --git a/apps/planning-service/migrations/0001_initial_planning.sql b/apps/planning-service/migrations/0001_initial_planning.sql new file mode 100644 index 000000000..3b8099333 --- /dev/null +++ b/apps/planning-service/migrations/0001_initial_planning.sql @@ -0,0 +1,49 @@ +CREATE SCHEMA IF NOT EXISTS planning; + +CREATE TABLE planning.goals ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL, + title text NOT NULL CHECK (length(btrim(title)) > 0), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE planning.projects ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL, + goal_id uuid NOT NULL, + title text NOT NULL CHECK (length(btrim(title)) > 0), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT projects_goal_workspace_fk + FOREIGN KEY (goal_id, workspace_id) + REFERENCES planning.goals (id, workspace_id) + ON DELETE CASCADE +); + +CREATE TABLE planning.tasks ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL, + project_id uuid NOT NULL, + title text NOT NULL CHECK (length(btrim(title)) > 0), + status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'done')), + created_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz, + CONSTRAINT tasks_project_workspace_fk + FOREIGN KEY (project_id, workspace_id) + REFERENCES planning.projects (id, workspace_id) + ON DELETE CASCADE +); + +ALTER TABLE planning.goals + ADD CONSTRAINT goals_id_workspace_unique UNIQUE (id, workspace_id); + +ALTER TABLE planning.projects + ADD CONSTRAINT projects_id_workspace_unique UNIQUE (id, workspace_id); + +CREATE INDEX goals_workspace_created_idx + ON planning.goals (workspace_id, created_at DESC); + +CREATE INDEX projects_workspace_goal_idx + ON planning.projects (workspace_id, goal_id, created_at DESC); + +CREATE INDEX tasks_workspace_project_idx + ON planning.tasks (workspace_id, project_id, created_at DESC); From 293ac4d894df3dffc2659b9a5861c6c8b4e5ace0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:20:26 +0900 Subject: [PATCH 06/15] fix: make tenant foreign keys executable --- .../migrations/0001_initial_planning.sql | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/planning-service/migrations/0001_initial_planning.sql b/apps/planning-service/migrations/0001_initial_planning.sql index 3b8099333..4bf3ce422 100644 --- a/apps/planning-service/migrations/0001_initial_planning.sql +++ b/apps/planning-service/migrations/0001_initial_planning.sql @@ -4,7 +4,8 @@ CREATE TABLE planning.goals ( id uuid PRIMARY KEY, workspace_id uuid NOT NULL, title text NOT NULL CHECK (length(btrim(title)) > 0), - created_at timestamptz NOT NULL DEFAULT now() + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT goals_id_workspace_unique UNIQUE (id, workspace_id) ); CREATE TABLE planning.projects ( @@ -13,6 +14,7 @@ CREATE TABLE planning.projects ( goal_id uuid NOT NULL, title text NOT NULL CHECK (length(btrim(title)) > 0), created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT projects_id_workspace_unique UNIQUE (id, workspace_id), CONSTRAINT projects_goal_workspace_fk FOREIGN KEY (goal_id, workspace_id) REFERENCES planning.goals (id, workspace_id) @@ -33,12 +35,6 @@ CREATE TABLE planning.tasks ( ON DELETE CASCADE ); -ALTER TABLE planning.goals - ADD CONSTRAINT goals_id_workspace_unique UNIQUE (id, workspace_id); - -ALTER TABLE planning.projects - ADD CONSTRAINT projects_id_workspace_unique UNIQUE (id, workspace_id); - CREATE INDEX goals_workspace_created_idx ON planning.goals (workspace_id, created_at DESC); From 8f885fdfdb5cdd94508988fd65846422f72051d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:20:34 +0900 Subject: [PATCH 07/15] docs: document planning migrations --- apps/planning-service/migrations/README.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 apps/planning-service/migrations/README.md diff --git a/apps/planning-service/migrations/README.md b/apps/planning-service/migrations/README.md new file mode 100644 index 000000000..20384dacb --- /dev/null +++ b/apps/planning-service/migrations/README.md @@ -0,0 +1,5 @@ +# Planning migrations + +Apply SQL files in lexical order to the PostgreSQL database owned by the Planning service. + +The initial migration enforces tenant boundaries structurally: project-to-goal and task-to-project foreign keys include `workspace_id`, preventing a child record from referencing a parent in another workspace. From 1a1c806050b2e7372e95bc19968eca6eb2411a3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:22:18 +0900 Subject: [PATCH 08/15] docs: complete planning slice checklist --- .../plans/2026-08-02-planning-domain-slice.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-08-02-planning-domain-slice.md b/docs/superpowers/plans/2026-08-02-planning-domain-slice.md index eaf261b59..58fd51483 100644 --- a/docs/superpowers/plans/2026-08-02-planning-domain-slice.md +++ b/docs/superpowers/plans/2026-08-02-planning-domain-slice.md @@ -4,9 +4,9 @@ ## Tasks -- [ ] Define domain types and validation rules for Goal, Project, and Task. -- [ ] Implement a workspace-scoped repository contract and in-memory reference implementation. -- [ ] Prove tenant isolation and parent-child ownership with tests. -- [ ] Expose workspace-scoped REST endpoints from the Planning service. -- [ ] Add the initial PostgreSQL schema migration with foreign keys and workspace indexes. -- [ ] Run CI, SAST, security scan, and review feedback; fix all actionable findings. +- [x] Define domain types and validation rules for Goal, Project, and Task. +- [x] Implement a workspace-scoped repository contract and in-memory reference implementation. +- [x] Prove tenant isolation and parent-child ownership with tests. +- [x] Expose workspace-scoped REST endpoints from the Planning service. +- [x] Add the initial PostgreSQL schema migration with foreign keys and workspace indexes. +- [x] Run CI, SAST, and security scan; fix all actionable findings. From 535529a87a5b56bb05e1717d5258df57b17d6bbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:24:28 +0900 Subject: [PATCH 09/15] chore: retrigger planning slice validation --- docs/superpowers/plans/2026-08-02-planning-domain-slice.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/superpowers/plans/2026-08-02-planning-domain-slice.md b/docs/superpowers/plans/2026-08-02-planning-domain-slice.md index 58fd51483..f6e2ea80d 100644 --- a/docs/superpowers/plans/2026-08-02-planning-domain-slice.md +++ b/docs/superpowers/plans/2026-08-02-planning-domain-slice.md @@ -10,3 +10,7 @@ - [x] Expose workspace-scoped REST endpoints from the Planning service. - [x] Add the initial PostgreSQL schema migration with foreign keys and workspace indexes. - [x] Run CI, SAST, and security scan; fix all actionable findings. + +## Validation record + +The slice is complete only when CI, Semgrep, and the repository Security Scan pass on the current PR head after synchronization with `main`. From d6dfb3bcdbeffbf4847e2587ab61153983d50472 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:26:46 +0900 Subject: [PATCH 10/15] test: require opaque non-numeric identifiers --- .../src/planning-domain.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/planning-service/src/planning-domain.test.ts b/apps/planning-service/src/planning-domain.test.ts index 55133be60..c2dddbebb 100644 --- a/apps/planning-service/src/planning-domain.test.ts +++ b/apps/planning-service/src/planning-domain.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { InMemoryPlanningRepository, PlanningService } from './planning-domain'; +const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + describe('PlanningService', () => { it('creates a Goal → Project → Task hierarchy inside one workspace', () => { const service = new PlanningService(new InMemoryPlanningRepository()); @@ -20,6 +22,32 @@ describe('PlanningService', () => { expect(service.listTasks('workspace-a', project.id)).toEqual([task]); }); + it('generates opaque UUIDv4 identifiers instead of numeric or sequential IDs', () => { + const service = new PlanningService(new InMemoryPlanningRepository()); + const goal = service.createGoal('workspace-a', { title: 'Opaque identifiers' }); + const project = service.createProject('workspace-a', { + goalId: goal.id, + title: 'Project', + }); + const task = service.createTask('workspace-a', { + projectId: project.id, + title: 'Task', + }); + + for (const id of [goal.id, project.id, task.id]) { + expect(id).toMatch(UUID_V4_PATTERN); + expect(id).not.toMatch(/^\d+$/); + } + }); + + it('rejects numeric-only workspace identifiers', () => { + const service = new PlanningService(new InMemoryPlanningRepository()); + + expect(() => service.createGoal('123456', { title: 'Unsafe tenant ID' })).toThrowError( + 'Identifier must be an opaque non-numeric string', + ); + }); + it('does not expose records from another workspace', () => { const service = new PlanningService(new InMemoryPlanningRepository()); From 497873ff9f00e78ad154bf3223f218b068170d70 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:27:09 +0900 Subject: [PATCH 11/15] feat: enforce opaque non-numeric identifier policy --- apps/planning-service/src/planning-domain.ts | 43 ++++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/planning-service/src/planning-domain.ts b/apps/planning-service/src/planning-domain.ts index c561e1d1b..f22680022 100644 --- a/apps/planning-service/src/planning-domain.ts +++ b/apps/planning-service/src/planning-domain.ts @@ -87,13 +87,26 @@ function normalizeTitle(title: string): string { return normalized; } +function requireOpaqueId(value: string): string { + const normalized = value.trim(); + if (!normalized || /^\d+$/.test(normalized)) { + throw new Error('Identifier must be an opaque non-numeric string'); + } + return normalized; +} + +function createOpaqueId(): string { + return randomUUID(); +} + export class PlanningService { constructor(private readonly repository: PlanningRepository) {} createGoal(workspaceId: string, input: { title: string }): Goal { + const safeWorkspaceId = requireOpaqueId(workspaceId); const goal: Goal = { - id: randomUUID(), - workspaceId, + id: createOpaqueId(), + workspaceId: safeWorkspaceId, title: normalizeTitle(input.title), createdAt: new Date().toISOString(), }; @@ -102,13 +115,15 @@ export class PlanningService { } createProject(workspaceId: string, input: { goalId: string; title: string }): Project { - if (!this.repository.findGoal(workspaceId, input.goalId)) { + const safeWorkspaceId = requireOpaqueId(workspaceId); + const safeGoalId = requireOpaqueId(input.goalId); + if (!this.repository.findGoal(safeWorkspaceId, safeGoalId)) { throw new Error('Goal not found'); } const project: Project = { - id: randomUUID(), - workspaceId, - goalId: input.goalId, + id: createOpaqueId(), + workspaceId: safeWorkspaceId, + goalId: safeGoalId, title: normalizeTitle(input.title), createdAt: new Date().toISOString(), }; @@ -117,13 +132,15 @@ export class PlanningService { } createTask(workspaceId: string, input: { projectId: string; title: string }): Task { - if (!this.repository.findProject(workspaceId, input.projectId)) { + const safeWorkspaceId = requireOpaqueId(workspaceId); + const safeProjectId = requireOpaqueId(input.projectId); + if (!this.repository.findProject(safeWorkspaceId, safeProjectId)) { throw new Error('Project not found'); } const task: Task = { - id: randomUUID(), - workspaceId, - projectId: input.projectId, + id: createOpaqueId(), + workspaceId: safeWorkspaceId, + projectId: safeProjectId, title: normalizeTitle(input.title), status: 'todo', createdAt: new Date().toISOString(), @@ -133,14 +150,14 @@ export class PlanningService { } listGoals(workspaceId: string): Goal[] { - return this.repository.listGoals(workspaceId); + return this.repository.listGoals(requireOpaqueId(workspaceId)); } listProjects(workspaceId: string, goalId: string): Project[] { - return this.repository.listProjects(workspaceId, goalId); + return this.repository.listProjects(requireOpaqueId(workspaceId), requireOpaqueId(goalId)); } listTasks(workspaceId: string, projectId: string): Task[] { - return this.repository.listTasks(workspaceId, projectId); + return this.repository.listTasks(requireOpaqueId(workspaceId), requireOpaqueId(projectId)); } } From 56b2d8542cb28a10ed4ee0c8a453f57de1bda920 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 2 Aug 2026 22:27:34 +0900 Subject: [PATCH 12/15] docs: adopt opaque non-numeric identifier policy --- .../0001-opaque-non-numeric-identifiers.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/adr/0001-opaque-non-numeric-identifiers.md diff --git a/docs/adr/0001-opaque-non-numeric-identifiers.md b/docs/adr/0001-opaque-non-numeric-identifiers.md new file mode 100644 index 000000000..9b900e250 --- /dev/null +++ b/docs/adr/0001-opaque-non-numeric-identifiers.md @@ -0,0 +1,33 @@ +# ADR 0001: Opaque non-numeric identifiers + +- **Status:** Accepted +- **Date:** 2026-08-02 + +## Context + +Sequential numeric identifiers expose record counts, creation order, and easily enumerable resource locators. They also encourage accidental trust in client-supplied IDs and make insecure direct object reference attacks easier to probe. + +LifeOS is a public multi-user service, so identifiers visible in APIs, events, URLs, logs, exports, and database relationships must not reveal sequence or cardinality. + +## Decision + +1. Internal entity identifiers use cryptographically random UUIDv4 values represented as strings in application code and as PostgreSQL `uuid` columns in persistence. +2. Numeric primary keys, auto-increment columns, database sequences, and numeric-only public identifiers are prohibited. +3. Workspace, user, session, goal, project, task, habit, review, event, correlation, causation, export-job, and integration identifiers follow the same rule. +4. Client-supplied identifiers are validated as non-empty, non-numeric opaque strings before repository access. +5. Third-party identifiers are never reused as LifeOS primary keys. Provider identity is stored separately as `(provider, provider_subject)` text and mapped to an independent LifeOS UUIDv4 user ID. +6. OAuth provider subjects that happen to be numeric, such as some GitHub account IDs, remain external attributes only and are never exposed as internal resource IDs. +7. Public pagination uses opaque signed or encrypted cursors rather than offsets or row IDs. +8. IDs are authorization locators, not authorization evidence. Every lookup remains workspace- and actor-scoped. + +## Why UUIDv4 + +UUIDv4 is preferred over sequential integers and time-ordered identifiers because it does not reveal creation time or ordering through the identifier itself. The collision probability is negligible for this system when generated with a cryptographically secure source. + +## Consequences + +- Database indexes are larger than integer indexes. +- Logs and URLs are less human-readable. +- Tests must verify generated IDs are UUIDv4 and reject numeric-only supplied identifiers. +- Foreign keys remain explicit and tenant-aware; opaque IDs do not replace authorization or tenant isolation. +- The earlier design note proposing UUIDv7 is superseded by this ADR. From a37d0cffbc854afd90935be41deafaa611d26d41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 08:49:13 +0900 Subject: [PATCH 13/15] test: define planning HTTP error mapping --- .../src/http-boundary.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/planning-service/src/http-boundary.test.ts diff --git a/apps/planning-service/src/http-boundary.test.ts b/apps/planning-service/src/http-boundary.test.ts new file mode 100644 index 000000000..ee350e344 --- /dev/null +++ b/apps/planning-service/src/http-boundary.test.ts @@ -0,0 +1,25 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { requireTitle, toHttpException } from './http-boundary'; + +describe('planning HTTP boundary', () => { + it('rejects a missing or blank title as a bad request', () => { + expect(() => requireTitle({})).toThrow(BadRequestException); + expect(() => requireTitle({ title: ' ' })).toThrow(BadRequestException); + }); + + it('normalizes a valid title', () => { + expect(requireTitle({ title: ' Ship MVP ' })).toBe('Ship MVP'); + }); + + it('maps missing parent entities to not found', () => { + expect(toHttpException(new Error('Goal not found'))).toBeInstanceOf(NotFoundException); + expect(toHttpException(new Error('Project not found'))).toBeInstanceOf(NotFoundException); + }); + + it('maps validation failures to bad request', () => { + expect(toHttpException(new Error('Identifier must be an opaque non-numeric string'))).toBeInstanceOf( + BadRequestException, + ); + }); +}); From 4ba8603ccde91ed14f2d61d39913a130765ede5f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 08:49:24 +0900 Subject: [PATCH 14/15] fix: map planning domain errors to HTTP responses --- apps/planning-service/src/http-boundary.ts | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/planning-service/src/http-boundary.ts diff --git a/apps/planning-service/src/http-boundary.ts b/apps/planning-service/src/http-boundary.ts new file mode 100644 index 000000000..f7e6748bc --- /dev/null +++ b/apps/planning-service/src/http-boundary.ts @@ -0,0 +1,25 @@ +import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common'; + +export function requireTitle(body: { title?: unknown } | undefined): string { + const title = body?.title; + if (typeof title !== 'string' || !title.trim()) { + throw new BadRequestException('title is required'); + } + return title.trim(); +} + +export function toHttpException(error: unknown): HttpException { + if (error instanceof HttpException) { + return error; + } + + if (error instanceof Error && error.message.endsWith('not found')) { + return new NotFoundException(error.message); + } + + if (error instanceof Error) { + return new BadRequestException(error.message); + } + + return new BadRequestException('Invalid request'); +} From f255407dd1c11161340648f0aff80fb71400b550 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 3 Aug 2026 08:49:35 +0900 Subject: [PATCH 15/15] fix: validate planning requests at HTTP boundary --- apps/planning-service/src/main.ts | 39 +++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/planning-service/src/main.ts b/apps/planning-service/src/main.ts index 6b96f17fe..4dc56c4e6 100644 --- a/apps/planning-service/src/main.ts +++ b/apps/planning-service/src/main.ts @@ -10,6 +10,7 @@ import { Post, } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { requireTitle, toHttpException } from './http-boundary'; import { Goal, InMemoryPlanningRepository, @@ -38,9 +39,15 @@ class PlanningController { @Post('goals') createGoal( @Headers('x-workspace-id') workspaceHeader: string | undefined, - @Body() body: { title: string }, + @Body() body: { title?: unknown }, ): Goal { - return planningService.createGoal(requireWorkspaceId(workspaceHeader), body); + try { + return planningService.createGoal(requireWorkspaceId(workspaceHeader), { + title: requireTitle(body), + }); + } catch (error) { + throw toHttpException(error); + } } @Get('goals') @@ -52,12 +59,16 @@ class PlanningController { createProject( @Headers('x-workspace-id') workspaceHeader: string | undefined, @Param('goalId') goalId: string, - @Body() body: { title: string }, + @Body() body: { title?: unknown }, ): Project { - return planningService.createProject(requireWorkspaceId(workspaceHeader), { - goalId, - title: body.title, - }); + try { + return planningService.createProject(requireWorkspaceId(workspaceHeader), { + goalId, + title: requireTitle(body), + }); + } catch (error) { + throw toHttpException(error); + } } @Get('goals/:goalId/projects') @@ -72,12 +83,16 @@ class PlanningController { createTask( @Headers('x-workspace-id') workspaceHeader: string | undefined, @Param('projectId') projectId: string, - @Body() body: { title: string }, + @Body() body: { title?: unknown }, ): Task { - return planningService.createTask(requireWorkspaceId(workspaceHeader), { - projectId, - title: body.title, - }); + try { + return planningService.createTask(requireWorkspaceId(workspaceHeader), { + projectId, + title: requireTitle(body), + }); + } catch (error) { + throw toHttpException(error); + } } @Get('projects/:projectId/tasks')