Implement life event cancel / POST /v1/events/{id}/cancel #75

Closed
opened 2026-09-23 04:05:50 +00:00 by yuris · 0 comments
Owner

Context

docs/technical/cli-api.md's command-to-route table documents:

Command Method and route Scope
life event cancel ID --etag ETAG --input - POST /v1/events/{id}/cancel write

Neither half exists today. EventSubcommands has only Add and Update (so life event cancel is a clap usage error), and the router registers no /v1/events/{id}/cancel route (grep -c 'events/.*cancel' src/api/mod.rs → 0).

Found during the #32 audit (PR #74), which moved the command to the skill's not-implemented table so the package stopped over-claiming — honest, but the contract still advertises it.

Why it matters

Cancellation is the one event transition with a documented non-obvious semantic: docs/technical/cli-api.md says cancellation sets STATUS:CANCELLED, not DELETE — a distinct operation from removal, and the same distinction #12 draws for tasks (cancel is not complete). Without it, an agent told "cancel that block" has no correct command and would reasonably reach for an update patch carrying a status, which is exactly the free-form status edit the domain model avoids.

What to build

  • EventSubcommands::Cancel { id, etag, input, operation_id } and POST /v1/events/{id}/cancel, following #12's task-transition shape and #11's preflight: mandatory quoted strong If-Match (missing/weak/unquoted/*/multi-tag refused before any dispatch), Idempotency-Key required, deny_unknown_fields body carrying an optional reason and optional occurred_at.
  • The write sets STATUS:CANCELLED on the target component and must not DELETE the resource. A CANCELLED event still occupies no capacity (consumes_capacity is already false for cancelled events), so the agenda/planning paths need no change — assert that instead of assuming it.
  • Lifecycle discipline copied from #12: a no-op on an already-CANCELLED event is a recorded applied operation with changed:false, 0 PUTs and no duplicate timeline fact; the reverse transition (un-cancelling) is out of scope unless explicitly specified later.
  • Journal the transition with an accurate before_json/after_json, and on a stale ETag return etag_conflict (412 / exit 4) with operations.state='failed' and no retry — never a refreshed-ETag overwrite.
  • Recurring events keep #15's scoping rule: a bare cancellation of a recurring item asks for scope rather than guessing (cancelling the series and cancelling one occurrence are different operations with the same name).
  • Update skills/life/references/commands.md to move the row back to the implemented table.

Acceptance criteria

  • life event cancel ID --etag '"v1"' --input - --json cancels the event: STATUS:CANCELLED in the resource, resource still present (no DELETE), envelope exit 0.
  • Missing/weak/unquoted/*/multi-tag ETag → 400 invalid_input / exit 2 with zero backend requests and no journal row.
  • Stale ETag → 412 etag_conflict / exit 4, operations.state='failed', no retry with a refreshed ETag.
  • Cancelling an already-cancelled event is a recorded no-op (applied, changed:false, 0 PUTs, no duplicate fact).
  • A cancelled event still appears in the agenda and consumes no capacity, asserted rather than assumed.
  • A recurring target without an explicit scope returns needs_clarification (exit 3) with the series/occurrence choice.
  • before_json/after_json recorded in one transaction with the operation result.
  • The skill's command reference lists it as implemented, and tests/skill_package_tests.rs passes.

Notes

  • Reviewed by an independent reviewer agent; verdict went REQUEST CHANGES → APPROVE across two heads.
  • Reviewer finding (major), fixed before merge: the not-implemented count assertion was checked >= 1, which accepted both the old count (2) and the new one (1) — so it did not establish that life event cancel left the not-implemented section, which is the whole point of the criterion. It now asserts the parsed command-path set equals exactly {sync}, via the existing validate_strict helper that also validates each row against the real clap tree; the author falsified it both ways by hand (re-adding the row fails that row's own exit assertion; emptying the section fails the set equality).
  • Reviewer independently confirmed: one PUT / zero DELETEs with X- properties and VALARM preserved, all six unusable ETag forms refused before dispatch with zero requests and zero journal rows, stale ETag → 412 / exit 4 / failed / no refreshed-ETag retry, the recorded no-op, the agenda + consumes_capacity claim asserted through the existing predicate with a positive control, write-scope enforcement (a read credential is refused before the handler), reuse of #15's clarification payload, and the journal boundary. It called the SQLite-atomicity caveat accurate.
  • Suite count wording: 766 passed / 0 failed across 40 test binaries plus the doc-test target (41 test result lines).

Blocked by

None — #11 (preflight/updates), #12 (lifecycle discipline) and #15 (recurring scope) are all merged.

Refs #32 (where the gap was found), #11 (event update), #12 (task cancellation semantics).

## Context `docs/technical/cli-api.md`'s command-to-route table documents: | Command | Method and route | Scope | |---|---|---| | `life event cancel ID --etag ETAG --input -` | POST /v1/events/{id}/cancel | write | Neither half exists today. `EventSubcommands` has only `Add` and `Update` (so `life event cancel` is a clap usage error), and the router registers no `/v1/events/{id}/cancel` route (`grep -c 'events/.*cancel' src/api/mod.rs` → 0). Found during the #32 audit (PR #74), which moved the command to the skill's not-implemented table so the package stopped over-claiming — honest, but the contract still advertises it. ## Why it matters Cancellation is the one event transition with a documented non-obvious semantic: `docs/technical/cli-api.md` says cancellation **sets `STATUS:CANCELLED`, not `DELETE`** — a distinct operation from removal, and the same distinction `#12` draws for tasks (`cancel` is not `complete`). Without it, an agent told "cancel that block" has no correct command and would reasonably reach for an update patch carrying a status, which is exactly the free-form status edit the domain model avoids. ## What to build - `EventSubcommands::Cancel { id, etag, input, operation_id }` and `POST /v1/events/{id}/cancel`, following `#12`'s task-transition shape and `#11`'s preflight: mandatory quoted strong `If-Match` (missing/weak/unquoted/`*`/multi-tag refused before any dispatch), `Idempotency-Key` required, `deny_unknown_fields` body carrying an optional `reason` and optional `occurred_at`. - The write sets `STATUS:CANCELLED` on the target component and **must not `DELETE`** the resource. A `CANCELLED` event still occupies no capacity (`consumes_capacity` is already false for cancelled events), so the agenda/planning paths need no change — assert that instead of assuming it. - Lifecycle discipline copied from `#12`: a no-op on an already-`CANCELLED` event is a recorded `applied` operation with `changed:false`, 0 PUTs and no duplicate timeline fact; the reverse transition (un-cancelling) is out of scope unless explicitly specified later. - Journal the transition with an accurate `before_json`/`after_json`, and on a stale ETag return `etag_conflict` (412 / exit 4) with `operations.state='failed'` and no retry — never a refreshed-ETag overwrite. - Recurring events keep `#15`'s scoping rule: a bare cancellation of a recurring item asks for scope rather than guessing (cancelling the series and cancelling one occurrence are different operations with the same name). - Update `skills/life/references/commands.md` to move the row back to the implemented table. ## Acceptance criteria - [x] `life event cancel ID --etag '"v1"' --input - --json` cancels the event: `STATUS:CANCELLED` in the resource, resource still present (no DELETE), envelope exit 0. - [x] Missing/weak/unquoted/`*`/multi-tag ETag → 400 `invalid_input` / exit 2 with zero backend requests and no journal row. - [x] Stale ETag → 412 `etag_conflict` / exit 4, `operations.state='failed'`, no retry with a refreshed ETag. - [x] Cancelling an already-cancelled event is a recorded no-op (`applied`, `changed:false`, 0 PUTs, no duplicate fact). - [x] A cancelled event still appears in the agenda and consumes no capacity, asserted rather than assumed. - [x] A recurring target without an explicit scope returns `needs_clarification` (exit 3) with the series/occurrence choice. - [x] `before_json`/`after_json` recorded in one transaction with the operation result. - [x] The skill's command reference lists it as implemented, and `tests/skill_package_tests.rs` passes. ## Notes - Reviewed by an independent reviewer agent; verdict went REQUEST CHANGES → APPROVE across two heads. - **Reviewer finding (major), fixed before merge:** the not-implemented count assertion was `checked >= 1`, which accepted both the old count (2) and the new one (1) — so it did not establish that `life event cancel` left the not-implemented section, which is the whole point of the criterion. It now asserts the parsed command-path **set** equals exactly `{sync}`, via the existing `validate_strict` helper that also validates each row against the real clap tree; the author falsified it both ways by hand (re-adding the row fails that row's own exit assertion; emptying the section fails the set equality). - Reviewer independently confirmed: one PUT / zero DELETEs with `X-` properties and `VALARM` preserved, all six unusable ETag forms refused before dispatch with zero requests and zero journal rows, stale ETag → 412 / exit 4 / `failed` / no refreshed-ETag retry, the recorded no-op, the agenda + `consumes_capacity` claim asserted through the existing predicate with a positive control, **write-scope enforcement** (a read credential is refused before the handler), reuse of `#15`'s clarification payload, and the journal boundary. It called the SQLite-atomicity caveat accurate. - Suite count wording: 766 passed / 0 failed across 40 test binaries plus the doc-test target (41 `test result` lines). ## Blocked by None — #11 (preflight/updates), #12 (lifecycle discipline) and #15 (recurring scope) are all merged. ## Related Refs #32 (where the gap was found), #11 (event update), #12 (task cancellation semantics).
yuris closed this issue 2026-09-23 05:20:09 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yuris/life-service#75
No description provided.