Skip to content

0.2.0: portability escape hatches, end_action, and the docs to match - #4

Merged
baraline merged 73 commits into
mainfrom
feat/portability-escape-hatches
Sep 2, 2026
Merged

baraline merged 73 commits into
mainfrom
feat/portability-escape-hatches

Conversation

@baraline

@baraline baraline commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Cuts 0.2.0 — the first release since 0.1.0, and 69 commits on top of main.

Why this is large

A 0.2.0 was versioned and dated 2026-08-18 but never tagged or uploaded, so none of that work ever reached anyone. Rather than strand it under a version number that does not exist on PyPI, this merges it into the release being cut. PyPI carries only 0.1.0 and origin only the 0.1.0 tag, so 0.2.0 is free.

What is in it

Portability escape hatches (the branch's original purpose): every per-deployment value — label language order, the ~ wildcard, the department context, response-envelope casing — is now a parameter defaulting to the measured behaviour, instead of a constant that only suits one instance.

end_action, new here. An action is born open, and an open action's text does not render in the ticket history — so the package could create an action nobody could read, and the pre-release walkthrough had to reach into client._transport with a hand-built body to finish one. It is addressed by the ticket: the path segment is the rfc_number and the action is named in the body, because actions/{action_id} answers 404 for this verb.

Ending is not bookkeeping. Measured 2026-09-01 (2/2 tickets), ending a fresh ticket's open type-20 workflow action moved the ticket En cours → Résolu and spawned a new open action; a control (3/3) showed ending a caller-created type-94 action changed neither status nor action count. The vendor's id-less "end every open action" form is therefore behind an explicit end_all=True, and a bare action_id=None is refused — Action.action_id is legitimately None all over this package (a create response carries no id; a fields= projection without ACTION_ID drops it), so forwarding one would otherwise resolve the ticket in silence.

The export stopped disagreeing with the screen. _resolve_action_body now resolves COMMENT when DESCRIPTION is empty, mirroring the UI's own rule. Resolving DESCRIPTION alone dropped the body of exactly the actions a human can read.

Breaking changes — five

Each replaces a silently wrong answer with a correct one or a refusal. All are marked **BREAKING** in CHANGELOG.md with the reasoning; RequestUpdate.status_id is the removal most likely to bite, since it returned HTTP 200 while dropping the status.

Retractions

Several confident claims in this repo turned out to be false and are retracted with the measurement that refuted them: ending an action is not 590-blocked (that message means "no open action matched", e.g. replaying against an already-ended one), ending does not clear GROUP_ID, and end_date_ut is stamped at resolution, not closure — so it answers "stop working this ticket", not "closed".

PostTask — the model for posting a comment, used in six user-guide snippets — was exported and missing from docs/api_reference.rst entirely. Sphinx renders only what that file lists, so nothing warned. scripts/tests/test_api_reference_coverage.py now fails when an __all__ export has no autodoc entry.

Verification

All five CI gates green on the tip, and on each of the three commits individually: 1289 passed, ruff clean, _sync/ up to date, mypy clean on 42 source files, sphinx -W builds. python -m build plus twine check pass on both artifacts.

Beyond that, the changed behaviour was exercised against a live EasyVista 2025.3 instance: a nine-step walkthrough confirmed step by step in the UI, and end_action verified on both the sync and async surfaces. The 28 test tickets that produced these findings are closed.

Before merging

docs/publishing.rst wants the tag v0.2.0 (v-prefixed; 0.1.0 is the unprefixed outlier). Publishing a GitHub release with that tag triggers the PyPI upload, so the tag should not be cut until this is merged and CI is green.

🤖 Generated with Claude Code

baraline and others added 30 commits August 17, 2026 17:13
Adds format_ev_datetime, the inverse the interval filter builders need.
Behaviour-preserving for reporting: its eight existing tests are unchanged.
ev_since_filter/ev_between_filter emit FIELD:(a;b), the only range grammar
this API honours. ev_contains_filter/ev_starts_with_filter use '~' with an
explicit wildcard, correcting the belief that '~' is exact-match only.
_TIMESTAMP_RE accepted well-shaped but impossible timestamps (9999-99-99,
25:61:61) and Unicode digits, and a dropped condition returns the whole
table rather than an error, so a typo'd watermark silently degraded a sync
sweep to a full-table read. _interval_bound now also requires
parse_ev_datetime(text) to succeed. Switch \d -> [0-9] and .match()+$ ->
fullmatch() so the anchoring no longer depends on the prior .strip(). Add
acceptance-side coverage for the five renderings measured live, and soften
the ev_since_filter docstring's "cannot be malformed" overclaim about
datetime input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…unsorted

EasyVista honours 'FIELD DESC' but silently drops 'FIELD:DESC', returning the
default order. Measured live 2026-08-17; closes O-DIR-1.
The colon-vs-space rule was measured on LAST_UPDATE (a date column), never on
RFC_NUMBER. Applying it to RFC_NUMBER is sound inference from a syntactic rule,
not a live-verified fact about that field -- Task 9's live guard is what
actually pins RFC_NUMBER DESC. Prose-only: no constant, assertion, or
behaviour changed.
BREAKING: Request/Employee timestamp fields are datetime, not str. The format
is offset-bearing ISO 8601 with ms precision (verified live), so no caller-side
server_timezone is needed. Write models untouched: write format unverified.

Also fixes tests/test_reporting.py::test_window_excludes_missing_or_unparseable_dates
(renamed to test_window_excludes_missing_dates): its "garbage"-dated ticket can
no longer be constructed, since Request.model_validate now rejects a malformed
CREATION_DATE_UT before aggregate_tickets ever sees it. That coverage moved to
models/tests/test_common.py's new unparseable-timestamp test.
CRITICAL: _fields._text() and references._scalar() returned "" / None for
anything that wasn't a str/int, so a retyped timestamp column silently
vanished from every consumer of a model_dump(by_alias=True) dict:
TicketContext.to_markdown() dropped its Created/Updated rows, and
Request.reference("LAST_UPDATE") / aggregate_tickets(dimensions=(...)) on a
timestamp column resolved to nothing. Both extractors now render a datetime
via format_ev_datetime (falling back to plain .isoformat() for the naive case,
since neither extractor may raise), fixed once at the shared root rather than
patched per call site.

Also fixes the Important finding that OptionalDateTime didn't honour its own
"aware" promise for a datetime passed in directly (only strings routed through
parse_ev_datetime's naive->UTC normalization; a bare second isinstance(value,
str) guard skipped it for everything else) -- dropped, so every value now
routes through parse_ev_datetime uniformly.

Three minors: test_employee.py now directly asserts last_update parses (it
had no failing pre-existing assertion, so was previously only covered
transitively); the unparseable-timestamp test now pins that the
ValidationError names the field (errors()[0]["loc"] == ("when",)), which is
the entire reason the coercer hands the original value back instead of
raising itself; reporting.py's docstring now explains why the
now-partially-unreachable missing/unparseable guard is kept rather than
deleted as dead code.

CHANGELOG.md intentionally untouched -- Task 10 owns changelog consolidation.
All present on the item-level GET and reachable via a fields= projection;
previously available only as untyped extras. Closes EV-R1.
…laim

The docstring and field comment said the default LIST projection omits all
ten new fields while the same sentence listed ACTION_NUMBER and DONE_BY_ID
among what it carries. The real split is three-way: six fields genuinely
absent from the default list row, two already present top-level, and two
(ACTION_TYPE_ID, REQUEST_ID) present on a list row only nested inside
ACTION_TYPE/REQUEST -- so the declared top-level-aliased field reads None
off a list row despite the data being present. Also notes that only one of
a fresh ticket's ~12 auto-spawned actions is human-authored and that
generated actions carry an undeclared STATUS_ID_ON_CREATE. Parametrized
the empty-string-sentinel test over all eight OptionalInt fields so its
name matches what it actually checks. No field, alias or type changed.
The actions list honours fields= and grants every scalar requested, so a
caller can read all timestamps and authors for a ticket in one request
instead of one item fetch per action. Closes EV-R3.
Review found the "*" isn't-a-wildcard and dotted-path-is-dropped caveats only
lived in the private builder comment, unreachable from help()/IDE tooltips.
Adds one sentence to the client-facing docstring, mirrored verbatim into the
sync tree. No behavior change.
Both live-verified by re-reading the record, not by status code. Uses the
top-level actions/{id} and nested requests/{rfc}/documents/{id} paths; the
alternatives return 403. Closes EV-R4.
Each verified writable by re-reading the ticket. severity_id and urgency_id
deliberately excluded: one is refused, the other returns 590 while still
applying (tracked as O-590-PARTIAL). Closes EV-R9, EV-R10.
Adds a differential change-window characterization (a single count cannot
prove a range filter works on this API) and corrects three tilde tests whose
assertions were right but whose stated conclusion over-generalized.

Also settles live whether '%' is a wildcard for '~' (it is, matching '*'
exactly), verifies ActionUpdate's lowercase-cased body actually lands through
update_action, rewords ActionUpdate's docstring to name the field rather than
quote a body it no longer sends, and corrects this module's own inherited
assumption that every LAST_UPDATE comparison-operator rendering is silently
dropped -- two of the three instead raise a hard type-mismatch error (590),
only the colon-free rendering is structurally unparseable enough to be
dropped.
Round-1 review found three Important defects in the change-window
characterization: split_instants leaked a raw datetime into the
comparison-operator f-strings (space separator, 6-digit fraction) despite
its str-literal contract; that same test asserted two 590s with no control
isolating them to the embedded operator rather than the column rejecting
FIELD:"value" outright; and the closed-interval test was a single count that
could not distinguish a real upper bound from one silently dropped down to
the open-ended form. Also fixes RECENT_TICKETS_SORT's guard, which computed
an unsorted baseline but never compared against it (monotonicity-only, same
fate EV-R6's sibling test was written to avoid), a strict assertion in the
percent-wildcard probe that could redden on a data-availability gap instead
of skipping, two compound is-not-None asserts that risked printing a live
instant on failure, and a stale ticket/action/update count in conftest.py's
mutation-footprint docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
'~' is a pattern operator requiring an explicit wildcard (* or %), not
exact-match-only -- degenerates to equality without one. Corrects the claim
in the user guide, the search-syntax and asset-workflow skills, and the
changelog; adds ev_contains_filter/ev_starts_with_filter examples in their
place.

Documents the change-window builders (ev_since_filter/ev_between_filter),
the timestamp helpers (parse_ev_datetime/format_ev_datetime), and the
two-fate comparison-operator behaviour (silent-drop vs. HTTP 590
type-mismatch, depending on whether FIELD: syntax survives). Closes the
O-DIR-1 sort-token hedges in both the reporting-and-context and
search-syntax skills now that FIELD DESC is live-confirmed.

Consolidates the CHANGELOG's Unreleased section: merges the duplicate
Changed headings, strikes the now-false "no datetime parsing is claimed"
line, adds the scope note that only Employee.last_update is a true break
relative to 0.1.0, and fills in the entries this branch was still missing
(the four filter builders, list_actions(fields=...), update_action/
delete_document/ActionUpdate, Action's new fields, RequestUpdate's
widening, and the RECENT_TICKETS_SORT fix).
Same failure mode as the earlier easyvista-asset-workflow catch: skill and
changelog content outside the brief's file list still described a
pre-task-9 API. delete_document, update_action and list_actions(fields=)
now exist in easyvista-document-workflow and easyvista-ticket-actions;
RequestUpdate's impact_id/owner_id/external_reference widening is
documented in easyvista-ticket-workflow; skills/README.md's index no
longer contradicts the skills it indexes.

Rewrites the CHANGELOG's BREAKING scope note: the 0.1.0 git tag resolves
to a commit ~150 commits after the 0.1.0 release commit the changelog
documents, and at the tag commit all seven timestamp fields -- not just
Employee.last_update -- were already str. Verified against both commits
before rewriting.

Also fixes a genuine dangling reference (ChangedRef, which never existed)
in a test docstring to the real field it was describing (Action.updated_at).
…l map

Add ActionUpdate to the _WRITE_MODELS dict so its snippets in
easyvista-ticket-actions are now validated for keyword correctness.

Add a new test_write_models_map_is_complete() test that asserts every
EasyvistaWriteModel subclass exported from the package appears in the map.
This prevents silent validation skips when a new write model is added
without updating the map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-2 live probing found that EasyVista *accepts* an offset-less timestamp
literal and reads it in a different zone. Measured against one instance, the
same wall-clock text enumerated 13 rows with its offset and 11 without: the
offset-less form moves the bound later and skips records, with no error of any
kind. A watermark that silently skips is the worst outcome this grammar has.

`format_ev_datetime` already refused a naive `datetime` on exactly this
reasoning, and said so in its docstring. The string path was unguarded, so the
identical hazard reached the wire by the other route. Both paths now refuse. A
bare date stays legal -- day granularity has no time to misplace, and it is a
form measured live as honoured.

Also withdraws a claim this package shipped: `RequestUpdate`'s docstring said
`DESCRIPTION` is empty on every ticket of the verified instance. A pooled 77-row
sample across four orderings found `COMMENT` on 57 rows, `DESCRIPTION` on 27 and
both on 24, the proportions flipping by slice. The earlier 0/15 reading was
drawn from probe-authored tickets. The load-bearing claim is untouched and still
verified -- `RequestUpdate.description` writes the `COMMENT` memo -- but the
generalisation is gone, and with it any hope of auto-detecting an instance's
body memo by sampling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`download_document` returns `bytes`, so a consumer mirroring attachments
between systems has to hold the whole file. With the base64 inflation an
upload applies, a 32 MB attachment peaked near 76 MB of worker memory for
what is conceptually a pass-through. `stream_document` hands the body over
in 64 KiB chunks (`chunk_size=` to change it) so the file never has to
exist in memory whole. It accepts exactly what `download_document` accepts
and resolves the URL through the same `resolve_url`, so the same-origin
refusal, `follow_redirects=True` and the error mapping are shared rather
than re-derived.

Only the download direction can stream. EasyVista takes an attachment as
base64 inside a JSON body, so `add_document` must materialise the whole
payload before it can send anything; the asymmetry is the API's.

The retry decision, which is the part worth questioning: a streamed
response cannot be restarted once bytes have reached the caller, because
restarting would deliver them twice, and this method will not silently
duplicate data. But refusing to retry at all would make streaming strictly
less reliable than `download_document`, which retries. So the retried unit
is "open the download AND take its first chunk" -- everything inside
`_open_stream`, which produces nothing the caller has seen yet and is
therefore safe to repeat under the same policy `get_bytes` uses (same
attempt count, same backoff, 590 still not retried). From the first chunk
onwards nothing is retried: a transport failure surfaces as
`EasyvistaConnectionError` and a partly consumed stream is never resumed,
which is stated in the docstring because it is the caller's problem to
handle. A test asserts the request count on a mid-body failure, so making
this resumable fails loudly.

Two consequences of streaming forced small decisions. `_raise_for_response`
reads `.content`, which a streaming response refuses until the body has
been read, so the error path reads it first -- that is what keeps a 403 on
the streaming path identical to a 403 on the buffered one, asserted as an
equality between the two rather than against a hardcoded type. And httpx
spells its streaming methods with a leading `a` rather than the `Async`
prefix unasync's convention knows about, so `aread` and `aiter_bytes` join
`aclose` in TOKEN_REPLACEMENTS, with the rationale recorded there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of 8ac38fa found seven statements about `stream_document` that nothing
backed, plus one connection released later than it reads.

What was false and is now true:

- The document-workflow skill's sync/async banner carved `async for` out for
  the `iter_*` methods only, so a reader following it literally wrote
  `await client.stream_document(doc)` -- a TypeError, because the method is
  deliberately not named `iter_*`. The carve-out now names it, and says why
  awaiting it fails. Only this skill's copy of the shared banner changed; it is
  the only one with a byte-streaming method.
- `unasync_build.py`'s rationale for the `aread`/`aiter_bytes` entries claimed
  an unmapped httpx async name would raise `AttributeError`. It would not:
  httpx 0.28.1 defines `aread`, `aiter_bytes` AND `aclose` on `httpx.Response`
  itself, so in sync code the attribute exists and merely misbehaves -- an
  un-awaited coroutine leaving the body unread (then `ResponseNotRead` from the
  error mapping instead of the mapped EasyVista exception), or `TypeError:
  'async_generator' object is not iterable`. Only `aclose` on the *client* is
  genuinely absent. The comment now says so, which is a stronger argument for
  mapping every one of these names than the version it replaces.
- CHANGELOG called "a 32 MB attachment peaked near 76 MB of worker memory" a
  measurement of ours. It is not ours, appeared nowhere else in the repo, and
  pointed at the wrong half: of that peak only the download buffer is what this
  change removes, while the base64 payload `add_document` builds is untouched.
  The bullet now states the motivation without borrowing a number.
- `docs/user_guide.rst`'s new streaming subsection showed only the sync loop
  while the guide tells async readers every method is a coroutine. It now spells
  out `async for chunk in client.stream_document(...)`, matching the treatment
  the pagination section already gives `iter_tickets`.
- A test comment said 10240 bytes was "not a multiple of the chunk size" at
  chunk_size=1024. It was exactly ten chunks, and no streaming test anywhere
  used a ragged body, so a short final chunk was never exercised. Both the
  transport and the client case are now genuinely off the boundary and assert
  the tail.

Claims that were true but untested, now pinned:

- `test_stream_bytes_retries_a_failure_fetching_the_first_chunk`: the design
  decision three documents assert -- the first chunk is fetched inside the
  retried unit, so a failure before any byte reaches the caller is still a safe
  restart. The rejected alternative (retry the open alone) passed all twelve
  existing `stream_bytes` tests; it fails this one.
- `test_stream_bytes_chunks_at_the_documented_default_size`: every other
  chunk-counting test passed `chunk_size` explicitly, so
  `DEFAULT_STREAM_CHUNK_SIZE` could change to anything and leave "64 KiB by
  default" false in three places with a green suite.
- `test_stream_document_closes_the_inner_stream_when_stopped_early`, with the
  fix it needs: `stream_document` iterated the inner `stream_bytes` generator
  and never closed it, so a caller that stopped early left the response -- and
  its pooled connection -- checked out until the generator became garbage, which
  on the async surface means the collector plus the event loop's asyncgen
  finalizer. An explicit try/finally releases it at once on both surfaces;
  `contextlib.aclosing` could not be used because the codegen cannot map it to
  its sync twin. `stream_bytes` is now annotated `AsyncGenerator[bytes, None]`,
  which is what it always was.

Gates: 729 passed (was 723), 99 skills-contract, docs examples 24 passed,
mypy clean over 38 files, ruff check + format clean, `unasync_build.py --check`
up to date, sphinx -W succeeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The verify pass on the streaming download found six pieces of prose that were
wrong rather than merely untidy. Four are the same defect class this branch has
been closing all along: a comment or docstring in `_async/` is copied verbatim
into `_sync/`, so one that is false there is false twice.

- A test helper's comment said its unreachable `yield` stops the function being
  a coroutine. In the generated sync tree there is no coroutine to avoid; the
  yield is simply what makes it a generator function. Now says that.
- `_ClosableStream`'s docstring named only `aclose()`. unasync rewrites the
  method to `close()` but not the name inside a docstring, so the sync twin
  documented a method it does not have. Names both, as the rest of the tree does.
- A comment pointed at "the join above" when the assertion is eight lines below.
- The new `try/finally` comment claimed it fixes the `break` case. It does not,
  and the report that introduced it argued so correctly: unwinding this
  generator is itself deferred to the event loop's finaliser on the async
  surface, so a bare `break` still defers. What the `finally` removes is the
  second wait, for the inner generator to become garbage on its own. Says that
  now, including why the sync surface never needed it.
- `skills/README.md` still told readers to `await` every method while its own
  inventory advertises `stream_document`, which is an async generator. That is
  the third file to carry this exact defect, after the skill's banner and the
  user guide.
- One `--` inside a CHANGELOG paragraph whose other prose uses an em dash.
  Markdown applies no smart typography, so both rendered in the published notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Minor, not patch. The release carries two breaking changes, and CHANGELOG.md's
own policy line says breaking changes land between minor versions while the
package is pre-1.0 — 0.1.1 would have contradicted the file it appears in:

- read-model timestamps are timezone-aware `datetime` instead of `str`;
- `ev_since_filter` / `ev_between_filter` now refuse a bound whose time carries
  no UTC offset, which they previously passed to the wire.

The version lives in more places than the two the release workflow names, and
two of them are enforced: `testing/test_public_api.py` asserts `__version__`
outright, and `scripts/tests/test_skills_contract.py` asserts every skill's
frontmatter version equals it — with a comment saying a release that bumps
`__version__` and forgets the skills fails there. It does. All eight are bumped.

The link block is also repaired. `[0.1.0]` pointed at `releases/tag/v0.1.0`,
which has never existed: the tag that was pushed is bare `0.1.0`, so that link
404s in the published changelog today. It now points at the real tag. `[0.2.0]`
is written v-prefixed to match the convention `.github/workflows/release.yml`
documents and validates against; GitHub's compare view accepts the mixed pair.

No tag is created here. Tagging is outward-facing and deliberately left to a
human — use `v0.2.0`, both to satisfy the workflow's convention and to avoid
repeating the bare-tag mistake that broke the 0.1.0 link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…k stamps

Three measured behaviour changes, each closing a gap between what the code
claimed and what the wire does.

1. An interval bound naming a time is now NORMALISED, not passed through.
   `_TIMESTAMP_RE` admitted renderings the API rejects: measured live
   2026-08-18, `LAST_UPDATE:(2025-11-28T16:14:41+01:00;)` returns HTTP 590, as
   do minute precision, `seconds+00:00` and a space separator instead of `T`
   (what `str(aware_datetime)` produces). Only a bare date and
   millisecond-precision-with-offset are honoured. That mattered because the
   offset gate added earlier on this branch makes an offset MANDATORY on a
   time, and the obvious way to comply with a stored `"2026-08-17T20:26:40"`
   watermark is to append `+02:00` -- which 590s every sweep. An admitted
   string bound is now re-rendered through
   `format_ev_datetime(parse_ev_datetime(text))`, a bare date passes through
   unchanged, and the string and datetime paths finally emit byte-identical
   bounds. The comment at filters.py claiming the regex "accepts only the
   renderings measured live" was false; it is now the admission gate and says
   so. `test_since_emits_the_open_ended_interval` pinned the 590-ing literal as
   canonical and no longer does.

   Two sub-cases handled with it: the RENDERED bound is validated, so a zone
   whose UTC offset is not a whole number of minutes (any pre-1900 zoneinfo
   entry) raises locally instead of emitting `+05:53:20` that the string path
   would refuse; and lowercase `z` is now accepted, since `parse_ev_datetime`
   accepts it on the read path and the gate rejected it with a misleading "not
   a timestamp" message.

2. `ev_contains_filter`/`ev_starts_with_filter` now refuse `_` and `[` as well
   as `*` and `%`. All four are metacharacters to `~`: measured live,
   replacing one character of an RFC that matched 1 row with `_` matched 9, and
   `[0-9]` likewise, while `[<realchar>x]` matched 1. No escape exists -- `\_`
   matched 0 rows, so the backslash is compared literally. `_` is pervasive in
   EasyVista codes, so `ev_contains_filter("ASSET_TAG", "LAPTOP_01")` silently
   also matching `LAPTOP-01` with HTTP 200 was a routine input producing wrong
   rows. Refusing is the rationale already written there for `*`/`%`.

3. A malformed timestamp column now RAISES instead of falling through to
   pydantic. The fallthrough defeated its own purpose: `"20260817"` became
   `1970-08-23T12:00:17Z`, 56 years off and silent, and `1755434441610` -- what
   an epoch-millis format change looks like -- became a wholly credible
   `2025-08-17T12:40:41.610Z`, absorbing the one signal the guard exists to
   raise. The docstring already promised a raise; the obsolete epoch-seconds
   paragraph is gone. The `""` unset sentinel still becomes `None`.

Also documents the deliberate read/write asymmetry in `timestamps.py`:
`parse_ev_datetime` assumes UTC for an offset-less literal because a read must
never fail a record, while `_interval_bound` refuses the same shape because a
mis-zoned bound skips records silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nk_size

`list_actions` sent no `max_rows` and never paginates, so a ticket's action log
was truncated at the server's own default (25 on the verified instance) -- the
one search-backed call on this client that did not inject
`config.default_max_rows`. It now passes it explicitly, so the truncation point
is the caller's to see and to raise. Pagination is deliberately NOT added here:
it changes behaviour and needs live verification that this endpoint's `@next`
behaves like the others'.

What this branch newly did was attach completeness claims to that truncation --
`list_actions`'s docstring said "read every action's timestamps and author in
one request", `models/action.py` said "every one of these top-level in one
request", and the CHANGELOG said "a whole ticket's action metadata". All three
are false for any ticket with more actions than one page, and a freshly created
ticket already carries about twelve. They now say "a page", and both
`list_actions` and `get_ticket_context` state plainly that at most one page is
returned, that nothing paginates, and that the excess is dropped with no error
-- `get_ticket_context` because it consumes the list, so
`TicketContext.to_markdown()` renders a silently truncated log.

`Transport.stream_bytes` now rejects a non-positive `chunk_size` locally.
Measured: `chunk_size=0` escaped as "ValueError: range() arg 3 must not be
zero" and `-8` as "IndexError: list index out of range", both thrown from
inside httpx's ByteChunker several frames below this client, so a caller
computing a size read a library bug rather than bad input.

Prose corrections on the same surfaces, all previously false or absent:

- `resolve_url` said "The API is trusted to describe its own instance, not to
  redirect us off it". Both download paths run `follow_redirects=True` and a
  `302` to another host IS followed; the credential is dropped, but the foreign
  bytes are returned as the attachment. The docstring now states what is
  actually guaranteed. Behaviour unchanged -- signed-location hops need it.
- `stream_document` documents that stopping early on the async surface needs an
  explicit `aclose()`, or the response stays checked out of the pool for a GC
  cycle. This was written only in a comment inside the method body.
- `iter_tickets` documents the accepted sort token: space-separated `FIELD DESC`
  works, `FIELD:DESC`/`-FIELD`/`DESC(FIELD)` are silently ignored, and the sort
  is load-bearing on a change-window sweep.
- `update_action`'s return value is the API's unverified echo and may be sparse;
  re-read with `get_action`.
- `RECENT_TICKETS_SORT` sorts a varchar, so `get_department_context`'s
  "newest-first" is now "descending RFC_NUMBER" -- the live test proves a string
  ordering, and on an instance issuing more than one RFC prefix letter every
  `R...` ticket outranks every `I...` one regardless of date.
- `aggregate_tickets` records that an offset-less `created_since` bound is read
  as UTC, which silently shortens the window by the instance's offset.
- `_fields.py` named `reporting` as a co-consumer; it has never imported the
  module (`references.resolve_reference` is its path). And "byte-identical to
  what the API sent" is false for any input whose fraction is not 3 digits --
  the repo's own fixture shows it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pdate's writes

Adds the guards the prose in the previous two commits now depends on, and
repairs four assertions that could report green while asserting nothing.

New:

- `test_only_some_timestamp_renderings_are_accepted_as_an_interval_bound` walks
  the matrix normalisation rests on: a bare date, `ms+offset` and `ms+Z` are
  honoured; `seconds+offset`, `minutes+offset` and a space separator each raise
  590. The seconds case is the one that matters -- it is how a caller naturally
  satisfies the offset rule, and the unit suite used to pin it as canonical.
- `test_the_ascending_sort_token_the_docs_recommend_is_honoured`. Round 1
  measured bare `LAST_UPDATE` and `LAST_UPDATE ASC` as ascending, but only the
  DESC form was pinned; the sweep guidance now tells callers to use the
  ascending one, so it is pinned rather than remembered. Skips when the default
  page order is already ascending, so it cannot pass for a coincidental reason.
- `test_request_update_writes_impact_owner_and_external_reference`
  (test_live_ticket_identity.py). `RequestUpdate`'s three new fields had no live
  read-back, while their unit test's docstring read as though one existed. Under
  this branch's own measured rule -- a 200 on a PUT is not a receipt, a field
  the API cannot honour is silently dropped -- that is exactly the gap that ships
  a field which does nothing. One field per PUT so a failure names the field;
  the impact and owner ids are sampled from the instance rather than hardcoded,
  because writing back the value `ticket_factory` already set would pass even if
  the field were dropped. The unit test's docstring no longer credits itself
  with a verification it does not perform.
- The `%`-wildcard characterization is extended to `_` and `[0-9]`, the two
  metacharacters the builders newly refuse, plus a `\_` probe showing no escape
  exists. Renamed to say what it now covers. Distinguishes "matched nothing"
  (compared literally -- the regression) from "matched no more than exact" (a
  sparse sample -- a skip).
- The `update_action` live test now characterizes the PUT's echo, which had
  never been captured. It asserts the echo never names a DIFFERENT action;
  asserting it names THIS one would pin a shape nobody has measured, which is
  why the docstring and skill instead say to re-read with `get_action`.

Repaired:

- The comparison-operator control asserted `0 <= control <= baseline`, which is
  unfalsifiable: `_count` never returns a negative and a filtered count cannot
  exceed the unfiltered one. Its failure message described a state that could
  not occur. Now `0 < control < baseline`, which additionally proves the literal
  was honoured rather than merely not rejected -- a strictly stronger licence for
  attributing the two 590s to the embedded comparison syntax.
- The `LAST_UPDATE DESC` monotonicity check guarded the length of the RFC list
  while checking the timestamp list, so it passed vacuously whenever fewer than
  two timestamps came back. Guards the list it actually checks.
- The tilde test asserted `exact <= by_prefix`, satisfied by
  `by_prefix == exact == 1` -- the state its own sibling test skips as
  inconclusive, and in which it proved nothing about `~` being a pattern
  operator. Now skips there and asserts the strict bound.
- `RECENT_TICKETS_SORT`'s failure message said "newest-first" for what is a
  string ordering on a varchar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…se claims

Prose only; no code in this commit.

CHANGELOG:

- The `Changed` section asserted, as a verified live fact, that 0/15 sampled
  tickets have a non-empty `DESCRIPTION` -- a figure the `Fixed` section fifty
  lines below explicitly retracts as a sampling artifact (a pooled 77-row sample
  found `DESCRIPTION` populated on 27 rows). One release entry must not
  contradict itself, and the CHANGELOG was the last place in the repo still
  asserting the withdrawn number. The load-bearing claim --
  `RequestUpdate.description` writes `COMMENT` -- is untouched.
- The BREAKING retype bullet documented the seven changed types but not the
  consequence: `json.dumps(ticket.classify_fields().official)` now raises
  `TypeError`, and `mode="json"` appeared nowhere in the repo. Added to the
  migration note, the user guide's Timestamps section and the ticket-workflow
  skill.
- "during this same unreleased cycle" -> "during this 0.2.0 cycle": the section
  is headed `[0.2.0]`, so a released entry described itself as unreleased.
- "~150 commits" -> "117 commits" (`git rev-list --count 6df6a75..3216a33`). The
  note's other three facts are correct; a wrong count invites distrust of them.
- New entries for the interval normalisation, the `_`/`[` refusal, the
  malformed-timestamp raise, the inclusive lower bound and the sweep-sort
  hazard.

The watermark-sweep hazard, documented in three places plus the docstring: an
unsorted offset sweep over a change window can skip a record permanently,
because the rows the filter selects are by construction the rows that are
changing -- a ticket touched between pages can land before the read cursor, and
the next sweep starts from a later watermark. Sorting ascending on the filtered
column moves such a row toward the tail so it is seen twice; every sweep example
now carries `sort="LAST_UPDATE"` and de-duplicates by `rfc_number`. Nothing in
the branch had acknowledged pagination stability at all.

Release documentation:

- `docs/publishing.rst` and `release.yml` both said the repository's existing
  tags are v-prefixed. `git tag -l` prints one line: `0.1.0`, unprefixed. The
  workflow's tag-stripping logic is right; the reason given for it was untrue.
- `publishing.rst` said bump the version in "both places". Four tracked sites
  hardcode it, and two of them are gated -- so the documented procedure
  guaranteed a red CI run on every release. All four are now named.
- `twine>=5.1` -> `twine>=7.0`: hatchling stamps `Metadata-Version: 2.5` and
  twine <=6.2 caps its valid-metadata list at 2.4, so `twine check` fails a
  perfectly good wheel and sdist (measured: 6.2.0 fails both, 7.0.0 passes).
- The coverage comment's "1272 statements / 99.21% exactly" is now 1437 /
  99.37%; re-stated as a snapshot rather than a canary, since it moves with
  every added line.
- README: the pre-1.0 paragraph -- the second thing a PyPI visitor reads, on an
  artifact that cannot be re-uploaded -- had three grammar errors, and three
  relative links 404 on the project page because PyPI does not rewrite them.

Skills and user guide, each a claim that was incomplete or false:

- search-syntax: `*` and `%` are not the only `~` metacharacters; a dotted
  relation path (`REQUEST.RFC_NUMBER`) IS honoured in `search`, which
  `list_actions` depends on, while the "only top-level scalars" rule is about
  bare nested sub-keys; the ascending sort tokens are named.
- ticket-actions: the one-page cap, the unverified PUT echo, and the
  `created_at`/`updated_at` divergence from `Request`/`Employee`, which a
  consumer would otherwise meet as an `AttributeError`.
- reporting-and-context: "genuinely sorted newest-first" attached "verified
  live" to an inference about a varchar sort; and an offset-less
  `created_since` is read as UTC.
- client-setup, the designated async reference: "returns coroutines" is false
  for `stream_document` and every `iter_*`.
- document-workflow: the async early-exit close, the `chunk_size` guard, and
  that streamed bytes are not proof of instance origin.
- user guide: the JSON note, the declared-vs-undeclared date column split, and
  that a `datetime` in `custom_fields` will not serialise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tightening the timestamp validator so junk raises instead of becoming a bogus
epoch instant also made it raise on `None`. That is wrong: a JSON `null` is an
ordinary wire absence on a column whose own type is `datetime | None`, and a
caller passing the field's default explicitly is not an error either. Only `""`
and junk were meant to change behaviour.

Regression guard added, because this was introduced by the very change that was
meant to make the validator stricter — the two absences it must accept are now
named in the docstring and pinned by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two commits ago this branch published the opposite ruling on every surface that
documents a watermark sweep: sort ASCENDING, on the reasoning that ascending
turns a permanent miss into a duplicate. That reasoning was wrong, and the
recommendation with it.

Offset pagination over a set sorted by the column being mutated can drop a row
in either direction; what differs is where the dropped row's own stamp lands
relative to the next watermark. Ascending, the re-touched row moves tail-ward
and the row that crosses the cursor is a neighbour whose stamp did NOT change:
it falls below the new watermark and no later sweep selects it -- lost.
Descending, the row that slips behind the cursor is the re-touched one, whose
stamp is now above the watermark, so the next sweep re-selects it -- deferred
and self-healing.

So `sort="LAST_UPDATE DESC"` plus de-duplication is now the guidance in
ev_since_filter, iter_tickets, the user guide, the search-syntax skill and the
CHANGELOG, each carrying the real reason instead of the old one. Keyset
pagination is named as the fully robust alternative for a caller who cannot
tolerate even a deferred miss, with the honest note that iter_tickets cannot
express it because it owns its own offset.

The live sort characterization now runs with the change window applied: the
guidance is exclusively about a FILTERED sweep, and `sort` has the same
silent-ignore fate a search condition has, so "honoured alongside a search" was
unmeasured. The ascending pin is kept -- both tokens really are honoured, which
the docs still state -- but renamed and re-framed so it no longer reads as the
recommended sweep form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…re recommended

The last round taught the search-syntax skill that `_` and `[` are
metacharacters under `~` and that ev_contains_filter / ev_starts_with_filter now
raise for them, but it stopped there. The primary published doc still enumerated
only `*` and `%` and steered callers to those two builders using an ASSET_TAG
example -- the very column whose codes are underscore-pervasive -- as did the
README and the asset-workflow skill. A reader following any of the three passed
`LAPTOP_01` and got a ValueError none of them warned about.

The user guide's grammar bullet, the README snippet, the user guide's asset
example and the asset skill (snippet plus its own Gotcha) now all carry the
`_` / `[` / no-escape facts.

They also carry the exit none of them offered: `:` does not expand a wildcard,
so an exact match on a value containing a metacharacter is expressible with
ev_equals_filter, and only pattern-matching AROUND a literal metacharacter is
impossible. That exit is now stated by the builders' own ValueError message and
their docstrings too, so the person who hits it at runtime is not left without a
path -- and it is measured rather than remembered: the live characterization now
also probes `RFC_NUMBER:"<stem>_"` and requires it to return strictly fewer rows
than the `~` probe on the identical pattern, which a wildcard-expanding `:` (or
a silently dropped condition) could not do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
baraline and others added 27 commits August 28, 2026 10:25
…, add boundary test

Review round 1 on the memo parameterisation (Task 7): the round-trip and
peak-in-flight docstring numbers were unconditional statements that only
held for the default two-memo memo_fields; generalised them to
len(memo_fields) + 2 (+ 2N), keeping the async/sync framing and the existing
measurement scoped to the default case it was taken against. Tagged the
path-segment memo-selector claim tier 2, citing docs/vendor-api-reference.md
(declared in the instance's OpenAPI paths). Warned callers off the bare
string footgun -- str satisfies Sequence[str], so memo_fields="solution"
type-checks and iterates characters -- as a docstring caveat only, per the
no-new-refusals constraint. Restored the memo_fields=() test that was
verified live then dropped for not being in the brief's literal list: the
brief's tests are a floor, not a ceiling, and this is the exact boundary the
slicing arithmetic depends on.
…itten against

Reads info.description from the instance's own OpenAPI spec. Asserts 201, not
200: a GET to /swagger answers 201, and a habitual == 200 check would skip the
version assertion and pass for the wrong reason.
Documents what tasks 1-7 actually landed, not the pre-implementation draft:
workflow_start is tier 3 (instance OpenAPI schema only), not vendor-documented
like the other eleven new PostRequest fields; origin and impact_id were each
widened to int | str for a different reason, not the same one. Also corrects
CHANGELOG.md:378 for a present-day reader without rewriting the released
0.2.0 entry: PostRequest.catalog_guid is back, Request.catalog_guid stays gone.
`department_id` and `recipient_id` were typed `int` on the authority of a
sentence claiming they were "the fields that are genuinely int-only here",
while docs/vendor-api-reference.md types both as strings at tier 1 -- the
same evidence that had already widened `location_id` two paragraphs below,
with no explanation for the opposite outcome. Both are now `int | str`, and
the sentence now says what it actually means: which types this model accepts
and why, rather than an API claim the neighbouring "ids may be sent as JSON
numbers or as strings" sentence denies.

`impact_id` gets `Field(union_mode="left_to_right")` with `int` first, so a
quoted value coerces back into the integer the vendor documents. Widening it
to `int | str` had silently changed the wire form for a caller passing "28":
pydantic's smart union keeps the exact type match where the declared `int`
used to coerce, which inverted the stated reason for its `str` branch. A
non-numeric string is still accepted and passes through as written, so
nothing previously accepted is refused.

Also restates the create-body test as tier 4 and renames it. Its docstring
still claimed the seven-field body was "the vendor-documented body" and that
sending a subset "is what produces the 590" -- the artifact it cites says the
opposite (required is catalog_guid OR catalog_code, everything else optional)
and PostRequest's own docstring already calls the fuller body a hedge against
per-catalog configuration.

BREAKING CHANGE: `PostRequest(department_id="9")` and
`PostRequest(recipient_id="42")` now send `"9"` / `"42"` where they used to
coerce to `9` / `42`. Both forms were measured accepted on one instance
(tier 4, 2026-08-25); a deployment that discriminates on JSON type would see
a behaviour change. Pass an `int` for the old bytes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`to_api` promised `extra_payload` "wins ... over a declared field" and then
merged by exact key match, while EasyVista's field names are case-insensitive
(tier 1). So `PostRequest(urgency_id=8, extra_payload={"URGENCY_ID": "4"})`
put BOTH on the wire with conflicting values and left the winner undefined --
and the ALL_CAPS spelling is the likely one, not a corner case, because it
mirrors the read side's ALL_CAPS convention, which is where callers copy
names from. The branch's own `extra_payload` test already used that form.

An `extra_payload` key now replaces any declared or `custom_fields`-produced
key it matches when case is ignored, and `extra_payload`'s own spelling and
value are what ship. This is a merge rule, not a validation: a collision is
never an error, and nothing raises that did not raise before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…comment

`to_markdown` rendered only `description`/`comment`, so the headline case for
`memo_fields` -- `get_ticket_context(rfc, memo_fields=("solution",))` on a
deployment whose body memo is neither default -- produced a Markdown export
with no body section and no warning. Fetch was parameterised and render was
not. It now applies the same role-naming rule to `memos`: when neither
default memo has text, a single populated one becomes the body under
"## Description", and several each get a heading derived from the field name
requested.

The comment above that block was also asserting "0/15 sampled tickets had a
non-empty DESCRIPTION, 15/15 had a COMMENT" while models/request.py says an
earlier 15-ticket sample "was not representative; do not rely on that being
true of any instance" and cites a pooled 77-row one. Two shipped files, one
contradicting the other. The comment now carries the 77-row finding, tagged
tier 4 with its date, and keeps its reasoning about why neither memo is
hard-coded as "the body".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The user guide presented `custom_fields` as *the* escape hatch on write
models, which leaves a reader concluding there is no route for an undeclared
*official* field -- incomplete in exactly the way this branch exists to fix.
And `catalog_guid` (the vendor-preferred subject identifier this branch
restored), `extra_payload` and `memo_fields`/`TicketContext.memos` appeared in
no .rst, no README and no SKILL.md.

- user_guide: a "two escape hatches" passage distinguishing `custom_fields`
  (e_-prefixed custom columns) from `extra_payload` (anything else,
  un-prefixed, wins on merge ignoring case, bypasses validation); a
  `catalog_guid` mention where the create body is introduced; a `memo_fields`
  passage where the context bundle is covered, with the bare-string trap; and
  the heading note extended to memos.
- README: the create note now says a subject is what a create needs, guid
  preferred, instead of "catalog_code + title work for incident catalogs".
- easyvista-ticket-workflow: same subject correction, `extra_payload` beside
  `custom_fields`, and `status_id` dropped from the RequestUpdate field list
  -- that field was removed earlier in this branch and now raises at
  construction, so the skill was advertising a call that cannot be made.
- easyvista-reporting-and-context: `memo_fields`/`memos` and what
  `to_markdown` does with them.
- vendor-api-reference: the `~` row glossed "Contains" with no caveat while
  filters.py records the measured finding that `~` without an explicit
  wildcard degenerates to exact match. The tier-1 tag is honest -- the vendor
  does say that -- so the counter-evidence is now noted beside it, pointing at
  `ev_contains_filter`.
- test_source_citations: its header told a maintainer to keep `_UNPUBLISHED`
  in sync with the sibling tuple while the comment fifteen lines below
  explains it is narrower "and deliberately so". Obeying the header would
  fail tracked documentation. It now says: in sync where they overlap.
- CHANGELOG: a `### Changed` section recording the wire-form change for
  numeric-string callers of `origin`, `department_id` and `recipient_id`, the
  `impact_id` coercion, and the `extra_payload` case rule; plus the
  to_markdown fix. The claim "Whichever type is passed reaches the wire
  unchanged" was true when written and is no longer, so it is corrected too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ields

The override rule was illustrated with `extra_payload={"URGENCY_ID": 4}`
beside `urgency_id=8`, directly under a `RequestUpdate` example.
`RequestUpdate` declares neither `urgency_id` nor anything else outside its
seven fields, and write models set `extra="forbid"`, so a reader copying that
line got a ValidationError at construction. Illustrate it with `impact_id`,
which the model shown does declare.

The same bullet justified the case-insensitive match with a flat claim that
"EasyVista field names are case-insensitive". The vendor documents that for
the ticket *create* body only; the other write bodies are an assumption. Scope
the claim to match `EasyvistaWriteModel.to_api`, which already had it right.

The ticket-workflow skill described the create body as it stood before the
twelve vendor-documented fields were declared, so a reader would reach for
`custom_fields` for a field `PostRequest` now declares -- and get it wrongly
`e_`-prefixed. Name them. `workflow_start` stays out: its only source is the
instance OpenAPI schema, not the vendor docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extra_payload entry justified the case-insensitive merge with a flat
"EasyVista's field names are case-insensitive". The vendor documents that for
the ticket create body only; the other write bodies are an assumption this
package makes deliberately. `EasyvistaWriteModel.to_api` and the user guide
both state it that way already -- the changelog was the last place carrying
the unscoped version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…laims

Three doc examples were plain SyntaxErrors -- a module-level `async for` in
two user_guide streaming/sweep blocks and a module-level `async with` in the
README quickstart. Two more were NameErrors: `internal_note_type_id` was never
defined, and the error-handling block used `PostRequest` without importing it.
Verified by compiling every Python block in the docs: 3 syntax failures before,
0 after.

Two claims are retracted rather than reworded. The error-handling example said
a create is rejected for a "missing mandatory title"; the measured cause is the
omitted origin/department_id/urgency_id/impact_id, and the fuller body is
accepted with no title at all. And a 590 on create does not mean the ticket was
not created -- it means *possibly created* (measured, one instance, 2026-08-25),
which now carries a warning where the example claimed safety.

`validate_docs_examples.py` carried the same false claim in its own live tier,
where it does real damage: `create_ticket(PostRequest(catalog_code=...))` sends
no external_reference, so the row a 590 leaves behind cannot be reconciled by
marker the way the integration suite reconciles its own. The comment claiming
"no ticket is created, so this is safe" is gone and the leak is documented. The
script's docstring no longer claims to check the docs: it never opens an .rst,
and a green run is evidence about its hand-maintained transcription only.

The id legends in the create example (`4 = total outage`, `28 = test`) read as
an API-wide legend for values that are per-instance configuration; they are now
marked as placeholders and pointed at `reference("URGENCY")`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An action has two independent text memos, DESCRIPTION and COMMENT, each
addressable at actions/{id}/{memo}. `PostAction` declared only the first, so
the second was unreachable at create time except through `extra_payload`.

The omission rested on a bad inference, recorded in the model's own docstring:
that an action's text "lives in DESCRIPTION while COMMENT is empty" on the
verified instance, making `comment` a mirror for deployments configured the
other way round. COMMENT was empty because nothing had ever written to it. A
single create carrying both channels was sent live on 2026-08-28 and each read
back with exactly the text sent, separately addressable. The instance's own
OpenAPI declares both on the create body and its example populates both.

What has NOT changed: there is still no visibility flag. The item-level action
record was re-captured the same day -- 88 columns, far more than the model
suggests -- and none is a public/private boolean. So `comment` is a second
channel, not a private one, and the docs now say exactly that: whether a
portal surfaces one and not the other is portal configuration to confirm with
an administrator, not an API guarantee. The previous docs said this API "has
no private-comment feature" full stop, which overstated a real absence into a
false one and hid the channel that does exist.

Marked breaking only because the create body gains a field when `comment` is
set; an unset `comment` is omitted, so every existing caller's bytes are
unchanged (pinned by test).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Creating an action is half the job. An action is a unit of work: created open
(a task to do), then ended (work reported). Only an ended action appears in the
ticket history with its text visible -- an open one renders as a pending row
with no body, which reads as though the text vanished. It did not; the action
was never ended. Measured live 2026-08-28: a type-95 action stayed invisible as
a message until it was ended, at which point its description appeared in the
history.

Ending sets START_DATE_UT, END_DATE_UT, ELAPSED_TIME and
STATUS_ID_ON_TERMINATE, fills DONE_BY_ID and clears GROUP_ID -- the record moves
from "assigned to a group" to "done by a person". None of those can be set on
create: they return HTTP 200 and are dropped in silence.

The vendor documents ending as PUT actions/{rfc_number} with an `end_action`
wrapper and dates in the instance's DATE_FORMAT (dd/mm/yyyy, NOT ISO 8601). Not
implemented here, and it could not be made to work on the verified instance:
every documented form returned 590 "Action not found", including for a user who
could end the same action through the UI. Recorded as an instance/profile
restriction to raise with an administrator, not as a payload to keep guessing at.

Two retractions, both of claims this package stated in its own voice:

- "This API has no private-comment feature" overstated a real absence (no
  per-action visibility flag, confirmed across 88 item-level columns) into a
  false one. Visibility is carried by the action TYPE: 94 = Commentaire
  [Public] / Customer Comment, 95 = Note Interne [Prive] / Internal Note.
- "The API cannot reveal which type is which" was wrong, and the CHANGELOG
  correction that established it deleted a true finding. Two bracket
  conventions exist: a whole label bracketed and echoing another language is an
  untranslated placeholder, but a bracketed suffix on distinct text with real
  sibling translations is a genuine visibility marker. The correction
  generalised the first over the second. Type ids are also discoverable despite
  GET action-types being 403 -- every action record carries ACTION_TYPE_ID
  beside translated ACTION_LABEL_* columns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A task and an action are the same underlying record, created in different
states. An action is born OPEN -- work still to do -- and the UI renders it as
a pending row with its text NOT displayed, which reads as though the note was
lost. A task is born ENDED, so it lands in the ticket history with its text in
a single call.

`POST requests/{rfc}/tasks` differs from the actions create in two ways, both
measured live 2026-08-28: the body is flat at the root rather than wrapped, and
the record comes back with END_DATE_UT and STATUS_ID_ON_TERMINATE already set.
An internal-note task also needs no parent_action_id, where the equivalent
action does. Verified end to end through the new method: type 95, ENDED, text
readable at actions/{id}/description.

Adds `PostTask` (mandatory action_type_id|action_type_name and one of
group_id|group_name|group_mail, refused locally rather than drawing a 590 that
names no field), `build_create_task`, and `create_task` on both clients.

This is what `create_action`'s docstring should have said all along, and the
docs it lands beside were wrong twice over: the previous commit routed callers
through a two-call action-then-end path whose second call cannot be made on the
verified instance, and the commit before that offered `comment` as if a second
text channel were the mechanism. Neither was. The user guide and the actions
skill now lead with a task-vs-action table and send comments to `create_task`.

Also corrects a test that asserted the false generalisation directly:
`test_a_bracketed_action_label_is_an_untranslated_placeholder_not_a_marker`
became two tests, because both bracket conventions are real and mean opposite
things. `_usable_label` already drew the line correctly -- it rejects only a
label that is entirely bracketed -- so the code was right and only the prose
around it was wrong.

Why this was missed: `/requests/{rfc}/tasks` was found in the instance's
OpenAPI early and set aside because its example showed `group_mail` while
GET /groups is 403. The vendor documents group_id, group_name OR group_mail --
an example was read as a requirement, which is exactly the tier-3 trap
docs/vendor-api-reference.md warns about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendor documents closing as PUT requests/{rfc_number} with a
{"closed": {...}} wrapper -- the exact route this package already sends. So
O-CLOSE resolves in the package's favour: PUT|PATCH requests/{rfc}/close is an
alternate path, not the canonical one, and there is nothing to migrate to. The
open item claimed switching "needs its own live check"; the check needed was
reading the documentation.

https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md

That page also names two body fields this package never declared, so they were
unreachable -- and unreachable with no workaround, since neither close_ticket
nor any write model would carry them:

  * end_date    -- omitting it stamps now; supplying it back-dates the closure
  * catalog_GUID -- requalifies the ticket as it closes

Both are now arguments on close_ticket / build_close_ticket. end_date stays a
string: it takes the instance's own DATE_FORMAT, which is dd/mm/yyyy on the
verified instance and NOT ISO 8601, so formatting a datetime here would be a
guess. delete_actions widens to int | bool -- the vendor types it boolean and
the package accepted only int.

close_ticket also had no docstring at all. It now carries the verification
methodology, because getting this wrong bit during this session's own testing:
a cleanup script assumed status_id 12 meant closed and skipped a ticket that
was still open. On the verified instance 8 is Cloture and 12 is En cours --
adjacent ids, opposite meanings, neither guessable. The docstring directs
callers at end_date_ut instead, which is empty on an open ticket and stamped on
a closed one, and is therefore portable in a way no status id is. Verified live
2026-08-28: end_date None -> 2026-08-28T17:18:45, status 12 -> 8.

Four tests pin the body shape, the all-optional envelope, the route (so nobody
"fixes" it into the subpath), and the bool passthrough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Agent working notes, not project documentation: they describe the private
preprod instance's behaviour and this machine's credential layout, which is
the same reason docs/API_Info.md and scripts/probe_*.py are already ignored.

Anything a public reader needs belongs in CONTRIBUTING.md, docs/ or a skill
instead -- and the citation guard already refuses a tracked file that points
at a gitignored path, so this cannot become a dead link for a stranger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This package wraps roughly ten of the hundred-odd paths an instance
advertises in its own OpenAPI document, and every default in it was
measured against one French 2025.3 deployment. Anything else needed a
fork. These are the seams that make it adaptable by configuration.

- client.send(method, path, *, params=, json=, headers=) reaches any
  api_root-relative route this package does not wrap, sharing the retry
  policy and the error mapping with every typed method. An absolute URL
  is never followed: path always joins to api_root, which is what keeps
  the credential scoped to the configured instance.
- params= on the fifteen get/search/iter methods, layered UNDER the
  resource builder's own parameters, so a caller can add formatDate
  without being able to replace the ticket filter on list_actions or the
  offset an iter_* sweep is stepping.
- EasyvistaConfig gains extra_headers, user_agent, default_params and
  additional_download_hosts, and verify_ssl widens to a CA-bundle path
  or a prepared SSLContext.

extra_headers and RequestSpec.headers both refuse an Authorization key,
in any casing, at construction. A header bag may override anything this
client sets EXCEPT the credential: silently shadowing config.token would
send a secret the client cannot see, redact from a repr, or rotate.
extra_headers is deliberately not sent to a host allow-listed by
additional_download_hosts, which is where a second secret would leak.

from_env() reads none of these. They describe how one deployment differs
from another, and for a pip-installed library a constructor argument is
the right home for that; the environment is a last resort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every human label this package resolved came back English first, French
second, and a hardcoded scan after that. On a deployment whose primary
language is neither, the answer was a bracketed placeholder echoing the
primary text rather than the text itself.

languages= now threads through resolve_reference, localized_label,
EasyvistaModel.reference, TicketContext.to_markdown, aggregate_tickets,
ticket_statistics and get_department_context, defaulting to
DEFAULT_LANGUAGE_ORDER -- the order the package already used, so nothing
changes for a deployment the old order suited.

BREAKING: a [bracketed] untranslated label no longer wins over a real
sibling translation, and a usable non-English/non-French column now
beats a *_PATH value. Both were silent wrong answers rather than errors,
so a caller relying on the old output was relying on a placeholder.

Two bracket conventions mean opposite things, and the resolver now keeps
them apart. A label wrapped ENTIRELY in brackets echoing another
language is an untranslated placeholder and is skipped; a bracketed
SUFFIX on distinct text with real sibling translations ("Commentaire
[Public]") is genuine content and is kept. Conflating them once deleted
a true finding.

Action.label reads the ACTION_LABEL_<lang> columns on that rule. Prefer
it over action_label_fr, which names one column and yields
"[Customer Comment]" on an English instance rather than None.

_fields._label is gone. Label resolution now lives in references alone,
so the package has one placeholder rule and one language order rather
than three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The vendor documents `~` as plain Contains (tier 1). This package
measured it live on 2026-08-17 against one instance as a pattern
operator that matches nothing without an explicit wildcard (tier 4, may
not generalise), and appends `*` on that reading.

Both readings cannot be right, and on a deployment this one is wrong for
the appended `*` is compared literally: the filter returns zero rows
with HTTP 200 and no hint. That is a silent narrowing, the mirror of the
silent widening the metacharacter guard already prevents.

ev_contains_filter and ev_starts_with_filter now take a keyword-only
wildcard: Literal["*", "%"] | None = "*". Pass None on a deployment that
follows the vendor's reading, or "%" for a LIKE-style backend. The
default is unchanged, so output against the verified instance is
identical.

The metacharacter guard now splits by who owns each character. `_` and
`[` are metacharacters of `~` itself and stay refused at every setting,
which this repository's own wildcard-free live probe settles. `*` and
`%` are refused only while a wildcard is being appended, because that is
the only time they are ambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A read model that refuses a value fails the whole record, and because a
search validates a page in one comprehension, one odd column fails every
record on that page -- and, through get_department_context, the whole
bundle. Every widening here is a column measured on one instance being
allowed to differ on another.

- Asset.asset_id and .status_id accept EasyVista's "" sentinel. They
  were the only read-model ints left on a bare int | None, so a CMDB row
  with STATUS_ID: "" took the page and the bundle down with it.
- Request.time_used_to_solve_request and Document.document_id widen to
  str | int, left-to-right so neither coerces.
- EasyvistaConfig.datetime_input_formats names extra strptime patterns,
  tried only after EasyVista's own ISO 8601 form fails. Nothing is
  guessed: an unmatched value still raises, and a real ISO stamp cannot
  change meaning, because it is tried first. The context is bound at
  build time, so the parser signature never changes.
- get_ticket(fields=) projects the item route, for when one column
  poisons the record. With the tier-2 caveat in its docstring: the
  verified instance declares fields on the list route, not the item one.

BREAKING: PostAction now requires an action type and a group, on the
same tier-1 sentence PostTask has always been guarded by. Sent without
them the route answers HTTP 590 and names no field. Both guards now read
the body to_api() will actually send, so a field supplied through
extra_payload satisfies them.

PostAction.action_type_id and .group_id widen from int to int | str, and
the model gains action_type_guid, group_mail and parent_action_id. They
had diverged from PostTask for no recorded reason, so a non-numeric type
or group id worked through create_task and failed through create_action.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Envelope casing is not stable across deployments. The instance OpenAPI
document read 2026-08-27 spells every envelope lowercase in its
examples, yet the live GET requests/{rfc}/documents on the verified
instance answers a capital-D "Documents". Matching stays over the same
fixed candidate list -- never over whatever keys the payload happens to
carry, which would let an arbitrary record column win -- and the
priority order is unchanged.

A matched list must also be empty or hold at least one dict to be
accepted as the envelope. Without that, a payload whose envelope-named
key held scalars returned [] silently, indistinguishably from an empty
page, rather than falling through. {"records": []} still means an empty
page.

The create_action, create_task, close_ticket and add_document parsers
now name their envelope key instead of relying on a hardcoded fallback
tuple that belongs to no resource in particular. A deployment echoing
the created record under a wrapper handed model_validate the wrapper
itself, and extra="allow" accepted it: a well-formed record with every
field None, at HTTP 200.

EasyvistaConfig.document_delete_path_style selects between the two
attachment-delete routes the instance OpenAPI declares. The default
"nested" is the form verified live; set "top_level" on a deployment that
grants that one instead, or when only a document id is in hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_department_context hardcoded the column list, the sort, the memo
field and the statistics cap it read a department with. Every one of
those is a per-deployment fact, so on any instance but the verified one
the bundle came back thin with no way to widen it.

recent_tickets_sort, ticket_fields, memo_fields and the sample cap are
arguments now, and get_department_comment takes memo_field -- the API
models the memo name as a path segment, so a deployment may carry
others. TicketContext.to_markdown takes the field table as fields=.

Nothing swallowed is silent any more. DepartmentContext.degraded and
TicketContext.degraded record which sub-resource fetches were refused,
as "<branch>:<http-status>". to_markdown renders a one-line notice for a
refused section rather than omitting it, so an export cannot read as
"this ticket has no attachments" when the attachment list was denied.
TicketStatistics.truncated and .population_total do the same for a
capped sample: a total of 1 out of a population of 3 was previously
indistinguishable from a population of 1.

BREAKING: find_departments with an all-digit name now tries
DEPARTMENT_CODE before DEPARTMENT_ID. A department whose code is all
digits was looked up as an id, and a DIFFERENT department came back with
HTTP 200 and no hint. That costs one extra round trip when the digits
really are an id. Pass by= to pin the columns, or an empty sequence to
skip the fast path.

BREAKING: the fuzzy fallback now folds accents as well as case, spaces
and hyphens, so "Systemes" matches the same name written with its
accents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Almost every value a write needs -- the catalog, the status GUID, the
action type ids, the group ids -- is configured per deployment and is
not portable from anyone else's. Until now the only way to find them was
to read a ticket and guess, and this package's documentation had to
spell that out by hand for each one.

Four methods. get_api_spec reads the instance's own OpenAPI document
(it answers 201, not 200, so code gating on == 200 skips it),
list_reference_table reads one reference table, discover resolves one
name by route or by sampling live records, and describe_instance
gathers a whole profile.

list_reference_table lets a 403 propagate and never returns []. An empty
table is a legitimate answer on a lightly configured instance, so
collapsing the two would let a caller build a status map from nothing
and conclude the instance has no statuses. describe_instance is the
layer that swallows, and it names every gap in .unavailable -- read that
before the tables, because a total outage otherwise looks exactly like a
bare instance. It catches EasyvistaError alone, so a bug in this package
still propagates instead of being buried as a fake instance limitation.

GenericRecord declares no columns, on purpose. A reference table's
response schema is tier 3, and the verified instance's own /status
schema is visibly wrong: it describes an SLA-shaped object with DELAY
and WORKING_HOURS_ID and no status id at all. A column list written from
that would be a guess frozen into the public API.

Action types are sampled rather than listed: GET action-types is 403,
but every action record carries ACTION_TYPE_ID beside its translated
ACTION_LABEL_* columns. The label falls back to those sibling columns
for ACTION_TYPE alone -- an action's type label does not live in a
nested object, which is the one shape resolve_reference cannot read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The user guide and the actions skill each stated the visibility rule
correctly and then retracted it a dozen lines later. Both halves were
true and neither said so: there is no action-types route at all, so
there is nothing to enumerate and nothing for an administrator to
unblock -- AND the ids are recoverable from the data, which
client.discover() now makes a one-liner.

INTERNAL_NOTE_TYPE_ID = 20 appears in no test, probe or fixture. It was
invented, and it predates the measurement that established 94 and 95, so
it is deleted rather than replaced with another number.

Several docstrings read an HTTP 403 as a permission verdict. This API
answers 403 for an unknown path as well as a denied one, so none of them
ever distinguished "you may not" from "there is no such route". The
instance OpenAPI document read 2026-08-27 settles each case: the nested
requests/{rfc}/actions/{id} route does not exist, and actions/{id}
declares no DELETE.

close_ticket's "omitting status_guid closes to the instance's default
Closed meta-status" is retracted, not corrected. It is asserted in two
docstrings, absent from the citable artifact, and never exercised: every
close_ticket in integration_tests passes an explicit guid. Both
docstrings now hedge, and it is recorded as open item O-CLOSE-DEFAULT.
Writing a tier-1 row from a docstring that cites the vendor page would
launder an unverified claim into the file whose whole purpose is telling
those apart.

A new "First steps on your instance" section is now section 2 of the
user guide. The nearest equivalent sat 86% in, and a reader needs it
before they write anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps found by running the pre-release walkthrough against a live
instance, both of which made the package disagree with the ticket on
screen.

end_action(rfc, action_id=..., ...) finishes what create_action starts.
An action is born OPEN and its text does not render in the ticket history
until it is ended, so without this the package could create an action
nobody could read -- the walkthrough had to reach into client._transport
with a hand-built body to finish one. Addressed by the TICKET: the path
segment is the rfc_number and the action is named in the body, because
actions/{action_id} answers 404 for this verb.

Ending is not bookkeeping. Measured 2026-09-01 (2/2 tickets), ending a
fresh ticket's open type-20 workflow action moved the TICKET from En
cours to Resolu and spawned a new open action; a control (3/3) showed
ending a caller-created type-94 action changed neither status nor action
count. The vendor's id-less "end every open action" form is therefore
behind an explicit end_all=True, and a bare action_id=None is refused --
Action.action_id is legitimately None all over this package (a create
response carries no id, a fields= projection without ACTION_ID drops
it), so forwarding one would otherwise resolve the ticket in silence.

_resolve_action_body now resolves COMMENT when DESCRIPTION is empty. The
UI renders one text field per action -- DESCRIPTION, falling back to
COMMENT -- so resolving DESCRIPTION alone dropped the body of exactly
the actions a human CAN read, and an exported log disagreed with the
screen. Costs a third request only in the fallback case.

Retracts two measured-false claims in PostAction's docstring: ending
does not clear GROUP_ID (it survived on both an ended workflow action
and an ended caller-created one), and "this package does not implement
it" no longer holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Carries the retractions into the prose. The actions skill still told the
reader, in its Examples section -- where an agent copies code from --
that "ending it is 590-blocked on the verified instance", two hundred
lines after the same file retracts exactly that claim. The user guide
said the package does not implement ending. Both now point at
end_action, and both carry the warning that ending a WORKFLOW action
advances the workflow while ending your own does not.

Also corrected: the guide's tasks-vs-actions table gave the retracted
reason for parent_action_id (it is resolved implicitly; an explicit one
is needed at 0 or 2+ open actions), its first create_action example
taught the invisible write the page now warns about, and the reporting
skill described action bodies as merely "pre-resolved" without saying
which memo wins.

PostTask -- the model for posting a comment, used in six user-guide
snippets -- was exported and absent from docs/api_reference.rst
entirely, along with two exported constants. Sphinx renders only what
that file lists, so the omission was invisible: no warning, nothing for
sphinx -W to catch. test_api_reference_coverage.py now fails when an
__all__ export has no autodoc entry.

The README covered tickets, assets and documents but never actions,
which is where this API is easiest to misuse: it now shows create_task
for a comment and create_action/end_action for work, with the
description-shadows-comment rule.

publishing.rst still described the PyPI publisher as *pending*, which is
only true before a project's first upload; 0.1.0 has been on PyPI since
2026-08.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merges the 0.2.0 section prepared and dated 2026-08-18 into this one.
That version was versioned and documented but never tagged or uploaded,
so none of its work had reached anyone; leaving it stranded under a
version number that does not exist on PyPI would have been the only
alternative. 944 content lines carried across unchanged, and the eight
duplicate ### Added / ### Changed / ### Fixed headings the section had
accumulated over 69 commits are consolidated into four.

Five breaking changes ship here, each replacing a silently wrong answer
with a correct one or a refusal; the entry opens with them.

PyPI carries only 0.1.0 and origin only the 0.1.0 tag, so 0.2.0 is free.
Per docs/publishing.rst the tag for this one is v-prefixed: v0.2.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The malformed-timestamp guard was interpreter-dependent, which CI caught
on this branch: 3.10 green, 3.11 and 3.12 red on
test_a_numeric_shaped_value_raises_instead_of_becoming_an_epoch_instant.

From 3.11, datetime.fromisoformat accepts the ISO *basic* forms --
"20260817", "20260817T154041.610", week dates like "2026W331" -- which
3.10 rejects. parse_ev_datetime reached that call for anything it had
not already normalised, so the same wire value parsed to an instant on
four of the five supported Pythons and raised on the fifth. The package
declares requires-python >=3.10 and CI tests 3.10-3.14, so the
documented "raise rather than guess" contract held only on the oldest
one, and the silent-wrong-instant this guard exists to prevent was live
for most users.

A value must now start with an extended ISO date (YYYY-MM-DD). Stated
positively on purpose: the first attempt at this rejected digit-only
strings, which catches "20260817" and misses a basic date-time (it
contains a '.') and a week date (it contains a 'W') -- both of which
3.13 duly parsed. EasyVista's format always carries separators, so none
of these is one of its timestamps on any interpreter; a deployment that
genuinely sends one names it through
EasyvistaConfig(datetime_input_formats=...), which is tried after this
returns None.

Verified on 3.10 and 3.13: 1294 passed on both, where before the fix
3.13 failed the two tests CI failed on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@baraline
baraline merged commit 9df446f into main Sep 2, 2026
7 checks passed
@baraline
baraline deleted the feat/portability-escape-hatches branch September 2, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants