Conditional Task & Event Creation with Prepared Intent #10

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

Summary

Implement conditional creation for tasks (POST /v1/tasks / life task add) and calendar events (POST /v1/events / life event add), enforcing two-phase commit journaling (local prepared intent committed before network dispatch), CalDAV If-None-Match: * preconditions against Radicale, mandatory task commitment strength categories, and atomic timeline event logging.


Background & Technical Rationale

  1. Two-Phase Commit Protocol for Remote Writes:
    Directly sending a network write without a durable local record invites split-brain states and phantom updates if the client crashes or network drops mid-flight. To guarantee operational durability and crash recovery, item creation follows a strict two-phase commit pattern:

    • Phase 1 (Local Intent): The service validates input schemas, verifies that the target collection exists, generates a deterministic UID, computes the canonical request fingerprint, and commits a record to the SQLite operations table with state: 'prepared' and intent_json. No network transmission occurs while this database transaction is open.
    • Phase 2 (Conditional Remote Dispatch): The service issues an HTTP PUT to Radicale with If-None-Match: *. This ensures that if a resource with the generated UID already exists, Radicale rejects the write with HTTP 412 Precondition Failed.
    • Phase 3 (Result Journal Commit): Upon receiving HTTP 201 Created with a confirmed resource ETag, the service commits a local transaction that:
      1. Transitions operations.state to 'applied' and stores result_json.
      2. Appends an immutable timeline event to life_events with kind: 'task_created' (or 'event_created'), evidence_class: 'confirmed_service', and the confirmed ETag.
      3. Inserts the projected item into item_projection.
      4. Enqueues an outbox entry if background context processing was requested.
    • Phase 4 (Client Response): Returns HTTP 201 Created with the confirmed item, ETag, and operation ID.
  2. Task Domain Invariants & Commitment Strengths:

    • Mandatory Strength: Tasks require an explicit commitment strength in CATEGORIES: exactly one of life-active, life-queued, or life-someday. New actionable tasks without a valid strength category MUST be rejected with invalid_input (HTTP 422 / CLI exit code 2).
    • Lifecycle Status: New tasks default to STATUS:NEEDS-ACTION.
    • Deadlines vs Work Periods: deadline maps to iCalendar DUE. planned_start maps to DTSTART. If both are provided, DUE must be strictly later than DTSTART as required by RFC 5545 and docs/technical/domain-model.md:35; equal or inverted values are rejected with invalid_input. Incompatible types (e.g. comparing a date-only value with an instant without timezone context) are rejected. Undated tasks must not have arbitrary default dates invented.
    • Preservation-Safe Extended Properties: Optional user-asserted metadata maps to explicit custom properties:
      • estimated_minutes -> X-LIFE-ESTIMATED-MINUTES (integer)
      • energy -> X-LIFE-ENERGY (low, medium, high)
      • project_id -> X-LIFE-PROJECT-ID (string)
  3. Event Domain Invariants:

    • Mandatory start and end instants or dates; end must be strictly after start.
    • status is CONFIRMED or TENTATIVE.
    • transparency is OPAQUE (default) or TRANSPARENT.
    • Optional related_task_id maps to iCalendar RELATED-TO.
  4. Concurrency & Conflict Semantics:
    If Radicale returns HTTP 412, the service maps it to etag_conflict (HTTP 409 / CLI exit code 4). The service must never automatically retry by fabricating a new UID or overwriting existing data without caller intervention.


Exact Technical References & Specifications

Normative Quotations from Technical Architecture

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

Command Method and route Scope
life task add --input - POST /v1/tasks write
life event add --input - POST /v1/events write

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

"All state-changing requests require Idempotency-Key. This includes proposals, context, sync and timeline captures, not read-only POST searches/judgments/reviews. CLI generates a high-entropy operation ID before transmission when --operation-id is absent and includes it in success/failure data. Key scope is the single-user service identity, including rotated tokens for that identity. Canonical request fingerprint includes method, route, semantic JSON body, baseline ETags and approval digest; it excludes transport request IDs and secret tokens.

Same key and fingerprint returns the recorded outcome or current pending state without another remote mutation. Different fingerprint returns idempotency_conflict. Keep operation identity/fingerprint for the retained journal lifetime; never expire it silently while clients can retry...

Creates use conditional remote creation. Existing-item mutations require caller ETag; no hidden read-then-overwrite shortcut."

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

"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."

From docs/technical/domain-model.md (Task axes, lines 22–25):

"VTODO STATUS is exactly NEEDS-ACTION, IN-PROCESS, COMPLETED or CANCELLED. A missing external STATUS normalizes to unspecified, not inferred completed. New Life tasks default to NEEDS-ACTION. Blockers are explicit structured user facts in linked context, never a fabricated standard STATUS named BLOCKED.

Commitment strength is separate: at most one of life-active, life-queued, life-someday in CATEGORIES. New actionable tasks require an explicit strength; agent capture requests clarification when unclear. External objects without these categories have unknown strength and are not automatically activated. Multiple strength categories are an invalid-policy observation requiring correction before planning. Other categories are preserved. Life-area labels such as area-career and area-health are independent and can coexist with any lifecycle or strength."

From docs/technical/timeline-consistency.md (Operation state machine, line 129):

"Normal path: validate authorization/input and baseline → commit prepared intent including UID, target, before-state, intended patched representation and precondition → conditional remote write → transaction appending result and updating operation/projection/outbox → report result."

From docs/technical/verification.md (Workflow scenarios, Scenario W01, line 15):

ID Requirement and specification Input / action Observable acceptance
W01 Structured capture Explicit task capture with collection, strength and date-only deadline; operation ID One conditional create, confirmed journal fact, returned item/ETag; no NL agent loop

Request & Response Wire Envelopes

1. POST /v1/tasks Request Payload (stdin)

{
  "collection_id": "personal",
  "title": "Draft fictional portfolio outline",
  "strength": "life-active",
  "deadline": {
    "kind": "date",
    "value": "2030-04-12"
  },
  "categories": [
    "area-career"
  ],
  "estimated_minutes": 90,
  "energy": "medium",
  "project_id": "proj-portfolio"
}

2. Generated iCalendar Object Transmitted via CalDAV PUT

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

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

3. Successful HTTP 201 Response Envelope

{
  "schema_version": 1,
  "request_id": "req_demo_create",
  "status": "ok",
  "data": {
    "operation_id": "op_demo_create",
    "operation_state": "applied",
    "changed": true,
    "item": {
      "id": "itm_demo",
      "kind": "task",
      "collection_id": "personal",
      "uid": "fictional-demo-uid",
      "recurrence_id": null,
      "etag": "\"v1\"",
      "title": "Draft fictional 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": 90,
      "energy": "medium",
      "project_id": "proj-portfolio"
    },
    "context_delivery": "not_requested"
  },
  "warnings": [],
  "error": null
}

4. Precondition Conflict (HTTP 412 mapped to HTTP 409 / Exit code 4)

{
  "schema_version": 1,
  "request_id": "req_create_conflict",
  "status": "error",
  "data": {
    "operation_id": "op_demo_create",
    "operation_state": "failed"
  },
  "warnings": [],
  "error": {
    "code": "etag_conflict",
    "message": "Resource with target UID already exists on remote calendar backend",
    "retryable": false,
    "operation_id": "op_demo_create"
  }
}

What to Build (Step-by-Step)

1. Domain Validation & Serialization (src/domain/task.rs, src/domain/event.rs)

  • Task creation validator:
    • Verify title is non-empty after trimming.
    • Verify strength is provided and is one of life-active, life-queued, life-someday.
    • Ensure CATEGORIES contains the strength tag, merging with any provided area tags.
    • If deadline (DUE) and planned_start (DTSTART) are both present, verify deadline > planned_start (strictly later).
  • iCalendar resource builder using icalendar:
    • Construct a new VCALENDAR with VERSION:2.0.
    • Add VTODO component with UID, DTSTAMP, SUMMARY, STATUS:NEEDS-ACTION, CATEGORIES, DUE, and custom X-LIFE-* properties.
    • Ensure proper line folding and CRLF endings.
  • Event creation validator:
    • Verify start < end.
    • Generate VEVENT with DTSTART, DTEND, SUMMARY, STATUS:CONFIRMED (or TENTATIVE), and TRANSP:OPAQUE (or TRANSPARENT).

2. Two-Phase Journal Coordination (src/db/operations.rs, src/db/events.rs)

  • In operations:
    • Compute request hash over normalized JSON payload.
    • Insert row: (operation_id, request_hash, schema_version=1, state='prepared', actor, prepared_at=now, updated_at=now, intent_json, baseline_json=NULL, result_json=NULL).
  • If an operation with operation_id already exists:
    • If request hash matches: replay recorded result without remote dispatch.
    • If request hash differs: return idempotency_conflict (HTTP 409, exit code 4).

3. Conditional Remote Dispatch (src/calendar/mutation.rs)

  • Execute CalDAV PUT to /{collection_url}/{uid}.ics:
    • Header: If-None-Match: *.
    • Body: Serialized iCalendar string.
  • If Radicale returns HTTP 201 Created:
    • Extract ETag header. If missing, perform a single bounded HEAD/PROPFIND read to obtain the confirmed ETag.
  • If Radicale returns HTTP 412 Precondition Failed:
    • Update operations to state: 'failed'.
    • Return etag_conflict error.

4. Result Transaction Commit

  • In a single SQLite transaction:
    • Update operations record: state = 'applied', result_json = <serialized response>.
    • Insert into life_events:
      • event_id: Generated UUID / CUID.
      • dedupe_key: ${operation_id}:applied:create.
      • kind: 'task_created' (or 'event_created').
      • evidence_class: 'confirmed_service'.
      • after_json: Serialized normalized Item.
      • operation_id: Reference to parent operation.
    • Upsert into item_projection.

5. API Handlers & CLI Commands (src/api/tasks.rs, src/cli/task.rs)

  • Axum handlers for POST /v1/tasks and POST /v1/events:
    • Enforce mandatory Idempotency-Key header; reject with HTTP 400 invalid_input if absent. All database writes go through the dedicated blocking worker.
    • Parse stdin JSON payload.
  • CLI commands:
    • life task add --input - [--operation-id KEY] [--json]
    • life event add --input - [--operation-id KEY] [--json]

Acceptance Criteria

  • Strength Requirement Enforcement: Creating a task without a valid commitment strength (life-active, life-queued, life-someday) returns validation error (HTTP 422, exit code 2).
  • Precondition Header Verification: The HTTP PUT request to Radicale contains If-None-Match: *.
  • Two-Phase Journal Durability: SQLite operations table contains a 'prepared' row before network dispatch; upon HTTP 201 from Radicale, operations.state is updated to 'applied' and an event is appended to life_events.
  • Structured Capture Acceptance (Scenario W01): Executing task creation with collection, strength, date-only deadline, and operation ID results in exactly one conditional create, confirmed journal fact, and returned item with confirmed ETag.
  • Conflict Handling: If Radicale returns HTTP 412 (resource already exists), the service returns etag_conflict (HTTP 409 / exit code 4) and sets operations.state = 'failed' without retrying with a new UID.
  • Idempotency Replay (Scenario R01): Repeating task creation with the same operation_id and payload returns the previously recorded result with changed: false (or original applied response) without sending a second PUT to Radicale.
  • Idempotency Conflict (Scenario R02): Submitting a request with the same operation_id but a modified payload returns idempotency_conflict (HTTP 409, exit code 4).
  • Preservation of Custom Properties: estimated_minutes, energy, and project_id are written as X-LIFE-ESTIMATED-MINUTES, X-LIFE-ENERGY, and X-LIFE-PROJECT-ID in the generated iCalendar resource.
  • Strict Deadline Temporal Ordering: Creating a task with deadline (DUE) equal to or earlier than planned_start (DTSTART) returns validation error (HTTP 422, exit code 2).
  • Mandatory Idempotency Header: Calling POST /v1/tasks or POST /v1/events without an Idempotency-Key header returns HTTP 400 invalid_input (exit code 2).

Notes

  • Review round on PR #46 added: operation ID preserved on transport failures (error.operation_id), deny_unknown_fields on create payloads, and an explicit method guard.

Blocked by

  • #005: Two-Phase Commit Journaling & Idempotency Fingerprinting
  • #009: Agenda & Tasks List CLI/API with Completeness Metrics
## Summary Implement conditional creation for tasks (`POST /v1/tasks` / `life task add`) and calendar events (`POST /v1/events` / `life event add`), enforcing two-phase commit journaling (local prepared intent committed before network dispatch), CalDAV `If-None-Match: *` preconditions against Radicale, mandatory task commitment strength categories, and atomic timeline event logging. --- ## Background & Technical Rationale 1. **Two-Phase Commit Protocol for Remote Writes:** Directly sending a network write without a durable local record invites split-brain states and phantom updates if the client crashes or network drops mid-flight. To guarantee operational durability and crash recovery, item creation follows a strict two-phase commit pattern: - **Phase 1 (Local Intent):** The service validates input schemas, verifies that the target collection exists, generates a deterministic UID, computes the canonical request fingerprint, and commits a record to the SQLite `operations` table with `state: 'prepared'` and `intent_json`. No network transmission occurs while this database transaction is open. - **Phase 2 (Conditional Remote Dispatch):** The service issues an HTTP `PUT` to Radicale with `If-None-Match: *`. This ensures that if a resource with the generated UID already exists, Radicale rejects the write with HTTP `412 Precondition Failed`. - **Phase 3 (Result Journal Commit):** Upon receiving HTTP `201 Created` with a confirmed resource `ETag`, the service commits a local transaction that: 1. Transitions `operations.state` to `'applied'` and stores `result_json`. 2. Appends an immutable timeline event to `life_events` with `kind: 'task_created'` (or `'event_created'`), `evidence_class: 'confirmed_service'`, and the confirmed ETag. 3. Inserts the projected item into `item_projection`. 4. Enqueues an outbox entry if background context processing was requested. - **Phase 4 (Client Response):** Returns HTTP `201 Created` with the confirmed item, ETag, and operation ID. 2. **Task Domain Invariants & Commitment Strengths:** - **Mandatory Strength:** Tasks require an explicit commitment strength in `CATEGORIES`: exactly one of `life-active`, `life-queued`, or `life-someday`. New actionable tasks without a valid strength category MUST be rejected with `invalid_input` (HTTP 422 / CLI exit code 2). - **Lifecycle Status:** New tasks default to `STATUS:NEEDS-ACTION`. - **Deadlines vs Work Periods:** `deadline` maps to iCalendar `DUE`. `planned_start` maps to `DTSTART`. If both are provided, `DUE` must be strictly later than `DTSTART` as required by RFC 5545 and `docs/technical/domain-model.md:35`; equal or inverted values are rejected with `invalid_input`. Incompatible types (e.g. comparing a date-only value with an instant without timezone context) are rejected. Undated tasks must not have arbitrary default dates invented. - **Preservation-Safe Extended Properties:** Optional user-asserted metadata maps to explicit custom properties: - `estimated_minutes` -> `X-LIFE-ESTIMATED-MINUTES` (integer) - `energy` -> `X-LIFE-ENERGY` (`low`, `medium`, `high`) - `project_id` -> `X-LIFE-PROJECT-ID` (string) 3. **Event Domain Invariants:** - Mandatory `start` and `end` instants or dates; `end` must be strictly after `start`. - `status` is `CONFIRMED` or `TENTATIVE`. - `transparency` is `OPAQUE` (default) or `TRANSPARENT`. - Optional `related_task_id` maps to iCalendar `RELATED-TO`. 4. **Concurrency & Conflict Semantics:** If Radicale returns HTTP 412, the service maps it to `etag_conflict` (HTTP 409 / CLI exit code 4). The service must never automatically retry by fabricating a new UID or overwriting existing data without caller intervention. --- ## Exact Technical References & Specifications ### Normative Quotations from Technical Architecture From `docs/technical/cli-api.md` (*Command-to-route table*, lines 32, 37): > | Command | Method and route | Scope | > |---|---|---| > | `life task add --input -` | POST /v1/tasks | write | > | `life event add --input -` | POST /v1/events | write | From `docs/technical/cli-api.md` (*Idempotency and approval*, lines 61–65): > "All state-changing requests require `Idempotency-Key`. This includes proposals, context, sync and timeline captures, not read-only POST searches/judgments/reviews. CLI generates a high-entropy operation ID before transmission when `--operation-id` is absent and includes it in success/failure data. Key scope is the single-user service identity, including rotated tokens for that identity. Canonical request fingerprint includes method, route, semantic JSON body, baseline ETags and approval digest; it excludes transport request IDs and secret tokens. > > Same key and fingerprint returns the recorded outcome or current pending state without another remote mutation. Different fingerprint returns `idempotency_conflict`. Keep operation identity/fingerprint for the retained journal lifetime; never expire it silently while clients can retry... > > Creates use conditional remote creation. Existing-item mutations require caller ETag; no hidden read-then-overwrite shortcut." From `docs/technical/calendar.md` (*Discovery and conditional writes*, lines 11–12): > "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." From `docs/technical/domain-model.md` (*Task axes*, lines 22–25): > "VTODO `STATUS` is exactly `NEEDS-ACTION`, `IN-PROCESS`, `COMPLETED` or `CANCELLED`. A missing external STATUS normalizes to unspecified, not inferred completed. New Life tasks default to NEEDS-ACTION. Blockers are explicit structured user facts in linked context, never a fabricated standard STATUS named BLOCKED. > > Commitment strength is separate: at most one of `life-active`, `life-queued`, `life-someday` in CATEGORIES. New actionable tasks require an explicit strength; agent capture requests clarification when unclear. External objects without these categories have unknown strength and are not automatically activated. Multiple strength categories are an invalid-policy observation requiring correction before planning. Other categories are preserved. Life-area labels such as `area-career` and `area-health` are independent and can coexist with any lifecycle or strength." From `docs/technical/timeline-consistency.md` (*Operation state machine*, line 129): > "Normal path: validate authorization/input and baseline → commit prepared intent including UID, target, before-state, intended patched representation and precondition → conditional remote write → transaction appending result and updating operation/projection/outbox → report result." From `docs/technical/verification.md` (*Workflow scenarios*, Scenario W01, line 15): > | ID | Requirement and specification | Input / action | Observable acceptance | > |---|---|---|---| > | `W01` | [Structured capture](workflows.md#capture-and-retrieval) | Explicit task capture with collection, strength and date-only deadline; operation ID | One conditional create, confirmed journal fact, returned item/ETag; no NL agent loop | --- ### Request & Response Wire Envelopes #### 1. `POST /v1/tasks` Request Payload (stdin) ```json { "collection_id": "personal", "title": "Draft fictional portfolio outline", "strength": "life-active", "deadline": { "kind": "date", "value": "2030-04-12" }, "categories": [ "area-career" ], "estimated_minutes": 90, "energy": "medium", "project_id": "proj-portfolio" } ``` #### 2. Generated iCalendar Object Transmitted via CalDAV `PUT` ```http PUT /user/calendars/personal/fictional-demo-uid.ics HTTP/1.1 Host: radicale.example.internal If-None-Match: * Content-Type: text/calendar; charset=utf-8 Authorization: Basic <base64(user:pass)> BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Life service//EN BEGIN:VTODO UID:fictional-demo-uid DTSTAMP:20300408T000000Z SUMMARY:Draft fictional portfolio outline STATUS:NEEDS-ACTION CATEGORIES:area-career,life-active DUE;VALUE=DATE:20300412 X-LIFE-ESTIMATED-MINUTES:90 X-LIFE-ENERGY:medium X-LIFE-PROJECT-ID:proj-portfolio END:VTODO END:VCALENDAR ``` #### 3. Successful HTTP 201 Response Envelope ```json { "schema_version": 1, "request_id": "req_demo_create", "status": "ok", "data": { "operation_id": "op_demo_create", "operation_state": "applied", "changed": true, "item": { "id": "itm_demo", "kind": "task", "collection_id": "personal", "uid": "fictional-demo-uid", "recurrence_id": null, "etag": "\"v1\"", "title": "Draft fictional 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": 90, "energy": "medium", "project_id": "proj-portfolio" }, "context_delivery": "not_requested" }, "warnings": [], "error": null } ``` #### 4. Precondition Conflict (HTTP 412 mapped to HTTP 409 / Exit code 4) ```json { "schema_version": 1, "request_id": "req_create_conflict", "status": "error", "data": { "operation_id": "op_demo_create", "operation_state": "failed" }, "warnings": [], "error": { "code": "etag_conflict", "message": "Resource with target UID already exists on remote calendar backend", "retryable": false, "operation_id": "op_demo_create" } } ``` --- ## What to Build (Step-by-Step) ### 1. Domain Validation & Serialization (`src/domain/task.rs`, `src/domain/event.rs`) - Task creation validator: - Verify `title` is non-empty after trimming. - Verify `strength` is provided and is one of `life-active`, `life-queued`, `life-someday`. - Ensure `CATEGORIES` contains the strength tag, merging with any provided area tags. - If `deadline` (`DUE`) and `planned_start` (`DTSTART`) are both present, verify `deadline > planned_start` (strictly later). - iCalendar resource builder using `icalendar`: - Construct a new `VCALENDAR` with `VERSION:2.0`. - Add `VTODO` component with `UID`, `DTSTAMP`, `SUMMARY`, `STATUS:NEEDS-ACTION`, `CATEGORIES`, `DUE`, and custom `X-LIFE-*` properties. - Ensure proper line folding and CRLF endings. - Event creation validator: - Verify `start < end`. - Generate `VEVENT` with `DTSTART`, `DTEND`, `SUMMARY`, `STATUS:CONFIRMED` (or `TENTATIVE`), and `TRANSP:OPAQUE` (or `TRANSPARENT`). ### 2. Two-Phase Journal Coordination (`src/db/operations.rs`, `src/db/events.rs`) - In `operations`: - Compute request hash over normalized JSON payload. - Insert row: `(operation_id, request_hash, schema_version=1, state='prepared', actor, prepared_at=now, updated_at=now, intent_json, baseline_json=NULL, result_json=NULL)`. - If an operation with `operation_id` already exists: - If request hash matches: replay recorded result without remote dispatch. - If request hash differs: return `idempotency_conflict` (HTTP 409, exit code 4). ### 3. Conditional Remote Dispatch (`src/calendar/mutation.rs`) - Execute CalDAV `PUT` to `/{collection_url}/{uid}.ics`: - Header: `If-None-Match: *`. - Body: Serialized iCalendar string. - If Radicale returns HTTP 201 Created: - Extract `ETag` header. If missing, perform a single bounded `HEAD`/`PROPFIND` read to obtain the confirmed ETag. - If Radicale returns HTTP 412 Precondition Failed: - Update `operations` to `state: 'failed'`. - Return `etag_conflict` error. ### 4. Result Transaction Commit - In a single SQLite transaction: - Update `operations` record: `state = 'applied'`, `result_json = <serialized response>`. - Insert into `life_events`: - `event_id`: Generated UUID / CUID. - `dedupe_key`: `${operation_id}:applied:create`. - `kind`: `'task_created'` (or `'event_created'`). - `evidence_class`: `'confirmed_service'`. - `after_json`: Serialized normalized `Item`. - `operation_id`: Reference to parent operation. - Upsert into `item_projection`. ### 5. API Handlers & CLI Commands (`src/api/tasks.rs`, `src/cli/task.rs`) - Axum handlers for `POST /v1/tasks` and `POST /v1/events`: - Enforce mandatory `Idempotency-Key` header; reject with HTTP 400 `invalid_input` if absent. All database writes go through the dedicated blocking worker. - Parse stdin JSON payload. - CLI commands: - `life task add --input - [--operation-id KEY] [--json]` - `life event add --input - [--operation-id KEY] [--json]` --- ## Acceptance Criteria - [x] **Strength Requirement Enforcement:** Creating a task without a valid commitment strength (`life-active`, `life-queued`, `life-someday`) returns validation error (HTTP 422, exit code 2). - [x] **Precondition Header Verification:** The HTTP `PUT` request to Radicale contains `If-None-Match: *`. - [x] **Two-Phase Journal Durability:** SQLite `operations` table contains a `'prepared'` row before network dispatch; upon HTTP 201 from Radicale, `operations.state` is updated to `'applied'` and an event is appended to `life_events`. - [x] **Structured Capture Acceptance (Scenario W01):** Executing task creation with collection, strength, date-only deadline, and operation ID results in exactly one conditional create, confirmed journal fact, and returned item with confirmed ETag. - [x] **Conflict Handling:** If Radicale returns HTTP 412 (resource already exists), the service returns `etag_conflict` (HTTP 409 / exit code 4) and sets `operations.state = 'failed'` without retrying with a new UID. - [x] **Idempotency Replay (Scenario R01):** Repeating task creation with the same `operation_id` and payload returns the previously recorded result with `changed: false` (or original applied response) without sending a second `PUT` to Radicale. - [x] **Idempotency Conflict (Scenario R02):** Submitting a request with the same `operation_id` but a modified payload returns `idempotency_conflict` (HTTP 409, exit code 4). - [x] **Preservation of Custom Properties:** `estimated_minutes`, `energy`, and `project_id` are written as `X-LIFE-ESTIMATED-MINUTES`, `X-LIFE-ENERGY`, and `X-LIFE-PROJECT-ID` in the generated iCalendar resource. - [x] **Strict Deadline Temporal Ordering:** Creating a task with deadline (DUE) equal to or earlier than planned_start (DTSTART) returns validation error (HTTP 422, exit code 2). - [x] **Mandatory Idempotency Header:** Calling `POST /v1/tasks` or `POST /v1/events` without an `Idempotency-Key` header returns HTTP 400 `invalid_input` (exit code 2). --- ## Notes - Review round on PR #46 added: operation ID preserved on transport failures (`error.operation_id`), `deny_unknown_fields` on create payloads, and an explicit method guard. ## Blocked by - #005: Two-Phase Commit Journaling & Idempotency Fingerprinting - #009: Agenda & Tasks List CLI/API with Completeness Metrics
yuris changed title from Issue #010: Conditional Task & Event Creation with Prepared Intent to Conditional Task & Event Creation with Prepared Intent 2026-09-22 20:02:39 +00:00
yuris closed this issue 2026-09-22 21:15:10 +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#10
No description provided.