feat(api): conditional task and event creation with prepared intent #46

Merged
yuris merged 2 commits from feat/issue-10-creation into main 2026-09-22 21:15:10 +00:00
Owner

Motivation

Directly sending a network write without a durable local record invites split-brain states: if the client crashes or the network drops mid-flight, neither side knows whether the resource exists. Issue #10 requires the creation path to close that gap for tasks and calendar events, and to enforce the domain invariants that make a captured task plannable at all — an explicit commitment strength, a deadline that is not earlier than the work it is due after, and preservation of user-asserted metadata that has no standard iCalendar property.

Reads (#9) could observe a calendar that nothing in this service was able to write to. This lands the write half of the capture workflow (scenario W01) with the idempotency and conflict semantics of scenarios R01/R02.

Changes

  • src/api/creation.rs (new): POST /v1/tasks and POST /v1/events. Validation and normalization, then the two-phase commit boundary:
    1. the payload is validated, the target collection resolved, a deterministic UID derived from the operation key, and the canonical fingerprint computed;
    2. an operations row with state = 'prepared' records the UID, target, If-None-Match: * precondition and intended representation — the dispatch happens only after that commit, and a journal failure blocks the write entirely (journal_unavailable, HTTP 503 / exit 5);
    3. on HTTP 201 the operation moves to applied, a life_events fact (task_created / event_created, confirmed_service) is appended and the item projection is upserted, all in one SQLite transaction;
    4. the confirmed item and its confirmed ETag are reported (HTTP 201). A create response without an ETag triggers a single bounded reread; if version evidence still cannot be established the outcome is reported as needs_reconciliation in a partial envelope instead of being claimed as committed.
  • Task invariants: exactly one of life-active / life-queued / life-someday in CATEGORIES (else HTTP 422 / exit 2), STATUS:NEEDS-ACTION by default, DUE strictly later than DTSTART with date-only values staying date-only and no dates invented for undated tasks, and estimated_minutes / energy / project_id mapped to X-LIFE-ESTIMATED-MINUTES / X-LIFE-ENERGY / X-LIFE-PROJECT-ID.
  • Event invariants: mandatory start/end with end > start, status of CONFIRMED (default) or TENTATIVE, transparency of OPAQUE (default) or TRANSPARENT, and optional related_task_id written as RELATED-TO.
  • Idempotency: replaying the same Idempotency-Key with the same semantic payload returns the recorded outcome with changed: false and no second remote write; a changed payload under the same key is idempotency_conflict (HTTP 409 / exit 4). A 412 from the backend is etag_conflict (HTTP 409 / exit 4) with operations.state = 'failed', and is never retried with a fresh UID or turned into an overwrite.
  • src/calendar/ical.rs (new): iCalendar generation for new resources only, via the icalendar crate, as docs/technical/calendar.md requires — an existing external resource is never round-tripped through the new-item builder. Because that crate escapes separator commas inside a single text value (which would fold life-active into an unrelated category), CATEGORIES is emitted one property per value, which RFC 5545 permits; src/calendar/report.rs now unions repeated CATEGORIES properties so the value survives the round trip.
  • src/calendar/client.rs: put_new_resource — a conditional PUT on the existing pooled transport, so there is no second HTTP path.
  • src/history/journal.rs: commit_operation_result optionally commits the item projection in the same transaction as the operation update and the fact it was confirmed by.
  • src/api/mod.rs: the two routes, plus validation_failed / idempotency_conflict / etag_conflict / journal_unavailable failures and an operation id on the error envelope.
  • src/client.rs, src/cli.rs: life task add --input - and life event add --input - (--operation-id optional, generated when absent; --collection-id / --title override the payload; --json prints the envelope). The CLI error-code map knows the new codes, so a conflict exits 4 and a validation failure exits 2. The caller's operation key is threaded into the transport failure path, so a transmitted write that times out (exit 6) or cannot be reached (exit 5) still reports error.operation_id and the operation stays nameable; a method the client cannot express is refused rather than downgraded to GET.
  • Request bodies reject unknown members (deny_unknown_fields, as docs/technical/cli-api.md requires): a misspelled dead_line is a 400 invalid_input with zero dispatch instead of a silently dropped field.

Non-goals, left to their own issues: no update/patch of existing items (#11), no lifecycle transitions (#12), no recurrence (#13–15), no Hindsight/Jev enqueueing (#16+), no auth middleware (#35).

Verification

  • nix develop --command cargo test — all suites pass (128 tests).
  • tests/creation_tests.rs (new, in-process daemon + mock Radicale over TCP) asserts real HTTP behavior: exactly one PUT carrying If-None-Match: *; the generated VTODO/VEVENT content including DUE;VALUE=DATE, STATUS:NEEDS-ACTION, TRANSP, RELATED-TO and the X-LIFE-* properties; that operations.state read from a separate SQLite connection is prepared at the moment the create is dispatched; that applied plus exactly one life_events fact plus one projection row exist afterwards; R01 replay returns the recorded item with changed: false and no second PUT; R02 returns idempotency_conflict; 412 returns etag_conflict with a failed operation, no fact and no retry; a create response without an ETag performs exactly one bounded reread; a 500 is reported as needs_reconciliation rather than committed; a missing journal dispatches nothing; strength, temporal-ordering, collection and missing-header rejections; event creation, defaults and end > start; and that a created task reads back through GET /v1/tasks with its strength, categories and extensions intact.
  • tests/cli_creation_tests.rs (new) spawns the real binary against a mock daemon: stdin payload handling, operation-key forwarding and generation, route selection, flag overrides, human and --json output, the exit codes 0 / 2 / 4, and a hanging daemon that proves a timed-out write exits 6 with error.operation_id set to the locally known operation key.
  • Unit tests in src/client.rs cover the operation id reported on a failed write and the refusal of an unparseable HTTP method; tests/creation_tests.rs covers unknown request members (400, zero dispatches).
  • nix develop --command cargo clippy --all-targets -- -D warnings is clean and cargo fmt --check reports no diff.

Closes #10

## Motivation Directly sending a network write without a durable local record invites split-brain states: if the client crashes or the network drops mid-flight, neither side knows whether the resource exists. Issue #10 requires the creation path to close that gap for tasks and calendar events, and to enforce the domain invariants that make a captured task plannable at all — an explicit commitment strength, a deadline that is not earlier than the work it is due after, and preservation of user-asserted metadata that has no standard iCalendar property. Reads (#9) could observe a calendar that nothing in this service was able to write to. This lands the write half of the capture workflow (scenario W01) with the idempotency and conflict semantics of scenarios R01/R02. ## Changes - `src/api/creation.rs` (new): `POST /v1/tasks` and `POST /v1/events`. Validation and normalization, then the two-phase commit boundary: 1. the payload is validated, the target collection resolved, a deterministic UID derived from the operation key, and the canonical fingerprint computed; 2. an `operations` row with `state = 'prepared'` records the UID, target, `If-None-Match: *` precondition and intended representation — the dispatch happens only after that commit, and a journal failure blocks the write entirely (`journal_unavailable`, HTTP 503 / exit 5); 3. on HTTP 201 the operation moves to `applied`, a `life_events` fact (`task_created` / `event_created`, `confirmed_service`) is appended and the item projection is upserted, all in one SQLite transaction; 4. the confirmed item and its confirmed ETag are reported (HTTP 201). A create response without an ETag triggers a single bounded reread; if version evidence still cannot be established the outcome is reported as `needs_reconciliation` in a `partial` envelope instead of being claimed as committed. - Task invariants: exactly one of `life-active` / `life-queued` / `life-someday` in `CATEGORIES` (else HTTP 422 / exit 2), `STATUS:NEEDS-ACTION` by default, `DUE` strictly later than `DTSTART` with date-only values staying date-only and no dates invented for undated tasks, and `estimated_minutes` / `energy` / `project_id` mapped to `X-LIFE-ESTIMATED-MINUTES` / `X-LIFE-ENERGY` / `X-LIFE-PROJECT-ID`. - Event invariants: mandatory `start`/`end` with `end > start`, `status` of `CONFIRMED` (default) or `TENTATIVE`, `transparency` of `OPAQUE` (default) or `TRANSPARENT`, and optional `related_task_id` written as `RELATED-TO`. - Idempotency: replaying the same `Idempotency-Key` with the same semantic payload returns the recorded outcome with `changed: false` and no second remote write; a changed payload under the same key is `idempotency_conflict` (HTTP 409 / exit 4). A `412` from the backend is `etag_conflict` (HTTP 409 / exit 4) with `operations.state = 'failed'`, and is never retried with a fresh UID or turned into an overwrite. - `src/calendar/ical.rs` (new): iCalendar generation for new resources only, via the `icalendar` crate, as `docs/technical/calendar.md` requires — an existing external resource is never round-tripped through the new-item builder. Because that crate escapes separator commas inside a single text value (which would fold `life-active` into an unrelated category), `CATEGORIES` is emitted one property per value, which RFC 5545 permits; `src/calendar/report.rs` now unions repeated `CATEGORIES` properties so the value survives the round trip. - `src/calendar/client.rs`: `put_new_resource` — a conditional `PUT` on the existing pooled transport, so there is no second HTTP path. - `src/history/journal.rs`: `commit_operation_result` optionally commits the item projection in the same transaction as the operation update and the fact it was confirmed by. - `src/api/mod.rs`: the two routes, plus `validation_failed` / `idempotency_conflict` / `etag_conflict` / `journal_unavailable` failures and an operation id on the error envelope. - `src/client.rs`, `src/cli.rs`: `life task add --input -` and `life event add --input -` (`--operation-id` optional, generated when absent; `--collection-id` / `--title` override the payload; `--json` prints the envelope). The CLI error-code map knows the new codes, so a conflict exits 4 and a validation failure exits 2. The caller's operation key is threaded into the transport failure path, so a transmitted write that times out (exit 6) or cannot be reached (exit 5) still reports `error.operation_id` and the operation stays nameable; a method the client cannot express is refused rather than downgraded to GET. - Request bodies reject unknown members (`deny_unknown_fields`, as `docs/technical/cli-api.md` requires): a misspelled `dead_line` is a 400 `invalid_input` with zero dispatch instead of a silently dropped field. Non-goals, left to their own issues: no update/patch of existing items (#11), no lifecycle transitions (#12), no recurrence (#13–15), no Hindsight/Jev enqueueing (#16+), no auth middleware (#35). ## Verification - `nix develop --command cargo test` — all suites pass (128 tests). - `tests/creation_tests.rs` (new, in-process daemon + mock Radicale over TCP) asserts real HTTP behavior: exactly one `PUT` carrying `If-None-Match: *`; the generated VTODO/VEVENT content including `DUE;VALUE=DATE`, `STATUS:NEEDS-ACTION`, `TRANSP`, `RELATED-TO` and the `X-LIFE-*` properties; that `operations.state` read from a separate SQLite connection is `prepared` at the moment the create is dispatched; that `applied` plus exactly one `life_events` fact plus one projection row exist afterwards; R01 replay returns the recorded item with `changed: false` and no second `PUT`; R02 returns `idempotency_conflict`; `412` returns `etag_conflict` with a `failed` operation, no fact and no retry; a create response without an ETag performs exactly one bounded reread; a 500 is reported as `needs_reconciliation` rather than committed; a missing journal dispatches nothing; strength, temporal-ordering, collection and missing-header rejections; event creation, defaults and `end > start`; and that a created task reads back through `GET /v1/tasks` with its strength, categories and extensions intact. - `tests/cli_creation_tests.rs` (new) spawns the real binary against a mock daemon: stdin payload handling, operation-key forwarding and generation, route selection, flag overrides, human and `--json` output, the exit codes 0 / 2 / 4, and a hanging daemon that proves a timed-out write exits 6 with `error.operation_id` set to the locally known operation key. - Unit tests in `src/client.rs` cover the operation id reported on a failed write and the refusal of an unparseable HTTP method; `tests/creation_tests.rs` covers unknown request members (400, zero dispatches). - `nix develop --command cargo clippy --all-targets -- -D warnings` is clean and `cargo fmt --check` reports no diff. ## Related Issues Closes #10
Add POST /v1/tasks and POST /v1/events (life task add / life event add) as
conditional creates on the two-phase commit boundary:

- validate and normalize the payload, resolve the target collection, derive a
  deterministic UID from the operation key, and commit an `operations` row with
  state 'prepared' before anything is dispatched; a journal failure blocks the
  write entirely (journal_unavailable)
- PUT the generated iCalendar resource with If-None-Match: *, so an existing
  resource under the generated UID is rejected with 412 -> etag_conflict
  (HTTP 409 / exit 4) and operations.state='failed', never retried with a fresh
  UID or overwritten
- on 201, one transaction moves the operation to 'applied', appends the
  confirmed life_events fact and upserts the item projection; a missing ETag is
  resolved by a single bounded reread, and an unconfirmed outcome is reported
  as needs_reconciliation rather than as committed
- idempotency: the same key and semantic payload replays the recorded outcome
  (changed:false) without a second remote write; a changed payload under the
  same key is idempotency_conflict (HTTP 409 / exit 4)

Task invariants: exactly one of life-active/life-queued/life-someday in
CATEGORIES, STATUS defaults to NEEDS-ACTION, DUE must be strictly later than
DTSTART with no invented dates, and estimated_minutes/energy/project_id map to
X-LIFE-ESTIMATED-MINUTES/X-LIFE-ENERGY/X-LIFE-PROJECT-ID. Event invariants:
mandatory start/end with end > start, CONFIRMED|TENTATIVE status, OPAQUE
default transparency, optional related_task_id -> RELATED-TO.

New resources are generated with the `icalendar` crate only; existing external
resources are never round-tripped through the new-item builder. Because that
crate escapes separator commas inside a single text value, CATEGORIES is
emitted one property per value (RFC 5545 allows repetition) and the reader
unions repeated CATEGORIES.
Author
Owner

Coordinator review — CHANGES REQUESTED

Reviewed the full diff at fea1e62 and ran it myself: cargo test 124 passed / 0 failed (incl. 18 creation + 7 cli_creation), cargo clippy --all-targets -- -D warnings clean, cargo fmt --check clean.

Genuinely good: prepared row committed before any dispatch; the 412 path is definite-failure with operations.state='failed' and no UID retry; replay_response correctly returns the recorded outcome for applied/failed and a disclosed partial (exit 6, no second write) for prepared/unknown/needs_reconciliation; unconfirmed outcomes become needs_reconciliation rather than claimed success; If-None-Match: * is asserted on the captured request; strength/temporal/event invariants and the missing-key 400 are all covered by real tests; the repeated-CATEGORIES decision is correct per RFC 5545 and the read-side union makes it round-trip.

Three changes before merge:

1. (major) A write's operation ID is lost exactly when it is needed. docs/technical/cli-api.md requires: "Client timeout after transmitting a write exits 6 with the locally known operation ID". src/cli.rs::run_creation_command generates op_<uuid> and passes it to client::post_envelope, but request_envelope → transport_envelope only ever sets a locally generated request id — the operation ID is never placed on the envelope. So a timeout (request_pending, exit 6) or dependency_unavailable leaves the caller with no way to run life operation get <id> against a create that may well have been dispatched and applied remotely. Thread the operation ID into the transport failure path and set error.operation_id (the envelope already has the field).

2. (moderate) Write bodies silently ignore unknown members. TaskCreateRequest/EventCreateRequest use plain Deserialize (see the doc comment "Unknown members are ignored"), which contradicts docs/technical/cli-api.md: "Reject unknown request fields and unsupported schema versions." A typo — dead_line, planned_startt, estimate_minutes — is dropped and the create proceeds as if the caller had not asked for it, so the user believes a deadline/estimate was recorded when it was not. Reject unknown fields (deny_unknown_fields, matching how src/config.rs already treats config) with invalid_input 400 / exit 2 and zero dispatch. Fingerprint stability does not require accepting them — strict rejection is deterministic too.

3. (minor) Silent method fallback. request_envelope does reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::GET): an unrecognized method silently becomes a GET instead of failing. The values are internal constants today, but a wrong-method bug would be invisible. Return a transport_error/invalid_input instead of defaulting.

Please also add tests: (a) a post-transmission timeout on POST /v1/tasks returns exit 6 and an envelope whose error.operation_id equals the CLI-generated/locally known operation ID; (b) POST /v1/tasks with an unknown field (dead_line) returns 400 invalid_input and performs zero dispatches; (c) the method guard rejects an unknown method rather than issuing a GET.

## Coordinator review — CHANGES REQUESTED Reviewed the full diff at `fea1e62` and ran it myself: `cargo test` 124 passed / 0 failed (incl. 18 creation + 7 cli_creation), `cargo clippy --all-targets -- -D warnings` clean, `cargo fmt --check` clean. Genuinely good: `prepared` row committed before any dispatch; the 412 path is definite-failure with `operations.state='failed'` and no UID retry; `replay_response` correctly returns the recorded outcome for `applied`/`failed` and a disclosed `partial` (exit 6, no second write) for `prepared`/`unknown`/`needs_reconciliation`; unconfirmed outcomes become `needs_reconciliation` rather than claimed success; `If-None-Match: *` is asserted on the captured request; strength/temporal/event invariants and the missing-key 400 are all covered by real tests; the repeated-`CATEGORIES` decision is correct per RFC 5545 and the read-side union makes it round-trip. Three changes before merge: **1. (major) A write's operation ID is lost exactly when it is needed.** `docs/technical/cli-api.md` requires: *"Client timeout after transmitting a write exits 6 with the locally known operation ID"*. `src/cli.rs::run_creation_command` generates `op_<uuid>` and passes it to `client::post_envelope`, but `request_envelope` → `transport_envelope` only ever sets a locally generated **request id** — the operation ID is never placed on the envelope. So a timeout (`request_pending`, exit 6) or `dependency_unavailable` leaves the caller with no way to run `life operation get <id>` against a create that may well have been dispatched and applied remotely. Thread the operation ID into the transport failure path and set `error.operation_id` (the envelope already has the field). **2. (moderate) Write bodies silently ignore unknown members.** `TaskCreateRequest`/`EventCreateRequest` use plain `Deserialize` (see the doc comment "Unknown members are ignored"), which contradicts `docs/technical/cli-api.md`: *"Reject unknown request fields and unsupported schema versions."* A typo — `dead_line`, `planned_startt`, `estimate_minutes` — is dropped and the create proceeds as if the caller had not asked for it, so the user believes a deadline/estimate was recorded when it was not. Reject unknown fields (`deny_unknown_fields`, matching how `src/config.rs` already treats config) with `invalid_input` 400 / exit 2 and zero dispatch. Fingerprint stability does not require accepting them — strict rejection is deterministic too. **3. (minor) Silent method fallback.** `request_envelope` does `reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::GET)`: an unrecognized method silently becomes a GET instead of failing. The values are internal constants today, but a wrong-method bug would be invisible. Return a `transport_error`/`invalid_input` instead of defaulting. Please also add tests: (a) a post-transmission timeout on `POST /v1/tasks` returns exit 6 **and** an envelope whose `error.operation_id` equals the CLI-generated/locally known operation ID; (b) `POST /v1/tasks` with an unknown field (`dead_line`) returns 400 `invalid_input` and performs zero dispatches; (c) the method guard rejects an unknown method rather than issuing a GET.
Review follow-ups on #46:

- The CLI now threads the caller's operation key into the transport failure
  path, so a transmitted write that times out (exit 6) or cannot be reached
  (exit 5) still reports `error.operation_id`, letting the caller query
  `life operation get <id>` for a create that may have been applied remotely.
  Read commands keep reporting no operation id.
- `TaskCreateRequest`/`EventCreateRequest` reject unknown members
  (`deny_unknown_fields`), as docs/technical/cli-api.md requires: a misspelled
  `dead_line` is a 400 invalid_input with zero dispatch instead of a silently
  dropped field that makes the caller believe a deadline was recorded.
- `request_envelope` refuses a method it cannot express instead of silently
  falling back to GET.

Tests: unparseable method and operation-id-on-failed-write unit tests in
src/client.rs; a hanging-daemon CLI test asserting exit 6 with the locally
known operation id; an unknown-field test asserting 400 with zero dispatches.
Author
Owner

Coordinator re-review — APPROVED

Verified 4ee58c3 myself (read the diff, ran the suite):

  • Operation id on transport failures: request_envelope now takes operation_id: Option<&str> and transport_envelope sets error.operation_id on every client-side failure, so a transmitted write that times out (exit 6, request_pending) or is unreachable (exit 5) still names the operation for life operation get <id>. Read commands pass None. Covered by a hanging-daemon test asserting exit 6 plus error.operation_id == "op-cli-timeout".
  • Unknown fields rejected: deny_unknown_fields on both create DTOs → 400 invalid_input / exit 2, zero dispatches, no operations row; fingerprint semantics unchanged.
  • Method guard: an inexpressible method returns transport_error instead of silently becoming GET.

Independent verification on 4ee58c3: cargo test 128 passed / 0 failed across 11 suites; cargo clippy --all-targets -- -D warnings 0 errors; cargo fmt --check clean.

Merging.

## Coordinator re-review — APPROVED Verified `4ee58c3` myself (read the diff, ran the suite): - **Operation id on transport failures:** `request_envelope` now takes `operation_id: Option<&str>` and `transport_envelope` sets `error.operation_id` on every client-side failure, so a transmitted write that times out (exit 6, `request_pending`) or is unreachable (exit 5) still names the operation for `life operation get <id>`. Read commands pass `None`. Covered by a hanging-daemon test asserting exit 6 plus `error.operation_id == "op-cli-timeout"`. - **Unknown fields rejected:** `deny_unknown_fields` on both create DTOs → 400 `invalid_input` / exit 2, zero dispatches, no `operations` row; fingerprint semantics unchanged. - **Method guard:** an inexpressible method returns `transport_error` instead of silently becoming GET. Independent verification on `4ee58c3`: `cargo test` 128 passed / 0 failed across 11 suites; `cargo clippy --all-targets -- -D warnings` 0 errors; `cargo fmt --check` clean. Merging.
yuris merged commit 29528e561e into main 2026-09-22 21:15:10 +00:00
yuris changed title from feat(api): conditional task and event creation with prepared intent (#10) to feat(api): conditional task and event creation with prepared intent 2026-09-23 05:12:08 +00:00
Sign in to join this conversation.
No reviewers
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!46
No description provided.