Conditional Item Updates with Quoted ETag Preflight #11

Closed
opened 2026-09-22 17:58:54 +00:00 by yuris · 0 comments
Owner

Summary

Implement conditional update routes (PATCH /v1/tasks/{id} / life task update and PATCH /v1/events/{id} / life event update), enforcing mandatory caller-supplied If-Match quoted ETags (rejecting missing/weak ETags before network dispatch), immutable item identity, preservation-safe selective iCalendar patching, optimistic concurrency conflict handling (etag_conflict), and two-phase commit journaling.


Background & Technical Rationale

  1. Mandatory Caller ETag Preflight (Scenario R03):
    A common design flaw in calendar adapters is performing a "silent read-modify-write" shortcut: if a caller omits a version token, the service silently fetches the current server ETag, merges the update, and writes it back. This creates silent data loss and overwrites concurrent external modifications.

    • The caller MUST explicitly supply the current entity tag (If-Match: "\"v1\"" or --etag '"v1"').
    • If the ETag is missing, empty, or weak (W/), the request MUST be rejected immediately before any network dispatch with invalid_input (HTTP 400 / CLI exit code 2).
    • Exact quote formatting must be preserved: "\"v1\"" must not be accidentally stripped or modified.
  2. Immutable Item Identity:

    • Canonical item identity is (collection_id, UID, optional RECURRENCE-ID).
    • The item's UID and collection assignment are strictly immutable. A task or event cannot be moved to a different collection or assigned a new UID via a PATCH update. Cross-collection relocation requires creating a new item with an explicit link, followed by deactivating the original.
    • Attempting to pass a differing uid, collection_id, or kind in the update payload fails validation with invalid_input.
  3. Preservation-Safe Selective Patching:
    WebDAV/CalDAV lacks native sub-resource patching; an HTTP PUT replaces the entire calendar object on the server. To update a single property (e.g. title or deadline) without corrupting external data:

    • The service must load the raw unexpanded iCalendar resource corresponding to the confirmed baseline ETag.
    • It parses the resource components using ical.
    • It modifies ONLY the explicitly requested properties in the target component (e.g. updating SUMMARY or DUE).
    • It strictly preserves all unknown properties, custom parameters, alarms (VALARM), embedded timezone definitions (VTIMEZONE), and unrelated recurrence overrides.
    • It MUST NEVER re-serialize an existing resource through the new-item generator. If preservation cannot be guaranteed, the operation must refuse with calendar_unsupported.
  4. Concurrency Conflict Detection (Scenario C10):

    • The service dispatches an HTTP PUT to Radicale with the header If-Match: <caller_etag>.
    • If the resource has been modified on Radicale since the caller observed it, Radicale responds with HTTP 412 Precondition Failed.
    • The service maps HTTP 412 directly to etag_conflict (HTTP 412 / CLI exit code 4).
    • Under no circumstances may the service automatically reread the new ETag and reapply the patch without explicit caller review and renewed decision.
  5. Two-Phase Journal Durability & Before/After Audit:

    • The local operation journal commits state: 'prepared' containing the target UID, baseline ETag, and intended representation.
    • Upon confirmed HTTP 200/204 from Radicale, the service commits a transaction updating operations.state = 'applied' and appends a task_updated (or event_updated) fact to life_events with both before_json (the baseline item state) and after_json (the newly confirmed state).

Exact Technical References & Specifications

Normative Quotations from Technical Architecture

From docs/technical/cli-api.md (Command-to-route table, lines 33, 38):

Command Method and route Scope
life task update ID --etag ETAG --input - PATCH /v1/tasks/{id} write
life event update ID --etag ETAG --input - PATCH /v1/events/{id} write

From docs/technical/cli-api.md (Idempotency and approval, line 65):

"Creates use conditional remote creation. Existing-item mutations require caller ETag; no hidden read-then-overwrite shortcut. Direct authorized instruction covers the explicit mutation."

From docs/technical/cli-api.md (Command payloads and results, line 83):

Family Required / optional input Response data
Task update Nonempty patch of mutable add fields or status/progress; no identity/collection mutation; optional reason,scope Same mutation result
Event update Nonempty mutable patch or cancellation reason; ETag, recurring scope Same mutation result; cancellation sets STATUS:CANCELLED, not DELETE

From docs/technical/calendar.md (Discovery and conditional writes, lines 11–14):

"New objects use If-None-Match: *; all existing-object writes/deletions use If-Match with the caller's exact ETag. Missing/weak unusable version evidence blocks mutation. HTTP 412 is conflict; never silently retry as overwrite. Obtain the resulting ETag by response or bounded reread; uncertain evidence follows recovery.

Store original unexpanded raw resource separately from normalized projection. Same-UID master and detached overrides share one resource; different UIDs/component kinds do not share an ordinary CalDAV object. Existing-object edits patch only requested properties in a preservation-oriented representation, retaining parameters, unknown properties, alarms, timezone definitions and unrelated overrides. Never serialize an external object through the new-item builder. If preservation cannot be guaranteed, refuse with calendar_unsupported. Preserve line folding/escaping semantically; compare normalized intent for recovery while retaining raw bytes for provenance. Raw textual equality alone is not semantic identity."

From docs/technical/domain-model.md (Identity and relationships, line 45):

"Canonical item identity is (configured_collection_id, UID, optional RECURRENCE-ID). Preserve recurrence value type and timezone semantics; a moved occurrence keeps its original recurrence identity. A master and its detached overrides share the same UID and CalDAV resource. A resource href and ETag are separate transport/version data, never the logical identity. The API uses opaque persistent item IDs; callers cannot construct fetch URLs."

From docs/technical/verification.md (Recovery & Calendar scenarios, lines 44, 89):

ID Boundary / injected failure Observable acceptance
R03 Missing If-Match on existing-item mutation Rejected before dispatch; client cannot silently fetch a substitute baseline
C10 Concurrent edit yields HTTP 412 etag_conflict; no overwrite/retry with refreshed ETag without renewed decision

Request & Error JSON Wire Envelopes

1. PATCH /v1/tasks/{id} Request Payload (stdin)

PATCH /v1/tasks/itm_demo HTTP/1.1
Host: life.example.internal
If-Match: "v1"
Idempotency-Key: op_demo_update
Content-Type: application/json

{
  "title": "Draft finalized portfolio outline",
  "estimated_minutes": 120,
  "energy": "high",
  "reason": "Expanded scope based on team feedback"
}

2. Conditional CalDAV PUT Dispatch with If-Match

PUT /user/calendars/personal/fictional-demo-uid.ics HTTP/1.1
Host: radicale.example.internal
If-Match: "v1"
Content-Type: text/calendar; charset=utf-8
Authorization: Basic <base64(user:pass)>

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Radicale//NONSGML Radicale Server//EN
BEGIN:VTODO
UID:fictional-demo-uid
DTSTAMP:20300408T000000Z
SUMMARY:Draft finalized portfolio outline
STATUS:NEEDS-ACTION
CATEGORIES:area-career,life-active
DUE;VALUE=DATE:20300412
X-LIFE-ESTIMATED-MINUTES:120
X-LIFE-ENERGY:high
X-LIFE-PROJECT-ID:proj-portfolio
END:VTODO
END:VCALENDAR

3. Successful Update Response Envelope (200 OK)

{
  "schema_version": 1,
  "request_id": "req_demo_update",
  "status": "ok",
  "data": {
    "operation_id": "op_demo_update",
    "operation_state": "applied",
    "changed": true,
    "item": {
      "id": "itm_demo",
      "kind": "task",
      "collection_id": "personal",
      "uid": "fictional-demo-uid",
      "recurrence_id": null,
      "etag": "\"v2\"",
      "title": "Draft finalized portfolio outline",
      "status": "NEEDS-ACTION",
      "categories": ["area-career", "life-active"],
      "strength": "life-active",
      "deadline": { "kind": "date", "value": "2030-04-12" },
      "planned_start": null,
      "estimated_minutes": 120,
      "energy": "high",
      "project_id": "proj-portfolio"
    },
    "context_delivery": "not_requested"
  },
  "warnings": [],
  "error": null
}

4. Concurrency Conflict Response (412 Precondition Failed mapped to HTTP 412, exit code 4)

{
  "schema_version": 1,
  "request_id": "req_demo_conflict",
  "status": "error",
  "data": {
    "operation_id": "op_demo_update",
    "operation_state": "failed"
  },
  "warnings": [],
  "error": {
    "code": "etag_conflict",
    "message": "Item changed; reread before deciding",
    "retryable": false,
    "operation_id": "op_demo_update"
  }
}

5. Missing ETag Error Envelope (400 Bad Request, exit code 2)

{
  "schema_version": 1,
  "request_id": "req_missing_etag",
  "status": "error",
  "data": null,
  "warnings": [],
  "error": {
    "code": "invalid_input",
    "message": "Existing-item mutation requires caller ETag in If-Match header (--etag); missing or weak version evidence blocks mutation",
    "retryable": false
  }
}

What to Build (Step-by-Step)

1. Patch Validation Engine (src/domain/patch.rs)

  • Model TaskPatch and EventPatch:
    • Enforce that at least one mutable field is provided; empty patches return invalid_input.
    • Check for disallowed immutable fields: if payload contains id, uid, collection_id, or kind that conflicts with the target item, reject with invalid_input.
    • Validate field modifications:
      • If strength is updated, ensure it is one of life-active, life-queued, life-someday.
      • If deadline or planned_start are modified, verify deadline > planned_start (strictly later).
      • If status is modified, verify allowed domain transitions.

2. ETag Preflight & Header Parsing

  • Axum middleware / extractor RequiredIfMatch:
    • Inspect HTTP If-Match header.
    • If header is absent or empty, reject immediately with HTTP 400 (invalid_input).
    • If header starts with W/ (weak ETag), reject immediately.
    • Ensure entity tag is preserved with surrounding double quotes ("\"...\"").
  • CLI argument --etag <ETAG>:
    • Validate that the user passed a non-empty string.
    • Send value directly in the HTTP If-Match header to the API.

3. Preservation-Safe iCalendar Patcher (src/calendar/preservation.rs)

  • Fetch raw baseline resource from Radicale or verify against cached item_projection.raw_resource:
    • Parse raw calendar using ical::parser::ical::IcalParser.
    • Locate the component matching target UID (and RECURRENCE-ID if an occurrence patch).
    • Apply patches property by property:
      • Replace or add SUMMARY, DUE, DTSTART, CATEGORIES, X-LIFE-*.
      • Leave all other properties (VALARM, ATTENDEE, VTIMEZONE, custom client extensions) completely intact.
    • Re-serialize preserving RFC 5545 line folding and CRLF endings.

4. Two-Phase Journaling & Remote Dispatch (src/calendar/mutation.rs)

  • Insert state: 'prepared' in operations table with baseline ETag.
  • Dispatch HTTP PUT to Radicale with If-Match: <baseline_etag>.
  • Conflict handling:
    • If Radicale returns HTTP 412:
      • Update operations table: state = 'failed'.
      • Return etag_conflict (HTTP 412, exit code 4).
      • Never perform automatic refetch or overwrite.
  • Success handling:
    • Extract confirmed ETag from HTTP response headers (or bounded GET).
    • In a single SQLite transaction:
      • Update operations.state = 'applied'.
      • Append to life_events with kind: 'task_updated' (or 'event_updated'), recording before_json (baseline) and after_json (new item state).
      • Update item_projection row.

5. CLI Presentation (src/cli/task.rs, src/cli/event.rs)

  • Commands:
    • life task update <ID> --etag <ETAG> --input - [--operation-id KEY] [--json]
    • life event update <ID> --etag <ETAG> --input - [--operation-id KEY] [--json]
  • In case of etag_conflict, print an explicit instruction to rerun life item get <ID> and review differences before updating.

Acceptance Criteria

  • Mandatory ETag Preflight (Scenario R03): Calling life task update without --etag (or HTTP PATCH without If-Match) is rejected before any network dispatch with exit code 2 (invalid_input); the service never performs a silent read-modify-write.
  • Weak ETag Rejection: Supplying a weak ETag (e.g. W/"v1") fails validation before remote dispatch.
  • Strict Deadline Ordering: Updating a deadline to be equal to or earlier than planned_start returns validation error (HTTP 422, exit code 2).
  • Immutable Identity Enforcement: Attempting to alter uid or collection_id in the patch payload returns validation error (HTTP 422, exit code 2).
  • Concurrency Conflict Handling (Scenario C10): When Radicale responds with HTTP 412 Precondition Failed, the service returns etag_conflict with HTTP 412 / exit code 4 without retrying or fetching a fresh ETag.
  • Preservation of Unrelated Properties (Scenario C09): Updating a task title preserves existing alarms (VALARM), custom properties, and unknown parameters without round-tripping through the new-item builder.
  • Two-Phase Journal Audit: A successful update commits operations.state = 'applied' and appends a task_updated event to life_events with accurate before_json and after_json snapshots.
  • Idempotent Replay (Scenario R01): Replaying the update with the same operation_id and payload returns the confirmed applied result without sending a duplicate PUT to Radicale.

Notes

  • Preservation uses a dedicated lossless line/component AST (no ical dependency), which #14's spec explicitly permits and which is the stronger guarantee for a preservation-critical path.
  • Cargo.toml intentionally untouched; the corrected author landed in src/cli.rs with the update commands.

Blocked by

  • #010: Conditional Task & Event Creation with Prepared Intent
## Summary Implement conditional update routes (`PATCH /v1/tasks/{id}` / `life task update` and `PATCH /v1/events/{id}` / `life event update`), enforcing mandatory caller-supplied `If-Match` quoted ETags (rejecting missing/weak ETags before network dispatch), immutable item identity, preservation-safe selective iCalendar patching, optimistic concurrency conflict handling (`etag_conflict`), and two-phase commit journaling. --- ## Background & Technical Rationale 1. **Mandatory Caller ETag Preflight (Scenario R03):** A common design flaw in calendar adapters is performing a "silent read-modify-write" shortcut: if a caller omits a version token, the service silently fetches the current server ETag, merges the update, and writes it back. This creates silent data loss and overwrites concurrent external modifications. - The caller MUST explicitly supply the current entity tag (`If-Match: "\"v1\""` or `--etag '"v1"'`). - If the ETag is missing, empty, or weak (`W/`), the request MUST be rejected immediately before any network dispatch with `invalid_input` (HTTP 400 / CLI exit code 2). - Exact quote formatting must be preserved: `"\"v1\""` must not be accidentally stripped or modified. 2. **Immutable Item Identity:** - Canonical item identity is `(collection_id, UID, optional RECURRENCE-ID)`. - The item's UID and collection assignment are strictly immutable. A task or event cannot be moved to a different collection or assigned a new UID via a `PATCH` update. Cross-collection relocation requires creating a new item with an explicit link, followed by deactivating the original. - Attempting to pass a differing `uid`, `collection_id`, or `kind` in the update payload fails validation with `invalid_input`. 3. **Preservation-Safe Selective Patching:** WebDAV/CalDAV lacks native sub-resource patching; an HTTP `PUT` replaces the entire calendar object on the server. To update a single property (e.g. `title` or `deadline`) without corrupting external data: - The service must load the raw unexpanded iCalendar resource corresponding to the confirmed baseline ETag. - It parses the resource components using `ical`. - It modifies ONLY the explicitly requested properties in the target component (e.g. updating `SUMMARY` or `DUE`). - It strictly preserves all unknown properties, custom parameters, alarms (`VALARM`), embedded timezone definitions (`VTIMEZONE`), and unrelated recurrence overrides. - It MUST NEVER re-serialize an existing resource through the new-item generator. If preservation cannot be guaranteed, the operation must refuse with `calendar_unsupported`. 4. **Concurrency Conflict Detection (Scenario C10):** - The service dispatches an HTTP `PUT` to Radicale with the header `If-Match: <caller_etag>`. - If the resource has been modified on Radicale since the caller observed it, Radicale responds with HTTP `412 Precondition Failed`. - The service maps HTTP 412 directly to `etag_conflict` (HTTP 412 / CLI exit code 4). - Under no circumstances may the service automatically reread the new ETag and reapply the patch without explicit caller review and renewed decision. 5. **Two-Phase Journal Durability & Before/After Audit:** - The local operation journal commits `state: 'prepared'` containing the target UID, baseline ETag, and intended representation. - Upon confirmed HTTP `200/204` from Radicale, the service commits a transaction updating `operations.state = 'applied'` and appends a `task_updated` (or `event_updated`) fact to `life_events` with both `before_json` (the baseline item state) and `after_json` (the newly confirmed state). --- ## Exact Technical References & Specifications ### Normative Quotations from Technical Architecture From `docs/technical/cli-api.md` (*Command-to-route table*, lines 33, 38): > | Command | Method and route | Scope | > |---|---|---| > | `life task update ID --etag ETAG --input -` | PATCH /v1/tasks/{id} | write | > | `life event update ID --etag ETAG --input -` | PATCH /v1/events/{id} | write | From `docs/technical/cli-api.md` (*Idempotency and approval*, line 65): > "Creates use conditional remote creation. Existing-item mutations require caller ETag; no hidden read-then-overwrite shortcut. Direct authorized instruction covers the explicit mutation." From `docs/technical/cli-api.md` (*Command payloads and results*, line 83): > | Family | Required / optional input | Response data | > |---|---|---| > | Task update | Nonempty patch of mutable add fields or status/progress; no identity/collection mutation; optional reason,scope | Same mutation result | > | Event update | Nonempty mutable patch or cancellation reason; ETag, recurring scope | Same mutation result; cancellation sets STATUS:CANCELLED, not DELETE | From `docs/technical/calendar.md` (*Discovery and conditional writes*, lines 11–14): > "New objects use `If-None-Match: *`; all existing-object writes/deletions use `If-Match` with the caller's exact ETag. Missing/weak unusable version evidence blocks mutation. HTTP 412 is conflict; never silently retry as overwrite. Obtain the resulting ETag by response or bounded reread; uncertain evidence follows [recovery](timeline-consistency.md#operation-state-machine). > > Store original unexpanded raw resource separately from normalized projection. Same-UID master and detached overrides share one resource; different UIDs/component kinds do not share an ordinary CalDAV object. Existing-object edits patch only requested properties in a preservation-oriented representation, retaining parameters, unknown properties, alarms, timezone definitions and unrelated overrides. Never serialize an external object through the new-item builder. If preservation cannot be guaranteed, refuse with calendar_unsupported. Preserve line folding/escaping semantically; compare normalized intent for recovery while retaining raw bytes for provenance. Raw textual equality alone is not semantic identity." From `docs/technical/domain-model.md` (*Identity and relationships*, line 45): > "Canonical item identity is `(configured_collection_id, UID, optional RECURRENCE-ID)`. Preserve recurrence value type and timezone semantics; a moved occurrence keeps its original recurrence identity. A master and its detached overrides share the same UID and CalDAV resource. A resource href and ETag are separate transport/version data, never the logical identity. The API uses opaque persistent item IDs; callers cannot construct fetch URLs." From `docs/technical/verification.md` (*Recovery & Calendar scenarios*, lines 44, 89): > | ID | Boundary / injected failure | Observable acceptance | > |---|---|---| > | `R03` | Missing If-Match on existing-item mutation | Rejected before dispatch; client cannot silently fetch a substitute baseline | > | `C10` | Concurrent edit yields HTTP 412 | etag_conflict; no overwrite/retry with refreshed ETag without renewed decision | --- ### Request & Error JSON Wire Envelopes #### 1. `PATCH /v1/tasks/{id}` Request Payload (stdin) ```http PATCH /v1/tasks/itm_demo HTTP/1.1 Host: life.example.internal If-Match: "v1" Idempotency-Key: op_demo_update Content-Type: application/json { "title": "Draft finalized portfolio outline", "estimated_minutes": 120, "energy": "high", "reason": "Expanded scope based on team feedback" } ``` #### 2. Conditional CalDAV `PUT` Dispatch with `If-Match` ```http PUT /user/calendars/personal/fictional-demo-uid.ics HTTP/1.1 Host: radicale.example.internal If-Match: "v1" Content-Type: text/calendar; charset=utf-8 Authorization: Basic <base64(user:pass)> BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Radicale//NONSGML Radicale Server//EN BEGIN:VTODO UID:fictional-demo-uid DTSTAMP:20300408T000000Z SUMMARY:Draft finalized portfolio outline STATUS:NEEDS-ACTION CATEGORIES:area-career,life-active DUE;VALUE=DATE:20300412 X-LIFE-ESTIMATED-MINUTES:120 X-LIFE-ENERGY:high X-LIFE-PROJECT-ID:proj-portfolio END:VTODO END:VCALENDAR ``` #### 3. Successful Update Response Envelope (`200 OK`) ```json { "schema_version": 1, "request_id": "req_demo_update", "status": "ok", "data": { "operation_id": "op_demo_update", "operation_state": "applied", "changed": true, "item": { "id": "itm_demo", "kind": "task", "collection_id": "personal", "uid": "fictional-demo-uid", "recurrence_id": null, "etag": "\"v2\"", "title": "Draft finalized portfolio outline", "status": "NEEDS-ACTION", "categories": ["area-career", "life-active"], "strength": "life-active", "deadline": { "kind": "date", "value": "2030-04-12" }, "planned_start": null, "estimated_minutes": 120, "energy": "high", "project_id": "proj-portfolio" }, "context_delivery": "not_requested" }, "warnings": [], "error": null } ``` #### 4. Concurrency Conflict Response (`412 Precondition Failed` mapped to HTTP 412, exit code 4) ```json { "schema_version": 1, "request_id": "req_demo_conflict", "status": "error", "data": { "operation_id": "op_demo_update", "operation_state": "failed" }, "warnings": [], "error": { "code": "etag_conflict", "message": "Item changed; reread before deciding", "retryable": false, "operation_id": "op_demo_update" } } ``` #### 5. Missing ETag Error Envelope (`400 Bad Request`, exit code 2) ```json { "schema_version": 1, "request_id": "req_missing_etag", "status": "error", "data": null, "warnings": [], "error": { "code": "invalid_input", "message": "Existing-item mutation requires caller ETag in If-Match header (--etag); missing or weak version evidence blocks mutation", "retryable": false } } ``` --- ## What to Build (Step-by-Step) ### 1. Patch Validation Engine (`src/domain/patch.rs`) - Model `TaskPatch` and `EventPatch`: - Enforce that at least one mutable field is provided; empty patches return `invalid_input`. - Check for disallowed immutable fields: if payload contains `id`, `uid`, `collection_id`, or `kind` that conflicts with the target item, reject with `invalid_input`. - Validate field modifications: - If `strength` is updated, ensure it is one of `life-active`, `life-queued`, `life-someday`. - If `deadline` or `planned_start` are modified, verify `deadline > planned_start` (strictly later). - If `status` is modified, verify allowed domain transitions. ### 2. ETag Preflight & Header Parsing - Axum middleware / extractor `RequiredIfMatch`: - Inspect HTTP `If-Match` header. - If header is absent or empty, reject immediately with HTTP 400 (`invalid_input`). - If header starts with `W/` (weak ETag), reject immediately. - Ensure entity tag is preserved with surrounding double quotes (`"\"...\""`). - CLI argument `--etag <ETAG>`: - Validate that the user passed a non-empty string. - Send value directly in the HTTP `If-Match` header to the API. ### 3. Preservation-Safe iCalendar Patcher (`src/calendar/preservation.rs`) - Fetch raw baseline resource from Radicale or verify against cached `item_projection.raw_resource`: - Parse raw calendar using `ical::parser::ical::IcalParser`. - Locate the component matching target `UID` (and `RECURRENCE-ID` if an occurrence patch). - Apply patches property by property: - Replace or add `SUMMARY`, `DUE`, `DTSTART`, `CATEGORIES`, `X-LIFE-*`. - Leave all other properties (`VALARM`, `ATTENDEE`, `VTIMEZONE`, custom client extensions) completely intact. - Re-serialize preserving RFC 5545 line folding and CRLF endings. ### 4. Two-Phase Journaling & Remote Dispatch (`src/calendar/mutation.rs`) - Insert `state: 'prepared'` in `operations` table with baseline ETag. - Dispatch HTTP `PUT` to Radicale with `If-Match: <baseline_etag>`. - Conflict handling: - If Radicale returns HTTP 412: - Update `operations` table: `state = 'failed'`. - Return `etag_conflict` (HTTP 412, exit code 4). - **Never** perform automatic refetch or overwrite. - Success handling: - Extract confirmed ETag from HTTP response headers (or bounded GET). - In a single SQLite transaction: - Update `operations.state = 'applied'`. - Append to `life_events` with `kind: 'task_updated'` (or `'event_updated'`), recording `before_json` (baseline) and `after_json` (new item state). - Update `item_projection` row. ### 5. CLI Presentation (`src/cli/task.rs`, `src/cli/event.rs`) - Commands: - `life task update <ID> --etag <ETAG> --input - [--operation-id KEY] [--json]` - `life event update <ID> --etag <ETAG> --input - [--operation-id KEY] [--json]` - In case of `etag_conflict`, print an explicit instruction to rerun `life item get <ID>` and review differences before updating. --- ## Acceptance Criteria - [x] **Mandatory ETag Preflight (Scenario R03):** Calling `life task update` without `--etag` (or HTTP `PATCH` without `If-Match`) is rejected before any network dispatch with exit code 2 (`invalid_input`); the service never performs a silent read-modify-write. - [x] **Weak ETag Rejection:** Supplying a weak ETag (e.g. `W/"v1"`) fails validation before remote dispatch. - [x] **Strict Deadline Ordering:** Updating a deadline to be equal to or earlier than planned_start returns validation error (HTTP 422, exit code 2). - [x] **Immutable Identity Enforcement:** Attempting to alter `uid` or `collection_id` in the patch payload returns validation error (HTTP 422, exit code 2). - [x] **Concurrency Conflict Handling (Scenario C10):** When Radicale responds with HTTP 412 Precondition Failed, the service returns `etag_conflict` with HTTP 412 / exit code 4 without retrying or fetching a fresh ETag. - [x] **Preservation of Unrelated Properties (Scenario C09):** Updating a task title preserves existing alarms (`VALARM`), custom properties, and unknown parameters without round-tripping through the new-item builder. - [x] **Two-Phase Journal Audit:** A successful update commits `operations.state = 'applied'` and appends a `task_updated` event to `life_events` with accurate `before_json` and `after_json` snapshots. - [x] **Idempotent Replay (Scenario R01):** Replaying the update with the same `operation_id` and payload returns the confirmed applied result without sending a duplicate `PUT` to Radicale. --- ## Notes - Preservation uses a dedicated lossless line/component AST (no `ical` dependency), which #14's spec explicitly permits and which is the stronger guarantee for a preservation-critical path. - `Cargo.toml` intentionally untouched; the corrected author landed in `src/cli.rs` with the update commands. ## Blocked by - #010: Conditional Task & Event Creation with Prepared Intent
yuris changed title from Issue #011: Conditional Item Updates with Quoted ETag Preflight to Conditional Item Updates with Quoted ETag Preflight 2026-09-22 20:02:39 +00:00
yuris closed this issue 2026-09-22 21:36:58 +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#11
No description provided.