Skip to content

Feat/itsm connector client changes - #3

Merged
baraline merged 36 commits into
mainfrom
feat/itsm-connector-client-changes
Sep 2, 2026
Merged

baraline merged 36 commits into
mainfrom
feat/itsm-connector-client-changes

Conversation

@baraline

Copy link
Copy Markdown
Owner

No description provided.

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 6 commits August 18, 2026 13:22
… direction

Five smaller accuracy gaps the verify lenses found on the previous round.

filters.py: a string bound whose only fault is a sub-minute UTC offset
(`...41+05:53:20`, what `isoformat()` gives for any pre-1900 zoneinfo instant)
was refused with the generic "is not an EasyVista timestamp ... pass a datetime
to be certain". That was wrong twice: the value IS valid ISO 8601, and passing
the datetime raises too. It now gets its own message naming the offset as the
cause and saying the datetime path refuses it for the same reason; the unit test
pins both paths to the same diagnosis instead of accepting "timestamp".

_interval_bound's docstring said the sub-millisecond truncation happens but not
which way. It truncates DOWN, and that is not symmetric: harmless on the
inclusive lower bound (it can only re-read), but on an upper bound it moves the
bound up to 999 microseconds earlier and silently NARROWS the window.
Documented, with the advice to pass millisecond precision when an exact upper
bound matters -- and ev_between_filter's docstring, which said nothing about the
normalisation at all, now states both.

CHANGELOG, user guide and ticket-workflow skill prescribed `mode="json"` for two
breakages but it only fixes one: classify_fields() takes no arguments, so there
is nowhere to put the keyword. All three now split the two paths and give the
classified-bucket case a remedy that exists.

The live rendering matrix asserted `0 < got <= baseline` for each honoured
interval rendering, which the silently-dropped whole-table fate also satisfies --
not an assertion by this project's own rule. Each rendering is now a differential
across two bounds in that same rendering, which a dropped condition cannot pass.

The live RequestUpdate read-back wrote an OWNER_ID / IMPACT_ID sampled off an
arbitrary other ticket. Those are foreign keys constrained by the ticket's
catalog and domain, so a refusal was a plausible false red reported as
"RequestUpdate cannot write OWNER_ID". It now tries several sampled candidates,
treats a validation refusal as a skip naming only the column, and asserts
EXTERNAL_REFERENCE -- the one column needing no instance-side legality -- first
and unconditionally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The change-window sort ruling was corrected to descending two commits ago
(49ef8d8) on solid reasoning that still holds, but the correction left a
trap undocumented: because DESC yields the newest row first, the watermark
reaches its final value on page 1 of any sweep. A sweep that is interrupted,
or capped with max_records -- exactly the pattern the guide's, the skill's
and this package's own pagination examples use elsewhere -- still ends up
holding the newest stamp. Advancing the watermark from that permanently
excludes every row the incomplete sweep never read, with no error of any
kind. Under the ascending advice that correction retracted, the same
interruption was resumable; DESC is not, unless the caller advances the
watermark only after a sweep runs to completion.

State that caveat at all four sites that recommend the DESC sweep:
ev_since_filter's docstring, the user guide's change-window warning, the
search-syntax skill, and the changelog entry that made the correction.

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

_write_first_accepted PUT each IMPACT_ID/OWNER_ID candidate and treated "no
exception raised" as "the instance accepted it". But this API's documented
refusal mode -- restated in the very test's own docstring -- is HTTP 200
with the field silently dropped, not a raised error. When the instance
declined a sampled candidate this way, the helper returned it anyway,
never tried the rest, and the final assertion failed claiming "RequestUpdate
cannot write IMPACT_ID" when the truth was "that impact is not valid for
this ticket" -- the exact false red this helper exists to prevent.

Move the read-back inside the loop: after each PUT, re-fetch the ticket and
compare the column, and only return a candidate the instance demonstrably
applied. A mismatch now falls through to the next candidate the same way a
raised EasyvistaValidationError already did, so both refusal shapes end in
a clean skip instead of a failure.

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

Three fixes to test_live_change_window.py ahead of a live run against a
shared preprod instance:

- The comment above the interval-rendering differential claimed the counts
  "are not printed (P2)". False: pytest's assertion rewriter reprints both
  operands of a comparison even when the assert carries a message, so
  binding a count to a local does not close that channel -- only binding
  the comparison itself to a bool does, which is already this module's own
  idiom (is_non_increasing, colon_did_not_expand). Bind the two comparisons
  and correct the comment.

- test_descending_sort_needs_the_space_separated_token now runs with a
  window applied, which turned its colon-token check into a membership
  race: a ticket below the bound can be touched by anyone in the seconds
  between the stale unsorted snapshot and the colon-token snapshot, enter
  the filtered set, and make the test fail claiming "LAST_UPDATE:DESC now
  reorders results" for an ordinary concurrent write. Take the unsorted
  snapshot immediately before the colon-token snapshot instead of reusing
  one from three round trips earlier, and retry once on mismatch before
  failing.

- test_a_comparison_operator_never_narrows_the_result asserted the filtered
  count equals the session-cached tickets_baseline, so one concurrent
  create anywhere on the shared instance fails it claiming "a bare
  comparison operator was honoured" -- the opposite of what happened.
  Compare against a same-instant unfiltered re-read instead, keeping the
  property under test without assuming the instance is quiescent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The module docstring claimed a full run issues "4 updates". That was
already off by 2 before this round, and the new ticket-identity test's
IMPACT_ID/OWNER_ID read-back (up to 5 candidate PUTs per column, some of
which the instance may reject) makes the true count variable. Restate it
as the real range -- 6 to 14 ticket updates -- so anyone deciding whether
to point this suite at a given instance is reading an honest upper bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three call shapes were wrong. Each was measured against a live instance,
and in every case the instance was correctly configured -- the fault was
ours. docs/API_Info.md settles all three; it had been under-read.

PostRequest's docstring claimed a ticket needs "at minimum catalog_code
plus title". Wrong, and expensively so: the documented body is
catalog_code + origin + title + description + department_id + urgency_id
+ impact_id, and the same body minus those four ids is accepted on some
catalogs and rejected on others with the IDENTICAL remaining bytes. The
rejection is 590/2013 whose message is a bare SQL parser error naming no
field, at a position that never moves with our text -- so it reads like a
server defect and is not one. Every id in the documented body was
verified to persist by reading it back under an explicit projection;
these columns are absent from the default projection, like TITLE, so an
unprojected read shows None whatever the server stored.

Also documented: a rejected create may still have created the ticket. 12
attempts returned 3 RFC_NUMBERs and afterwards all 12 tickets existed.

BREAKING CHANGE: RequestUpdate.status_id is removed. There is no flat
status update on this API and the field never worked -- sent alone the
PUT is rejected 590/2013, and sent beside any other field it returns 200,
applies the other field, and drops the status in silence. A write that
reports success and stores nothing is worse than one that fails, so
extra="forbid" now makes RequestUpdate(status_id=...) raise at
construction. Use set_status.

set_status(rfc, status_guid=..., comment=None) is added on both clients,
with build_set_status beside it. It sends the documented
{"closed": {"status_GUID": ...}} body -- the same request close_ticket
sends, under a name that says what it does, because "close" is what the
wire calls it and not what it is limited to: given six different status
GUIDs in turn, a fresh ticket landed on exactly the status requested
every time, non-terminal ones included.

test_live_smoke leaked one ticket per live run. Its
test_missing_mandatory_field_raises_validation_error asserted that a
create with a catalog but no title is rejected "(no ticket created), so
this stays read-only-safe by construction" -- both halves false. title is
not the mandatory field (the full documented body with no title creates
fine), and the rejection does write a row. Replaced with a test that
omits the ids which really are required and reconciles the leftover
ticket by its external_reference marker, which survives the failed insert
and is searchable. Two tests added beside it: the documented body lands
every id, and set_status reaches a NON-terminal status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@baraline
baraline merged commit a1e297d into main Sep 2, 2026
2 of 7 checks passed
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.

1 participant