Skip to content

feat(content): Markdown <-> memo HTML converter, literal-safe (0.4.0) - #6

Merged
baraline merged 11 commits into
mainfrom
feat/content-conversion
Oct 2, 2026
Merged

baraline merged 11 commits into
mainfrom
feat/content-conversion

Conversation

@baraline

@baraline baraline commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Why

This package could only strip memo HTML down to plain text. A downstream GLPI ↔ EasyVista sync therefore wrote its own Markdown converter. On 2026-09-30 a description it synced arrived with neither of its links clickable (tier 4, measured on one instance). Conversion belongs in the connector library, so this branch adds one.

What

  • New converter: easyvista_python_client.content.EasyvistaContentConverter, a port of glpi_python_client's Markdown ↔ HTML converter in its literal-safe version (fix(content)!: spell literal text so python-markdown reads it as text (0.6.0) glpi_python_client#39).
  • Optional extra: it ships behind pip install "easyvista-python-client[content]", which brings beautifulsoup4>=4.12, markdown>=3.6 and markdownify>=1.2. import easyvista_python_client does not load these packages. Importing the subpackage without them raises an ImportError that names the extra.
  • New error: EasyvistaContentError(EasyvistaError), in the core package.
  • Literal text stays literal. A character is escaped only where python-markdown, with the four extensions to_transport uses, would read it as syntax. These read back as themselves:
    • __init__ and \\serveur;
    • #4521 at the start of a line;
    • a | inside a table cell;
    • a memo that displays &lt;script&gt;.
  • Structure: nested lists nest and keep their numbers, and <script>, <style> and <title> bodies are dropped.
  • It is not a sanitiser in the writing direction. Raw HTML and javascript: targets in the caller's Markdown go out live, as in the GLPI library. Callers neutralise them.
  • Version: 0.4.0 (unreleased), with docs (docs/content.rst, the API reference, two skills) and a CHANGELOG entry.

Parity with glpi_python_client

  • The two converters have the same 86 top-level definitions. Their ASTs are identical once docstrings, names, messages and the import guard are normalised.
  • Over 3,275 bodies, both give identical Markdown and HTML on CPython 3.12 and 3.10, and the two environments agree with each other.

Verification

  • pytest -m "not integration" --strict-markers --strict-config with coverage: 1934 passed, 23 xfailed, 98.8 % (the gate is 95 %).
  • ruff, mypy, unasync_build.py --check, scripts/lint_hand_written_sync.py --check and sphinx -E -W pass. Sphinx was built offline, with intersphinx disabled locally. The wheel and sdist were checked for test files.

Still to do before this leaves draft

These are the same items as on the GLPI PR, landing on both branches together so the converters stay equivalent:

  • tables nested inside a cell lose their text;
  • plain-text memos, with no HTML element, are not yet spelled literally;
  • edge-whitespace line rules;
  • two quadratic list shapes;
  • the setext rule depends on the python-markdown version;
  • five guards lack a failing test;
  • link destinations and titles, and TicketContext.to_markdown;
  • narrow the documented fixed-point claim.

Also: 0.4.0 is declared by commits that hold two different readers, the earlier port and this literal-safe one. Releasing only this branch's head as 0.4.0 keeps the number unambiguous.

🤖 Generated with Claude Code

baraline and others added 7 commits September 30, 2026 18:16
…rter

Adds easyvista_python_client.content.EasyvistaContentConverter, with two
static methods: from_transport reads an EasyVista memo (rich-text HTML or
plain text) as canonical Markdown, and to_transport renders Markdown as
HTML. It is a port of glpi_python_client's content/conversion.py at
0d43528, helper for helper: markdownify inbound, python-markdown outbound,
the same options and extensions, the RecursionError / ParserRejectedMarkup
fallback that degrades a too-deep body to its text instead of raising, the
parser-driven scan behind it, and the <br /> rewrite. The module docstring
says the two files should move together.

Why here: until now this package could only strip HTML to plain text, so a
downstream GLPI-to-EasyVista sync wrote its own converter. On 2026-09-30 a
GLPI description holding a pasted URL and a titled link reached EasyVista
with neither link clickable (tier 4, one instance: EasyVista stored exactly
the HTML it was sent). That converter escaped the pivot's &lt; a second
time, fused a link title into the href, cut a URL at its first ")" and read
two lone asterisks as emphasis. Markdown to ITSM-format conversion belongs
in the connector library, and the GLPI library's converter already handled
every one of those cases. Each is pinned here as a regression, with the
host replaced by example.org.

EasyvistaContentError joins the core taxonomy under EasyvistaError, raised
for any converter fault other than depth, and is exported at top level so
it can be caught without the extra.

The one code divergence: the three libraries are an optional extra,
easyvista-python-client[content], rather than hard dependencies, so the
core stays httpx + pydantic + tenacity. Importing the subpackage without
them raises an ImportError naming the pip command, and a fresh-interpreter
test pins that `import easyvista_python_client` loads none of them. The
three are repeated in dev (CI runs the tests) and docs (autodoc imports
the module); a test fails if the copies drift.

Re-measured rather than copied, 2026-09-30: the inbound depth cliff is 493
levels on CPython 3.12-3.14 but 328 on 3.10, which spends about three
frames per level; and the beautifulsoup4 <br> / <br /> defect no longer
reproduces on 4.15.0. The rewrite stays, since the extra accepts 4.12, and
a new test checks it is still applied, which no output test can notice on
4.15.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t is not sanitised

The round-trip corpus from glpi_python_client's content tests, adapted and
extended with the cases the converter port was made for: autolinks, link
titles, parenthesised URLs, lone asterisks, accents, a query string, the
2026-09-30 synced description, and what the memo that defect stored reads
back as. As there, it is an inventory rather than a property: the eight
cases a write-then-read cycle does not reproduce exactly are strict xfails
carrying the measured reason, so fixing one fails the suite until the list
is updated.

The weaker property is the one a two-way sync depends on, and it is new
here: whatever one cycle changes, a second changes nothing more. It holds
over the same corpus except for two cases, both recorded -- a nested list,
which a second cycle flattens, and angle-bracket text, whose doubled space
collapses one cycle after the word itself is lost. A memo-side twin reads a
memo, writes that Markdown back and reads it again; the four memos whose
first write is itself lossy are named, among them a plain-text memo's lone
newlines, which become hard breaks once.

Four tests pin, on purpose, that the converter is not a sanitiser, as in
glpi_python_client: raw HTML and a javascript: target are rendered live,
an executable scheme in angle brackets is not made a link, and text a memo
displays as markup reads back as raw markup. A caller relaying Markdown it
did not write has to neutralise those itself, and these say exactly what
it has to cover.

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

Adds docs/content.rst to the user guide: what a memo holds, what each
direction does, what survives a round trip, where the converter comes from,
and -- in its own section -- that it is not a sanitiser, with the four
things a caller relaying Markdown it did not write has to neutralise. The
API reference gains the converter's autodoc entry under a new "Rich-text
content" section; installation.rst and the README name the extra.

docs/vendor-api-reference.md records the only evidence the converter's
premise rests on, as tier 4: on 2026-09-30, on one instance, a ticket's
COMMENT memo written through the API with HTML was stored byte for byte and
the web UI rendered its paragraphs. No vendor documentation of the memo
format is recorded, and the new open item O-MEMOFORMAT lists what is still
unmeasured: what the UI's own editor writes, whether the UI treats the
newlines between blocks as HTML whitespace, and what it shows for raw
markup.

The ticket-workflow and ticket-actions skills each gain a gotcha: a memo
stores what it is sent, nothing renders Markdown for you, and the optional
extra converts both ways. Prose only -- the skills contract admits imports
from the package root alone, and the converter deliberately lives in a
subpackage the root does not import.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Bumps the version in the four places docs/publishing.rst enumerates --
pyproject.toml, __version__, the literal test_public_api.py asserts, and
every skill's metadata.version -- and moves the converter work into a dated
0.4.0 section of CHANGELOG.md, with the compare links updated to bare tags.

A minor bump for new public surface, not for a break: the content extra is
optional, the core package imports none of it, and the one new root export
is EasyvistaContentError. Nothing an existing caller does changes.

Prepared locally only. The section is dated 2026-09-30, the day it was
prepared; if the release is cut later, move the date with it, since the tag
is what defines the version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A review pass over the prose the previous commits added, holding each
factual claim to its evidence tier.

- The content page opened by saying a memo "holds the HTML it was sent"
  before the tier-4 caveat that limits it to one measured sample; it now
  says a memo holds whatever it was sent, HTML where that was measured.
  The same sentence in from_transport now carries its tier.
- The ticket-workflow skill told the reader to expect HTML back from
  resolve_memo, and said the UI rendered the memo "as rich text". What was
  measured is that the UI rendered <p> elements as paragraphs, and a memo
  can equally hold plain text or unrendered Markdown; both now say so.
- from_transport's Raises section and EasyvistaContentError's docstring
  said the error is raised only for non-depth faults. It is also raised in
  the one depth case the converter cannot degrade: a caller whose stack is
  too short even to strip the tags. Both now name it.
- Smaller corrections on the page: a plain-text memo comes back stripped,
  not literally unchanged; the dropped whitespace was measured at the end of
  a paragraph, not at both edges; the fixed-point sentence now says what it
  means; and the example says why it reads COMMENT but writes description.
- The copied void-element set still equals beautifulsoup4 4.15.0's private
  table, checked 2026-09-30, and the comment now records it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The reader handed markdownify's output on as Markdown, and markdownify
escapes nothing it is not asked to, so text a memo displayed changed
meaning as soon as anything rendered it: \\serveur lost a backslash,
__init__ became bold, "* point" and "> merci" lines made a list and a
quote, #4521 at a line start made a heading, a | in a cell split the row,
and a memo showing &lt;script&gt; read back as a live <script>.
glpi_python_client fixed that in 588e97d and 7ea833a; this is the same
converter, brought over by a three-way merge of 0d43528 -> 4fc3bed onto
this port, so the EasyVista names, messages, memo notes and the optional
extra's import guard are kept -- the guard now wraps every one of the
three libraries' imports the new code needs.

A character is escaped exactly where python-markdown, with the four
extensions to_transport uses, would read it as syntax, and nowhere else:
ordinary prose comes back byte for byte. Nested lists nest at four spaces
and keep their numbers, script, style and title bodies are dropped, the
obsolete elements make a memo HTML, and every pass is linear.

Parity, measured: the two conversion.py files have the same 86 top-level
definitions and the same ASTs once docstrings are stripped and the
converter, its error, the import guard and the error messages are
normalised; and over 3,275 bodies -- the realistic corpus, 3,000 fuzzed
bodies and every HTML the tests pin -- both converters give identical
Markdown and HTML in each library's venv (CPython 3.12 with bs4 4.14.3,
markdown 3.10.2 and markdownify 1.2.2; CPython 3.10 with 4.15.0, 3.10.3
and 1.2.3), and the two venvs agree with each other.

Tests: test_literal_text.py is glpi_python_client's, ported whole -- the
property that what a memo displays the Markdown displays, compared with an
HTML parser, and that the Markdown is a fixed point, over the regressions,
realistic bodies and a seeded fuzzer, plus the inventory of what Markdown
cannot carry. test_conversion.py follows the converter: the defect memo now
reads back as the "&lt;" text it displayed and writes back as the very
paragraph EasyVista stores; displayed markup comes back as text; the
nested-list and defect-readback round trips and the nested-list and
defect-memo read-backs are no longer losses; the degraded path is compared
with what the converting path displays; and the script/style parity case
says the converting path drops those bodies while the degraded one keeps
them. The deepest stack-can-hold case is 250 rather than 300: CPython
3.10's cliff is 328 levels from a shallow stack, and 300 left 14 levels of
margin under pytest.

The content extra, and the dev and docs extras that copy it, need
markdownify>=1.2: the converter subclasses MarkdownConverter and relies on
the 1.x hooks, which 0.13 does not have.

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

docs/content.rst describes the reader as it is now: text in a memo is
literal and the Markdown spells it so, with the example checked against the
code; ordinary prose carries no escape; nested lists nest; a memo holding
one real HTML element is read as HTML throughout. "It is not a sanitiser"
keeps the three outbound cases and states the one inbound change -- text a
memo displays as markup reads back as text, where it used to come back
live. "What survives a round trip" drops the nested list from the losses,
adds the memo-side property and the inventory of what Markdown cannot
carry, and the port reference moves to 4fc3bed.

The 0.4.0 changelog entry, which has not shipped, is corrected to match:
markdownify>=1.2 and why, the port commit, what the reader escapes, and six
lossy round-trip shapes rather than eight. The two skills that document the
content extra say not to strip the backslashes and not to mix Markdown and
HTML in one memo.

models/request.py no longer calls the memo format "unverified (O4)": it is
whatever its writer sent, measured on one instance, and what is still
unknown is open item O-MEMOFORMAT in docs/vendor-api-reference.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
baraline and others added 3 commits October 2, 2026 15:33
CPython 3.10 reaches end of life in October 2026 (PEP 619), and
glpi_python_client, whose converter the content extra ports, dropped it in
524304a. requires-python is now >=3.11, the 3.10 classifier is gone, and the
CI and release matrices run 3.11-3.14. typing-extensions and tomli, needed
only on 3.10, go with it, and ruff and mypy target 3.11. datetime.UTC
replaces timezone.utc (the same object); the timestamp parser accepts and
refuses exactly the values it did. On 3.10, pip keeps resolving 0.3.0, which
has no content extra; README and the installation page say so.

Three release.yml comments were wrong before this change and are corrected:
the release job gates no coverage (ci.yml's coverage job does), every tag is
unprefixed, and "same coverage as 3.10" is history. The two model test files
carry ruff 0.16.0's formatting, so the pre-commit hook does not rewrite them
at commit time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rk-gfm

The content extra followed glpi_python_client's python-markdown design
(4fc3bed). glpi_python_client replaced that design in 917f030: markdownify
plus mdformat inbound, cmark-gfm outbound. This package now carries that
converter, plus 15 fixes measured on this package's synthetic corpus and on
a 367-memo preproduction sample: alt-text escapes, ~~~ and ! before a link,
a ^ alt, a second <, <br> in inline code, tables in headings and links,
<center>, raw <u>/<mark>/<ins>, linear list numbering and edge splitting,
unreadable colspan/start, and the CVE-2025-6069 tail. The extra now
installs beautifulsoup4>=4.15, cmarkgfm>=2025.10.22, markdown-it-py>=3.0,<4,
markdownify>=1.2.3,<1.3, mdformat>=0.7.22,<0.8 and mdformat-tables>=1.0,<1.1;
markdown is gone.

Tests: GLPI's display oracle, round-trip and contract tests are ported,
plus:
- one regression test per fix (GLPI 917f030 fails 36 of 54);
- seeded property families, including underline/highlight and blocks in
  flattened cells (GLPI fails 39-100% of each);
- growth tests asserting time(4n)/time(n) < 8: a linear pass reads 2.4-6.8,
  a reverted fix 11-37;
- the "not a sanitiser" contract, pinned exactly;
- the 107 HTML bodies of this package's earlier regression tests, held to
  display plus fixed point.

The pre-commit mypy hook installs the extra's four typed packages with
pyproject's bounds, and a test keeps them in step; cmarkgfm and
mdformat-tables add no types. Nothing checked that requires-python, the
classifiers and the two CI matrices agree, so dropping 3.10 had to chase four
lists by hand: test_the_supported_pythons_agree_everywhere_they_are_written
now binds them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uded

docs/content.rst describes the CommonMark-with-GFM-tables dialect, what the
reader escapes, and the dependency bounds. Two bounds were measured against
the next major; two are precautionary caps on releases that do not exist
yet.

The round trip is stated as an aim with its measurement: on 367 memos read
from one preproduction instance on 2026-10-01 and measured 2026-10-02
(tier 4, may not generalise), no word changed and all were fixed points; 65
displayed differently in listed, word-keeping ways. A "Known holes" list
names the synthetic shapes that do lose words or display: ragged rows, a |
in a cell's code or link, caption/colgroup without tbody, an image title
holding ", a list or <hr> in a cell, and more. Also stated plainly:
- plain_text_is_markdown=True reads Markdown that opens with < and holds
  HTML as HTML;
- the converter is not a sanitiser;
- a caller near the recursion limit can still get a bare RecursionError.

Tier 1: the vendor's comment-log page (read 2026-10-02) sets a custom
comment field to "Text area". That leans towards HTML display, against the
converter's literal-lines reading of a tag-less memo, but says nothing of
the built-in memos. O-MEMOFORMAT records it. The CHANGELOG's 0.4.0 entry
matches, and says the 15 fixes are still to be proposed to
glpi_python_client. README and the installation page list the extra's six
packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Oct 2, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 99.20635% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.92%. Comparing base (3f61679) to head (8f427ee).

Files with missing lines Patch % Lines
easyvista_python_client/content/conversion.py 98.78% 4 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main       #6      +/-   ##
==========================================
+ Coverage   98.86%   98.92%   +0.05%     
==========================================
  Files          37       40       +3     
  Lines        2116     2595     +479     
==========================================
+ Hits         2092     2567     +475     
- Misses         24       28       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…emove set_status; add reassign_action (#7)

* feat(workflow): name what a write may do to a ticket's workflow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workflow): refuse an encoded slash or a backslash in a path

The classifier split a path on '/' before percent-decoding, so
requests%2FI1%2Fclose was one opaque segment and named nothing, yet a server
may read it as a separator. Refuse it, and a backslash, outright, as dot
segments already are. Fails closed: whether the server decodes %2F or treats a
backslash as a separator is not measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workflow): judge a request by every method it names, and cite the vendor

A method-override header that said GET replaced the real method, so a POST to
requests/{rfc}/close with X-HTTP-Method-Override: GET was classified as a read
and named nothing; with two override headers the first won. A request is now a
read only when the real method and every override value are reads, and a
DELETE among them applies the DELETE rule. A tuple body is scanned like a list,
as httpx serialises it as a JSON array.

Cite the update-page exclusions and the requalification passage with URL, tier
and read date, and state every refusal and the override rule in the public
workflow_triggers docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(transport)!: refuse a workflow write unless the call allows it

A request naming a workflow effect is refused before any I/O unless its spec
allows it; an allowed one is sent once, never retried.

set_status, close_ticket and end_action opt in to the effect they have always
had, so their behaviour is unchanged for now; the interim opt-ins are replaced
by the caller's own in the tasks that rework those methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(client)!: take allow_workflow_effect on every writer; refuse a non-numeric action id

send, update_ticket, update_action, create_action and create_task take a
keyword-only allow_workflow_effect (a WorkflowEffect or an iterable of them,
default none) and widen the request spec with it, so the transport's guard
refuses a write that may change a ticket's workflow unless the call named the
effect. The typed writers send nothing different without it: the fields their
models declare need no opt-in.

update_action refuses anything but a positive integer action id. PUT
actions/{rfc_number} is the vendor's end-action route on the same path
template as PUT actions/{action_id}, so an RFC number passed where an action
id belongs would not edit one action; with an end_action body it would end
every open action on the ticket. Action.action_id is legitimately None across
the package, so None is refused rather than addressing actions/None.

build_get_action gains an optional fields projection, as the list builder has.

BREAKING: update_action raises ValueError for an RFC number, a bool, a float,
a blank string or a non-positive id, where it used to send the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(client): pin the allowed path of every writer; qualify the end-action claim

update_action, create_action and create_task had only refusal tests, which pass
whether or not allow_workflow_effect reaches the request spec: a writer that
accepted the keyword and dropped .allowing(...) would have kept the suite
green. Each now has a positive test that the allowed call sends exactly once,
to the right route, and that the body on the wire carries the gated key. The
update_ticket allowed-path test asserts the body the same way.

Checked by mutation: with .allowing(...) removed from those three writers, the
three new tests fail with EasyvistaWorkflowEffectRefused; restored, they pass.

_require_action_id's docstring (and the test that mirrors it) said an RFC number
with an end_action body ends every open action on the ticket. That holds only
when the body names no action_id, as build_end_action's own docstring says;
qualify it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(client)!: remove set_status; close_ticket requires allow_workflow_effect

set_status was the vendor CLOSE request under a name that hid it: per the
vendor close page it interrupts the workflow and closes the open actions
whatever status it names. There is no status setter on this API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(client): cite the workflow page for the status clause; fix two stale counts

RequestUpdate's status_id bullet now cites docs.easyvista.com/docs/workflow.md
for "a ticket's status follows its workflow", beside the close page.
build_close_ticket says delete_actions deletes the unfinished actions rather
than ending them, as close_ticket already does. The live-suite docstring drops
one ticket_factory ticket (21 -> 20 tickets, 18 -> 17) now that the set_status
test is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(live): pin WORKFLOW_ID on an action's item read, which end_action's guard needs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(live): scan the newest tickets for the WORKFLOW_ID probe

The default ticket order is oldest-first, and on the measured instance
(2026-10-02) the oldest tickets carry only an already-ended CALL action and
no workflow step, so both live tests skipped. Sort REQUEST_ID DESC so the scan
reads recent tickets, which carry both kinds of action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(actions)!: end_action refuses to end a workflow step unless ADVANCES is allowed

Ending a workflow step's action advances the ticket's workflow, so
end_action now makes one projected read (ACTION_ID, WORKFLOW_ID) first and
raises EasyvistaWorkflowEffectRefused, with no end request sent, when the
action is a workflow step or the record names no WORKFLOW_ID at all.
end_all=True is refused outright. allow_workflow_effect=
WorkflowEffect.ADVANCES opts in and skips the read. Ending the caller's
own action needs no opt-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(actions): end_action validates its action id and checks the read named it

A blank or whitespace action_id got past the workflow-step guard: nothing
checked it, so the guard's read addressed the collection (GET actions/),
took the first row's empty WORKFLOW_ID and let the end through. A probe
that answered a different ACTION_ID was accepted the same way.

build_end_action now requires a positive integer (the rule update_action
already applies) and sends it as an integer, so a blank or an RFC number is
refused before any request. The guard reads the integer id and refuses when
the record names a different ACTION_ID; an absent ACTION_ID stays allowed.

Also: pin the step-specific refusal and its triggers, pin that UNKNOWN does
not skip the guard, and reword EasyvistaWorkflowEffectRefused so it no
longer says "no request made" of the end_action path, where one read
precedes the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(live): census what reassigning a workflow step does

Adds the live census behind a typed reassign_action: what PUT actions/{id}
with a group or a person does to a ticket's open workflow step (does the
column store, does the step stay open, does the ticket's status and end
date stay, how many action rows appear), plus the title write the sync
makes on every sweep. The census WRITES and is not run by this commit; it
runs only with the user's explicit approval.

The live_reassign_config fixture is module-local so the file also runs
from a checkout whose conftest.py lacks it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(live): make the workflow census gated, safe and conclusive

Review fixes to the reassignment census, which WRITES to a live instance
and is not run by this commit.

- Opt-in: all three census tests take a session-scoped census_opt_in
  fixture first and skip unless EASYVISTA_TEST_RUN_WORKFLOW_CENSUS=1, so
  nothing is created without the user's approval.
- Conclusive: before and after both go through one projected item read;
  the column must be named, differ from the target, and (for the group)
  be non-empty before the write. The step's REQUEST_ID must equal the fresh
  ticket's, so a PUT can never reach another ticket's action.
- Time: the after-state is read twice (immediately and after 5 s); the
  verdict is taken on the second read and both are printed.
- Key spelling: if the lower-case key did not store, the upper-case key is
  sent to the same step and both outcomes are recorded.
- Failure output: every comparison is bound to a boolean and asserted with a
  label-only message, so a failure never renders an Action or Request repr.
  A raised write prints only the exception type and status code, still
  takes the after reads, then re-raises.
- Misconfigured targets and a step with no action id stop before any ticket
  or write; the title test now prints a CENSUS line and asserts the
  ticket's END_DATE_UT unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(live): fail a raised census write with a label, not the exception

A write that raises used to be re-raised, so pytest printed the
transport's message, which carries server prose this suite keeps out of
test output. Now the type name and status code are printed, the after-state
reads are still taken and printed, and the test fails with a label-only
message; the original exception is not chained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(vendor-api-reference): the ticket workflow model, and close O-CLOSE-DEFAULT at tier 1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(workflow): re-attribute the status sentence, document workflowstart, name the guard's sets exactly

The sentence 'Advancing through the steps of a workflow changes the status of
a ticket.' is on references-tables.md (Statuses), not workflow.md; every
citation now says so. The vendor's PUT requests/{rfc}/workflowstart is added
to the table and to workflow.py's count of workflow-touching writes. The
'Named' list is now a table copied from workflow.py's sets, route by route.
The 'everything else' row is narrowed to what each vendor page shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): there is no status setter; the workflow guard; check required keywords

The skills still told agents to use set_status for a status and showed
close_ticket without the now-required allow_workflow_effect, and agents
run a skill's snippet verbatim. Rewrite them to match the guard: no status
write exists, close_ticket is the vendor close request and interrupts the
workflow, end_action reads the action first and refuses a workflow step
unless ADVANCES is allowed, and a refused write is a ValueError raised
before sending and never retried.

The contract test now also fails a snippet that omits a required
keyword-only parameter of a client method (a **kwargs splat is exempt).
Seen failing on the close_ticket snippet before the skills were fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(skills): attribute the close-request claims to their evidence; self-test the keyword check

Review fixes for the skills commit.

- The synchroniser incident is now attributed: root cause established
  2026-10-01/02 from the synchroniser's code plus the vendor close page
  (tier 1); the drain across a close-envelope status write measured
  2026-09-01 on one instance, one ticket (tier 4, may not generalise).
- "Ends the open actions ... whatever status" no longer reads as settled:
  ending is this package's reading of the page, which documents final
  statuses only. Same softening in the instance-discovery skill.
- Procedure 5 says ending a workflow step needs ADVANCES; the end_action
  paragraph names the single read first, that a failing read propagates
  (a 403 there is not a permission verdict), and the ACTION_ID mismatch
  refusal.
- The "drains the open set" claim carries its basis; the vendor update
  pages are cited by URL.
- The required-keyword check is a helper with its own parametrised test
  (missing keyword, **kwargs splat, compliant call), so it cannot go inert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: changing a ticket's status, the guard in the guide and README, create starts the workflow

Add a user-guide section on how a ticket's status follows its workflow (no
setter; end_action advances, close_ticket interrupts, suspend/reopen are
unwrapped), the guard, the end_action pre-read and the retry rule. Thread
allow_workflow_effect through every close_ticket example, record the vendor
default for an omitted status_GUID at tier 1, and stop describing end_date_ut
as stamped on closure. Give create_ticket a docstring that says it starts the
workflow, mark PostRequest.workflow_start as a measured no-op, and note the
refusal in to_api and the unknown-key error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: restore the unmeasured-WORKFLOW_ID hedge; say who must pass allow_workflow_effect

Ending your own action needs no opt-in, except that whether an action created
under the workflow step by create_action carries a WORKFLOW_ID is unmeasured:
if it does, end_action refuses it without ADVANCES. State that in the guide and
the README. Say allow_workflow_effect is required on close_ticket and optional
(default: nothing allowed) on the other writers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): 0.4.0 is breaking -- the workflow guard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): scope the workflow-guard claims to their evidence

Close-page effects credited as documented (deleting; ending is the package's
reading), the synchroniser claim scoped to its evidence, the status-setter
wording aligned with the vendor reference, the extra_headers gap and the
send()-only scope stated, the single-attempt change marked BREAKING, the
Retracted line quoted verbatim, and the skill list corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(actions): add reassign_action, backed by a live census

PUT actions/{id} with a group (and/or a person) reassigns an action
without ending it. The vendor documents no such route (tier 1), so the
docstring carries the census: measured 2026-10-02 on one instance, two
tickets -- the group stored, the step stayed open, the ticket's status
did not move, no new action rows appeared, and the ticket's own owning
group did not follow. The person write is unmeasured and says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: record the reassign_action census, and say how to escalate a step

Closes the 'not yet measured' sentence in the vendor-API reference with
the 2026-10-02 census (tier 4, one instance, two tickets), adds a
'Reassign an action' section to the ticket-actions skill, a sentence to
the user guide's status section, and the 0.4.0 changelog entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: scope the reassign_action census to the tickets that carry it

The first ticket alone showed the type-20 step, its unchanged WORKFLOW_ID,
the empty DONE_BY_ID and that no ticket field changed; both tickets showed
the stored group, the step open, status and END_DATE_UT unmoved, the open
set unchanged, no new action rows, and re-reads at once and 5 s later.
The owning group was read on one ticket only, and now every text says so.

Also: say where the group and person ids come from (GET groups answered
403 on the measured instance), list reassign_action in the ticket-actions
skill's description and method count, and use the YOUR_* placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(workflow): name the end-action route, and classify configured override headers

Two fail-closed gaps the final branch review found.

workflow_triggers named a write to actions/<x> only by its body, but PUT
actions/{rfc_number} is the vendor's end-action route: update_action refuses an
RFC id client-side, yet send("PUT", "actions/<rfc>", json={"description": ...})
went out unclassified. A write to an actions/<x> path whose segment is not
ASCII digits now names ("actions/{rfc}", ADVANCES) beside any column rule.
end_action's own path already allows ADVANCES after its pre-flight.

BaseTransport.gate classified spec.headers only, while headers() merges
config.extra_headers onto every request, so a method-override header set in the
configuration could hide a write behind a GET. It now reads
{**config.extra_headers, **spec.headers}: the headers as they go on the wire,
the spec winning. The gate docstring also names the encoded-slash and backslash
refusals it was missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: make the 0.4.0 breaking-change list true, and hedge the absence claims

Final-review fixes for the workflow guard branch.

CHANGELOG: the intro says every break is marked BREAKING in Changed or Removed.
Two were not: the spec-builder break (a build_close_ticket / build_end_action
spec needs .allowing(...); build_end_action and build_update_action refuse a
non-positive-integer id) and the method-override-header classification. Both
are now BREAKING bullets in Changed, with an Upgrading line for the first.
The named-routes bullet and the vendor reference's table add the end-action
route, say the column rules apply to requests/ and actions/ routes only, and
drop the claim that config.extra_headers is unclassified (it is now read).

vendor-api-reference: workflow_start was still "unverified until tested"; it
records the dated tier-4 measurement (2026-09-01, one instance, two tickets
byte-identical) beside the tier-1 fact that the create page documents no such
parameter.

Flat absence claims ("there is no status setter", "there is none", "no status
write") in the README, user guide, update_ticket and close_ticket docstrings,
RequestUpdate and the ticket-workflow skill now say the vendor documents none
and this package has none.

integration_tests: test_live_workflow_guard.py's two read-only tests used bare
asserts on Action attributes, which print the model repr (href host) on failure;
they go through _require. conftest's write count now mentions the opt-in
workflow census. test_live_smoke's stale "also used by" sentence is reworded.

Wording: a paraphrase loses its quotation marks; directory.py dates the
end_date_ut-at-resolution claim; reassign_action says "closest API
equivalent" and drops the unverified French label; "short list" is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: use a synthetic RFC where a real instance ticket number was quoted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@baraline
baraline marked this pull request as ready for review October 2, 2026 13:56
@baraline
baraline merged commit 31318e6 into main Oct 2, 2026
6 checks passed
@baraline
baraline deleted the feat/content-conversion branch October 2, 2026 14:03
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