diff --git a/apps/gateway/src/missions/missions.controller.ts b/apps/gateway/src/missions/missions.controller.ts index f422d900..ae7bfd0c 100644 --- a/apps/gateway/src/missions/missions.controller.ts +++ b/apps/gateway/src/missions/missions.controller.ts @@ -108,11 +108,13 @@ export class MissionsController { ) { const mission = await this.brain.missions.findByIdAndUser(missionId, user.id); if (!mission) throw new NotFoundException('Mission not found'); + // dto.status is deliberately not forwarded: mission_tasks.status is + // write-prohibited through the N-1 window (SHARED-CONTRACT §5.1 phase 1); + // the repo strips it as well. return this.brain.missionTasks.create({ missionId, taskId: dto.taskId, userId: user.id, - status: dto.status, description: dto.description, notes: dto.notes, pr: dto.pr, diff --git a/apps/gateway/src/missions/missions.dto.ts b/apps/gateway/src/missions/missions.dto.ts index d425e9ba..16908d3a 100644 --- a/apps/gateway/src/missions/missions.dto.ts +++ b/apps/gateway/src/missions/missions.dto.ts @@ -77,6 +77,12 @@ export class CreateMissionTaskDto { @IsUUID() taskId?: string; + /** + * @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status + * is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1). + * The field stays declared because the global ValidationPipe runs with + * forbidNonWhitelisted, and removing it would 400 frozen legacy consumers. + */ @IsOptional() @IsIn(taskStatuses) status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; @@ -102,6 +108,12 @@ export class UpdateMissionTaskDto { @IsUUID() taskId?: string; + /** + * @deprecated Accepted for N-1 wire compatibility but ignored: mission_tasks.status + * is write-prohibited through the migration window (SHARED-CONTRACT §5.1 phase 1). + * The field stays declared because the global ValidationPipe runs with + * forbidNonWhitelisted, and removing it would 400 frozen legacy consumers. + */ @IsOptional() @IsIn(taskStatuses) status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled'; diff --git a/packages/brain/src/mission-tasks.spec.ts b/packages/brain/src/mission-tasks.spec.ts new file mode 100644 index 00000000..fb1ab0f4 --- /dev/null +++ b/packages/brain/src/mission-tasks.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createMissionTasksRepo } from './mission-tasks.js'; + +/** + * SHARED-CONTRACT §5.5 "mission_tasks.status write prohibition": the repo is + * the sole write path, and it must never forward a caller-supplied status to + * the database on create or update. Callers keep working (the field is + * accepted and ignored), so these tests assert on what reaches the Drizzle + * chain, not on rejection. + */ + +function makeInsertDb(returned: unknown[]) { + const values = vi.fn((_v: unknown) => ({ returning: vi.fn().mockResolvedValue(returned) })); + return { db: { insert: vi.fn(() => ({ values })) }, values }; +} + +function makeUpdateDb(returned: unknown[]) { + const set = vi.fn((_v: unknown) => ({ + where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(returned) })), + })); + return { db: { update: vi.fn(() => ({ set })) }, set }; +} + +describe('createMissionTasksRepo — status write prohibition', () => { + it('create strips a caller-supplied status before insert', async () => { + const { db, values } = makeInsertDb([{ id: 'mt1', status: 'not-started' }]); + const repo = createMissionTasksRepo(db as never); + + const result = await repo.create({ + missionId: 'm1', + userId: 'u1', + status: 'done', + description: 'd', + } as never); + + expect(values).toHaveBeenCalledTimes(1); + const inserted = values.mock.calls[0]![0] as Record; + expect('status' in inserted).toBe(false); + expect(inserted.missionId).toBe('m1'); + expect(inserted.description).toBe('d'); + expect(result.id).toBe('mt1'); + }); + + it('create without status still inserts (DB default applies)', async () => { + const { db, values } = makeInsertDb([{ id: 'mt2' }]); + const repo = createMissionTasksRepo(db as never); + + await repo.create({ missionId: 'm1', userId: 'u1' } as never); + + const inserted = values.mock.calls[0]![0] as Record; + expect('status' in inserted).toBe(false); + }); + + it('update strips a caller-supplied status but keeps the other fields', async () => { + const { db, set } = makeUpdateDb([{ id: 'mt1', notes: 'n' }]); + const repo = createMissionTasksRepo(db as never); + + const result = await repo.update('mt1', { status: 'done', notes: 'n' } as never); + + expect(set).toHaveBeenCalledTimes(1); + const updated = set.mock.calls[0]![0] as Record; + expect('status' in updated).toBe(false); + expect(updated.notes).toBe('n'); + expect(updated.updatedAt).toBeInstanceOf(Date); + expect(result?.id).toBe('mt1'); + }); + + it('update with only status degenerates to a timestamp-only update', async () => { + const { db, set } = makeUpdateDb([{ id: 'mt1' }]); + const repo = createMissionTasksRepo(db as never); + + await repo.update('mt1', { status: 'blocked' } as never); + + const updated = set.mock.calls[0]![0] as Record; + expect(Object.keys(updated)).toEqual(['updatedAt']); + }); +}); diff --git a/packages/brain/src/mission-tasks.ts b/packages/brain/src/mission-tasks.ts index acd4235b..d08baeed 100644 --- a/packages/brain/src/mission-tasks.ts +++ b/packages/brain/src/mission-tasks.ts @@ -3,6 +3,18 @@ import { eq, and, type Db, missionTasks } from '@mosaicstack/db'; export type MissionTask = typeof missionTasks.$inferSelect; export type NewMissionTask = typeof missionTasks.$inferInsert; +// SHARED-CONTRACT §5.1 phase 1 / §5.4: mission_tasks.status is prohibited as a +// write source through the N-1 window. This repo is the sole write path, so the +// field is stripped here — accepted and ignored rather than rejected, because +// the legacy surface is frozen with existing consumers kept working +// (tool-gateway-mapping.md §3.2). The column keeps its DB default, stays +// declared and readable, and is retired only after no readers remain. +function stripStatus(data: T): Omit { + const rest = { ...data }; + delete rest.status; + return rest; +} + export function createMissionTasksRepo(db: Db) { return { async findByMission(missionId: string): Promise { @@ -30,14 +42,14 @@ export function createMissionTasksRepo(db: Db) { }, async create(data: NewMissionTask): Promise { - const rows = await db.insert(missionTasks).values(data).returning(); + const rows = await db.insert(missionTasks).values(stripStatus(data)).returning(); return rows[0]!; }, async update(id: string, data: Partial): Promise { const rows = await db .update(missionTasks) - .set({ ...data, updatedAt: new Date() }) + .set({ ...stripStatus(data), updatedAt: new Date() }) .where(eq(missionTasks.id, id)) .returning(); return rows[0];