Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2228500
feat(workflow): name what a write may do to a ticket's workflow
baraline Oct 2, 2026
8b32cfe
fix(workflow): refuse an encoded slash or a backslash in a path
baraline Oct 2, 2026
c3605dd
fix(workflow): judge a request by every method it names, and cite the…
baraline Oct 2, 2026
cd19ec2
feat(transport)!: refuse a workflow write unless the call allows it
baraline Oct 2, 2026
11ff68e
feat(client)!: take allow_workflow_effect on every writer; refuse a n…
baraline Oct 2, 2026
653cd17
test(client): pin the allowed path of every writer; qualify the end-a…
baraline Oct 2, 2026
4008b1b
feat(client)!: remove set_status; close_ticket requires allow_workflo…
baraline Oct 2, 2026
0b0c079
docs(client): cite the workflow page for the status clause; fix two s…
baraline Oct 2, 2026
ab38629
test(live): pin WORKFLOW_ID on an action's item read, which end_actio…
baraline Oct 2, 2026
4fe8a36
test(live): scan the newest tickets for the WORKFLOW_ID probe
baraline Oct 2, 2026
09c8d04
feat(actions)!: end_action refuses to end a workflow step unless ADVA…
baraline Oct 2, 2026
9788a75
fix(actions): end_action validates its action id and checks the read …
baraline Oct 2, 2026
c227d35
test(live): census what reassigning a workflow step does
baraline Oct 2, 2026
fe0aadb
test(live): make the workflow census gated, safe and conclusive
baraline Oct 2, 2026
cc30932
test(live): fail a raised census write with a label, not the exception
baraline Oct 2, 2026
e8f4161
docs(vendor-api-reference): the ticket workflow model, and close O-CL…
baraline Oct 2, 2026
90c87be
docs(workflow): re-attribute the status sentence, document workflowst…
baraline Oct 2, 2026
7721828
docs(skills): there is no status setter; the workflow guard; check re…
baraline Oct 2, 2026
aef541f
docs(skills): attribute the close-request claims to their evidence; s…
baraline Oct 2, 2026
c722bc9
docs: changing a ticket's status, the guard in the guide and README, …
baraline Oct 2, 2026
38726cb
docs: restore the unmeasured-WORKFLOW_ID hedge; say who must pass all…
baraline Oct 2, 2026
302038d
docs(changelog): 0.4.0 is breaking -- the workflow guard
baraline Oct 2, 2026
0283b2f
docs(changelog): scope the workflow-guard claims to their evidence
baraline Oct 2, 2026
15fd44b
feat(actions): add reassign_action, backed by a live census
baraline Oct 2, 2026
8ab8b8e
docs: record the reassign_action census, and say how to escalate a step
baraline Oct 2, 2026
e438e00
docs: scope the reassign_action census to the tickets that carry it
baraline Oct 2, 2026
99f3cc4
fix(workflow): name the end-action route, and classify configured ove…
baraline Oct 2, 2026
448858e
docs: make the 0.4.0 breaking-change list true, and hedge the absence…
baraline Oct 2, 2026
c4b8231
test: use a synthetic RFC where a real instance ticket number was quoted
baraline Oct 2, 2026
6143c23
Merge feat/content-conversion into feat/workflow-guard
baraline Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 213 additions & 8 deletions CHANGELOG.md

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ from easyvista_python_client import (
EasyvistaClient,
EasyvistaConfig,
PostRequest,
WorkflowEffect,
ev_equals_filter,
)

Expand Down Expand Up @@ -80,12 +81,10 @@ with EasyvistaClient(config) as client:
for t in client.iter_tickets(search=open_status, page_size=100, max_records=1000):
... # async: `async for t in client.iter_tickets(...)`

# close it with your instance's "closed" status GUID. Every argument is
# optional -- `client.close_ticket(ticket.rfc_number)` sends the close with
# no status of its own, but where that lands the ticket is not established
# by this package; see the user guide before relying on it.
# close only when closing is the intent: it interrupts the ticket's workflow.
client.close_ticket(
ticket.rfc_number,
allow_workflow_effect=WorkflowEffect.INTERRUPTS,
status_guid="{00000000-0000-0000-0000-000000000000}",
delete_actions=1,
comment="Resolved",
Expand Down Expand Up @@ -143,7 +142,15 @@ client.end_action(
> action only ends it; ending the ticket's open workflow step advances the
> workflow and moves the ticket's status. Naming `action_id` is therefore
> required — the vendor's id-less "end every open action" form is behind an
> explicit `end_all=True`.
> explicit `end_all=True`. Ending a workflow step, or ending every open action
> with `end_all=True`, is refused unless the call passes
> `allow_workflow_effect=WorkflowEffect.ADVANCES`. Ending an action you
> created yourself needs no opt-in, with one unmeasured exception: whether an
> action `create_action` creates under the workflow step carries a
> `WORKFLOW_ID` has not been measured, and if it does, `end_action` refuses to
> end it without `ADVANCES` (the safe direction).
> The vendor documents no status setter, and this package has none: a ticket's
> status follows its workflow (user guide, "Changing a ticket's status").

## Assets and documents

Expand Down
21 changes: 21 additions & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,25 @@ direction does, what survives a round trip, and why it is not a sanitiser.

.. autoclass:: easyvista_python_client.content.EasyvistaContentConverter

Workflow guard
--------------

A write that may change a ticket's workflow is refused before it is sent unless
the call allows the effect explicitly. See the module docstring for what is
named and why, and ``docs/vendor-api-reference.md``, "Ticket workflow".

.. automodule:: easyvista_python_client.workflow
:no-members:
:no-special-members:

.. autoclass:: easyvista_python_client.workflow.WorkflowEffect

.. autofunction:: easyvista_python_client.workflow.as_effects

.. autofunction:: easyvista_python_client.workflow.workflow_triggers

.. autofunction:: easyvista_python_client.workflow.classify_workflow_effects

Exceptions
----------

Expand All @@ -165,6 +184,8 @@ Exceptions

.. autoexception:: easyvista_python_client.exceptions.EasyvistaContentError

.. autoexception:: easyvista_python_client.exceptions.EasyvistaWorkflowEffectRefused

Resource engine
---------------

Expand Down
191 changes: 166 additions & 25 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ The short version is one call:
print("gap:", gap, reason)

for status in profile.references["STATUS"]:
# .guid is what close_ticket and set_status address a status by.
# .guid is what close_ticket addresses a status by.
print(status.id, status.label, status.guid)

That is :meth:`~easyvista_python_client.EasyvistaClient.describe_instance`; see
Expand Down Expand Up @@ -169,7 +169,8 @@ returns ``TITLE`` empty, for instance, so a listing wants
**Never infer "closed" from a status id.** They are per-instance: on the
verified instance ``8`` is *Clôturé* and ``12`` is *En cours* — adjacent
numbers, opposite meanings. ``end_date_ut`` is the portable signal: empty on
an open ticket, stamped on a closed one.
an open ticket, stamped once it is resolved or closed -- at resolution, not
closure (measured 2026-09-02 on one instance; it may not generalise).

Step 6 — **pin what you found in your own configuration.** This package holds
no registry of instance values and never will: they belong to your deployment,
Expand Down Expand Up @@ -207,9 +208,11 @@ asynchronous client inside an event loop (FastAPI, aiohttp) or for concurrent fa
a ticket, 7 branches for a department.

Two practical consequences. ``max_retries`` defaults to ``0``, so raise it if you fan out — a
429 from a rate-limited instance is not retried otherwise. And share one open client across your
tasks rather than opening one per task: ``aclose()`` is terminal and is not reference-counted, so
the first ``async with`` block to exit closes the client for everyone still using it.
429 from a rate-limited instance is not retried otherwise (a write that names a workflow effect
and was allowed with ``allow_workflow_effect=`` is sent once whatever ``max_retries`` says). And
share one open client across your tasks rather than opening one per task: ``aclose()`` is
terminal and is not reference-counted, so the first ``async with`` block to exit closes the
client for everyone still using it.

``create_tickets`` is deliberately **not** concurrent. Those are writes, EasyVista assigns the
RFC number server-side, and a failure part-way through a concurrent batch would leave you unable
Expand Down Expand Up @@ -344,41 +347,43 @@ Create several tickets in one call with :meth:`~easyvista_python_client.Easyvist
PostRequest(catalog_code="INC_STANDARD", title="Printer B down"),
])

Fetch, update, and close a ticket by its RFC number:
Fetch, update, and close a ticket by its RFC number. Closing is not a status
change -- it interrupts the ticket's workflow, which is why the call must say so
with ``allow_workflow_effect`` (see :ref:`changing-a-tickets-status`):

.. code-block:: python

from easyvista_python_client import RequestUpdate
from easyvista_python_client import RequestUpdate, WorkflowEffect

fetched = client.get_ticket(ticket.rfc_number)
client.update_ticket(ticket.rfc_number, RequestUpdate(description="Updated details"))

# Close with your instance's "closed" status GUID.
# Close with your instance's "closed" status GUID. Close only when closing
# is the intent: it interrupts the ticket's workflow.
client.close_ticket(
ticket.rfc_number,
allow_workflow_effect=WorkflowEffect.INTERRUPTS,
status_guid="{00000000-0000-0000-0000-000000000000}",
delete_actions=1,
comment="Resolved",
)

# Every argument is optional -- this sends the close with no status of its
# own, letting the instance decide where the ticket lands.
client.close_ticket(ticket.rfc_number)

# Verify by re-reading, not by the return value: end_date_ut is empty on an
# open ticket and stamped on a closed one, and is more portable than any
# status id (on the verified instance 8 is "Clôturé" and 12 is "En cours").
# open ticket and stamped once it is resolved or closed, and is more portable
# than any status id (on the verified instance 8 is "Clôturé" and 12 is
# "En cours").
assert client.get_ticket(ticket.rfc_number).end_date_ut is not None

.. warning::

Where a ticket lands when ``status_guid`` is omitted is **not established by
this package**. The client simply omits the key; what the server does with a
status-less ``closed`` body has never been measured against a live instance
here, and the behaviour is not recorded in ``docs/vendor-api-reference.md``.
Try it on a throwaway ticket and re-read before you build on it. Passing
your instance's closed ``status_guid`` explicitly is the form this package's
live suite actually exercises.
Where a ticket lands when ``status_guid`` is omitted is **not measured by
this package**. The client simply omits the key. The vendor's close page
documents an omitted ``status_GUID`` as defaulting to the Closed meta-status
(tier 1, recorded in ``docs/vendor-api-reference.md``, "Ticket workflow"),
but no live instance has been asked here. Try it on a throwaway ticket and
re-read before you build on it. Passing your instance's closed
``status_guid`` explicitly is the form this package's live suite actually
exercises.

.. note::

Expand All @@ -401,7 +406,9 @@ any write model; keys are serialized to their ``e_*`` API names automatically.
There are **two** escape hatches, and they are not interchangeable. ``custom_fields`` only ever
emits ``e_``-prefixed keys, so it cannot reach an *official* column this package declines to
declare. ``extra_payload`` — also on every write model — is the un-prefixed one: whatever you put
in it reaches the wire exactly as written.
in it reaches the wire exactly as written -- unless it may change a ticket's workflow, in which
case the transport refuses the request before sending it (see
:ref:`changing-a-tickets-status`).

.. code-block:: python

Expand All @@ -425,6 +432,121 @@ Three properties are worth knowing before you reach for it:
deliberately omits. Re-read the record afterwards: on this API a write can return HTTP 200,
apply one field and drop another in silence.

.. _changing-a-tickets-status:

Changing a ticket's status
--------------------------

The vendor documents no status setter, and this package has none. A ticket's
status follows its workflow -- "Advancing through the steps of a workflow
changes the status of a ticket." (vendor reference-tables page, Statuses
section, tier 1: https://docs.easyvista.com/docs/references-tables.md) -- and
the API offers three things that touch it:

* :meth:`~easyvista_python_client.EasyvistaClient.end_action` on the workflow
step's open action moves the workflow on. The vendor's REST page for the call
never mentions the workflow; the support is the UI's Finish wizard ("The
workflow will proceed to the next step.", tier 1,
https://docs.easyvista.com/docs/action.md) and one measurement (2026-09-01,
one instance, 2 of 2, so it may not generalise: the ticket reached its
resolved status). It needs ``allow_workflow_effect=WorkflowEffect.ADVANCES``.
* :meth:`~easyvista_python_client.EasyvistaClient.close_ticket` is the vendor's
close request. The vendor documents it as interrupting the workflow ("The
workflow of the ticket is interrupted.", tier 1,
https://docs.easyvista.com/docs/rest-api-close-an-incident-request.md) and
inserting an anticipated closing action. The ticket's unfinished actions are
deleted, or ended: that they are *ended* is this package's reading of the
page's ``end_date`` row rather than an explicit sentence, and the page
documents final statuses only, so do not read ``status_guid`` as a way to pick
an in-progress status. It needs ``allow_workflow_effect=WorkflowEffect.INTERRUPTS``.
* Suspend and reopen are documented by the vendor but not wrapped here; their
pages say only that a suspend, or a reopening, action is created, and their
effect on the open actions is unmeasured.
:meth:`~easyvista_python_client.EasyvistaClient.send` reaches them with
``allow_workflow_effect=WorkflowEffect.UNKNOWN``.

The vendor documents no REST write that sets a ticket's status outside those
(tier 1, ``docs/vendor-api-reference.md``, "Ticket workflow"), so this package
has no setter to offer. Not documented is not the same as impossible: a business
rule on your instance can run on any write.

To escalate the open workflow step to another group without ending it, use
:meth:`~easyvista_python_client.EasyvistaClient.reassign_action` (measured
2026-10-02, one instance, so it may not generalise: the step stays open and the
status does not move; the ticket's owning group, read on one ticket, did not
follow).

Creating a ticket starts its workflow -- the vendor's create page lists "The
workflow associated with the ticket is started." among what a create does (tier
1). So read the status a new ticket landed on with
:meth:`~easyvista_python_client.EasyvistaClient.get_ticket`; do not follow the
create with ``close_ticket`` to land an initial status, because that interrupts
the workflow you just started. ``PostRequest.workflow_start=False`` is not a way
to avoid it: measured a no-op (2026-09-01, one instance: two tickets identical
but for this flag came back byte-identical), and the vendor create page
documents no such parameter. A workflow-less create is the separate
virtual-agent route, ``POST requests/without-workflow``, which the guard below
refuses unless allowed.

A write that may change the workflow and does not say so is refused before it is
sent, with :class:`~easyvista_python_client.EasyvistaWorkflowEffectRefused` -- a
``ValueError``, deliberately not an ``EasyvistaError``, because retrying it can
never succeed. ``allow_workflow_effect`` is **required** on ``close_ticket``
and optional on the other writers -- ``update_ticket``, ``create_action``,
``create_task``, ``update_action``, ``end_action`` and ``send`` -- where it
defaults to allowing nothing. It takes one
:class:`~easyvista_python_client.WorkflowEffect` or an iterable of them, and
nothing else (a string is a ``TypeError``). A write that names a workflow effect
and was allowed is sent once, never retried, whatever ``max_retries`` says.

.. code-block:: python

from easyvista_python_client import (
EasyvistaWorkflowEffectRefused,
RequestUpdate,
WorkflowEffect,
)

try:
# A status column on a ticket may change its workflow, so this is
# refused before anything is sent.
client.update_ticket(rfc, RequestUpdate(extra_payload={"STATUS_ID": 4}))
except EasyvistaWorkflowEffectRefused as exc:
print(exc.effects, exc.triggers)

# Ending the workflow step's own open action moves the workflow on; say so.
client.end_action(
rfc,
action_id=step_action_id,
allow_workflow_effect=WorkflowEffect.ADVANCES,
)

``end_action`` is guarded differently, because what it does depends on which
action you name and the request body cannot say. Unless ``allow_workflow_effect``
includes ``WorkflowEffect.ADVANCES`` it first reads the action (one read,
projecting ``ACTION_ID`` and ``WORKFLOW_ID``) and refuses, with no end request
sent, a workflow step (``WORKFLOW_ID`` set), a record that comes back without the
``WORKFLOW_ID`` column at all -- which cannot be told from a step -- and a read
that returns a different ``ACTION_ID`` from the one you asked for.
``end_all=True``, which ends every open action on the ticket, is always refused
without ``ADVANCES``, and an explicit ``action_id`` must be a positive integer.
Ending an action you created yourself needs no opt-in, with one unmeasured
exception: whether an action that ``create_action`` creates under the workflow
step carries a ``WORKFLOW_ID`` has not been measured. If it does,
``end_action`` refuses to end it unless the call passes
``allow_workflow_effect=WorkflowEffect.ADVANCES`` -- the safe direction.
``WORKFLOW_ID`` is what separates the engine's rows from a caller's (tier 4:
1500 of 1500 rows, 2026-09-02, one instance; it may not generalise).

The guard is a deny-list of body keys, columns and routes (see
:mod:`easyvista_python_client.workflow`), and a deny-list of columns cannot be
complete: the vendor's update pages accept "all the fields from the SD_REQUEST
table except those mentioned below" (tier 1), so a write the guard does not name
is unclassified, not proven harmless. One refusal ignores
``allow_workflow_effect`` altogether: ``send`` refuses a path with a dot segment,
a percent-encoded slash or a backslash, because the request could reach a route
other than the one that was checked.

Actions (comments / followups)
-------------------------------

Expand Down Expand Up @@ -627,8 +749,13 @@ comes back early by your instance's UTC offset.
type-1 *Validation Self Service* action (2 tickets, 2/2); a control showed
ending a type-94 action the caller had created changed neither the status
nor the action count. Ending your own action is inert, ending a workflow
step is not. Omitting ``action_id`` ends **every** open action, which on a
ticket whose only open one is its workflow step means resolving it.
step is not -- which is why ``end_action`` refuses one unless the call
passes ``allow_workflow_effect=WorkflowEffect.ADVANCES`` (see
:ref:`changing-a-tickets-status`). The vendor documents the id-less form as
ending **every** open action, which on a ticket whose only open one is its
workflow step means resolving it; here that form is reachable only through
``end_all=True`` (also needing ``ADVANCES``), and a bare ``action_id=None``
raises ``ValueError``.

.. warning::

Expand Down Expand Up @@ -1385,14 +1512,26 @@ The hierarchy is: :class:`~easyvista_python_client.EasyvistaAuthError` (401/403)
:class:`~easyvista_python_client.EasyvistaServerError` (5xx), and
:class:`~easyvista_python_client.EasyvistaConnectionError` (transport / timeout).

One error sits **outside** that hierarchy on purpose:
:class:`~easyvista_python_client.EasyvistaWorkflowEffectRefused`, a plain
``ValueError``. It is raised before anything is sent, so it has no status code
and nothing transient about it: the same call can never succeed on a retry, and a
status-code-less ``EasyvistaError`` would invite exactly that retry. Catch it
separately; see :ref:`changing-a-tickets-status`.

End-to-end workflow
-------------------

Create a ticket, add a comment, close it, and read it back:

.. code-block:: python

from easyvista_python_client import EasyvistaClient, PostRequest, PostTask
from easyvista_python_client import (
EasyvistaClient,
PostRequest,
PostTask,
WorkflowEffect,
)

with EasyvistaClient.from_env() as client:
ticket = client.create_ticket(
Expand All @@ -1418,8 +1557,10 @@ Create a ticket, add a comment, close it, and read it back:
ticket.rfc_number,
PostTask(action_type_id=94, group_id=3, description="Investigating"),
)
# Close only when closing is the intent: it interrupts the ticket's workflow.
client.close_ticket(
ticket.rfc_number,
allow_workflow_effect=WorkflowEffect.INTERRUPTS,
status_guid="{00000000-0000-0000-0000-000000000000}",
comment="Replaced the VPN concentrator",
)
Expand Down
Loading
Loading