feat(api): implement life event cancel / POST /v1/events/{id}/cancel #80
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-75-event-cancel"
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
docs/technical/cli-api.md's command-to-route table advertiseslife event cancel ID --etag ETAG --input -→POST /v1/events/{id}/cancel(write), but neitherhalf was reachable: the client had no dispatch arm for
event canceland the router registered noroute, so the command answered
dependency_unavailable(exit 5). #32 moved the row to the skill'snot-implemented table so the package stopped over-claiming; this change makes the contract real.
Cancellation is the one event transition with a documented non-obvious semantic — it sets
STATUS:CANCELLED, neverDELETE— and it is the same distinction #12 draws for tasks. Without it,an agent told "cancel that block" has no correct command and would reasonably reach for a free-form
status patch, which is exactly the edit the domain model avoids.
Changes
src/domain/event_lifecycle.rs(new): the VEVENT lifecycle vocabulary —TENTATIVE/CONFIRMED/CANCELLED, with noCOMPLETED— andplan_cancel, which decides the transitionagainst the state the live component actually holds. A missing
STATUSis RFC 5545'sCONFIRMEDdefault (never read as cancelled); an already-
CANCELLEDevent plans a no-op; aSTATUSoutsidethe three values is refused as
calendar_unsupportedrather than coerced. No un-cancel is planned:the domain model names none.
src/api/lifecycle.rs:POST /v1/events/{id}/cancel(run_event_cancel) reuses the tasktransitions' two-phase discipline verbatim — mandatory quoted strong
If-Matchpreflight, preparedintent durable before dispatch, conditional
PUT, and one transaction that moves the operation toapplied, appends theevent_cancelledfact withbefore_json/after_jsonand upserts theprojection. The confirmed-fact commit path was generalised over a
Transitiondescriptor(component kind, fact kind, route family, verb) so events and tasks share it instead of duplicating
it; the task paths' behaviour is otherwise unchanged.
src/api/mod.rs: the route registration and its handler. The scope-table entry forPOST /v1/events/{id}/cancel(write) already existed insrc/api/auth.rsand is unchanged.src/cli.rs:EventSubcommands::Cancelnow dispatches instead of falling through tonot_implemented.run_lifecycle_commandtakes the route family and the verb, so tasks and eventsshare one body; the three task verbs pass
LifecycleAction::*.name(), so the domain vocabularystill names them rather than string literals at the call site.
skills/life/references/commands.md: thelife event cancelrow moves from the not-implementedtable to the implemented writes table, and
tests/skill_package_tests.rs's not-implemented countgoes 2 → 1 in the same commit.
Request surface
POST /v1/events/{id}/cancel,If-Match: "<quoted strong etag>",Idempotency-Keyrequired, bodydeny_unknown_fieldswith optionalreason, optionaloccurred_at(recorded as the fact'soccurred_at, defaulting to the instant of the action) and optionalscope(series|occurrence).Specification note
docs/technical/cli-api.mddocuments the route, the scope and theSTATUS:CANCELLED-not-DELETEsemantic, but is silent on the cancellation body members;
reason,occurred_atandscopeare thesurface chosen here.
docs/is excluded from version control in this clone (.git/info/excludelistsdocs/), so no specification page can travel with this PR; the choice is disclosed here rather thanappended to an untracked page.
Verification
Run in the worktree, on the final commit:
cargo test— whole suite: 766 passed, 0 failed, across 40 test binaries plus the doc-testtarget (41
test resultlines). Baselinemainis 743; the delta is 23 new tests (20 integrationtests in
tests/issue75_event_cancel.rs, 3 unit tests insrc/domain/event_lifecycle.rs).cargo clippy --all-targets -- -D warnings— clean, 0 errors.cargo fmt --check— clean.tests/issue75_event_cancel.rsdrives real HTTP throughrouter_with_stateagainst a mock Radicalethat captures every request (method, path,
If-Match, exact bytes) and reads the journal back fromSQLite on a separate connection, so each criterion below is asserted on observable behaviour rather
than on the code that produces it.
Per acceptance criterion:
life event cancel ID --etag '"v1"' --input - --jsoncancels the event;STATUS:CANCELLEDin theresource, resource still present, exit 0 — met.
cancelling_an_event_sets_status_cancelled_and_never_deletes_it(route: onePUT, 0DELETE, thePUTbody carriesSTATUS:CANCELLEDplus the external client'sX-CUSTOM-CLIENT-FLAGandVALARM, the resource is still stored,data.item.status == "CANCELLED",changed: true, and theoperation was already
preparedat dispatch time).the_cli_cancels_an_event_over_the_transition_route(CLI:
POST /v1/events/{id}/cancel, exactIf-Match, generatedop_key, exit 0).*/ multi-tag ETag → 400invalid_input/ exit 2, zero backendrequests, no journal row — met.
every_unusable_etag_is_refused_before_any_dispatchcovers allsix forms (absent, empty,
W/"v1",v1,*,"v1", "v2") and asserts 0 captured requests, 0operationsrows and 0life_eventsrows;the_cli_refuses_unusable_version_evidence_without_transmittingasserts exit 2 with nothing transmitted.
etag_conflict/ exit 4,operations.state='failed', no refreshed-ETag retry— met.
a_stale_etag_is_a_conflict_recorded_failed_and_never_retriedasserts exactly onePUT, thefailedstate, no fact, an unchanged resource, and that replaying the same key returns the recordedfailure rather than retrying.
applied,changed:false, 0 PUTs, noduplicate fact) — met.
cancelling_an_already_cancelled_event_is_a_recorded_no_opasserts onePUTtotal across both calls,changed:false,applied, exactly oneevent_cancelledfact, andthat the same key replays the no-op.
met.
a_cancelled_event_still_appears_in_the_agenda_and_consumes_no_capacityre-readsGET /v1/agendaafter the cancellation and finds the item withstatus: "CANCELLED"; the capacityclaim goes through the existing predicate
ApiItem::event_consumes_capacitywith the status andtransparency the agenda reported (plus the opaque-confirmed control case), rather than restating the
rule.
needs_clarification/ exit 3 with theseries/occurrence choice — met, reusing #15's scoping rule and payload shape unchanged.
a_recurring_event_without_a_scope_is_answered_with_the_scope_questionassertsstatus: needs_clarification,questions[0].id == "scope", options["series","occurrence"], 0 PUTs and nooperation row;
a_series_scoped_cancellation_cancels_the_mastercovers the answered case.before_json/after_jsonrecorded in one transaction with the operation result — met as far asit is observable from outside.
the_journal_records_the_transition_snapshots_with_the_operation_resultasserts both snapshots on the single
event_cancelledrow (beforeCONFIRMED, afterCANCELLED,same item id) together with
operations.state = 'applied'and the recordedreasonafter oneconfirmed call;
an_unconfirmed_cancellation_commits_no_factshows the other side of the sameboundary — when the remote write is unconfirmed the fact is absent and the operation is
needs_reconciliation, so the fact is never committed apart from the applied result.tests/skill_package_tests.rspasses— met. The row moved to the writes table, and the not-implemented section's parsed command set is
pinned to exactly
{"sync"}(see Tests adjusted); the file passes 7/7.Additional coverage in the same file:
deny_unknown_fieldsrefusal with nothing dispatched; a VTODOid addressed through the event route (422
invalid_input); an event carryingSTATUS:COMPLETEDrefused as
calendar_unsupported; a missingIdempotency-Key; no journal worker configured (503journal_unavailable, 0 PUTs); same key with a different payload (idempotency_conflict); and theCLI conflict path exiting 4.
Tests adjusted
tests/cli_protocol_tests.rs:life event cancelwas listed in theevery_body_taking_command_reads_stdin_and_refuses_a_malformed_bodycommand array (15 → 16 entries,with a valid cancellation body for the file variant) and the tail block asserting that the command
exits 5 with
dependency_unavailableand dispatches nothing was deleted — that behaviour is exactlywhat this change removes, and the block would otherwise pass only if the feature were absent. No
assertion was weakened: the command is now covered by the same stdin/file/malformed-body contract as
every other body-taking command.
tests/skill_package_tests.rs: the not-implemented assertion no longer tolerates a count. Itpreviously asserted
checked >= 2; the section now holds one command, and rather than lowering thefloor to
>= 1— which accepts both the old and the new count and so establishes nothing — the testnow asserts that the set of command paths parsed out of the section equals exactly
{"sync"}, whichstates the fact instead of a number. The per-row loop is unchanged: every row the section names is
still parsed with the real clap tree and dispatched, and must exit 5.
Falsified by hand, in both directions. Putting the
life event cancelrow back into thenot-implemented section fails the test at that row's own
assert_eq!(exit, DEPENDENCY_UNAVAILABLE)with
left: 2, right: 5— the command now really runs and exits 2 withconfiguration_invalidwhen no server is configured. Emptying the section, so the per-row loop checks nothing, fails at the
set equality with
left: {},right: {"sync"}— which is the set assertion carrying the claim onits own. Both temporary edits were removed again; the pushed commit is the clean state.
No product defect outside this scope was found.
Related Issues
Refs #32 (where the gap was found), #11 (event update preflight), #12 (task lifecycle discipline),
#15 (recurring scope).
Closes #75
`docs/technical/cli-api.md` documents `life event cancel ID --etag ETAG --input -` -> `POST /v1/events/{id}/cancel` (write), but the client had no dispatch arm for it and the router registered no route. Cancellation is the one event transition with a documented non-obvious semantic: it sets `STATUS:CANCELLED` and never deletes the resource. - `src/domain/event_lifecycle.rs`: the VEVENT lifecycle vocabulary (TENTATIVE/CONFIRMED/CANCELLED, no COMPLETED) and `plan_cancel`. A missing STATUS is RFC 5545's CONFIRMED default; an already-cancelled event plans a no-op; an unknown STATUS is refused as `calendar_unsupported`. No un-cancel is planned: the domain model names none. - `src/api/lifecycle.rs`: `run_event_cancel` reuses the task transitions' two-phase discipline verbatim (quoted strong If-Match preflight, prepared intent durable before dispatch, conditional PUT, one transaction committing the `event_cancelled` fact with before_json/after_json and the projection). The confirmed-fact commit path is generalised over a `Transition` descriptor so both kinds share it rather than duplicating it. - `src/api/mod.rs`: the route and its handler. - `src/cli.rs`: `event cancel` dispatches; `run_lifecycle_command` takes the route family and verb so tasks and events share one body. - `skills/life/references/commands.md`: the row moves to the implemented writes table; `tests/skill_package_tests.rs`'s not-implemented count goes 2 -> 1 in the same commit. - `tests/issue75_event_cancel.rs`: 20 integration tests covering the acceptance criteria against a mock Radicale and the real journal.REQUEST CHANGES
Acceptance criteria
STATUS:CANCELLED, never deletes, preserves the resourcetests/issue75_event_cancel.rs:456asserts one PUT, zero DELETEs, exactIf-Match, preservedX-CUSTOM-CLIENT-FLAGandVALARM, and a still-stored resource.tests/issue75_event_cancel.rs:1110separately proves anRRULEsurvives series cancellation. The implementation applies only aSTATUSproperty edit through the preservation path (src/domain/event_lifecycle.rs:130,src/api/lifecycle.rs:697).every_unusable_etag_is_refused_before_any_dispatch(tests/issue75_event_cancel.rs:665) covers absent, empty, weak, unquoted, wildcard, and multi-tag values and asserts zero backend requests, zerooperationsrows, and zerolife_eventsrows for every case. The CLI companion asserts exit 2 and zero transmission.a_stale_etag_is_a_conflict_recorded_failed_and_never_retried(tests/issue75_event_cancel.rs:813) asserts 412/etag_conflict, exit 4, exactly one PUT,operations.state='failed', zero facts, unchanged resource bytes, and no second PUT on same-key replay.cancelling_an_already_cancelled_event_is_a_recorded_no_op(tests/issue75_event_cancel.rs:566) checksapplied,changed:false, one PUT total across the real cancellation plus no-op, exactly oneevent_cancelledfact, and unchanged counters after replay.a_cancelled_event_still_appears_in_the_agenda_and_consumes_no_capacity(tests/issue75_event_cancel.rs:521) re-reads/v1/agenda, finds the cancelled event, asserts the returnedconsumes_capacity:false, calls the existingApiItem::event_consumes_capacitypredicate, and includes an opaque-confirmed positive control.src/api/auth.rs:120maps exactlyPOST /v1/events/{id}/canceltoScope::Write;src/api/auth.rs:356refuses credentials lacking that scope before the handler; the route-table unit case atsrc/api/auth.rs:581pins this exact path.d03_route_scopes_are_enforced_per_credentialproves the same middleware refuses a read credential on write routes.a_recurring_event_without_a_scope_is_answered_with_the_scope_question(tests/issue75_event_cancel.rs:1089) asserts the existingscopequestion/options, exit 3, zero PUTs, and zero operation rows.a_series_scoped_cancellation_cancels_the_masterasserts the master becomes cancelled andRRULE:FREQ=WEEKLY;COUNT=3remains. Both paths use the sharedresolve_scoped_target/clarification_response, not a second envelope shape.the_journal_records_the_transition_snapshots_with_the_operation_result(tests/issue75_event_cancel.rs:878) checks before/after snapshots andapplied;an_unconfirmed_cancellation_commits_no_factchecks zero facts withneeds_reconciliation. The implementation sends operation state, fact, and projection through the singlecommit_operation_resulttransaction (src/history/journal.rs:575). This is the strongest available observable boundary; the PR's atomicity caveat is accurate.skills/life/references/commands.md:68), and onlylife syncremains in the not-implemented table. However, the changed assertion ischecked >= 1(tests/skill_package_tests.rs:696), so it accepts both the intended count of one and the old count of two rather than pinning the claimed count.EventSubcommands::Cancelexposes positional ID plus--etag,--operation-id,--input, and--scope(src/cli.rs:365);run_event_commanddispatches to/v1/events/{id}/cancelthrough the shared conditional POST path (src/cli.rs:2889). The integration test checks generatedop_keys and exact ETag/body transmission;every_body_taking_command_reads_stdin_and_refuses_a_malformed_bodyincludes event cancel and proves malformed stdin dispatches nothing. The shared helper uses supplied--operation-idverbatim.Findings
tests/skill_package_tests.rs:696— Pin the not-implemented command count instead of usingchecked >= 1. The PR says onlylife syncremains and specifically changes the count from 2 to 1, but this predicate is true for both values, so it does not establish that thelife event cancelrow actually left that section. Use an exact assertion for the one expected row (or assert the parsed command set equals{sync}); this makes the skill-package regression check prove the count claimed by the PR.Verification I ran
nix develop --command cargo test— 766 passed, 0 failed, across 41 Cargo suite result lines (including zero-test binary/doc-test suites).nix develop --command cargo clippy --all-targets -- -D warnings— clean, 0 warnings/errors.nix develop --command cargo fmt --check— clean.git diff --check origin/main...HEAD— clean.PR-body claims
I reproduced the claimed 766/0 test result and clean clippy/fmt runs. The following claim is stronger than the code supports:
The loop checks behavior of rows it happens to find, but the final lower bound accepts either one or two checked rows. It therefore does not prove the PR's stated not-implemented count of one.
The journal caveat is honest: a black-box test cannot observe an intermediate state inside one SQLite transaction. The combination of the single transaction in
commit_operation_result, the confirmed-boundary test, and the unconfirmed zero-fact test is the strongest evidence available without transaction-level fault injection.Anything I could not check
commit_operation_result; atomicity was checked by reading the transaction implementation and by the observable confirmed/unconfirmed boundary tests.The section's assertion tolerated a count (`checked >= 2`, lowered to `checked >= 1`), which accepts both the old and the new count and so does not establish that `life event cancel` left the section. Assert the parsed command set equals `{"sync"}` instead: the fact the reference is making, stated as a fact. The per-row loop is unchanged. Falsified by hand: re-adding the `life event cancel` row fails the row's own `assert_eq!(exit, DEPENDENCY_UNAVAILABLE)` (left: 2, right: 5), and emptying the section fails the set equality (left: {}, right: {"sync"}).APPROVE
I re-reviewed the follow-up commit
c2d3d2841b653a3d70769669f0831a13b7724d12. The newBTreeSet<String>assertion is a real cutover pin: it requires the parsed not-implemented command paths to equal exactly{sync}, so it rejects both an omitted row ({}) and a stale extra row ({sync, event cancel}), unlike the former lower-bound count.validate_strict(&root, &tokens)makes each row meaningful by validating the whole documented command line against the actual clap tree: command path, options and their values, and positional arguments before the row is dispatched. The per-row dispatch assertion still requires every named row to return exit 5, so the set assertion cannot be satisfied by merely naming arbitrary parseable commands.The reported falsification is consistent with the diff and current behavior: restoring
life event cancelto that section would make its row be dispatched and fail the row-level exit-5 assertion (the implemented command returns exit 2 without a configured endpoint), while emptying the section leaves{}and fails the exact-set assertion. The temporary edits are not present in the pushed diff.I reran
nix develop --command cargo test --test skill_package_tests(7 passed),nix develop --command cargo clippy --all-targets -- -D warnings(clean), andnix develop --command cargo fmt --check(clean). The prior full-suite run on the implementation commit was 766 passed / 0 failed across 41 result lines; this follow-up only strengthens the skill-package regression assertion. No unpublished findings remain.feat(api): implementto feat(api): implement life event cancel / POST /v1/events/{id}/cancellife event cancel/ POST /v1/events/{id}/cancel