fix(api): reconcile calendar_unsupported to 422/exit 2 and evidence preservation conformance #52

Merged
yuris merged 2 commits from feat/issue-14-preservation into main 2026-09-22 22:37:52 +00:00
Owner

Motivation

Issue #014's preservation-safe patcher already exists and is in production use: src/calendar/preservation.rs (a lossless line/component AST) driven by src/api/update.rs, src/api/creation.rs and src/api/lifecycle.rs. It landed with #011, a deviation recorded on both issues, and #014's spec explicitly permits it ("leveraging ical::parser::ical::IcalParser or a dedicated lossless line/component AST").

The issue stayed open for the conformance evidence its criteria require. The audit found the patcher sound but under-evidenced: folding had been asserted only on "x".repeat(200) (pure ASCII), so nothing proved that a multi-octet character is never split, that continuation lines respect the 75-octet limit, or that a folded Unicode resource survives an edit byte for byte.

The patcher itself needed no fix — "audited, no defect found" is the result — and no dependency, rewrite or switch to the ical crate is included. One production change is in scope, requested in review: reconciling the calendar_unsupported refusal to HTTP 422 / exit 2 (finding 1 below). src/api/update.rs is untouched.

Changes

  • src/calendar/preservation.rs — unit conformance tests plus the issue's illustrative fixture:
    • a multibyte value (4-octet emoji, 3-octet em-dash/CJK, 2-octet, and a combining sequence) folds to physical lines of at most 75 octets, every continuation marked with a space, and reassembles byte for byte into the input logical line — which is what proves no character was split;
    • a 75-octet boundary that lands inside a 4-octet character backs off to the previous character boundary;
    • the complex fixture is preserved byte for byte when only SUMMARY is patched — the whole resource differs in exactly one physical line — with the client's folded Unicode DESCRIPTION, its X-APPLE-STRUCTURED-LOCATION/X-CUSTOM-VENDOR-PROP lines, VALARM and VTIMEZONE all intact, and the unfolded description still semantically identical;
    • the sibling detached override is byte-identical and the master is the only component that differs;
    • every refusal (empty, unclosed, stray END, no component, unparseable, unknown UID, ambiguous identity) is a CalendarError::Preservation and never a partial resource;
    • a patch writes the parameters it names for the property it replaces (see the parameter finding below).
  • tests/update_tests.rs — the same evidence through PATCH /v1/events/{id} and PATCH /v1/tasks/{id}, asserting on the exact bytes PUT, mirroring the suite's existing habit:
    • c09_a_title_edit_preserves_a_folded_unicode_description_and_vendor_properties;
    • c09_a_master_edit_leaves_the_sibling_detached_override_untouched;
    • a_long_multibyte_title_is_folded_within_75_octets_and_reads_back_exactly;
    • c09_an_ambiguous_target_is_refused_and_nothing_is_dispatched — calendar_unsupported at HTTP 422 with CLI exit 2, zero PUT, zero journalled operation, backend bytes untouched;
    • a_calendar_unsupported_refusal_exits_two — the real life task update CLI against a 422 envelope, asserting process exit 2;
    • a_custom_non_iana_tzid_is_refused_before_any_dispatch — the boundary recorded below, plus a MockState::resource accessor to prove no partial write.
  • src/api/mod.rs / src/client.rs — the one production change (review request): ApiFailure::calendar_unsupported now answers HTTP 422 / exit 2, and the CLI's error-code map answers 2 for the code. tests/update_tests.rs and tests/lifecycle_tests.rs pins that expected 400 for this refusal were updated to 422.

Audit against the acceptance criteria

Criterion Result Evidence
1. X-* properties, parameters, VALARM, VTIMEZONE preserved on a SUMMARY/STATUS edit Met Untouched logical lines are re-emitted from the bytes they were read from. New: the whole resource differs in exactly one physical line (differing_lines), the X-*/VALARM/VTIMEZONE regions are asserted byte-identical, and the semantic (unfolded) description is unchanged — unit and HTTP.
2. Folded multiline Unicode (including multibyte emoji) preserves encoding, folding and semantics Met, previously unevidenced fold uses octet lengths with is_char_boundary guards; new multibyte tests plus a boundary-straddling case, byte-exact reassembly, and an end-to-end multibyte title that reads back through the projection exactly.
3. A master edit does not alter or drop sibling detached overrides Met Only the component selected by (UID, RECURRENCE-ID) is rewritten and nested BEGIN/END regions are never descended into. New: the override block is asserted byte-identical via component_block.
4. An existing item never passes through the new-item builder Met src/calendar/ical.rs is referenced only from src/api/creation.rs; updates call apply_edits on the raw resource. Asserted by the byte-level preservation tests.
5. Failure to guarantee lossless preservation aborts with calendar_unsupported (HTTP 422 / exit 2) Met, including the mapping Refusals are CalendarError::Preservation and surface as calendar_unsupported with zero dispatches. New: HTTP 422 asserted on the ambiguous-identity refusal, CLI exit 2 asserted both through client::exit_code_for and by running the real CLI against a 422 envelope.
6. Conformance Gate 4 (docs/technical/calendar.md) and Scenario C09 (docs/technical/verification.md) Met The suite above is the gate's executable form.

No defect was found in fold or in the preservation path. Folding is octet-correct for every multi-octet case tried, including a dedicated search over 4,000 pseudo-random mixed-width strings (1/2/3/4-octet plus combining marks, values up to 400 characters): every physical line stayed within 75 octets, every continuation was marked correctly, and every reassembly matched the input byte for byte. That search was exploratory and is not committed.

Findings recorded, deliberately not changed here

  1. calendar_unsupported mapped to HTTP 400 / exit 1 while the calendar layer answered exit 2 — resolved in review. ApiFailure::calendar_unsupported was BAD_REQUEST / exit_codes::GENERAL_ERROR, CalendarError::CalendarUnsupported::exit_code() is INVALID_INPUT (2), docs/technical/cli-api.md maps exit 2 to 400/413/422, and the CLI's error-code map had no arm for the code so it exited 1. The API now refuses with 422 / exit 2 and the CLI exits 2. The agenda partial path for an unsupported collection is deliberately untouched: it stays status: partial, coverage unsupported, exit 6.
  2. A resource with a custom non-IANA TZID is refused as dependency_unavailable (503, retryable) instead of calendar_unsupported. The parser does produce CalendarError::CalendarUnsupported("Unsupported TZID …") as #013 requires, but scan_collections collapses the failure into "All configured CalDAV collections are unavailable" when no collection survives, and the update route reports that. The mutation is still refused with zero dispatches and no partial write (asserted), but the code and the retryable flag are wrong for a case that will never succeed. Same pattern in src/api/agenda.rs and src/api/tasks.rs; no test covered it before this one. Worth a follow-up decision — it is read-path classification, not preservation.
  3. A patch replaces the parameters of the property it names, rather than merging them into the written line. This is the deliberate reading of criterion 1's "unless the patch explicitly mutates or replaces them": the patch's parameters are the edit's parameters (PropertyEdit::parameterized exists for exactly this, e.g. VALUE=DATE), and untouched properties keep theirs verbatim. Pinned by a test so the interpretation is reviewable.
  4. The deferred Cargo.toml authors chore stays open. No dependency was needed, so Cargo.toml was not touched; authors is still unset.

Verification

  • nix develop --command cargo test — all suites green: 35 lib tests (15 in calendar::preservation), update_tests 33, lifecycle_tests 27, and every other suite unchanged and passing (recall the 2 updated pins and the 2 new refusal tests).
  • nix develop --command cargo clippy --all-targets -- -D warnings — clean.
  • nix develop --command cargo fmt --check — clean.
  • Refusal contract: HTTP 422 on the patcher's own calendar_unsupported refusal, life::client::exit_code_for == 2 for that envelope, and a real life task update process exiting 2 against a 422 envelope — with zero PUTs, zero operations rows and untouched backend bytes.
  • Not verified: any real CalDAV server or a client-produced object (the mock Radicale captures the bytes PUT); the issue's fixture verbatim over HTTP, because its Custom-Timezone TZID is refused by the read path before the patcher is consulted (finding 2) — it is exercised at the patcher level instead.
  • Closes #14
  • Refs #11 (the patcher this evidences), #13 (custom VTIMEZONE handling, finding 2), #15 (occurrence-scoped mutations, untouched here).
## Motivation Issue #014's preservation-safe patcher already exists and is in production use: `src/calendar/preservation.rs` (a lossless line/component AST) driven by `src/api/update.rs`, `src/api/creation.rs` and `src/api/lifecycle.rs`. It landed with #011, a deviation recorded on both issues, and #014's spec explicitly permits it ("leveraging `ical::parser::ical::IcalParser` **or a dedicated lossless line/component AST**"). The issue stayed open for the conformance evidence its criteria require. The audit found the patcher sound but under-evidenced: folding had been asserted only on `"x".repeat(200)` (pure ASCII), so nothing proved that a multi-octet character is never split, that continuation lines respect the 75-octet limit, or that a folded Unicode resource survives an edit byte for byte. The patcher itself needed **no fix** — "audited, no defect found" is the result — and no dependency, rewrite or switch to the `ical` crate is included. One production change *is* in scope, requested in review: reconciling the `calendar_unsupported` refusal to HTTP 422 / exit 2 (finding 1 below). `src/api/update.rs` is untouched. ## Changes - `src/calendar/preservation.rs` — unit conformance tests plus the issue's illustrative fixture: - a multibyte value (4-octet emoji, 3-octet em-dash/CJK, 2-octet, and a combining sequence) folds to physical lines of at most 75 octets, every continuation marked with a space, and reassembles byte for byte into the input logical line — which is what proves no character was split; - a 75-octet boundary that lands *inside* a 4-octet character backs off to the previous character boundary; - the complex fixture is preserved byte for byte when only `SUMMARY` is patched — the whole resource differs in exactly one physical line — with the client's folded Unicode `DESCRIPTION`, its `X-APPLE-STRUCTURED-LOCATION`/`X-CUSTOM-VENDOR-PROP` lines, `VALARM` and `VTIMEZONE` all intact, and the unfolded description still semantically identical; - the sibling detached override is byte-identical and the master is the only component that differs; - every refusal (empty, unclosed, stray `END`, no component, unparseable, unknown `UID`, ambiguous identity) is a `CalendarError::Preservation` and never a partial resource; - a patch writes the parameters it names for the property it replaces (see the parameter finding below). - `tests/update_tests.rs` — the same evidence through `PATCH /v1/events/{id}` and `PATCH /v1/tasks/{id}`, asserting on the exact bytes `PUT`, mirroring the suite's existing habit: - `c09_a_title_edit_preserves_a_folded_unicode_description_and_vendor_properties`; - `c09_a_master_edit_leaves_the_sibling_detached_override_untouched`; - `a_long_multibyte_title_is_folded_within_75_octets_and_reads_back_exactly`; - `c09_an_ambiguous_target_is_refused_and_nothing_is_dispatched` — `calendar_unsupported` at HTTP 422 with CLI exit 2, zero `PUT`, zero journalled operation, backend bytes untouched; - `a_calendar_unsupported_refusal_exits_two` — the real `life task update` CLI against a 422 envelope, asserting process exit 2; - `a_custom_non_iana_tzid_is_refused_before_any_dispatch` — the boundary recorded below, plus a `MockState::resource` accessor to prove no partial write. - `src/api/mod.rs` / `src/client.rs` — the one production change (review request): `ApiFailure::calendar_unsupported` now answers HTTP 422 / exit 2, and the CLI's error-code map answers 2 for the code. `tests/update_tests.rs` and `tests/lifecycle_tests.rs` pins that expected 400 for this refusal were updated to 422. ## Audit against the acceptance criteria | Criterion | Result | Evidence | | --- | --- | --- | | 1. `X-*` properties, parameters, `VALARM`, `VTIMEZONE` preserved on a `SUMMARY`/`STATUS` edit | Met | Untouched logical lines are re-emitted from the bytes they were read from. New: the whole resource differs in exactly one physical line (`differing_lines`), the `X-*`/`VALARM`/`VTIMEZONE` regions are asserted byte-identical, and the semantic (unfolded) description is unchanged — unit and HTTP. | | 2. Folded multiline Unicode (including multibyte emoji) preserves encoding, folding and semantics | Met, previously unevidenced | `fold` uses octet lengths with `is_char_boundary` guards; new multibyte tests plus a boundary-straddling case, byte-exact reassembly, and an end-to-end multibyte title that reads back through the projection exactly. | | 3. A master edit does not alter or drop sibling detached overrides | Met | Only the component selected by `(UID, RECURRENCE-ID)` is rewritten and nested `BEGIN`/`END` regions are never descended into. New: the override block is asserted byte-identical via `component_block`. | | 4. An existing item never passes through the new-item builder | Met | `src/calendar/ical.rs` is referenced only from `src/api/creation.rs`; updates call `apply_edits` on the raw resource. Asserted by the byte-level preservation tests. | | 5. Failure to guarantee lossless preservation aborts with `calendar_unsupported` (HTTP 422 / exit 2) | Met, including the mapping | Refusals are `CalendarError::Preservation` and surface as `calendar_unsupported` with zero dispatches. New: HTTP 422 asserted on the ambiguous-identity refusal, CLI exit 2 asserted both through `client::exit_code_for` and by running the real CLI against a 422 envelope. | | 6. Conformance Gate 4 (`docs/technical/calendar.md`) and Scenario C09 (`docs/technical/verification.md`) | Met | The suite above is the gate's executable form. | **No defect was found in `fold` or in the preservation path.** Folding is octet-correct for every multi-octet case tried, including a dedicated search over 4,000 pseudo-random mixed-width strings (1/2/3/4-octet plus combining marks, values up to 400 characters): every physical line stayed within 75 octets, every continuation was marked correctly, and every reassembly matched the input byte for byte. That search was exploratory and is not committed. ## Findings recorded, deliberately not changed here 1. **`calendar_unsupported` mapped to HTTP 400 / exit 1 while the calendar layer answered exit 2 — resolved in review.** `ApiFailure::calendar_unsupported` was `BAD_REQUEST` / `exit_codes::GENERAL_ERROR`, `CalendarError::CalendarUnsupported::exit_code()` is `INVALID_INPUT` (2), `docs/technical/cli-api.md` maps exit 2 to 400/413/422, and the CLI's error-code map had no arm for the code so it exited 1. The API now refuses with 422 / exit 2 and the CLI exits 2. The agenda *partial* path for an unsupported collection is deliberately untouched: it stays `status: partial`, coverage `unsupported`, exit 6. 2. **A resource with a custom non-IANA `TZID` is refused as `dependency_unavailable` (503, retryable) instead of `calendar_unsupported`.** The parser does produce `CalendarError::CalendarUnsupported("Unsupported TZID …")` as #013 requires, but `scan_collections` collapses the failure into "All configured CalDAV collections are unavailable" when no collection survives, and the update route reports that. The mutation is still refused with zero dispatches and no partial write (asserted), but the code and the `retryable` flag are wrong for a case that will never succeed. Same pattern in `src/api/agenda.rs` and `src/api/tasks.rs`; no test covered it before this one. Worth a follow-up decision — it is read-path classification, not preservation. 3. **A patch replaces the parameters of the property it names**, rather than merging them into the written line. This is the deliberate reading of criterion 1's "unless the patch explicitly mutates or replaces them": the patch's parameters *are* the edit's parameters (`PropertyEdit::parameterized` exists for exactly this, e.g. `VALUE=DATE`), and untouched properties keep theirs verbatim. Pinned by a test so the interpretation is reviewable. 4. **The deferred `Cargo.toml` authors chore stays open.** No dependency was needed, so `Cargo.toml` was not touched; `authors` is still unset. ## Verification - `nix develop --command cargo test` — all suites green: 35 lib tests (15 in `calendar::preservation`), `update_tests` 33, `lifecycle_tests` 27, and every other suite unchanged and passing (recall the 2 updated pins and the 2 new refusal tests). - `nix develop --command cargo clippy --all-targets -- -D warnings` — clean. - `nix develop --command cargo fmt --check` — clean. - Refusal contract: HTTP 422 on the patcher's own `calendar_unsupported` refusal, `life::client::exit_code_for` == 2 for that envelope, and a real `life task update` process exiting 2 against a 422 envelope — with zero `PUT`s, zero `operations` rows and untouched backend bytes. - Not verified: any real CalDAV server or a client-produced object (the mock Radicale captures the bytes `PUT`); the issue's fixture *verbatim* over HTTP, because its `Custom-Timezone` `TZID` is refused by the read path before the patcher is consulted (finding 2) — it is exercised at the patcher level instead. ## Related Issues - Closes #14 - Refs #11 (the patcher this evidences), #13 (custom `VTIMEZONE` handling, finding 2), #15 (occurrence-scoped mutations, untouched here).
Issue #014's patcher landed with #011 (`src/calendar/preservation.rs`, driven by
`src/api/update.rs`); this adds the conformance evidence its acceptance criteria
require and audits the implementation against them.

Audited, no defect found: `fold` is octet-correct for multi-octet characters
(75-octet physical lines including the continuation marker, folds back off to a
character boundary, and unfolding restores the input byte for byte). The
evidence is new and had not been asserted anywhere before: the previous folding
test folded 200 ASCII octets only.

New evidence:
* unit: multibyte (4-, 3-, 2-octet and combining) values fold at <=75 octets,
  never split a character and reassemble byte-exactly; a boundary landing inside
  a 4-octet character backs off to the previous boundary; the complex fixture is
  preserved byte for byte with only SUMMARY differing, its detached override
  untouched; every refusal is a Preservation error and never a partial resource;
  a patch replaces the parameters of the property it names.
* http: PATCH /v1/events/{id} on the complex object keeps the folded Unicode
  DESCRIPTION, the X-* vendor properties with their parameters, VALARM and
  VTIMEZONE byte-identical while changing only the master SUMMARY; the sibling
  detached override is byte-identical and the master is the only edited
  component; an ambiguous duplicate identity answers `calendar_unsupported` with
  zero dispatches, no journalled operation and untouched backend bytes; a long
  multibyte title folds within 75 octets and reads back exactly.

Recorded deviations from the criteria's letter (unchanged behaviour, reported in
the pull request): `calendar_unsupported` maps to HTTP 400 / exit 1 in this
deployment rather than 422 / exit 2, and a resource whose custom non-IANA TZID
the read path cannot resolve is refused as `dependency_unavailable` (503,
retryable) before the patcher is consulted.
Author
Owner

Coordinator review — CHANGES REQUESTED (one item)

Thanks for correcting my brief rather than following it — that is exactly the behaviour I want. You were right on both points and I have verified them myself:

  • ApiFailure::calendar_unsupported (src/api/mod.rs:202) is HTTP 400 / exit_codes::GENERAL_ERROR (1), not "422 / exit 2".
  • CalendarError::CalendarUnsupported(_).exit_code() (src/calendar/mod.rs:70) is exit_codes::INVALID_INPUT (2).
  • src/client.rs has no calendar_unsupported arm, so the CLI falls through to the default and exits 1.

So the same condition carries exit 2 in the calendar layer and exit 1 in the API/CLI layer, and #14's criterion 5 asks for "HTTP 422 / Exit code 2 or 5".

Change needed: reconcile the mapping. Set ApiFailure::calendar_unsupported to http_status: StatusCode::UNPROCESSABLE_ENTITY and exit_code: exit_codes::INVALID_INPUT, which makes it agree with CalendarError::exit_code(), with the file's existing validation_failed precedent (UNPROCESSABLE_ENTITY), and with docs/technical/cli-api.md ("Exit 2 … HTTP mapping 400/413/422"). Add assertions pinning HTTP 422 and CLI exit 2 on a calendar_unsupported terminal refusal (the ambiguous-duplicate-identity case you already drive is a good vehicle), and update the existing test that currently pins HTTP 400.

Scope note: keep this to the terminal-refusal mapping. The agenda's partial path for an unsupported collection is different behaviour and already correct (status: partial, coverage unsupported, exit 6) — do not touch it. This is a shared constant, so run the full suite and fix any assertion that pinned the old 400/1.

Everything else: approved as-is

The audit result ("audited, no defect found in fold or the preservation path") matches what I found reading the code, and the evidence you added is the right kind:

  • fold at octets with is_char_boundary backoff, and the case where octet 75 lands inside a 4-octet emoji asserting the backoff to octet 73 — that is the exact failure mode criterion 2 exists to prevent, and nothing else in the suite covered it.
  • Byte-level reassembly equality for mixed 1/2/3/4-octet and combining sequences.
  • Whole-resource-differs-in-exactly-one-physical-line assertions, and byte-identical VALARM/VTIMEZONE/X-APPLE-STRUCTURED-LOCATION/X-CUSTOM-VENDOR-PROP regions.
  • The master-vs-detached-override byte comparison, and the HTTP-level calendar_unsupported refusal with zero PUTs and zero operations rows.
  • Criterion 4 evidenced by the single ical:: import being confined to src/api/creation.rs.

No production change was needed, which is the correct outcome for an issue whose implementation landed with #11. Cargo.toml correctly untouched, so the deferred authors chore stays pending.

Keep cargo test / clippy -D warnings / fmt --check green and do not merge.

## Coordinator review — CHANGES REQUESTED (one item) Thanks for correcting my brief rather than following it — that is exactly the behaviour I want. You were right on both points and I have verified them myself: - `ApiFailure::calendar_unsupported` (`src/api/mod.rs:202`) is `HTTP 400` / `exit_codes::GENERAL_ERROR` (1), **not** "422 / exit 2". - `CalendarError::CalendarUnsupported(_).exit_code()` (`src/calendar/mod.rs:70`) is `exit_codes::INVALID_INPUT` (2). - `src/client.rs` has no `calendar_unsupported` arm, so the CLI falls through to the default and exits 1. So the same condition carries **exit 2 in the calendar layer and exit 1 in the API/CLI layer**, and #14's criterion 5 asks for "HTTP 422 / Exit code 2 or 5". **Change needed: reconcile the mapping.** Set `ApiFailure::calendar_unsupported` to `http_status: StatusCode::UNPROCESSABLE_ENTITY` and `exit_code: exit_codes::INVALID_INPUT`, which makes it agree with `CalendarError::exit_code()`, with the file's existing `validation_failed` precedent (`UNPROCESSABLE_ENTITY`), and with `docs/technical/cli-api.md` ("Exit 2 … HTTP mapping 400/413/422"). Add assertions pinning HTTP 422 and CLI exit 2 on a `calendar_unsupported` terminal refusal (the ambiguous-duplicate-identity case you already drive is a good vehicle), and update the existing test that currently pins HTTP 400. Scope note: keep this to the terminal-refusal mapping. The agenda's *partial* path for an unsupported collection is different behaviour and already correct (`status: partial`, coverage `unsupported`, exit 6) — do not touch it. This is a shared constant, so run the full suite and fix any assertion that pinned the old 400/1. ### Everything else: approved as-is The audit result ("audited, no defect found in fold or the preservation path") matches what I found reading the code, and the evidence you added is the right kind: - `fold` at octets with `is_char_boundary` backoff, and the case where octet 75 lands **inside** a 4-octet emoji asserting the backoff to octet 73 — that is the exact failure mode criterion 2 exists to prevent, and nothing else in the suite covered it. - Byte-level reassembly equality for mixed 1/2/3/4-octet and combining sequences. - Whole-resource-differs-in-exactly-one-physical-line assertions, and byte-identical `VALARM`/`VTIMEZONE`/`X-APPLE-STRUCTURED-LOCATION`/`X-CUSTOM-VENDOR-PROP` regions. - The master-vs-detached-override byte comparison, and the HTTP-level `calendar_unsupported` refusal with zero PUTs and zero `operations` rows. - Criterion 4 evidenced by the single `ical::` import being confined to `src/api/creation.rs`. No production change was needed, which is the correct outcome for an issue whose implementation landed with #11. `Cargo.toml` correctly untouched, so the deferred authors chore stays pending. Keep `cargo test` / `clippy -D warnings` / `fmt --check` green and do not merge.
The same refusal carried exit 2 in the calendar layer and exit 1 through the
API/CLI, so a caller could not tell "correct the request" from "something
broke":

* ApiFailure::calendar_unsupported was HTTP 400 / GENERAL_ERROR, while
  CalendarError::CalendarUnsupported::exit_code() is INVALID_INPUT (2) and
  docs/technical/cli-api.md maps exit 2 to 400/413/422;
* client::exit_code_for_error had no arm for the code, so the CLI exited 1.

The API now refuses with 422 / exit 2, matching the CalendarError layer and the
validation_failed precedent, and the CLI's error-code map answers 2 for it. The
agenda partial path for an unsupported collection is untouched: it stays
status partial with unsupported coverage and exit 6, a different outcome.

Assertions updated to the reconciled contract: the pins that expected 400 for a
calendar_unsupported refusal (tests/update_tests.rs, tests/lifecycle_tests.rs)
now expect 422. Two new tests pin it end to end — the ambiguous-duplicate
refusal asserts HTTP 422 and client::exit_code_for == 2, and
a_calendar_unsupported_refusal_exits_two runs the real CLI against a 422
envelope and asserts process exit 2.
yuris changed title from test(calendar): preservation conformance evidence for folded unicode and overrides (#14) to fix(api): reconcile calendar_unsupported to 422/exit 2 and evidence preservation conformance (#14) 2026-09-22 22:37:51 +00:00
Author
Owner

Coordinator re-review — APPROVED

Verified af3213f myself:

  • ApiFailure::calendar_unsupported is now UNPROCESSABLE_ENTITY + exit_codes::INVALID_INPUT, with a doc comment that states why (same class of refusal as validation_failed; retrying cannot make an uninterpretable resource interpretable) and names both the CalendarError::exit_code() alignment and the cli-api table. src/client.rs gained the matching "calendar_unsupported" => INVALID_INPUT arm — that detail matters, because the CLI maps by error code rather than status, so without it the exit code would still have been 1.
  • Scope was respected: the agenda partial path (unsupported coverage → status: partial, exit 6) is untouched and agenda_tasks_tests is green as-is.
  • The previously-400 pins were reconciled rather than deleted (tests/update_tests.rs occurrence refusal, two tests/lifecycle_tests.rs cases, plus the new C09 refusal test now asserting 422 + exit 2), and a_calendar_unsupported_refusal_exits_two drives the real binary end to end to prove process exit 2.

Independent verification on af3213f: cargo test 252 passed / 0 failed; cargo clippy --all-targets -- -D warnings 0 errors; cargo fmt --check clean.

I have retitled the PR to reflect that it carries a production fix as well as the conformance evidence. Finding 2 (a custom non-IANA TZID on the read path classified as dependency_unavailable) is outside the patcher's scope — src/calendar/report.rs:374 maps an unresolvable TZID to CalendarUnsupported and src/api/items.rs::live_read_failure maps that to calendar_unsupported, so the claim needs a reproduction before anything is changed. Filing it as a follow-up rather than blocking this merge.

Merging.

## Coordinator re-review — APPROVED Verified `af3213f` myself: - `ApiFailure::calendar_unsupported` is now `UNPROCESSABLE_ENTITY` + `exit_codes::INVALID_INPUT`, with a doc comment that states *why* (same class of refusal as `validation_failed`; retrying cannot make an uninterpretable resource interpretable) and names both the `CalendarError::exit_code()` alignment and the cli-api table. `src/client.rs` gained the matching `"calendar_unsupported" => INVALID_INPUT` arm — that detail matters, because the CLI maps by error code rather than status, so without it the exit code would still have been 1. - Scope was respected: the agenda partial path (unsupported coverage → `status: partial`, exit 6) is untouched and `agenda_tasks_tests` is green as-is. - The previously-400 pins were reconciled rather than deleted (`tests/update_tests.rs` occurrence refusal, two `tests/lifecycle_tests.rs` cases, plus the new C09 refusal test now asserting 422 + exit 2), and `a_calendar_unsupported_refusal_exits_two` drives the real binary end to end to prove process exit 2. Independent verification on `af3213f`: `cargo test` **252 passed / 0 failed**; `cargo clippy --all-targets -- -D warnings` 0 errors; `cargo fmt --check` clean. I have retitled the PR to reflect that it carries a production fix as well as the conformance evidence. **Finding 2** (a custom non-IANA `TZID` on the read path classified as `dependency_unavailable`) is outside the patcher's scope — `src/calendar/report.rs:374` maps an unresolvable `TZID` to `CalendarUnsupported` and `src/api/items.rs::live_read_failure` maps that to `calendar_unsupported`, so the claim needs a reproduction before anything is changed. Filing it as a follow-up rather than blocking this merge. Merging.
yuris merged commit 9ffd57d7f5 into main 2026-09-22 22:37:52 +00:00
yuris changed title from fix(api): reconcile calendar_unsupported to 422/exit 2 and evidence preservation conformance (#14) to fix(api): reconcile calendar_unsupported to 422/exit 2 and evidence preservation conformance 2026-09-23 05:12:06 +00:00
Sign in to join this conversation.
No reviewers
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!52
No description provided.