feat(api): conditional task and event creation with prepared intent #46
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-10-creation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/tasksandPOST /v1/events. Validation and normalization, then the two-phase commit boundary:operationsrow withstate = '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);applied, alife_eventsfact (task_created/event_created,confirmed_service) is appended and the item projection is upserted, all in one SQLite transaction;needs_reconciliationin apartialenvelope instead of being claimed as committed.life-active/life-queued/life-somedayinCATEGORIES(else HTTP 422 / exit 2),STATUS:NEEDS-ACTIONby default,DUEstrictly later thanDTSTARTwith date-only values staying date-only and no dates invented for undated tasks, andestimated_minutes/energy/project_idmapped toX-LIFE-ESTIMATED-MINUTES/X-LIFE-ENERGY/X-LIFE-PROJECT-ID.start/endwithend > start,statusofCONFIRMED(default) orTENTATIVE,transparencyofOPAQUE(default) orTRANSPARENT, and optionalrelated_task_idwritten asRELATED-TO.Idempotency-Keywith the same semantic payload returns the recorded outcome withchanged: falseand no second remote write; a changed payload under the same key isidempotency_conflict(HTTP 409 / exit 4). A412from the backend isetag_conflict(HTTP 409 / exit 4) withoperations.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 theicalendarcrate, asdocs/technical/calendar.mdrequires — 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 foldlife-activeinto an unrelated category),CATEGORIESis emitted one property per value, which RFC 5545 permits;src/calendar/report.rsnow unions repeatedCATEGORIESproperties so the value survives the round trip.src/calendar/client.rs:put_new_resource— a conditionalPUTon the existing pooled transport, so there is no second HTTP path.src/history/journal.rs:commit_operation_resultoptionally 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, plusvalidation_failed/idempotency_conflict/etag_conflict/journal_unavailablefailures and an operation id on the error envelope.src/client.rs,src/cli.rs:life task add --input -andlife event add --input -(--operation-idoptional, generated when absent;--collection-id/--titleoverride the payload;--jsonprints 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 reportserror.operation_idand the operation stays nameable; a method the client cannot express is refused rather than downgraded to GET.deny_unknown_fields, asdocs/technical/cli-api.mdrequires): a misspelleddead_lineis a 400invalid_inputwith 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 onePUTcarryingIf-None-Match: *; the generated VTODO/VEVENT content includingDUE;VALUE=DATE,STATUS:NEEDS-ACTION,TRANSP,RELATED-TOand theX-LIFE-*properties; thatoperations.stateread from a separate SQLite connection ispreparedat the moment the create is dispatched; thatappliedplus exactly onelife_eventsfact plus one projection row exist afterwards; R01 replay returns the recorded item withchanged: falseand no secondPUT; R02 returnsidempotency_conflict;412returnsetag_conflictwith afailedoperation, no fact and no retry; a create response without an ETag performs exactly one bounded reread; a 500 is reported asneeds_reconciliationrather than committed; a missing journal dispatches nothing; strength, temporal-ordering, collection and missing-header rejections; event creation, defaults andend > start; and that a created task reads back throughGET /v1/taskswith 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--jsonoutput, the exit codes 0 / 2 / 4, and a hanging daemon that proves a timed-out write exits 6 witherror.operation_idset to the locally known operation key.src/client.rscover the operation id reported on a failed write and the refusal of an unparseable HTTP method;tests/creation_tests.rscovers unknown request members (400, zero dispatches).nix develop --command cargo clippy --all-targets -- -D warningsis clean andcargo fmt --checkreports no diff.Related Issues
Closes #10
Coordinator review — CHANGES REQUESTED
Reviewed the full diff at
fea1e62and ran it myself:cargo test124 passed / 0 failed (incl. 18 creation + 7 cli_creation),cargo clippy --all-targets -- -D warningsclean,cargo fmt --checkclean.Genuinely good:
preparedrow committed before any dispatch; the 412 path is definite-failure withoperations.state='failed'and no UID retry;replay_responsecorrectly returns the recorded outcome forapplied/failedand a disclosedpartial(exit 6, no second write) forprepared/unknown/needs_reconciliation; unconfirmed outcomes becomeneeds_reconciliationrather 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-CATEGORIESdecision 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.mdrequires: "Client timeout after transmitting a write exits 6 with the locally known operation ID".src/cli.rs::run_creation_commandgeneratesop_<uuid>and passes it toclient::post_envelope, butrequest_envelope→transport_envelopeonly ever sets a locally generated request id — the operation ID is never placed on the envelope. So a timeout (request_pending, exit 6) ordependency_unavailableleaves the caller with no way to runlife 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 seterror.operation_id(the envelope already has the field).2. (moderate) Write bodies silently ignore unknown members.
TaskCreateRequest/EventCreateRequestuse plainDeserialize(see the doc comment "Unknown members are ignored"), which contradictsdocs/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 howsrc/config.rsalready treats config) withinvalid_input400 / exit 2 and zero dispatch. Fingerprint stability does not require accepting them — strict rejection is deterministic too.3. (minor) Silent method fallback.
request_envelopedoesreqwest::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 atransport_error/invalid_inputinstead of defaulting.Please also add tests: (a) a post-transmission timeout on
POST /v1/tasksreturns exit 6 and an envelope whoseerror.operation_idequals the CLI-generated/locally known operation ID; (b)POST /v1/taskswith an unknown field (dead_line) returns 400invalid_inputand performs zero dispatches; (c) the method guard rejects an unknown method rather than issuing a GET.Coordinator re-review — APPROVED
Verified
4ee58c3myself (read the diff, ran the suite):request_envelopenow takesoperation_id: Option<&str>andtransport_envelopesetserror.operation_idon every client-side failure, so a transmitted write that times out (exit 6,request_pending) or is unreachable (exit 5) still names the operation forlife operation get <id>. Read commands passNone. Covered by a hanging-daemon test asserting exit 6 pluserror.operation_id == "op-cli-timeout".deny_unknown_fieldson both create DTOs → 400invalid_input/ exit 2, zero dispatches, nooperationsrow; fingerprint semantics unchanged.transport_errorinstead of silently becoming GET.Independent verification on
4ee58c3:cargo test128 passed / 0 failed across 11 suites;cargo clippy --all-targets -- -D warnings0 errors;cargo fmt --checkclean.Merging.
feat(api): conditional task and event creation with prepared intent (#10)to feat(api): conditional task and event creation with prepared intent