Skip to content

feat!: refuse workflow-changing writes unless the call allows them; remove set_status; add reassign_action - #7

Merged
baraline merged 30 commits into
feat/content-conversionfrom
feat/workflow-guard
Oct 2, 2026
Merged

baraline merged 30 commits into
feat/content-conversionfrom
feat/workflow-guard

Conversation

@baraline

@baraline baraline commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #6: this PR targets feat/content-conversion, and the two ship together as one breaking 0.4.0.

Why

A GLPI ↔ EasyVista synchroniser was ending every EasyVista ticket's initial workflow action ("A faire") the first time it touched the ticket. The cause was in this package. set_status sends the vendor's close request, PUT requests/{rfc} with {"closed": {...}}, under a name that presented it as a neutral status setter. The vendor's close page (rest-api-close-an-incident-request, tier 1) describes four steps, and none depends on the status sent:

  • "1. The workflow of the ticket is interrupted."
  • The status is set.
  • The unfinished actions are deleted (or, by our reading of the end_date row, ended).
  • "An anticipated closing action … is inserted."

The vendor's model is that a ticket's status follows its workflow ("Advancing through the steps of a workflow changes the status of a ticket." — references-tables). The vendor documents no status setter.

What changes

The guard. easyvista_python_client/workflow.py classifies each request from its method, path, body and override headers as INTERRUPTS, ADVANCES or UNKNOWN. Transport.send refuses a named effect before any I/O unless the call passes allow_workflow_effect=. What gets named:

  • the closed, end_action, suspended and restarted bodies, on any path;
  • status and catalog columns on requests/{rfc};
  • end-date, type, parent, workflow-link and ticket-link columns on actions/{id};
  • ticket sub-routes that are workflow commands, and non-numeric actions/<x> (the end-action route).

Paths with dot segments, encoded slashes or backslashes are refused. A request counts as a read only if every method it names (including override headers, config.extra_headers too) is a read. An allowed workflow write is sent once, never retried. The refusal is EasyvistaWorkflowEffectRefused(ValueError), deliberately not an EasyvistaError: nothing was sent, and retrying can never succeed.

Client changes:

  • BREAKING: set_status and build_set_status are removed.
  • BREAKING: close_ticket requires allow_workflow_effect= (keyword-only, no default).
  • BREAKING: end_action reads the action first, projecting ACTION_ID and WORKFLOW_ID. Unless ADVANCES is allowed, it refuses a workflow step, a record without WORKFLOW_ID, or a read that returns a different id. end_all=True always needs ADVANCES. An explicit action_id must be a positive integer.
  • BREAKING: update_action refuses an id that is not a positive integer, because an RFC number there addresses the end-action route.
  • New: reassign_action(action_id, *, group_id=None, done_by_id=None). No REST reassignment route is documented; this is PUT actions/{id}, and its effect was measured live (below).
  • update_ticket, update_action, create_action, create_task and send take allow_workflow_effect=(). What the typed models declare needs no opt-in.

Docs:

  • docs/vendor-api-reference.md gains a "Ticket workflow" section: what each documented write does to the workflow, and exactly what the guard names. O-CLOSE-DEFAULT is closed at tier 1.
  • The skills, user guide (new "Changing a ticket's status" section), README and CHANGELOG are rewritten.
  • The skills contract test now fails a snippet that omits a required keyword-only parameter.

Evidence (tier 4: one instance, 2026-10-02, may not generalise)

  • An action's item read names WORKFLOW_ID on a workflow step and on a non-workflow action. The end_action pre-read relies on this.
  • PUT actions/{id} {"group_id": …} on the open workflow step, on two tickets:
    • the group stored;
    • the step stayed open;
    • status and END_DATE_UT did not move;
    • no new action row appeared.
    • Read on one of them: the ticket's OWNING_GROUP_ID did not follow.
  • A title update left the workflow step open.

The live tests are in integration_tests/test_live_workflow_guard.py. The two read-only checks run normally. The write census runs only with EASYVISTA_TEST_RUN_WORKFLOW_CENSUS=1.

Test plan

  • pytest -m "not integration": 2028 passed on Python 3.11, after merging the rebuilt base (6143c23; 2146 before, on the old converter's tests)
  • ruff check ., unasync_build.py --check, mypy easyvista_python_client, sphinx -E -W; coverage 98.92% against the 95% floor
  • CI green on 3.11–3.14 after the merge. The first run failed 20 content tests that were not this branch's. The old base's python-markdown converter broke under markdown 3.11, and the base failed the same way on its own run. The merge brings in the base's rebuilt converter and its Python 3.10 drop. Only CHANGELOG.md conflicted: the 0.4.0 intro now names all three changes, and the two ### Removed sections are one.
  • Live read-only check: both pass
  • Live census (approved): group reassignment and title write both pass; person reassignment not measured
  • Per-task spec and quality reviews, plus a final whole-branch review; all findings fixed or recorded

Known follow-ups

  • skills/easyvista-ticket-workflow/SKILL.md:87 says "the vendor documents no status write"; it should say "no status setter".
  • integration_tests/conftest.py still claims the smoke test's rejected create leaves no ticket (pre-existing).
  • Set the 0.4.0 date at tagging.
  • Downstream synchronisers: remove both set_status calls in the same change that widens the pin past <0.4.

🤖 Generated with Claude Code

baraline and others added 30 commits October 2, 2026 10:53
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
… 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>
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>
…on-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>
…ction 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>
…w_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>
…tale 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>
…n's guard needs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
…NCES 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>
…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>
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>
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>
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>
…OSE-DEFAULT at tier 1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…art, 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>
…quired 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>
…elf-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>
…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>
…ow_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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
…rride 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>
… 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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the rebuilt converter (markdownify + mdformat + cmark-gfm) and the
Python 3.10 drop. The old python-markdown converter this branch was built on
broke under markdown 3.11, which rewrote the backtick processor that
conversion.py replayed; that is what failed 20 content tests in CI here and on
the base itself (run 36812687842, 2026-10-01). This branch changed nothing in
content/.

Only CHANGELOG.md conflicted. The 0.4.0 intro now names all three changes
(converter, the 3.10 floor, the workflow guard): the base's version said the
3.10 drop was the one break and no existing call changes, which the guard makes
false. The two ### Removed sections are folded into one, and the converter
documentation and notes take the base's rewrite.

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

Copy link
Copy Markdown

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

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.92%. Comparing base (4292f16) to head (6143c23).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@                     Coverage Diff                     @@
##           feat/content-conversion       #7      +/-   ##
===========================================================
+ Coverage                    98.85%   98.92%   +0.06%     
===========================================================
  Files                           39       40       +1     
  Lines                         2449     2595     +146     
===========================================================
+ Hits                          2421     2567     +146     
  Misses                          28       28              

☔ 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.

@baraline
baraline merged commit 8f427ee into feat/content-conversion Oct 2, 2026
6 checks passed
baraline added a commit that referenced this pull request Oct 2, 2026
…#6)

* feat(content): port glpi_python_client's Markdown <-> memo HTML converter

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>

* test(content): pin the round-trip inventory, the fixed point, and what 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>

* docs(content): document the converter, the content extra, and the memo 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>

* release: 0.4.0

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>

* docs(content): claim only what was measured about memos

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>

* fix(content): port glpi_python_client's literal-safe converter (4fc3bed)

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>

* docs(content): say what the literal-safe reader escapes, and cite O-MEMOFORMAT

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>

* build!: drop Python 3.10

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>

* feat(content): rebuild the converter on markdownify, mdformat and cmark-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>

* docs(content): document the rebuilt converter as measured, holes included

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>

* feat!: refuse workflow-changing writes unless the call allows them; remove 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>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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