From b2f6a1b6e11427e8b9724b7685e65384ffefb982 Mon Sep 17 00:00:00 2001 From: John Collier Date: Fri, 21 Aug 2026 11:49:58 -0400 Subject: [PATCH 1/2] docs: add OpenSpec change for task status workflow Proposal-only change (no implementation) so /fs-grillme can be exercised against a typical small-app OpenSpec: status enum, list filter, and a few still-open decisions around cancelled vs delete and the legacy completed flag. Co-authored-by: Cursor --- .../task-status-workflow/.openspec.yaml | 2 + .../changes/task-status-workflow/design.md | 96 +++++++++++++++++++ .../changes/task-status-workflow/proposal.md | 50 ++++++++++ .../specs/task-status/spec.md | 65 +++++++++++++ .../changes/task-status-workflow/tasks.md | 20 ++++ openspec/config.yaml | 8 ++ 6 files changed, 241 insertions(+) create mode 100644 openspec/changes/task-status-workflow/.openspec.yaml create mode 100644 openspec/changes/task-status-workflow/design.md create mode 100644 openspec/changes/task-status-workflow/proposal.md create mode 100644 openspec/changes/task-status-workflow/specs/task-status/spec.md create mode 100644 openspec/changes/task-status-workflow/tasks.md create mode 100644 openspec/config.yaml 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..c404261 --- /dev/null +++ b/openspec/changes/task-status-workflow/design.md @@ -0,0 +1,96 @@ +## 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 +- Soft-delete; `DELETE` remains hard-delete +- Durability across restarts + +## 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" }`. + +**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). + +## Risks / Trade-offs + +**[`completed` and `status` on the same PATCH]** → if both are sent and +disagree, last-write-wins is ambiguous. Proposal: if both present, `status` +wins and `completed` is ignored. + +**[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 + +- Should `cancelled` tasks be excluded from unfiltered `GET /tasks`? +- When `completed: false` is PATCHed on a `blocked` task, is `todo` the + right fallback, or should we restore the previous non-done status? +- Do we need `updatedAt` once status can change independently of title? 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..17eaa0e --- /dev/null +++ b/openspec/changes/task-status-workflow/specs/task-status/spec.md @@ -0,0 +1,65 @@ +## 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` + +### 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..419ffbe --- /dev/null +++ b/openspec/changes/task-status-workflow/tasks.md @@ -0,0 +1,20 @@ +## 1. Store + +- [ ] 1.1 Add a `STATUSES` set and default `status: "todo"` in `TaskStore.create` +- [ ] 1.2 Reject unknown status strings in `create` and `update` (throw or return a sentinel the HTTP layer maps to 400) +- [ ] 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 +- [ ] 1.5 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; 400 on unknown value +- [ ] 2.3 Map store validation failures to `400 { "error": "invalid status" }` + +## 3. Tests + +- [ ] 3.1 Default status on create +- [ ] 3.2 Invalid status rejected +- [ ] 3.3 PATCH status and PATCH completed stay consistent +- [ ] 3.4 `list` filter by status; unfiltered includes cancelled 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//`. From 8cae49cfbad0393010bda9a5c1b9e3e58a4ea499 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:27:34 +0000 Subject: [PATCH 2/2] docs: apply grillme review decisions to OpenSpec files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design.md: - Reframe soft-delete non-goal to distinguish cancelled status from soft-delete endpoint - Add updatedAt to Non-Goals as explicit deferral - Mark Decision 2 completed:false→todo mapping as provisional - Promote conflict resolution (status wins over completed) to Decision 5 - Promote unfiltered GET returns all statuses to Decision 6 - Remove resolved open questions (cancelled in GET, updatedAt) specs/task-status/spec.md: - Add scenario for completed:false → status:todo mapping - Add scenario for PATCH with invalid status → 400 - Add scenario for status/completed conflict resolution tasks.md: - Specify options-object signature for TaskStore.create - Specify ValidationError for store validation (not sentinel) - Specify STATUSES exported from store as single source of truth - Add task 1.5 for status/completed conflict handling - Add provisional note to task 1.4 - Add test 3.5 for conflict case - Add section 4 (Docs) with README update task Addresses review feedback on #7 --- .../changes/task-status-workflow/design.md | 31 +++++++++++++----- .../specs/task-status/spec.md | 16 ++++++++++ .../changes/task-status-workflow/tasks.md | 32 +++++++++++++++---- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/openspec/changes/task-status-workflow/design.md b/openspec/changes/task-status-workflow/design.md index c404261..5b7eddc 100644 --- a/openspec/changes/task-status-workflow/design.md +++ b/openspec/changes/task-status-workflow/design.md @@ -33,8 +33,11 @@ is no notion of "started", "waiting", or "abandoned". - Multi-status filters (`?status=todo,blocked`) - Transition rules that reject `todo → done` or similar -- Soft-delete; `DELETE` remains hard-delete +- 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 @@ -53,7 +56,8 @@ coarse; `blocked` vs `cancelled` are different operational states. `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" }`. +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. @@ -75,11 +79,24 @@ now would invent policy we do not have (can you cancel from `blocked`? reopen `GET /tasks?status=blocked` returns only that status. Unknown status → `400`. Omitted query param → current behavior (all tasks). -## Risks / Trade-offs +### 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. -**[`completed` and `status` on the same PATCH]** → if both are sent and -disagree, last-write-wins is ambiguous. Proposal: if both present, `status` -wins and `completed` is ignored. +### 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 @@ -90,7 +107,5 @@ do not pretend otherwise in docs. ## Open Questions -- Should `cancelled` tasks be excluded from unfiltered `GET /tasks`? - When `completed: false` is PATCHed on a `blocked` task, is `todo` the right fallback, or should we restore the previous non-done status? -- Do we need `updatedAt` once status can change independently of title? diff --git a/openspec/changes/task-status-workflow/specs/task-status/spec.md b/openspec/changes/task-status-workflow/specs/task-status/spec.md index 17eaa0e..aac5013 100644 --- a/openspec/changes/task-status-workflow/specs/task-status/spec.md +++ b/openspec/changes/task-status-workflow/specs/task-status/spec.md @@ -41,6 +41,22 @@ other fields. - **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, diff --git a/openspec/changes/task-status-workflow/tasks.md b/openspec/changes/task-status-workflow/tasks.md index 419ffbe..e5ca971 100644 --- a/openspec/changes/task-status-workflow/tasks.md +++ b/openspec/changes/task-status-workflow/tasks.md @@ -1,20 +1,38 @@ ## 1. Store -- [ ] 1.1 Add a `STATUSES` set and default `status: "todo"` in `TaskStore.create` -- [ ] 1.2 Reject unknown status strings in `create` and `update` (throw or return a sentinel the HTTP layer maps to 400) +- [ ] 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 -- [ ] 1.5 Add `list({ status })` exact-match filter; omit filter → all tasks +- [ ] 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; 400 on unknown value -- [ ] 2.3 Map store validation failures to `400 { "error": "invalid status" }` +- [ ] 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 +- [ ] 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`