feat(api): conditional item updates with quoted etag preflight #47

Merged
yuris merged 1 commit from feat/issue-11-updates into main 2026-09-22 21:36:58 +00:00
Owner

Motivation

Callers could already create items, but an existing item could not be changed safely: the only way to update one is to rewrite a whole CalDAV object, which either overwrites a concurrent external edit or round-trips an external resource through the new-item builder and drops alarms, unknown properties, parameters, timezone definitions and unrelated overrides. This PR closes that gap (R03/C10/C09/R01): an update carries the caller's own quoted ETag, so a stale baseline is an explicit conflict rather than a silent overwrite, and only the properties the caller named are rewritten.

Changes

  • src/domain/patch.rs (new): TaskPatch/EventPatch models plus the field vocabulary now shared with the create slice (strength/energy/transparency parsing, category merging, required text, strict temporal ordering). Identity members (id, uid, collection_id, kind, recurrence_id) are refused rather than ignored; an empty patch — including one carrying only a reason — changes nothing and is refused.
  • src/calendar/preservation.rs (new): preservation-oriented patcher for an existing raw resource. It unfolds the resource, rewrites only the requested properties of the component selected by UID (and RECURRENCE-ID when present), re-emits every untouched physical line verbatim, folds new values at 75 octets, and refuses with calendar_unsupported when the target cannot be identified unambiguously (unknown UID, duplicate identity, malformed resource). An existing object is never serialized through the new-item builder.
  • src/api/update.rs (new): PATCH /v1/tasks/{id} and PATCH /v1/events/{id}. Order of work: operation key + If-Match preflight (no substitute baseline is ever fetched) → live item resolution → patch validation against the resource's actual state (ordering is checked on the state the resource is left in) → prepared intent carrying target, baseline ETag, before-state and intended representation → conditional PUT under the caller's exact ETag → one transaction recording applied, appending task_updated/event_updated with before_json/after_json, and upserting the projection. A 412 records failed and returns etag_conflict at HTTP 412 / exit 4 with no retry and no fresh-ETag refetch. A recorded operation is answered from the journal alone, so a replay never re-dispatches (and works even if the resource is gone). Unconfirmed outcomes stay needs_reconciliation + partial (exit 6).
  • src/calendar/client.rs: put_with_precondition (conditional PUT under If-Match) and reread_etag added to the existing pooled transport; the written representation, not a later readback, is the after-state.
  • src/api/mod.rs, src/api/items.rs: mutation plumbing extracted from the create slice and shared (mutation-result payload, replay/recorded-failure/operation-error envelopes, operation-key and body parsing, the actor identity, 422 mapping for patch errors, etag_precondition_failed at 412 next to create's 409); live item resolution extracted so update and item-get use one implementation, and absence is no longer claimed when a collection could not be read.
  • src/cli.rs, src/client.rs: life task update ID --etag ETAG --input - [--operation-id KEY] [--json] and life event update .... The CLI refuses a missing, empty, weak or unquoted ETag locally (exit 2, no request), forwards the quoted ETag verbatim as If-Match, and on a conflict points the caller at life item get <ID>.
  • One-line chore: src/cli.rs author metadata now reads Yuris Cakranegara <hello@yuris.dev> (clap derives from it; no behaviour change — clap's default help template does not print the author).

Deliberately out of scope: lifecycle transitions complete/reopen/cancel (#12) and recurrence/occurrence scoping (#13–#15). An occurrence-scoped patch is refused with calendar_unsupported rather than guessed.

Verification

  • nix develop --command cargo test — all suites green: 164 tests, including the new tests/update_tests.rs (27 HTTP-level tests against a mock CalDAV) and 8 patcher unit tests.
  • nix develop --command cargo clippy --all-targets -- -D warnings — clean.
  • nix develop --command cargo fmt --check — clean.
  • Acceptance criteria exercised at HTTP level: missing/empty/weak/unquoted/*/multi-tag ETags are rejected with 400 + exit 2 with zero requests reaching the backend and no journal row; the quoted ETag is captured verbatim as If-Match; ordering violations (including one-sided patches that invert the bounds) and identity members return 422 with no PUT; patching a title/deadline preserves VALARM, X- properties, custom parameters, DESCRIPTION, VTIMEZONE and a same-UID detached override in the exact bytes sent; a stale baseline yields 412 + etag_conflict, operations.state='failed', no retry and no fetch after the rejected write; a successful update commits applied plus a task_updated fact whose before/after snapshots are the pre- and post- item states; replaying an operation returns the recorded result with one PUT in total (and still resolves after the resource disappears); a byte-identical projection of what was written is stored; the CLI round-trips exit codes 0/2/4 and its envelope.

Closes #11

## Motivation Callers could already create items, but an existing item could not be changed safely: the only way to update one is to rewrite a whole CalDAV object, which either overwrites a concurrent external edit or round-trips an external resource through the new-item builder and drops alarms, unknown properties, parameters, timezone definitions and unrelated overrides. This PR closes that gap (R03/C10/C09/R01): an update carries the caller's own quoted ETag, so a stale baseline is an explicit conflict rather than a silent overwrite, and only the properties the caller named are rewritten. ## Changes - `src/domain/patch.rs` (new): `TaskPatch`/`EventPatch` models plus the field vocabulary now shared with the create slice (strength/energy/transparency parsing, category merging, required text, strict temporal ordering). Identity members (`id`, `uid`, `collection_id`, `kind`, `recurrence_id`) are refused rather than ignored; an empty patch — including one carrying only a `reason` — changes nothing and is refused. - `src/calendar/preservation.rs` (new): preservation-oriented patcher for an existing raw resource. It unfolds the resource, rewrites only the requested properties of the component selected by `UID` (and `RECURRENCE-ID` when present), re-emits every untouched physical line verbatim, folds new values at 75 octets, and refuses with `calendar_unsupported` when the target cannot be identified unambiguously (unknown UID, duplicate identity, malformed resource). An existing object is never serialized through the new-item builder. - `src/api/update.rs` (new): `PATCH /v1/tasks/{id}` and `PATCH /v1/events/{id}`. Order of work: operation key + `If-Match` preflight (no substitute baseline is ever fetched) → live item resolution → patch validation against the resource's actual state (ordering is checked on the state the resource is left in) → prepared intent carrying target, baseline ETag, before-state and intended representation → conditional `PUT` under the caller's exact ETag → one transaction recording `applied`, appending `task_updated`/`event_updated` with `before_json`/`after_json`, and upserting the projection. A `412` records `failed` and returns `etag_conflict` at HTTP 412 / exit 4 with no retry and no fresh-ETag refetch. A recorded operation is answered from the journal alone, so a replay never re-dispatches (and works even if the resource is gone). Unconfirmed outcomes stay `needs_reconciliation` + `partial` (exit 6). - `src/calendar/client.rs`: `put_with_precondition` (conditional `PUT` under `If-Match`) and `reread_etag` added to the existing pooled transport; the written representation, not a later readback, is the after-state. - `src/api/mod.rs`, `src/api/items.rs`: mutation plumbing extracted from the create slice and shared (mutation-result payload, replay/recorded-failure/operation-error envelopes, operation-key and body parsing, the actor identity, `422` mapping for patch errors, `etag_precondition_failed` at 412 next to create's 409); live item resolution extracted so update and item-get use one implementation, and absence is no longer claimed when a collection could not be read. - `src/cli.rs`, `src/client.rs`: `life task update ID --etag ETAG --input - [--operation-id KEY] [--json]` and `life event update ...`. The CLI refuses a missing, empty, weak or unquoted ETag locally (exit 2, no request), forwards the quoted ETag verbatim as `If-Match`, and on a conflict points the caller at `life item get <ID>`. - One-line chore: `src/cli.rs` author metadata now reads `Yuris Cakranegara <hello@yuris.dev>` (clap derives from it; no behaviour change — clap's default help template does not print the author). Deliberately out of scope: lifecycle transitions complete/reopen/cancel (#12) and recurrence/occurrence scoping (#13–#15). An occurrence-scoped patch is refused with `calendar_unsupported` rather than guessed. ## Verification - `nix develop --command cargo test` — all suites green: 164 tests, including the new `tests/update_tests.rs` (27 HTTP-level tests against a mock CalDAV) and 8 patcher unit tests. - `nix develop --command cargo clippy --all-targets -- -D warnings` — clean. - `nix develop --command cargo fmt --check` — clean. - Acceptance criteria exercised at HTTP level: missing/empty/weak/unquoted/`*`/multi-tag ETags are rejected with 400 + exit 2 with zero requests reaching the backend and no journal row; the quoted ETag is captured verbatim as `If-Match`; ordering violations (including one-sided patches that invert the bounds) and identity members return 422 with no `PUT`; patching a title/deadline preserves `VALARM`, `X-` properties, custom parameters, `DESCRIPTION`, `VTIMEZONE` and a same-UID detached override in the exact bytes sent; a stale baseline yields 412 + `etag_conflict`, `operations.state='failed'`, no retry and no fetch after the rejected write; a successful update commits `applied` plus a `task_updated` fact whose before/after snapshots are the pre- and post- item states; replaying an operation returns the recorded result with one `PUT` in total (and still resolves after the resource disappears); a byte-identical projection of what was written is stored; the CLI round-trips exit codes 0/2/4 and its envelope. ## Related Issues Closes #11
Author
Owner

Coordinator review — APPROVED (with one recorded deviation)

Verified fb95de7 by reading the diff and running it myself, not by trusting the summary:

  • R03 preflight is structurally guaranteed, not just tested: required_if_match is evaluated before resolve_live_item and before prepare_operation/put_with_precondition, so a missing/empty/weak/unquoted/*/multi-tag If-Match cannot reach the backend. validate_etag returns the caller's value verbatim after structural checks, so the quoted ETag is forwarded exactly.
  • No silent read-modify-write: the patched body is built from the live bytes, but the precondition is the caller's ETag, so a stale baseline becomes an explicit 412 rather than an overwrite. operations.state='failed', no retry, no post-rejection refetch.
  • C09 preservation is real: src/calendar/preservation.rs unfolds logical lines, walks BEGIN/END nesting, rewrites only the direct properties of the component selected by UID (and RECURRENCE-ID), and re-emits every untouched physical line verbatim; new values are folded at 75 octets and re-terminated with CRLF, so output stays RFC 5545 conformant. Nested VALARM and sub-components are never descended into, and an ambiguous or unidentifiable target is refused with calendar_unsupported instead of guessed.
  • Occurrence targets cannot silently hit the master: run_update refuses an item whose recurrence is set (calendar_unsupported) rather than applying a master patch — that would have been data corruption.
  • Injection-safe: escape_text escapes \ ; , and drops CR, so a title cannot introduce a new content line.

Independent verification on fb95de7: cargo test 164 passed / 0 failed across 12 suites including update_tests (27); cargo clippy --all-targets -- -D warnings clean; cargo fmt --check clean. Cargo.toml is untouched by this branch (author line correctly reverted); src/cli.rs carries the corrected author alongside the real life task update/life event update work.

Recorded deviation (no code change required)

docs/technical/calendar.md names the ical crate for component parsing; this slice instead implements a lossless line/component AST in src/calendar/preservation.rs with no new dependency. That is explicitly permitted by #14's own specification ("leveraging ical::parser::ical::IcalParser or a dedicated lossless line/component AST"), and for a preservation-critical path it is the stronger guarantee — a parser round-trip is precisely the failure mode C09 exists to prevent. Recording it here for traceability so #14 does not read the absence of ical as an oversight.

Merging.

## Coordinator review — APPROVED (with one recorded deviation) Verified `fb95de7` by reading the diff and running it myself, not by trusting the summary: - **R03 preflight is structurally guaranteed, not just tested:** `required_if_match` is evaluated before `resolve_live_item` and before `prepare_operation`/`put_with_precondition`, so a missing/empty/weak/unquoted/`*`/multi-tag `If-Match` cannot reach the backend. `validate_etag` returns the caller's value verbatim after structural checks, so the quoted ETag is forwarded exactly. - **No silent read-modify-write:** the patched body is built from the live bytes, but the precondition is the *caller's* ETag, so a stale baseline becomes an explicit 412 rather than an overwrite. `operations.state='failed'`, no retry, no post-rejection refetch. - **C09 preservation is real:** `src/calendar/preservation.rs` unfolds logical lines, walks BEGIN/END nesting, rewrites only the direct properties of the component selected by UID (and RECURRENCE-ID), and re-emits every untouched physical line verbatim; new values are folded at 75 octets and re-terminated with CRLF, so output stays RFC 5545 conformant. Nested `VALARM` and sub-components are never descended into, and an ambiguous or unidentifiable target is refused with `calendar_unsupported` instead of guessed. - **Occurrence targets cannot silently hit the master:** `run_update` refuses an item whose `recurrence` is set (`calendar_unsupported`) rather than applying a master patch — that would have been data corruption. - **Injection-safe:** `escape_text` escapes `\ ; , ` and drops CR, so a title cannot introduce a new content line. Independent verification on `fb95de7`: `cargo test` **164 passed / 0 failed** across 12 suites including `update_tests` (27); `cargo clippy --all-targets -- -D warnings` clean; `cargo fmt --check` clean. `Cargo.toml` is untouched by this branch (author line correctly reverted); `src/cli.rs` carries the corrected author alongside the real `life task update`/`life event update` work. ### Recorded deviation (no code change required) `docs/technical/calendar.md` names the `ical` crate for component parsing; this slice instead implements a lossless line/component AST in `src/calendar/preservation.rs` with **no new dependency**. That is explicitly permitted by #14's own specification ("leveraging `ical::parser::ical::IcalParser` *or a dedicated lossless line/component AST*"), and for a preservation-critical path it is the stronger guarantee — a parser round-trip is precisely the failure mode C09 exists to prevent. Recording it here for traceability so #14 does not read the absence of `ical` as an oversight. Merging.
yuris merged commit ff10b65dc8 into main 2026-09-22 21:36:58 +00:00
yuris changed title from feat(api): conditional item updates with quoted etag preflight (#11) to feat(api): conditional item updates with quoted etag preflight 2026-09-23 05:12:07 +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!47
No description provided.