fix(api): classify custom non-IANA TZID as calendar_unsupported, not dependency_unavailable #81
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-53-tzid"
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
Issue #53, raised as "finding 2" during the #14 review.
docs/technical/verification.mdC07 requires an unknown/custom
VTIMEZONEto produce "exact deployment conformance orexplicit
calendar_unsupported/partial; no unsupported window labeled free". The #14review claimed the classification was already right; the issue asked for a reproduction
before anything changed.
The reproduction says the suspicion was right and the code was wrong — but not where the
issue expected it.
src/calendar/report.rsdoes raiseCalendarError::CalendarUnsupportedfor an unresolvable
TZID, andlive_read_failure/coverage_state_fordo map that classcorrectly. What lost the distinction was the fan-out aggregation: every route that
reads all configured collections collapsed "no collection could be read" into
dependency_unavailable, whatever the failures actually were. So the #14 fixture(
TZID=Custom-Timezone) answered HTTP 503 /dependency_unavailable/ CLI exit 5 —telling a caller to retry a dependency that had answered every request it was sent.
Reproduction (before the change)
TZID=Custom-Timezonewith its matchingVTIMEZONE, served by a mock Radicale, driventhrough the daemon's real router over HTTP (
cargo test --test issue53_tzid_tests -- --nocapture,table printed by
every_refusing_path_reports_calendar_unsupported_over_http):error.codeGET /v1/agendastatus=partial(coverageunsupported)GET /v1/tasksdependency_unavailableGET /v1/items/{id}dependency_unavailablePATCH /v1/tasks/{id}dependency_unavailablePATCH /v1/events/{id}dependency_unavailablePOST /v1/tasks/{id}/completedependency_unavailablePOST /v1/resolvedependency_unavailablePOST /v1/nextdependency_unavailablePOST /v1/plansdependency_unavailablePOST /v1/reviewsdependency_unavailableEvery one of those 503s carried the giveaway message
All configured CalDAV collections are unavailable: Calendar unsupported: Unsupported TZID 'Custom-Timezone'— the interpretation failure was known, then discarded by theaggregation that decided the request had failed.
Only the agenda read was already right: it is a projection with per-collection coverage, so
an uninterpretable collection degrades coverage explicitly instead of failing (scenarios
C07/C13), and it never claimed an outage.
Changes
src/api/agenda.rs— one shared reading of a fan-out's failures: the privateFailureClassvocabulary (Interpretation/Bound/Outage),classify_failure,is_outage, andread_failure(context, failures), which takes(collection_id, error)pairs so a refusal can name the collections involved. The module header records the
precedence and the one deliberate exception.
dependency_unavailablefor anyall-collections-failed fan-out):
scan_collectionsandresolve_live_item(
src/api/items.rs),run_tasksandlive_task_inventory(src/api/tasks.rs),live_candidates(src/api/resolve.rs),run_agenda(src/api/agenda.rs). Thiscovers every read and mutation path that resolves an item or reads the inventory:
life agenda,life tasks,life item get,life task update,life event update,life task complete/reopen/cancel,life resolve,life next,life plan,life review.src/calendar/report.rs— the decision the issue asked for, recorded in the moduleheader: an unresolvable
TZIDstaysCalendarError::CalendarUnsupported(capability),not
Preservation.docs/technical/calendar.mdsays an unknown/custom zone must be"supported by expansion with verified mapping or reported unsupported" — the
specification's own term for this case is unsupported;
Preservationis reserved forthe other direction (the bytes were interpretable, but rewriting them would not be
lossless —
src/calendar/preservation.rs). Both land on the same refusal after the #14mapping, so the choice names the reason rather than changing the outcome.
live_read_failure(src/api/items.rs) andcoverage_state_for.Review follow-ups folded into this revision
introduced).
read_failurenow decides the status by class, not by which failurehappened to come first: if any collection in the fan-out failed with a genuine outage,
the request is
dependency_unavailable(HTTP 503 / exit 5,retryable), even whenanother collection's failure is a capability gap. Letting the capability gap win would
hide a broken dependency behind a non-retryable "unsupported" verdict and stop a caller
from ever retrying it. The capability gap is not dropped: it is named in the message
and carried as an
incomplete_calendarwarning, so an operator still learns that aretry alone will not be enough. Pinned by
an_outage_in_a_mixed_all_failed_fan_out_decides_the_status_and_the_gap_is_still_reported(one collection holding the custom zone + one answering 500) on
GET /v1/tasks,GET /v1/items/{id},PATCH /v1/tasks/{id}andPOST /v1/next.nextrow of the C01 test — added; all fiveroutes in
an_unreachable_backend_is_still_dependency_unavailablenow assertexit_code_for(...) == 5, not just the status and code.answers 500 → 503/exit 5".
a_connection_refused_backend_is_still_dependency_unavailableadds the shape an operator actually hits: the configured port is bound, released, and
nothing listens on it, so the connection is refused. Five routes plus the
life agendaCLI still answer 503 /
dependency_unavailable/ exit 5.test resultlines and suites separately.One deliberate exception, and why
The agenda read keeps degrading to
partialwhen its fan-out failed for reasons that areall non-outages (an uninterpretable collection, a bound), rather than refusing with 422.
That is not my invention and not a leftover: two pre-existing C07 acceptance tests pin it —
tests/recurrence_tests.rs::c07_an_unsupported_collection_is_partial_and_never_declared_free(a single collection failing with
RANGE=THISANDFUTURE) and::c07_an_unresolvable_vtimezone_is_reported_as_unsupported(a single collection holding anunresolvable
VTIMEZONE), both asserting HTTP 200 /partialwith coverageunsupported,no occurrences claimed and an
incomplete_calendarwarning. My first revision made everyfan-out refuse on an all-failed read and broke both; this revision restores the pinned
behaviour rather than rewriting the acceptance tests to match my taxonomy. The agenda read
still refuses an all-outage fan-out (C01), and in the mixed all-failed case it reports the
down collection as coverage
unavailableand the uninterpretable one asunsupported, sothe outage is disclosed rather than masked (asserted in the mixed all-failed test).
Flagging it because the taxonomy and the pinned tests disagree: if an all-failed,
all-non-outage agenda read should now be 422/exit 2, that is a change to C07 acceptance
behaviour and belongs in its own change with those two tests, not folded in here.
Per-criterion result
Custom non-IANA
TZIDiscalendar_unsupported(HTTP 422 / exit 2) on every read andmutation path that can encounter it, never
dependency_unavailable— met for every paththat refuses. Verified per route over HTTP after the change:
error.codeGET /v1/agendastatus=partial, coverageunsupported,incomplete_calendarGET /v1/taskscalendar_unsupportedGET /v1/items/{id}calendar_unsupportedPATCH /v1/tasks/{id}calendar_unsupportedPATCH /v1/events/{id}calendar_unsupportedPOST /v1/tasks/{id}/completecalendar_unsupportedPOST /v1/resolvecalendar_unsupportedPOST /v1/nextcalendar_unsupportedPOST /v1/planscalendar_unsupportedPOST /v1/reviewscalendar_unsupportedThe agenda read is the exception described above (partial, not 422), so this criterion is
reported as met for the nine refusing paths, partial for the agenda projection. Exit
codes for the CLI rows are the real binary's (spawned against the daemon's router), not a
re-derivation of the envelope.
A genuinely unreachable backend still reports
dependency_unavailable(503 / exit 5),C01 preserved — met, in both the unhealthy and the connection-refused shape.
an_unreachable_backend_is_still_dependency_unavailable(mock answers 500 to everything)and
a_connection_refused_backend_is_still_dependency_unavailable(nothing listening onthe configured port) each assert 503 +
dependency_unavailable+ exit 5 onGET /v1/agenda,GET /v1/tasks,GET /v1/items/{id},PATCH /v1/tasks/{id}andPOST /v1/next, with the CLI exiting 5 forlife agenda. The pre-existing C01 tests(
tests/agenda_tasks_tests.rs,tests/resolve_tests.rs,tests/next_command_tests.rs,tests/replan_tests.rs) all inject 503/500 from the backend and still pass.The #14 fixture is exercised over HTTP, not only at the patcher level — met. Every
row above is an HTTP request through
router_with_stateagainst a mock Radicale thatserves the fixture (custom
TZIDplus itsVTIMEZONEblock); the patcher route is one ofthem (
PATCH /v1/tasks/{id},PATCH /v1/events/{id}), andthe_patcher_refuses_the_custom_zone_fixture_over_httpadditionally asserts zeroPUTs,zero journal rows and byte-identical backend state.
Each affected route has a test pinning both the error code and the exit code — met.
tests/issue53_tzid_tests.rs(new) asserts code andclient::exit_code_forfor allten routes, plus the CLI binary's own exit codes for
life item get(2),life task update(2),life tasks(2),life agenda(6) and the outage direction (5).Verification
nix develop --command cargo fmt --check— clean.nix develop --command cargo clippy --all-targets -- -D warnings— 0 errors.nix develop --command cargo test --no-fail-fast— 752 passed, 0 failed across 43test resultlines (41 test binaries, doc tests included). Baselinemainat37c8101measured the same way: 743 passed, 0 failed across 42
test resultlines (40 suites);this branch adds the 9 tests in
tests/issue53_tzid_tests.rs.New tests (
tests/issue53_tzid_tests.rs, 9):every_refusing_path_reports_calendar_unsupported_over_http,no_path_reports_the_custom_zone_as_a_backend_outage,a_refused_mutation_dispatches_nothing_and_leaves_the_resource_intact,the_cli_exits_two_when_nothing_is_readable_and_six_when_a_collection_is,an_unreachable_backend_is_still_dependency_unavailable,a_connection_refused_backend_is_still_dependency_unavailable,an_outage_in_a_mixed_all_failed_fan_out_decides_the_status_and_the_gap_is_still_reported,a_mixed_deployment_degrades_coverage_and_still_serves_the_readable_collection,the_patcher_refuses_the_custom_zone_fixture_over_http.Adjusted test:
tests/update_tests.rs::a_custom_non_iana_tzid_is_refused_before_any_dispatchpreviously asserted
dependency_unavailable— i.e. it pinned the defect. It now assertscalendar_unsupported, HTTP 422, exit 2,retryable: false, and (unchanged) zeroPUTs,zero journal rows and untouched backend bytes. The assertion is strictly stronger, not
weakened. No other existing test was changed; the two C07 acceptance tests that my first
revision broke pass unchanged.
Pre-existing flake observed, not caused by this change:
tests/proposal_apply_tests.rs:: every_step_applied_marks_the_proposal_applied_and_replays_under_the_same_keyfailsintermittently (the replay comes back
Errorat HTTP 200). I measured it on this branch(8/10 green) and on
mainat37c8101with the same loop (9/10 green, and 1 failure in apaired 10-run comparison), so it reproduces without this diff. It is a journal/
commit_apply_outcomepath this change does not touch; I did not fix it.
Defect found outside this issue's scope (not fixed)
run_item_get(src/api/items.rs) answersnot_foundwhen a collection could not be readand another collection simply held no match — in a multi-collection deployment, absence is
not established there.
resolve_live_itemin the same file refuses instead (it always has),so the two id-resolution paths disagree. That is a pre-existing inconsistency in a different
classification (404 vs a refusal), not the TZID/outage confusion this issue is about, so it
is reported here rather than changed silently.
Documentation
No normative text was rewritten.
docs/is excluded from this repository(
.git/info/exclude), so the recorded decision for theTZIDclassification lives in thesrc/calendar/report.rsmodule header (the codebase's convention for such choices, cf.src/domain/analytics/mod.rs).skills/life/references/commands.mdanderrors.mdwerechecked and need no change: no command, route or error identifier is added, and
tests/skill_package_tests.rscounts are unaffected.Closes #53
REQUEST CHANGES
Acceptance criteria
TZIDiscalendar_unsupported(422 / exit 2) on the refusing read/mutation paths, notdependency_unavailabletests/issue53_tzid_tests.rs:597-631; each asserts code, 422, andclient::exit_code_for == 2. Agenda is correctly the exception:200 partial,complete=false,coverage[].state=unsupported,incomplete_calendar, exit 6 (:644-658,:748-788). However, mixed all-failed fan-outs can still misclassify a real outage as 422; see the major finding.dependency_unavailable(503 / exit 5)an_unreachable_backend_is_still_dependency_unavailableasserts code and 503 on agenda, tasks, item-get, task-update, and next (:794-866), and exit 5 for four direct envelopes plus the agenda CLI. Itsdown()mock returns HTTP 500 (:109-115), so it verifies an unhealthy response rather than a connection-refused backend; existing C01 tests also use the 500 fixture. The/v1/nextdirect assertion does not pin exit 5 (minor finding). Mixed outage + unsupported is not covered and is currently wrong (major finding).custom_tzid_ics()includesTZID=Custom-Timezoneplus a matchingVTIMEZONE(:167-203); the mock is served over TCP and requests userouter_with_state/real HTTP (:250-333). The matrix and patcher test reach the fixture through the daemon router.every_refusing_path_reports_calendar_unsupported_over_httpiterates all nine non-agenda rows and assertscalendar_unsupported, 422, and exit 2 (:615-631).200 partial,complete=false,coverage[0].state == "unsupported", andincomplete_calendar(:644-658). This matchesdocs/technical/verification.mdC07 anddocs/technical/calendar.md's requirement that unsupported semantics prevent a free-time claim.TZIDdecision is recorded and uses the domain termsrc/calendar/report.rs:1-31recordsCalendarUnsupportedrather thanPreservation, with the rationale that the deployment cannot interpret the wall-clock time; this matches the specification's “reported unsupported” terminology. The parser maps an unresolvableTZIDat:404-406.Findings
major —
src/api/agenda.rs:307-315— Preserve outage classification in mixed all-failed fan-outs.read_failurerecords interpretation and bound errors but ignoresFailureClass::Outagewhile selecting the response. If one configured collection returns a real transport/HTTP outage and another returns the customTZIDasCalendarUnsupported, every collection has failed, yetfirst_interpretationwins and the route returnscalendar_unsupported(422 / exit 2). This tells the caller not to retry even though one dependency is genuinely down, violating C01 and the stated outage/capability distinction. In this all-failed mixed case the outage must take precedence and remaindependency_unavailable(503 / exit 5); a successful-plus-failed fan-out may still be a disclosed partial result. There is no regression test combining one down collection with one uninterpretable collection—the added mixed test is unsupported plus readable (:903-925)—so this defect is currently unpinned across the shared callers (tasks, item resolution/mutations, resolve, next, plan and review).minor —
tests/issue53_tzid_tests.rs:856-866— Pin the next-route outage exit code. The C01 test asserts HTTP 503 anddependency_unavailableforPOST /v1/next, but unlike the other direct route assertions it never callsexit_code_for. The PR body claims the test asserts “503 +dependency_unavailable+ exit 5” on all five routes, which is not true for this row; add the explicit exit-5 assertion so a route-specific exit-code drift cannot pass unnoticed.Verification I ran
nix develop --command cargo test— 750 passed, 0 failed. The run contained 40Runningtest binaries plus the doc-test section (41 suite-result blocks), all successful. The newtests/issue53_tzid_tests.rsbinary ran all 7 tests successfully, including the HTTP fixture, C01 direction, and readable+unsupported mixed case.nix develop --command cargo clippy --all-targets -- -D warnings— clean.nix develop --command cargo fmt --check— clean.PR-body claims
The claim “750 passed; 0 failed (43 test binaries)” is only partly reproducible: the pass/fail total is correct, but this run reports 40 test binaries plus doc-tests (41 suite-result blocks), not 43. The claim that C01 asserts exit 5 on all five listed routes is overstated because
/v1/nextlacks that assertion. The claim that the mock is “genuinely down” should be read as unhealthy: the mock returns HTTP 500 rather than refusing the connection; this is still a valid dependency-unavailable response test, but it is not a connection-unreachable reproduction.Anything not checked
I did not run against a deployed Radicale instance or verify a particular Radicale version's native
VTIMEZONEexpansion; the repository contract explicitly treats the fixture and exact-deployment conformance as separate concerns.APPROVE
Acceptance criteria
TZIDiscalendar_unsupported(422 / exit 2) on refusing read and mutation paths, notdependency_unavailabletests/issue53_tzid_tests.rs::every_refusing_path_reports_calendar_unsupported_over_httpasserts code, HTTP 422, and exit 2 for tasks, item get, both patch routes, complete, resolve, next, plan, and review. The agenda assertions remain 200/partial,complete=false,coverage=unsupported,incomplete_calendar, exit 6.dependency_unavailable(503 / exit 5), with retryabilitya_connection_refused_backend_is_still_dependency_unavailableconfigures a port that is bound and then released before the daemon reads it, so this is a real refused connection rather than the mock's 500 response. The mixed all-failed regression assertsdependency_unavailable,retryable=true, exit 5, and retains the capability gap in both the message and anincomplete_calendarwarning.TZID=Custom-Timezoneand a matchingVTIMEZONE; the tests serve it through the mock CalDAV TCP server and send requests through the daemon router. The patcher test additionally asserts zero PUTs, zero journal rows, and unchanged bytes.incomplete_calendar. The revised mixed test additionally shows an outage asunavailableand the custom zone asunsupported, withcomplete=falseand no items. This is consistent withverification.mdC07's explicitcalendar_unsupported/partial allowance andcalendar.md's prohibition on claiming free time under unsupported semantics; changing these tests to 422 would be a separate normative behavior change, not a correction required by this issue.src/calendar/report.rs's module header records the choice ofCalendarUnsupported(capability) rather thanPreservation(lossless rewrite failure), matching the specification's “reported unsupported” term.Findings
None. The previous mixed-fan-out classification defect and missing
/v1/nextexit assertion are fixed in this head.Verification I ran
nix develop --command cargo test— 752 passed, 0 failed; the revisedissue53_tzid_testsbinary ran 9/9, including the mixed all-failed, connection-refused, retryability, and warning-preservation cases.nix develop --command cargo test --test issue53_tzid_tests an_outage_in_a_mixed_all_failed_fan_out_decides_the_status_and_the_gap_is_still_reported -- --nocapture— 1 passed.nix develop --command cargo test --test issue53_tzid_tests a_connection_refused_backend_is_still_dependency_unavailable -- --nocapture— 1 passed.nix develop --command cargo clippy --all-targets -- -D warnings— clean.nix develop --command cargo fmt --check— clean.The reported intermittent
proposal_apply_testsfailure was not observed in my full run. I did not run a deployed Radicale compatibility check; this review covers the repository fixture and daemon HTTP behavior.cargo testrun intermittently, truncating the suite #82fix(api): classify custom non-IANA TZID asto fix(api): classify custom non-IANA TZID as calendar_unsupported, not dependency_unavailablecalendar_unsupported, notdependency_unavailable