Repository navigation
docs: OpenSpec for task status workflow #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| schema: spec-driven | ||
| created: 2026-08-21 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] internal-consistency Decision 2 definitively states that completed: false is treated as status: todo, but the Open Questions section asks whether todo is the right fallback when completed: false is PATCHed on a blocked task. This internal contradiction means an implementer following Decision 2 would hardcode false to todo, while the open question signals that mapping may be wrong for certain states. Suggested fix: Either qualify Decision 2 with 'unless revisited per the open question below' or remove the open question and commit to todo as the fallback. |
||
| right fallback, or should we restore the previous non-done status? | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The derived
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, the service is still dev preview, so we don't need to worry about that yet |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The spec has a rejection scenario for create with an invalid status (line 19–22) but no corresponding scenario for Recommended: Add a scenario (e.g., "Update with invalid status is rejected — WHEN PATCH with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed |
||
|
|
||
| #### 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` | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] edge-case The spec has a scenario for completed: true mapping to status: done but no scenario for the reverse (completed: false). Design Decision 2 and task 1.4 both define this mapping, so the spec should have a corresponding scenario — especially since the design's open question casts doubt on the todo fallback. Suggested fix: Add a scenario: Unmark done via legacy completed flag — WHEN PATCH with completed: false THEN status is todo and completed is false. |
||
|
|
||
| #### 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This scenario asserts that unfiltered
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure, what do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Including Recommended: Close the open question in design.md by promoting it to a decision ("Unfiltered
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup, agreed. |
||
| `cancelled` | ||
|
|
||
| #### Scenario: Unknown filter value is rejected | ||
|
|
||
| - **WHEN** a client `GET /tasks?status=donezo` | ||
| - **THEN** the API responds `400` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The task list covers Store, HTTP, and Tests, but the README documents every endpoint and its behavior. This change adds a Recommended: Add a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. |
||
| - [ ] 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` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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/<change-name>/`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The non-goals say "Soft-delete; DELETE remains hard-delete." But
cancelledin the status enum is functionally soft-delete — the task persists in the store and appears in listings. The distinction seems to be naming rather than behavior. Should this non-goal be reframed (e.g., "No separate soft-delete endpoint"), or does the team seecancelledas semantically different from soft-delete?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, let's reframe it.