Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/task-status-workflow/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-21
111 changes: 111 additions & 0 deletions openspec/changes/task-status-workflow/design.md
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

Copy link
Copy Markdown

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 cancelled in 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 see cancelled as semantically different from soft-delete?

Copy link
Copy Markdown
Contributor Author

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.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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?
50 changes: 50 additions & 0 deletions openspec/changes/task-status-workflow/proposal.md
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The derived completed field is introduced for backward compatibility, and design.md calls it a "dual-field window" that is "cheap." But without a defined deprecation plan, temporary compatibility shims tend to become permanent API surface that every future change must maintain. Is there a trigger or timeline for dropping completed, or is this expected to live indefinitely?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
81 changes: 81 additions & 0 deletions openspec/changes/task-status-workflow/specs/task-status/spec.md
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 PATCH with an invalid status. Task 1.2 says "Reject unknown status strings in create and update," and earlier threads established that the spec is the implementation contract. Should the spec explicitly cover the update rejection case, or is symmetric behavior with create implicitly assumed?

Recommended: Add a scenario (e.g., "Update with invalid status is rejected — WHEN PATCH with {"status": "donezo"} THEN 400"). The spec already has four PATCH scenarios; a fifth for the rejection path costs two lines and removes ambiguity about whether update validation matches create validation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scenario asserts that unfiltered GET /tasks includes tasks of every status, including cancelled. But design.md line 92 still lists "Should cancelled tasks be excluded from unfiltered GET /tasks?" as an open question. The spec is the implementation contract — if an implementer follows this scenario, the open question is silently answered. Is this intentional, or should this scenario be held until the open question is explicitly resolved?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure, what do you think?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including cancelled in unfiltered GET /tasks is the right default. 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. The spec scenario on line 55–59 already encodes this, so the open question in design.md (line 92) is effectively answered.

Recommended: Close the open question in design.md by promoting it to a decision ("Unfiltered GET /tasks returns all statuses including cancelled") and let this spec scenario stand as written. An implicit answer buried in a scenario is harder to find than an explicit decision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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`
38 changes: 38 additions & 0 deletions openspec/changes/task-status-workflow/tasks.md
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 status query param to GET /tasks, a status field on task JSON, and new semantics on PATCH. Without a documentation task, the README will be stale after implementation. Is that intentional, or should there be a section 4 for docs?

Recommended: Add a ## 4. Docs section with a task to update the README endpoints table (add status query param, note the status field). A playground repo's README is often the only docs — keeping it current costs one task line and prevents a "but I followed the README" confusion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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`
8 changes: 8 additions & 0 deletions openspec/config.yaml
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>/`.
Loading