From 492c2f9702143794a2c93b6b360c7ec59f653077 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:50:44 +0900 Subject: [PATCH 01/20] feat(habit): add recurring habit domain kernel --- apps/habit-service/src/habit-domain.ts | 421 +++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 apps/habit-service/src/habit-domain.ts diff --git a/apps/habit-service/src/habit-domain.ts b/apps/habit-service/src/habit-domain.ts new file mode 100644 index 00000000..52c6a880 --- /dev/null +++ b/apps/habit-service/src/habit-domain.ts @@ -0,0 +1,421 @@ +import { randomUUID } from 'node:crypto'; + +export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7; + +export interface DailyRecurrence { + kind: 'daily'; + interval: number; +} + +export interface WeeklyRecurrence { + kind: 'weekly'; + interval: number; + weekdays: readonly IsoWeekday[]; +} + +export type HabitRecurrence = DailyRecurrence | WeeklyRecurrence; + +export interface Habit { + id: string; + workspaceId: string; + title: string; + timezone: string; + startsOn: string; + recurrence: HabitRecurrence; + createdAt: string; +} + +export interface HabitOccurrence { + habitId: string; + workspaceId: string; + scheduledLocalDate: string; +} + +export interface HabitCompletionEvent { + id: string; + workspaceId: string; + habitId: string; + scheduledLocalDate: string; + completedAt: string; + idempotencyKey: string; + recordedAt: string; +} + +export interface HabitRepository { + saveHabit(habit: Habit): Promise; + findHabit(workspaceId: string, habitId: string): Promise; + listHabits(workspaceId: string): Promise; + appendCompletion( + completion: HabitCompletionEvent, + ): Promise; + listCompletions( + workspaceId: string, + habitId: string, + ): Promise; +} + +const LOCAL_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +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; +const MAXIMUM_OCCURRENCE_RANGE_DAYS = 366; +const MILLISECONDS_PER_DAY = 86_400_000; + +interface ParsedLocalDate { + text: string; + epochDay: number; + isoWeekday: IsoWeekday; +} + +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 requireUuidV4(value: string): string { + if (!UUID_V4_PATTERN.test(value)) { + throw new Error('Idempotency key must be a UUIDv4'); + } + return value.toLowerCase(); +} + +function requireTitle(value: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new Error('Title is required'); + } + return normalized; +} + +function requireTimezone(value: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new Error('Timezone is required'); + } + try { + new Intl.DateTimeFormat('en-US', { timeZone: normalized }).format(); + } catch { + throw new Error('Timezone is invalid'); + } + return normalized; +} + +function requireInterval(value: number): number { + if (!Number.isSafeInteger(value) || value < 1 || value > 365) { + throw new Error('Recurrence interval must be between 1 and 365'); + } + return value; +} + +function requireTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error('Timestamp is invalid'); + } + return parsed.toISOString(); +} + +function parseLocalDate(value: string): ParsedLocalDate { + const match = LOCAL_DATE_PATTERN.exec(value); + if (!match) { + throw new Error('Local date must use YYYY-MM-DD'); + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const milliseconds = Date.UTC(year, month - 1, day); + const date = new Date(milliseconds); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + throw new Error('Local date is invalid'); + } + const utcWeekday = date.getUTCDay(); + const isoWeekday = (utcWeekday === 0 ? 7 : utcWeekday) as IsoWeekday; + return { + text: value, + epochDay: milliseconds / MILLISECONDS_PER_DAY, + isoWeekday, + }; +} + +function localDateFromEpochDay(epochDay: number): ParsedLocalDate { + const date = new Date(epochDay * MILLISECONDS_PER_DAY); + const text = [ + date.getUTCFullYear().toString().padStart(4, '0'), + (date.getUTCMonth() + 1).toString().padStart(2, '0'), + date.getUTCDate().toString().padStart(2, '0'), + ].join('-'); + return parseLocalDate(text); +} + +function normalizeWeekdays(values: readonly number[]): readonly IsoWeekday[] { + if (values.length === 0) { + throw new Error('Weekly recurrence requires at least one weekday'); + } + const normalized = [...new Set(values)].sort((left, right) => left - right); + if ( + normalized.some( + (value) => !Number.isSafeInteger(value) || value < 1 || value > 7, + ) + ) { + throw new Error('Weekday must be between 1 and 7'); + } + return normalized as IsoWeekday[]; +} + +function normalizeRecurrence(recurrence: HabitRecurrence): HabitRecurrence { + const interval = requireInterval(recurrence.interval); + if (recurrence.kind === 'daily') { + return { kind: 'daily', interval }; + } + if (recurrence.kind === 'weekly') { + return { + kind: 'weekly', + interval, + weekdays: normalizeWeekdays(recurrence.weekdays), + }; + } + throw new Error('Recurrence kind is invalid'); +} + +function cloneHabit(habit: Habit): Habit { + return { + ...habit, + recurrence: + habit.recurrence.kind === 'daily' + ? { ...habit.recurrence } + : { ...habit.recurrence, weekdays: [...habit.recurrence.weekdays] }, + }; +} + +function cloneCompletion( + completion: HabitCompletionEvent, +): HabitCompletionEvent { + return { ...completion }; +} + +function isScheduledOn( + habit: Habit, + date: ParsedLocalDate, + start: ParsedLocalDate, +): boolean { + const elapsedDays = date.epochDay - start.epochDay; + if (elapsedDays < 0) { + return false; + } + if (habit.recurrence.kind === 'daily') { + return elapsedDays % habit.recurrence.interval === 0; + } + const elapsedWeeks = Math.floor(elapsedDays / 7); + return ( + elapsedWeeks % habit.recurrence.interval === 0 && + habit.recurrence.weekdays.includes(date.isoWeekday) + ); +} + +export function generateHabitOccurrences( + habit: Habit, + fromLocalDate: string, + toLocalDate: string, +): HabitOccurrence[] { + const start = parseLocalDate(habit.startsOn); + const from = parseLocalDate(fromLocalDate); + const to = parseLocalDate(toLocalDate); + const span = to.epochDay - from.epochDay; + if (span < 0) { + throw new Error('Occurrence range is reversed'); + } + if (span + 1 > MAXIMUM_OCCURRENCE_RANGE_DAYS) { + throw new Error('Occurrence range exceeds 366 days'); + } + + const occurrences: HabitOccurrence[] = []; + for (let epochDay = from.epochDay; epochDay <= to.epochDay; epochDay += 1) { + const date = localDateFromEpochDay(epochDay); + if (isScheduledOn(habit, date, start)) { + occurrences.push({ + habitId: habit.id, + workspaceId: habit.workspaceId, + scheduledLocalDate: date.text, + }); + } + } + return occurrences; +} + +export class InMemoryHabitRepository implements HabitRepository { + private readonly habits = new Map(); + private readonly completions = new Map(); + private readonly completionIdempotency = new Map(); + + async saveHabit(habit: Habit): Promise { + this.habits.set(habit.id, cloneHabit(habit)); + } + + async findHabit( + workspaceId: string, + habitId: string, + ): Promise { + const habit = this.habits.get(habitId); + return habit?.workspaceId === workspaceId ? cloneHabit(habit) : undefined; + } + + async listHabits(workspaceId: string): Promise { + return [...this.habits.values()] + .filter((habit) => habit.workspaceId === workspaceId) + .sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id), + ) + .map(cloneHabit); + } + + async appendCompletion( + completion: HabitCompletionEvent, + ): Promise { + const idempotencyLookup = [ + completion.workspaceId, + completion.habitId, + completion.idempotencyKey, + ].join(':'); + const existingId = this.completionIdempotency.get(idempotencyLookup); + if (existingId) { + const existing = this.completions.get(existingId); + if (!existing) { + throw new Error('Completion history is inconsistent'); + } + return cloneCompletion(existing); + } + const stored = cloneCompletion(completion); + this.completions.set(stored.id, stored); + this.completionIdempotency.set(idempotencyLookup, stored.id); + return cloneCompletion(stored); + } + + async listCompletions( + workspaceId: string, + habitId: string, + ): Promise { + return [...this.completions.values()] + .filter( + (completion) => + completion.workspaceId === workspaceId && + completion.habitId === habitId, + ) + .sort( + (left, right) => + left.recordedAt.localeCompare(right.recordedAt) || + left.id.localeCompare(right.id), + ) + .map(cloneCompletion); + } +} + +export class HabitService { + constructor(private readonly repository: HabitRepository) {} + + async createHabit( + workspaceId: string, + input: { + title: string; + timezone: string; + startsOn: string; + recurrence: HabitRecurrence; + }, + ): Promise { + const habit: Habit = { + id: randomUUID(), + workspaceId: requireOpaqueId(workspaceId), + title: requireTitle(input.title), + timezone: requireTimezone(input.timezone), + startsOn: parseLocalDate(input.startsOn).text, + recurrence: normalizeRecurrence(input.recurrence), + createdAt: new Date().toISOString(), + }; + await this.repository.saveHabit(habit); + return cloneHabit(habit); + } + + async listHabits(workspaceId: string): Promise { + return await this.repository.listHabits(requireOpaqueId(workspaceId)); + } + + async listOccurrences( + workspaceId: string, + habitId: string, + fromLocalDate: string, + toLocalDate: string, + ): Promise { + const safeWorkspaceId = requireOpaqueId(workspaceId); + const safeHabitId = requireOpaqueId(habitId); + const habit = await this.repository.findHabit( + safeWorkspaceId, + safeHabitId, + ); + if (!habit) { + throw new Error('Habit not found'); + } + return generateHabitOccurrences(habit, fromLocalDate, toLocalDate); + } + + async completeHabit( + workspaceId: string, + habitId: string, + input: { + scheduledLocalDate: string; + completedAt: string; + idempotencyKey: string; + }, + ): Promise { + const safeWorkspaceId = requireOpaqueId(workspaceId); + const safeHabitId = requireOpaqueId(habitId); + const habit = await this.repository.findHabit( + safeWorkspaceId, + safeHabitId, + ); + if (!habit) { + throw new Error('Habit not found'); + } + const scheduledLocalDate = parseLocalDate(input.scheduledLocalDate).text; + if ( + generateHabitOccurrences( + habit, + scheduledLocalDate, + scheduledLocalDate, + ).length !== 1 + ) { + throw new Error('Habit is not scheduled on this date'); + } + const now = new Date().toISOString(); + return await this.repository.appendCompletion({ + id: randomUUID(), + workspaceId: safeWorkspaceId, + habitId: safeHabitId, + scheduledLocalDate, + completedAt: requireTimestamp(input.completedAt), + idempotencyKey: requireUuidV4(input.idempotencyKey), + recordedAt: now, + }); + } + + async listCompletionHistory( + workspaceId: string, + habitId: string, + ): Promise { + const safeWorkspaceId = requireOpaqueId(workspaceId); + const safeHabitId = requireOpaqueId(habitId); + if (!(await this.repository.findHabit(safeWorkspaceId, safeHabitId))) { + throw new Error('Habit not found'); + } + return await this.repository.listCompletions( + safeWorkspaceId, + safeHabitId, + ); + } +} From 29b205d9410982ca74b0195f6d87207736fd1fda Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:51:58 +0900 Subject: [PATCH 02/20] test(habit): recover recurrence and immutable history behavior --- apps/habit-service/src/habit-domain.test.ts | 236 ++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 apps/habit-service/src/habit-domain.test.ts diff --git a/apps/habit-service/src/habit-domain.test.ts b/apps/habit-service/src/habit-domain.test.ts new file mode 100644 index 00000000..d194d123 --- /dev/null +++ b/apps/habit-service/src/habit-domain.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; +import { + HabitService, + InMemoryHabitRepository, + generateHabitOccurrences, + type Habit, +} from './habit-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; +const FIRST_IDEMPOTENCY_KEY = '11111111-1111-4111-8111-111111111111'; + +function dailyHabit(overrides: Partial = {}): Habit { + return { + id: '22222222-2222-4222-8222-222222222222', + workspaceId: 'workspace-a', + title: 'Read deliberately', + timezone: 'Asia/Seoul', + startsOn: '2024-02-28', + recurrence: { kind: 'daily', interval: 2 }, + createdAt: '2024-02-28T00:00:00.000Z', + ...overrides, + }; +} + +describe('habit recurrence kernel', () => { + it('recovers an every-two-days schedule across leap day and month end', () => { + const occurrences = generateHabitOccurrences( + dailyHabit(), + '2024-02-27', + '2024-03-04', + ); + + expect( + occurrences.map((occurrence) => occurrence.scheduledLocalDate), + ).toEqual(['2024-02-28', '2024-03-01', '2024-03-03']); + }); + + it('generates weekly local-date occurrences without DST-hour drift', () => { + const habit: Habit = { + ...dailyHabit(), + startsOn: '2026-03-01', + timezone: 'America/New_York', + recurrence: { kind: 'weekly', interval: 1, weekdays: [1, 5] }, + }; + + const occurrences = generateHabitOccurrences( + habit, + '2026-03-01', + '2026-03-15', + ); + + expect( + occurrences.map((occurrence) => occurrence.scheduledLocalDate), + ).toEqual(['2026-03-02', '2026-03-06', '2026-03-09', '2026-03-13']); + }); + + it('keeps interval schedules stable across year boundaries', () => { + const habit: Habit = { + ...dailyHabit(), + startsOn: '2026-12-28', + recurrence: { kind: 'weekly', interval: 2, weekdays: [1, 3] }, + }; + + const first = generateHabitOccurrences( + habit, + '2026-12-28', + '2027-01-25', + ); + const second = generateHabitOccurrences( + habit, + '2026-12-28', + '2027-01-25', + ); + + expect(first).toEqual(second); + expect(first.map((occurrence) => occurrence.scheduledLocalDate)).toEqual([ + '2026-12-28', + '2026-12-30', + '2027-01-11', + '2027-01-13', + '2027-01-25', + ]); + }); + + it('bounds occurrence generation and rejects malformed dates', () => { + expect(() => + generateHabitOccurrences(dailyHabit(), '2026-01-01', '2025-12-31'), + ).toThrowError('Occurrence range is reversed'); + expect(() => + generateHabitOccurrences(dailyHabit(), '2025-01-01', '2026-01-02'), + ).toThrowError('Occurrence range exceeds 366 days'); + expect(() => + generateHabitOccurrences(dailyHabit(), '2026-02-30', '2026-03-01'), + ).toThrowError('Local date is invalid'); + }); +}); + +describe('HabitService', () => { + it('normalizes recurrence input and generates opaque entity identifiers', async () => { + const service = new HabitService(new InMemoryHabitRepository()); + + const habit = await service.createHabit('workspace-a', { + title: ' Walk after lunch ', + timezone: 'Asia/Seoul', + startsOn: '2026-08-04', + recurrence: { + kind: 'weekly', + interval: 1, + weekdays: [5, 1, 5], + }, + }); + + expect(habit.id).toMatch(UUID_V4_PATTERN); + expect(habit.title).toBe('Walk after lunch'); + expect(habit.recurrence).toEqual({ + kind: 'weekly', + interval: 1, + weekdays: [1, 5], + }); + }); + + it('rejects invalid tenant, timezone, interval, weekday, and title input', async () => { + const service = new HabitService(new InMemoryHabitRepository()); + + await expect( + service.createHabit('12345', { + title: 'Habit', + timezone: 'Asia/Seoul', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + }), + ).rejects.toThrowError('Identifier must be an opaque non-numeric string'); + await expect( + service.createHabit('workspace-a', { + title: 'Habit', + timezone: 'Not/A_Timezone', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + }), + ).rejects.toThrowError('Timezone is invalid'); + await expect( + service.createHabit('workspace-a', { + title: 'Habit', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 0 }, + }), + ).rejects.toThrowError('Recurrence interval must be between 1 and 365'); + await expect( + service.createHabit('workspace-a', { + title: 'Habit', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'weekly', interval: 1, weekdays: [] }, + }), + ).rejects.toThrowError( + 'Weekly recurrence requires at least one weekday', + ); + await expect( + service.createHabit('workspace-a', { + title: ' ', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'weekly', interval: 1, weekdays: [8] as never }, + }), + ).rejects.toThrowError('Title is required'); + }); + + it('isolates habits and occurrence reads by workspace', async () => { + const service = new HabitService(new InMemoryHabitRepository()); + const habit = await service.createHabit('workspace-a', { + title: 'Private habit', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + }); + + await expect(service.listHabits('workspace-b')).resolves.toEqual([]); + await expect( + service.listOccurrences( + 'workspace-b', + habit.id, + '2026-08-04', + '2026-08-05', + ), + ).rejects.toThrowError('Habit not found'); + }); + + it('appends completion history idempotently and returns immutable copies', async () => { + const repository = new InMemoryHabitRepository(); + const service = new HabitService(repository); + const habit = await service.createHabit('workspace-a', { + title: 'Complete safely', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + }); + const command = { + scheduledLocalDate: '2026-08-04', + completedAt: '2026-08-04T08:00:00.000Z', + idempotencyKey: FIRST_IDEMPOTENCY_KEY, + }; + + const first = await service.completeHabit('workspace-a', habit.id, command); + const replay = await service.completeHabit('workspace-a', habit.id, command); + expect(replay).toEqual(first); + expect(first.id).toMatch(UUID_V4_PATTERN); + + first.completedAt = '2030-01-01T00:00:00.000Z'; + const history = await service.listCompletionHistory( + 'workspace-a', + habit.id, + ); + expect(history).toHaveLength(1); + expect(history[0]?.completedAt).toBe('2026-08-04T08:00:00.000Z'); + }); + + it('rejects completion on a date without an occurrence', async () => { + const service = new HabitService(new InMemoryHabitRepository()); + const habit = await service.createHabit('workspace-a', { + title: 'Every other day', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 2 }, + }); + + await expect( + service.completeHabit('workspace-a', habit.id, { + scheduledLocalDate: '2026-08-05', + completedAt: '2026-08-05T08:00:00.000Z', + idempotencyKey: FIRST_IDEMPOTENCY_KEY, + }), + ).rejects.toThrowError('Habit is not scheduled on this date'); + }); +}); From 6121031c7739d36d98589e36bf288e5bc3c1cc0f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:52:23 +0900 Subject: [PATCH 03/20] feat(habit): add append-only recurring core schema --- .../migrations/0001_recurring_habit_core.sql | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/habit-service/migrations/0001_recurring_habit_core.sql diff --git a/apps/habit-service/migrations/0001_recurring_habit_core.sql b/apps/habit-service/migrations/0001_recurring_habit_core.sql new file mode 100644 index 00000000..a82c9286 --- /dev/null +++ b/apps/habit-service/migrations/0001_recurring_habit_core.sql @@ -0,0 +1,99 @@ +BEGIN; + +CREATE SCHEMA IF NOT EXISTS habit; + +CREATE TABLE habit.habit_definitions ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL, + title text NOT NULL, + timezone_name text NOT NULL, + recurrence_kind text NOT NULL, + recurrence_interval smallint NOT NULL, + weekday_mask smallint NOT NULL DEFAULT 0, + starts_on date NOT NULL, + created_at timestamptz NOT NULL, + CONSTRAINT habit_definitions_id_workspace_unique + UNIQUE (id, workspace_id), + CONSTRAINT habit_definitions_id_uuid_v4 CHECK ( + id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT habit_definitions_workspace_id_uuid_v4 CHECK ( + workspace_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT habit_definitions_title_nonblank CHECK ( + length(btrim(title)) > 0 + ), + CONSTRAINT habit_definitions_timezone_nonblank CHECK ( + length(btrim(timezone_name)) > 0 + ), + CONSTRAINT habit_definitions_recurrence_kind_valid CHECK ( + recurrence_kind IN ('daily', 'weekly') + ), + CONSTRAINT habit_definitions_recurrence_interval_valid CHECK ( + recurrence_interval BETWEEN 1 AND 365 + ), + CONSTRAINT habit_definitions_weekday_mask_valid CHECK ( + (recurrence_kind = 'daily' AND weekday_mask = 0) + OR + (recurrence_kind = 'weekly' AND weekday_mask BETWEEN 1 AND 127) + ) +); + +CREATE TABLE habit.completion_events ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL, + habit_id uuid NOT NULL, + scheduled_local_date date NOT NULL, + completed_at timestamptz NOT NULL, + idempotency_key uuid NOT NULL, + recorded_at timestamptz NOT NULL, + CONSTRAINT completion_events_id_workspace_unique + UNIQUE (id, workspace_id), + CONSTRAINT completion_events_idempotency_unique + UNIQUE (workspace_id, habit_id, idempotency_key), + CONSTRAINT completion_events_habit_workspace_foreign + FOREIGN KEY (habit_id, workspace_id) + REFERENCES habit.habit_definitions (id, workspace_id), + CONSTRAINT completion_events_id_uuid_v4 CHECK ( + id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT completion_events_workspace_id_uuid_v4 CHECK ( + workspace_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT completion_events_habit_id_uuid_v4 CHECK ( + habit_id::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ), + CONSTRAINT completion_events_idempotency_key_uuid_v4 CHECK ( + idempotency_key::text ~ '^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + ) +); + +CREATE INDEX habit_definitions_workspace_creation_idx + ON habit.habit_definitions (workspace_id, created_at ASC, id ASC); + +CREATE INDEX completion_events_workspace_habit_schedule_idx + ON habit.completion_events ( + workspace_id, + habit_id, + scheduled_local_date ASC, + recorded_at ASC, + id ASC + ); + +CREATE FUNCTION habit.reject_completion_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION USING + ERRCODE = '55000', + MESSAGE = 'Habit completion history is append-only'; +END; +$$; + +CREATE TRIGGER completion_events_append_only +BEFORE UPDATE OR DELETE ON habit.completion_events +FOR EACH ROW +EXECUTE FUNCTION habit.reject_completion_mutation(); + +COMMIT; From a1cef785d75db0b7370073aacfd709872bd79952 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:52:39 +0900 Subject: [PATCH 04/20] docs(habit): document recurrence persistence guarantees --- apps/habit-service/migrations/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 apps/habit-service/migrations/README.md diff --git a/apps/habit-service/migrations/README.md b/apps/habit-service/migrations/README.md new file mode 100644 index 00000000..59c7f54b --- /dev/null +++ b/apps/habit-service/migrations/README.md @@ -0,0 +1,15 @@ +# Habit migrations + +Apply Habit SQL files in lexical order to the PostgreSQL database owned by the Habit service before starting the corresponding application version. + +- `0001_recurring_habit_core.sql` creates tenant-safe habit definitions and append-only completion events. Weekly recurrence days are stored as a seven-bit ISO-weekday mask, while the service domain exposes normalized weekday numbers from Monday (`1`) through Sunday (`7`). + +## Integrity guarantees + +Every persisted entity, workspace, habit reference, event, and idempotency key is constrained to UUIDv4. Composite foreign keys carry `workspace_id` through the ownership path, and duplicate completion commands are identified by `(workspace_id, habit_id, idempotency_key)`. + +A database trigger rejects ordinary `UPDATE` and `DELETE` operations on completion events. A future data-rights migration must provide a separately authorized erasure path before production account deletion is enabled; application code must not bypass append-only history through direct SQL. + +## Rollback + +This migration is forward-only in automated environments. An operator-approved rollback must first export tenant data, then drop `habit.completion_events`, `habit.habit_definitions`, `habit.reject_completion_mutation()`, and the `habit` schema. Do not roll back after serving completion writes unless the exported history has been verified and the data-retention decision is documented. From 8c9b482788f7243ad7ca9ea8309977d6f83af561 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:52:55 +0900 Subject: [PATCH 05/20] docs(habit): plan recurring domain slice --- ...2026-08-04-habit-recurring-domain-slice.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md diff --git a/docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md b/docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md new file mode 100644 index 00000000..66d1fc9a --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md @@ -0,0 +1,27 @@ +# Habit Recurring Domain Slice + +## Goal + +Establish a tenant-safe recurring-habit kernel that generates bounded local-date occurrences and records immutable, idempotent completion history without coupling the domain to an HTTP framework or database driver. + +## Changes + +1. Define daily and weekly recurrence contracts with bounded intervals and normalized ISO weekdays. +2. Validate opaque tenant identifiers, UUIDv4 idempotency keys, titles, IANA timezones, calendar dates, timestamps, and bounded occurrence ranges. +3. Generate occurrences from local calendar dates rather than elapsed wall-clock hours so daylight-saving transitions do not duplicate or omit scheduled habits. +4. Add an asynchronous repository boundary and an in-memory adapter that preserves tenant isolation, deterministic ordering, idempotent completion commands, and copy-on-read history. +5. Add parameter-recovery tests across leap day, month and year boundaries, daylight-saving transition dates, recurrence intervals, malformed input, cross-workspace access, replayed completion commands, and unscheduled dates. +6. Add a PostgreSQL schema with UUIDv4 checks, composite tenant ownership, a normalized weekly bit mask, deterministic indexes, idempotency uniqueness, and an append-only completion-event trigger. +7. Document migration integrity, rollback risk, and the future controlled-erasure requirement. + +## Deferred slices + +- parameterized PostgreSQL repository implementation and pooled integration tests; +- validated PostgreSQL runtime and NestJS lifecycle wiring; +- versioned HTTP endpoints with RFC 9457-compatible problem details; +- pause, archive, streak, reminder, and calendar-integration workflows; +- separately authorized data-rights erasure that remains unavailable to ordinary application roles. + +## Validation + +Formatting, lint, type checking, recurrence tests, build, AppGuardrail, Semgrep, Security Scan, Commercial Readiness, CodeRabbit, and human review must pass on the exact pull-request head. From 3d8fbb048d18155c1d8f9dddd56091b6021c1409 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:53:20 +0900 Subject: [PATCH 06/20] chore(format): gate recurring habit slice --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2fc895bd..cc4d0b6d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "lint": "turbo run lint", "test": "turbo run test", "typecheck": "turbo run typecheck", - "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md", + "format:check": "prettier --single-quote --check README.md package.json turbo.json tsconfig.base.json pnpm-workspace.yaml compose.yaml .appguardrail.json .github/workflows/ci.yml .github/workflows/appguardrail.yml security/appguardrail-contract.json packages/appguardrail-contract/package.json packages/appguardrail-contract/src/verify-contract.mjs packages/appguardrail-contract/src/verify-contract.test.mjs tests/appguardrail-fixtures/dangerous-cors.ts tests/appguardrail-fixtures/oauth-open-redirect.ts docs/security/appguardrail-regressions.md docs/superpowers/specs/2026-08-03-appguardrail-security-gate-design.md docs/superpowers/plans/2026-08-03-appguardrail-security-gate.md .github/workflows/commercial-readiness.yml product/commercial-readiness-policy.json product/capabilities.json packages/commercial-readiness/package.json packages/commercial-readiness/src/schema.mjs packages/commercial-readiness/src/schema.test.mjs packages/commercial-readiness/src/audit.mjs packages/commercial-readiness/src/audit.test.mjs packages/commercial-readiness/src/pr-gate.mjs packages/commercial-readiness/src/pr-gate.test.mjs packages/commercial-readiness/src/render.mjs packages/commercial-readiness/src/render.test.mjs packages/commercial-readiness/src/github-client.mjs packages/commercial-readiness/src/github-client.test.mjs packages/commercial-readiness/src/cli.mjs packages/commercial-readiness/src/cli.test.mjs packages/commercial-readiness/src/workflow-contract.test.mjs docs/superpowers/specs/2026-08-03-commercial-readiness-loop-design.md docs/superpowers/plans/2026-08-03-commercial-readiness-loop.md apps/identity-service/src/oauth-http-boundary.ts apps/identity-service/src/oauth-http-application.ts apps/identity-service/src/oauth-http-boundary.test.ts docs/superpowers/plans/2026-08-03-oauth-http-boundary-slice.md apps/identity-service/package.json apps/identity-service/src/main.ts apps/identity-service/src/oauth-http-controller.ts apps/identity-service/src/oauth-http-controller.test.ts apps/identity-service/src/oauth-http.integration.test.ts apps/identity-service/src/identity-runtime.ts apps/identity-service/src/identity-runtime.test.ts docs/superpowers/plans/2026-08-03-oauth-controller-wiring-slice.md apps/identity-service/src/oauth-provider-http-client.ts apps/identity-service/src/tests/oauth-provider-http-client.test.ts docs/superpowers/plans/2026-08-03-oauth-provider-http-client-slice.md apps/identity-service/src/google-oidc-client.ts apps/identity-service/src/google-oidc-client.test.ts docs/superpowers/plans/2026-08-03-google-oidc-verifier-slice.md apps/identity-service/src/github-oauth-client.ts apps/identity-service/src/github-oauth-client.test.ts docs/superpowers/plans/2026-08-03-github-oauth-client-slice.md apps/identity-service/src/oauth-callback-application.ts apps/identity-service/src/oauth-callback-application.test.ts docs/superpowers/plans/2026-08-03-oauth-callback-orchestration-slice.md docs/superpowers/plans/2026-08-03-oauth-callback-runtime-wiring-slice.md docs/superpowers/plans/2026-08-03-oauth-open-redirect-regression-slice.md docs/superpowers/plans/2026-08-03-oauth-http-integration-slice.md apps/planning-service/package.json apps/planning-service/migrations/README.md apps/planning-service/src/main.ts apps/planning-service/src/http-boundary.ts apps/planning-service/src/http-boundary.test.ts apps/planning-service/src/planning-domain.ts apps/planning-service/src/planning-domain.test.ts apps/planning-service/src/planning-runtime.ts apps/planning-service/src/planning-runtime.test.ts apps/planning-service/src/postgres-planning-repository.ts apps/planning-service/src/postgres-planning-repository.test.ts apps/planning-service/src/postgres-planning-repository.integration.test.ts docs/superpowers/plans/2026-08-03-planning-postgres-repository-slice.md docs/superpowers/plans/2026-08-04-planning-postgres-runtime-slice.md apps/habit-service/package.json apps/habit-service/migrations/README.md apps/habit-service/src/main.ts apps/habit-service/src/habit-domain.ts apps/habit-service/src/habit-domain.test.ts docs/superpowers/plans/2026-08-04-habit-recurring-domain-slice.md", "format": "prettier --single-quote --write ." }, "devDependencies": { From 12836b5c750ebcefdc4c1efa74af68c4fbc37df6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:54:52 +0900 Subject: [PATCH 07/20] ci: render recurring habit formatting diagnostics --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cd3ba81..1949fca8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,15 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile + - name: Render habit formatting diff + run: | + pnpm exec prettier --single-quote --write \ + apps/habit-service/src/habit-domain.ts \ + apps/habit-service/src/habit-domain.test.ts + git diff --no-ext-diff --unified=100000 -- \ + apps/habit-service/src/habit-domain.ts \ + apps/habit-service/src/habit-domain.test.ts + - name: Check formatting run: pnpm format:check From b2bb9205b5da9371a745a31ab44f0a33fd22d5ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:58:39 +0900 Subject: [PATCH 08/20] fix(ci): preserve formatting gate integrity --- .github/workflows/ci.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1949fca8..1cd3ba81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,15 +49,6 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile - - name: Render habit formatting diff - run: | - pnpm exec prettier --single-quote --write \ - apps/habit-service/src/habit-domain.ts \ - apps/habit-service/src/habit-domain.test.ts - git diff --no-ext-diff --unified=100000 -- \ - apps/habit-service/src/habit-domain.ts \ - apps/habit-service/src/habit-domain.test.ts - - name: Check formatting run: pnpm format:check From 28ec325bc8044dad508d8bac4d5bf2de72eb31b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 00:59:20 +0900 Subject: [PATCH 09/20] fix(habit): harden recurrence and tenant invariants --- apps/habit-service/src/habit-domain.ts | 55 ++++++++++++++------------ 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/apps/habit-service/src/habit-domain.ts b/apps/habit-service/src/habit-domain.ts index 52c6a880..877b072e 100644 --- a/apps/habit-service/src/habit-domain.ts +++ b/apps/habit-service/src/habit-domain.ts @@ -55,6 +55,8 @@ export interface HabitRepository { } const LOCAL_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +const RFC_3339_TIMESTAMP_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/; 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; const MAXIMUM_OCCURRENCE_RANGE_DAYS = 366; @@ -110,6 +112,9 @@ function requireInterval(value: number): number { } function requireTimestamp(value: string): string { + if (!RFC_3339_TIMESTAMP_PATTERN.test(value)) { + throw new Error('Timestamp is invalid'); + } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { throw new Error('Timestamp is invalid'); @@ -199,6 +204,10 @@ function cloneCompletion( return { ...completion }; } +function entityLookupKey(workspaceId: string, entityId: string): string { + return `${workspaceId}:${entityId}`; +} + function isScheduledOn( habit: Habit, date: ParsedLocalDate, @@ -211,7 +220,9 @@ function isScheduledOn( if (habit.recurrence.kind === 'daily') { return elapsedDays % habit.recurrence.interval === 0; } - const elapsedWeeks = Math.floor(elapsedDays / 7); + const startWeekEpochDay = start.epochDay - (start.isoWeekday - 1); + const dateWeekEpochDay = date.epochDay - (date.isoWeekday - 1); + const elapsedWeeks = (dateWeekEpochDay - startWeekEpochDay) / 7; return ( elapsedWeeks % habit.recurrence.interval === 0 && habit.recurrence.weekdays.includes(date.isoWeekday) @@ -254,15 +265,18 @@ export class InMemoryHabitRepository implements HabitRepository { private readonly completionIdempotency = new Map(); async saveHabit(habit: Habit): Promise { - this.habits.set(habit.id, cloneHabit(habit)); + this.habits.set( + entityLookupKey(habit.workspaceId, habit.id), + cloneHabit(habit), + ); } async findHabit( workspaceId: string, habitId: string, ): Promise { - const habit = this.habits.get(habitId); - return habit?.workspaceId === workspaceId ? cloneHabit(habit) : undefined; + const habit = this.habits.get(entityLookupKey(workspaceId, habitId)); + return habit ? cloneHabit(habit) : undefined; } async listHabits(workspaceId: string): Promise { @@ -284,17 +298,18 @@ export class InMemoryHabitRepository implements HabitRepository { completion.habitId, completion.idempotencyKey, ].join(':'); - const existingId = this.completionIdempotency.get(idempotencyLookup); - if (existingId) { - const existing = this.completions.get(existingId); + const existingKey = this.completionIdempotency.get(idempotencyLookup); + if (existingKey) { + const existing = this.completions.get(existingKey); if (!existing) { throw new Error('Completion history is inconsistent'); } return cloneCompletion(existing); } const stored = cloneCompletion(completion); - this.completions.set(stored.id, stored); - this.completionIdempotency.set(idempotencyLookup, stored.id); + const completionKey = entityLookupKey(stored.workspaceId, stored.id); + this.completions.set(completionKey, stored); + this.completionIdempotency.set(idempotencyLookup, completionKey); return cloneCompletion(stored); } @@ -354,10 +369,7 @@ export class HabitService { ): Promise { const safeWorkspaceId = requireOpaqueId(workspaceId); const safeHabitId = requireOpaqueId(habitId); - const habit = await this.repository.findHabit( - safeWorkspaceId, - safeHabitId, - ); + const habit = await this.repository.findHabit(safeWorkspaceId, safeHabitId); if (!habit) { throw new Error('Habit not found'); } @@ -375,20 +387,14 @@ export class HabitService { ): Promise { const safeWorkspaceId = requireOpaqueId(workspaceId); const safeHabitId = requireOpaqueId(habitId); - const habit = await this.repository.findHabit( - safeWorkspaceId, - safeHabitId, - ); + const habit = await this.repository.findHabit(safeWorkspaceId, safeHabitId); if (!habit) { throw new Error('Habit not found'); } const scheduledLocalDate = parseLocalDate(input.scheduledLocalDate).text; if ( - generateHabitOccurrences( - habit, - scheduledLocalDate, - scheduledLocalDate, - ).length !== 1 + generateHabitOccurrences(habit, scheduledLocalDate, scheduledLocalDate) + .length !== 1 ) { throw new Error('Habit is not scheduled on this date'); } @@ -413,9 +419,6 @@ export class HabitService { if (!(await this.repository.findHabit(safeWorkspaceId, safeHabitId))) { throw new Error('Habit not found'); } - return await this.repository.listCompletions( - safeWorkspaceId, - safeHabitId, - ); + return await this.repository.listCompletions(safeWorkspaceId, safeHabitId); } } From 24d57cd94eb208a439ba398c1c6f7204c9c5656e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:00:12 +0900 Subject: [PATCH 10/20] test(habit): cover week anchoring and strict persistence input --- apps/habit-service/src/habit-domain.test.ts | 98 +++++++++++++++++---- 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/apps/habit-service/src/habit-domain.test.ts b/apps/habit-service/src/habit-domain.test.ts index d194d123..eddca9c7 100644 --- a/apps/habit-service/src/habit-domain.test.ts +++ b/apps/habit-service/src/habit-domain.test.ts @@ -4,6 +4,7 @@ import { InMemoryHabitRepository, generateHabitOccurrences, type Habit, + type IsoWeekday, } from './habit-domain'; const UUID_V4_PATTERN = @@ -55,6 +56,24 @@ describe('habit recurrence kernel', () => { ).toEqual(['2026-03-02', '2026-03-06', '2026-03-09', '2026-03-13']); }); + it('anchors multi-week intervals to the ISO week containing the start date', () => { + const habit: Habit = { + ...dailyHabit(), + startsOn: '2026-01-07', + recurrence: { kind: 'weekly', interval: 2, weekdays: [1] }, + }; + + const occurrences = generateHabitOccurrences( + habit, + '2026-01-07', + '2026-02-02', + ); + + expect( + occurrences.map((occurrence) => occurrence.scheduledLocalDate), + ).toEqual(['2026-01-19', '2026-02-02']); + }); + it('keeps interval schedules stable across year boundaries', () => { const habit: Habit = { ...dailyHabit(), @@ -62,16 +81,8 @@ describe('habit recurrence kernel', () => { recurrence: { kind: 'weekly', interval: 2, weekdays: [1, 3] }, }; - const first = generateHabitOccurrences( - habit, - '2026-12-28', - '2027-01-25', - ); - const second = generateHabitOccurrences( - habit, - '2026-12-28', - '2027-01-25', - ); + const first = generateHabitOccurrences(habit, '2026-12-28', '2027-01-25'); + const second = generateHabitOccurrences(habit, '2026-12-28', '2027-01-25'); expect(first).toEqual(second); expect(first.map((occurrence) => occurrence.scheduledLocalDate)).toEqual([ @@ -154,15 +165,25 @@ describe('HabitService', () => { startsOn: '2026-08-04', recurrence: { kind: 'weekly', interval: 1, weekdays: [] }, }), - ).rejects.toThrowError( - 'Weekly recurrence requires at least one weekday', - ); + ).rejects.toThrowError('Weekly recurrence requires at least one weekday'); + await expect( + service.createHabit('workspace-a', { + title: 'Habit', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { + kind: 'weekly', + interval: 1, + weekdays: [8] as unknown as readonly IsoWeekday[], + }, + }), + ).rejects.toThrowError('Weekday must be between 1 and 7'); await expect( service.createHabit('workspace-a', { title: ' ', timezone: 'UTC', startsOn: '2026-08-04', - recurrence: { kind: 'weekly', interval: 1, weekdays: [8] as never }, + recurrence: { kind: 'weekly', interval: 1, weekdays: [1] }, }), ).rejects.toThrowError('Title is required'); }); @@ -187,6 +208,31 @@ describe('HabitService', () => { ).rejects.toThrowError('Habit not found'); }); + it('keeps equal entity identifiers isolated across workspaces', async () => { + const repository = new InMemoryHabitRepository(); + const sharedId = '22222222-2222-4222-8222-222222222222'; + const workspaceAHabit = dailyHabit({ + id: sharedId, + workspaceId: 'workspace-a', + title: 'Workspace A habit', + }); + const workspaceBHabit = dailyHabit({ + id: sharedId, + workspaceId: 'workspace-b', + title: 'Workspace B habit', + }); + + await repository.saveHabit(workspaceAHabit); + await repository.saveHabit(workspaceBHabit); + + await expect(repository.findHabit('workspace-a', sharedId)).resolves.toEqual( + workspaceAHabit, + ); + await expect(repository.findHabit('workspace-b', sharedId)).resolves.toEqual( + workspaceBHabit, + ); + }); + it('appends completion history idempotently and returns immutable copies', async () => { const repository = new InMemoryHabitRepository(); const service = new HabitService(repository); @@ -203,7 +249,11 @@ describe('HabitService', () => { }; const first = await service.completeHabit('workspace-a', habit.id, command); - const replay = await service.completeHabit('workspace-a', habit.id, command); + const replay = await service.completeHabit( + 'workspace-a', + habit.id, + command, + ); expect(replay).toEqual(first); expect(first.id).toMatch(UUID_V4_PATTERN); @@ -216,6 +266,24 @@ describe('HabitService', () => { expect(history[0]?.completedAt).toBe('2026-08-04T08:00:00.000Z'); }); + it('rejects loose or malformed completion timestamps', async () => { + const service = new HabitService(new InMemoryHabitRepository()); + const habit = await service.createHabit('workspace-a', { + title: 'Strict completion time', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + }); + + await expect( + service.completeHabit('workspace-a', habit.id, { + scheduledLocalDate: '2026-08-04', + completedAt: 'August 4, 2026 08:00', + idempotencyKey: FIRST_IDEMPOTENCY_KEY, + }), + ).rejects.toThrowError('Timestamp is invalid'); + }); + it('rejects completion on a date without an occurrence', async () => { const service = new HabitService(new InMemoryHabitRepository()); const habit = await service.createHabit('workspace-a', { From 1e58dd83c1fb614b80f27b392779b4ce9a45f559 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:02:10 +0900 Subject: [PATCH 11/20] ci: render habit test formatting diagnostics --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cd3ba81..bbfd1ff7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,13 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile + - name: Render habit test formatting diff + run: | + pnpm exec prettier --single-quote --write \ + apps/habit-service/src/habit-domain.test.ts + git diff --no-ext-diff --unified=100000 -- \ + apps/habit-service/src/habit-domain.test.ts + - name: Check formatting run: pnpm format:check From ed1246282b7b0c54d11513d64e83bff70f07de2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:05:13 +0900 Subject: [PATCH 12/20] style(habit): apply Prettier formatting --- apps/habit-service/src/habit-domain.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/habit-service/src/habit-domain.test.ts b/apps/habit-service/src/habit-domain.test.ts index eddca9c7..33e0e64e 100644 --- a/apps/habit-service/src/habit-domain.test.ts +++ b/apps/habit-service/src/habit-domain.test.ts @@ -225,12 +225,12 @@ describe('HabitService', () => { await repository.saveHabit(workspaceAHabit); await repository.saveHabit(workspaceBHabit); - await expect(repository.findHabit('workspace-a', sharedId)).resolves.toEqual( - workspaceAHabit, - ); - await expect(repository.findHabit('workspace-b', sharedId)).resolves.toEqual( - workspaceBHabit, - ); + await expect( + repository.findHabit('workspace-a', sharedId), + ).resolves.toEqual(workspaceAHabit); + await expect( + repository.findHabit('workspace-b', sharedId), + ).resolves.toEqual(workspaceBHabit); }); it('appends completion history idempotently and returns immutable copies', async () => { From d5146655162c6ed2ec02781dd345d89f087f853e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:05:27 +0900 Subject: [PATCH 13/20] ci: remove temporary habit diagnostics --- .github/workflows/ci.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbfd1ff7..1cd3ba81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,13 +49,6 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile - - name: Render habit test formatting diff - run: | - pnpm exec prettier --single-quote --write \ - apps/habit-service/src/habit-domain.test.ts - git diff --no-ext-diff --unified=100000 -- \ - apps/habit-service/src/habit-domain.test.ts - - name: Check formatting run: pnpm format:check From 5e9bd9b3ce81699dda050ccc2f97860eb366b2a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:05:45 +0900 Subject: [PATCH 14/20] chore(ci): diagnose habit test formatting --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cd3ba81..f433dce5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,12 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile + - name: Render habit test formatting diff + run: | + pnpm exec prettier --single-quote --write apps/habit-service/src/habit-domain.test.ts + git diff -- apps/habit-service/src/habit-domain.test.ts + exit 1 + - name: Check formatting run: pnpm format:check From 288ba08eacfd999857d01aec55d219df6fb26b72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:07:02 +0900 Subject: [PATCH 15/20] ci: restore production validation workflow --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f433dce5..1cd3ba81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,12 +49,6 @@ jobs: - name: Install dependencies run: pnpm install --no-frozen-lockfile - - name: Render habit test formatting diff - run: | - pnpm exec prettier --single-quote --write apps/habit-service/src/habit-domain.test.ts - git diff -- apps/habit-service/src/habit-domain.test.ts - exit 1 - - name: Check formatting run: pnpm format:check From 81f62cf9175ef8ea258194e89ac89b0359c6d522 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:10:42 +0900 Subject: [PATCH 16/20] fix(habit): harden composite keys and idempotency replay --- apps/habit-service/src/habit-domain.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/habit-service/src/habit-domain.ts b/apps/habit-service/src/habit-domain.ts index 877b072e..24a04d92 100644 --- a/apps/habit-service/src/habit-domain.ts +++ b/apps/habit-service/src/habit-domain.ts @@ -205,7 +205,7 @@ function cloneCompletion( } function entityLookupKey(workspaceId: string, entityId: string): string { - return `${workspaceId}:${entityId}`; + return JSON.stringify([workspaceId, entityId]); } function isScheduledOn( @@ -293,17 +293,25 @@ export class InMemoryHabitRepository implements HabitRepository { async appendCompletion( completion: HabitCompletionEvent, ): Promise { - const idempotencyLookup = [ + const idempotencyLookup = JSON.stringify([ completion.workspaceId, completion.habitId, completion.idempotencyKey, - ].join(':'); + ]); const existingKey = this.completionIdempotency.get(idempotencyLookup); if (existingKey) { const existing = this.completions.get(existingKey); if (!existing) { throw new Error('Completion history is inconsistent'); } + if ( + existing.scheduledLocalDate !== completion.scheduledLocalDate || + existing.completedAt !== completion.completedAt + ) { + throw new Error( + 'Idempotency key reused with a different completion payload', + ); + } return cloneCompletion(existing); } const stored = cloneCompletion(completion); From 783ec5c68fa1843930a065b9d7eab54a35b91e95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:11:29 +0900 Subject: [PATCH 17/20] test(habit): cover composite collisions and replay conflicts --- apps/habit-service/src/habit-domain.test.ts | 56 ++++++++++++++------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/apps/habit-service/src/habit-domain.test.ts b/apps/habit-service/src/habit-domain.test.ts index 33e0e64e..a4b82571 100644 --- a/apps/habit-service/src/habit-domain.test.ts +++ b/apps/habit-service/src/habit-domain.test.ts @@ -208,29 +208,24 @@ describe('HabitService', () => { ).rejects.toThrowError('Habit not found'); }); - it('keeps equal entity identifiers isolated across workspaces', async () => { + it('uses delimiter-safe composite keys for tenant isolation', async () => { const repository = new InMemoryHabitRepository(); - const sharedId = '22222222-2222-4222-8222-222222222222'; - const workspaceAHabit = dailyHabit({ - id: sharedId, - workspaceId: 'workspace-a', - title: 'Workspace A habit', + const first = dailyHabit({ + id: 'c', + workspaceId: 'a:b', + title: 'First composite key', }); - const workspaceBHabit = dailyHabit({ - id: sharedId, - workspaceId: 'workspace-b', - title: 'Workspace B habit', + const second = dailyHabit({ + id: 'b:c', + workspaceId: 'a', + title: 'Second composite key', }); - await repository.saveHabit(workspaceAHabit); - await repository.saveHabit(workspaceBHabit); + await repository.saveHabit(first); + await repository.saveHabit(second); - await expect( - repository.findHabit('workspace-a', sharedId), - ).resolves.toEqual(workspaceAHabit); - await expect( - repository.findHabit('workspace-b', sharedId), - ).resolves.toEqual(workspaceBHabit); + await expect(repository.findHabit('a:b', 'c')).resolves.toEqual(first); + await expect(repository.findHabit('a', 'b:c')).resolves.toEqual(second); }); it('appends completion history idempotently and returns immutable copies', async () => { @@ -266,6 +261,31 @@ describe('HabitService', () => { expect(history[0]?.completedAt).toBe('2026-08-04T08:00:00.000Z'); }); + it('rejects an idempotency key replayed with a different payload', async () => { + const service = new HabitService(new InMemoryHabitRepository()); + const habit = await service.createHabit('workspace-a', { + title: 'Detect conflicting replay', + timezone: 'UTC', + startsOn: '2026-08-04', + recurrence: { kind: 'daily', interval: 1 }, + }); + + await service.completeHabit('workspace-a', habit.id, { + scheduledLocalDate: '2026-08-04', + completedAt: '2026-08-04T08:00:00.000Z', + idempotencyKey: FIRST_IDEMPOTENCY_KEY, + }); + await expect( + service.completeHabit('workspace-a', habit.id, { + scheduledLocalDate: '2026-08-04', + completedAt: '2026-08-04T09:00:00.000Z', + idempotencyKey: FIRST_IDEMPOTENCY_KEY, + }), + ).rejects.toThrowError( + 'Idempotency key reused with a different completion payload', + ); + }); + it('rejects loose or malformed completion timestamps', async () => { const service = new HabitService(new InMemoryHabitRepository()); const habit = await service.createHabit('workspace-a', { From 047996eeeb009518ea91436f23b1c9b164aba7f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:11:46 +0900 Subject: [PATCH 18/20] fix(habit): reject truncation of completion history --- apps/habit-service/migrations/0001_recurring_habit_core.sql | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/habit-service/migrations/0001_recurring_habit_core.sql b/apps/habit-service/migrations/0001_recurring_habit_core.sql index a82c9286..b150523b 100644 --- a/apps/habit-service/migrations/0001_recurring_habit_core.sql +++ b/apps/habit-service/migrations/0001_recurring_habit_core.sql @@ -96,4 +96,9 @@ BEFORE UPDATE OR DELETE ON habit.completion_events FOR EACH ROW EXECUTE FUNCTION habit.reject_completion_mutation(); +CREATE TRIGGER completion_events_reject_truncate +BEFORE TRUNCATE ON habit.completion_events +FOR EACH STATEMENT +EXECUTE FUNCTION habit.reject_completion_mutation(); + COMMIT; From bea9047d6aa51cddc1d5421b73a29190a19f1bd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:12:15 +0900 Subject: [PATCH 19/20] docs(habit): document append-only database privileges --- apps/habit-service/migrations/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/habit-service/migrations/README.md b/apps/habit-service/migrations/README.md index 59c7f54b..1b9634bb 100644 --- a/apps/habit-service/migrations/README.md +++ b/apps/habit-service/migrations/README.md @@ -8,7 +8,7 @@ Apply Habit SQL files in lexical order to the PostgreSQL database owned by the H Every persisted entity, workspace, habit reference, event, and idempotency key is constrained to UUIDv4. Composite foreign keys carry `workspace_id` through the ownership path, and duplicate completion commands are identified by `(workspace_id, habit_id, idempotency_key)`. -A database trigger rejects ordinary `UPDATE` and `DELETE` operations on completion events. A future data-rights migration must provide a separately authorized erasure path before production account deletion is enabled; application code must not bypass append-only history through direct SQL. +Database triggers reject `UPDATE`, `DELETE`, and `TRUNCATE` operations on completion events. The service runtime role must not receive `UPDATE`, `DELETE`, or `TRUNCATE` privileges on `habit.completion_events`. A future data-rights migration must provide a separately authorized erasure path before production account deletion is enabled; application code must not bypass append-only history through direct SQL. ## Rollback From 248da1de1a07e44d1e1c73709949314ff53cb16d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 4 Aug 2026 01:13:59 +0900 Subject: [PATCH 20/20] perf(habit): index completion history ordering --- .../migrations/0001_recurring_habit_core.sql | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/habit-service/migrations/0001_recurring_habit_core.sql b/apps/habit-service/migrations/0001_recurring_habit_core.sql index b150523b..3e4240bc 100644 --- a/apps/habit-service/migrations/0001_recurring_habit_core.sql +++ b/apps/habit-service/migrations/0001_recurring_habit_core.sql @@ -80,6 +80,14 @@ CREATE INDEX completion_events_workspace_habit_schedule_idx id ASC ); +CREATE INDEX completion_events_workspace_habit_recorded_idx + ON habit.completion_events ( + workspace_id, + habit_id, + recorded_at ASC, + id ASC + ); + CREATE FUNCTION habit.reject_completion_mutation() RETURNS trigger LANGUAGE plpgsql