diff --git a/openspec/changes/task-status-workflow/.openspec.yaml b/openspec/changes/task-status-workflow/.openspec.yaml new file mode 100644 index 0000000..d160e09 --- /dev/null +++ b/openspec/changes/task-status-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-21 diff --git a/openspec/changes/task-status-workflow/design.md b/openspec/changes/task-status-workflow/design.md new file mode 100644 index 0000000..5b7eddc --- /dev/null +++ b/openspec/changes/task-status-workflow/design.md @@ -0,0 +1,111 @@ +## Canonical Touchpoints + +No canonical document updates. This repo has no `specifications/` tree yet. + +## Context + +`node-api` is an in-memory task REST API. A task today is: + +```json +{ + "id": 1, + "title": "Buy groceries", + "description": "Milk and eggs", + "completed": false, + "createdAt": "2026-08-21T15:00:00.000Z" +} +``` + +`PATCH` may flip `completed`. `GET /tasks` always returns the full map. There +is no notion of "started", "waiting", or "abandoned". + +## Goals / Non-Goals + +**Goals:** + +- One explicit status per task, shared by create, update, and list +- Filter `GET /tasks` by a single status value +- Default new tasks to `todo` when the client omits `status` +- Keep response payloads backward-compatible enough that a client reading + `completed` still works + +**Non-Goals:** + +- Multi-status filters (`?status=todo,blocked`) +- Transition rules that reject `todo → done` or similar +- No separate soft-delete endpoint; `DELETE` remains hard-delete (note: + `cancelled` is a status, not a soft-delete mechanism — the task stays in + the store and appears in listings) +- Durability across restarts +- `updatedAt` — no timestamp tracking beyond `createdAt` in this change + +## Decisions + +### 1. Status enum, not extra booleans + +Statuses: `todo` | `in_progress` | `blocked` | `done` | `cancelled`. + +**Why this over keeping `completed` and adding `startedAt`:** two booleans +cannot express blocked vs cancelled, and timestamps still leave the current +state implicit. + +**Alternative considered:** `pending` / `active` / `closed`. Rejected — too +coarse; `blocked` vs `cancelled` are different operational states. + +### 2. Derived `completed` on every response + +`completed` is `true` iff `status === "done"`. It is not stored. `PATCH` with +`{ "completed": true }` is treated as `{ "status": "done" }`; `{ "completed": +false }` is treated as `{ "status": "todo" }` (provisional — see open question +below). + +**Why:** avoid breaking the existing store tests and any client that only +knows the boolean. + +**Alternative considered:** drop `completed` in one breaking change. Deferred +— this is a playground API, but a dual-field window is cheap. + +### 3. No enforced transitions + +Any status may be PATCH'd to any other valid status. Invalid strings return +`400`. + +**Why:** the store is a teaching API, not a workflow engine. Enforcing a DAG +now would invent policy we do not have (can you cancel from `blocked`? reopen +`done`?). + +### 4. Filter is exact match, single value + +`GET /tasks?status=blocked` returns only that status. Unknown status → `400`. +Omitted query param → current behavior (all tasks). + +### 5. `status` wins on conflict with `completed` + +If a `PATCH` includes both `status` and `completed` and they disagree, +`status` wins and `completed` is ignored. This avoids ambiguous +last-write-wins behavior. + +**Why:** the client that sends `status` is aware of the new field and is +the authoritative source. A conflicting `completed` is either stale or a +copy-paste error. + +### 6. Unfiltered `GET /tasks` returns all statuses + +Unfiltered `GET /tasks` returns tasks of every status, including `cancelled`. +The API is a data store, not a UI — hiding records by default violates +least-surprise for REST callers who expect the full collection. Clients that +want an "active only" view can filter by specific statuses. + +## Risks / Trade-offs + +**[cancelled vs DELETE]** → two ways to "get rid of" a task. Cancelled tasks +still appear in `GET /tasks` unless filtered. Clients that want a trash view +must filter; clients that want it gone must DELETE. + +**[in-memory only]** → status is lost on restart. Acceptable for this app; +do not pretend otherwise in docs. + +## Open Questions + +- When `completed: false` is PATCHed on a `blocked` task, is `todo` the + right fallback, or should we restore the previous non-done status? diff --git a/openspec/changes/task-status-workflow/proposal.md b/openspec/changes/task-status-workflow/proposal.md new file mode 100644 index 0000000..ea07c46 --- /dev/null +++ b/openspec/changes/task-status-workflow/proposal.md @@ -0,0 +1,50 @@ +## Why + +The task API only models work as `completed: true|false`. That is enough for a +checkbox, but not for a workflow: a task can be unstarted, in progress, blocked +on someone else, or abandoned. Callers currently overload `description` or +invent client-side conventions to express that. We want a first-class status +so list/filter and PATCH have a shared language. + +## What Changes + +- Replace the boolean `completed` field with a `status` enum on each task +- Accept `status` on create and update +- Allow `GET /tasks?status=` to filter the in-memory list +- Keep a derived `completed` boolean on responses (`status === "done"`) so + existing clients do not immediately break + +## Non-goals + +- Persistence beyond process lifetime (store stays in-memory) +- Auth, multi-tenancy, or per-user task lists +- Due dates, assignees, comments, or subtasks +- Pagination, sorting, or full-text search +- A state-machine library or workflow engine + +## Capabilities + +### New Capabilities + +- `task-status`: Task lifecycle status (`todo`, `in_progress`, `blocked`, + `done`, `cancelled`) on create/update, plus list filtering by status + +### Modified Capabilities + +_(none — this repo has no long-lived `openspec/specs/` yet)_ + +## Canonical Touchpoints + +- **PRDs / ADRs**: None +- **Long-lived specs (`openspec/specs/`)**: None in this change (new capability + only; promote after apply if we keep the model) + +**Change type**: feature-spec + +## Impact + +- **API**: `POST /tasks` and `PATCH /tasks/:id` accept `status`; `GET /tasks` + gains a `status` query parameter; task JSON gains `status` and keeps + `completed` as a derived field +- **Store**: `TaskStore.create` / `update` / `list` learn status + filter +- **Tests**: store tests cover default status, invalid status, and list filter diff --git a/openspec/changes/task-status-workflow/specs/task-status/spec.md b/openspec/changes/task-status-workflow/specs/task-status/spec.md new file mode 100644 index 0000000..aac5013 --- /dev/null +++ b/openspec/changes/task-status-workflow/specs/task-status/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Task status is a required field with a default + +Every task SHALL have a `status` of one of: `todo`, `in_progress`, +`blocked`, `done`, `cancelled`. If the client omits `status` on create, the +task SHALL be created with `status: "todo"`. + +#### Scenario: Create with explicit status + +- **WHEN** a client `POST /tasks` with `{"title": "Ship it", "status": "in_progress"}` +- **THEN** the created task has `status` `"in_progress"` and `completed` `false` + +#### Scenario: Create without status defaults to todo + +- **WHEN** a client `POST /tasks` with `{"title": "Ship it"}` +- **THEN** the created task has `status` `"todo"` and `completed` `false` + +#### Scenario: Create with invalid status is rejected + +- **WHEN** a client `POST /tasks` with `{"title": "Ship it", "status": "donezo"}` +- **THEN** the API responds `400` and no task is created + +### Requirement: Status can be updated independently of title + +`PATCH /tasks/:id` SHALL accept `status` and persist it without requiring +other fields. + +#### Scenario: Move a task to blocked + +- **WHEN** a client `PATCH /tasks/1` with `{"status": "blocked"}` +- **THEN** the task's `status` is `"blocked"` and `completed` is `false` + +#### Scenario: Mark done via status + +- **WHEN** a client `PATCH /tasks/1` with `{"status": "done"}` +- **THEN** the task's `status` is `"done"` and `completed` is `true` + +#### Scenario: Mark done via legacy completed flag + +- **WHEN** a client `PATCH /tasks/1` with `{"completed": true}` +- **THEN** the task's `status` is `"done"` and `completed` is `true` + +#### Scenario: Unmark done via legacy completed flag + +- **WHEN** a client `PATCH /tasks/1` with `{"completed": false}` +- **THEN** the task's `status` is `"todo"` and `completed` is `false` + +#### Scenario: Update with invalid status is rejected + +- **WHEN** a client `PATCH /tasks/1` with `{"status": "donezo"}` +- **THEN** the API responds `400` and the task is unchanged + +#### Scenario: Conflict between status and completed + +- **WHEN** a client `PATCH /tasks/1` with `{"status": "blocked", "completed": true}` +- **THEN** the task's `status` is `"blocked"` and `completed` is `false` + (status wins; completed is ignored) + +### Requirement: List can filter by a single status + +`GET /tasks` SHALL accept an optional `status` query parameter. When present, +only tasks with that exact status are returned. + +#### Scenario: Filter in-progress tasks + +- **GIVEN** the store has one `todo` task and one `in_progress` task +- **WHEN** a client `GET /tasks?status=in_progress` +- **THEN** the response is a JSON array of length 1 whose item has + `status` `"in_progress"` + +#### Scenario: Unfiltered list still returns every task + +- **WHEN** a client `GET /tasks` with no query string +- **THEN** the response includes tasks of every status, including + `cancelled` + +#### Scenario: Unknown filter value is rejected + +- **WHEN** a client `GET /tasks?status=donezo` +- **THEN** the API responds `400` diff --git a/openspec/changes/task-status-workflow/tasks.md b/openspec/changes/task-status-workflow/tasks.md new file mode 100644 index 0000000..e5ca971 --- /dev/null +++ b/openspec/changes/task-status-workflow/tasks.md @@ -0,0 +1,38 @@ +## 1. Store + +- [ ] 1.1 Export a `STATUSES` set as the single source of truth for valid + statuses. Switch `TaskStore.create` to an options-object signature + (`create({ title, description, status })`) and default `status` to `"todo"` +- [ ] 1.2 Reject unknown status strings in `create` and `update` by throwing + a `ValidationError` (extends `Error`) that the HTTP layer can catch + distinctly from `SyntaxError` +- [ ] 1.3 Derive `completed` from `status === "done"` on every returned task +- [ ] 1.4 Map `updates.completed === true` → `status: "done"` and `false` → + `status: "todo"` when `status` is not also present (provisional — see + design.md open question on fallback for non-done states) +- [ ] 1.5 When both `status` and `completed` are present in an update, + `status` wins and `completed` is ignored +- [ ] 1.6 Add `list({ status })` exact-match filter; omit filter → all tasks + +## 2. HTTP + +- [ ] 2.1 Parse `status` from POST/PATCH JSON bodies +- [ ] 2.2 Parse `status` from `GET /tasks` query string; import `STATUSES` + from the store module for validation; 400 on unknown value +- [ ] 2.3 Catch `ValidationError` from the store and map to + `400 { "error": "invalid status" }` (distinguish from `SyntaxError` in + the existing try/catch) + +## 3. Tests + +- [ ] 3.1 Default status on create +- [ ] 3.2 Invalid status rejected (both create and update) +- [ ] 3.3 PATCH status and PATCH completed stay consistent +- [ ] 3.4 `list` filter by status; unfiltered includes cancelled +- [ ] 3.5 Conflict: PATCH with both `status` and `completed` — `status` wins + +## 4. Docs + +- [ ] 4.1 Update README endpoints table: add `status` query param to + `GET /tasks`, document the `status` field on task JSON, and note + `status`/`completed` conflict resolution on `PATCH` diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..7e737e9 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,8 @@ +schema: spec-driven + +context: | + Domain: in-memory REST task API (node-api playground) + Tech stack: Node.js built-in http + Map store, no database, no auth + We use conventional commits + + Change-local OpenSpec workspaces live in `openspec/changes//`.