Tickets & Workflows
A ticket is an ordinary DMART entry that additionally carries workflow state. Instead of freely editing a status field, callers advance a ticket through a declarative state machine — a workflow that itself lives in the store as data. Every transition is validated against the workflow definition, the actor's roles, and (where required) a resolution reason, then persisted through the same permission-gated update path as any other write.
What Is a Ticket
A ticket is an entry whose resource_type is ticket (one of the 30 resource types). On top of the usual Meta fields (shortname, owner_shortname, acl, payload …) the entry row carries a handful of ticket-specific columns, defined on Dmart.Models.Core.Entry:
| Field | Type | Meaning |
|---|---|---|
state |
string? |
The current workflow state (e.g. pending, approved). Set to the workflow's initial_state at creation. |
is_open |
bool? |
Whether the ticket is still active. Each transition sets it from the state it lands on: true while that state has outgoing transitions, false once it lands on a terminal state or on one that declares "is_open": false. A new ticket starts true. |
workflow_shortname |
string? |
Which workflow definition governs this ticket. Looked up when a transition is attempted. |
reporter |
Reporter? |
Provenance for externally-submitted tickets (channel, distributor, etc.). See below. |
collaborators |
Dictionary<string, string>? |
Named collaborator slots (e.g. {"assignee": "alice"}), string values only. Set by create, replaced whole by an update or patch (see What an update changes), and given processed_by by a lock. assign does not write it. It grants no access of its own. |
resolution_reason |
string? |
Why the ticket was closed / rejected. Required by transitions that declare resolution_required. |
A ticket is created with the normal create request — you supply workflow_shortname and the engine sets the initial state: a state you send is replaced by the workflow's initial_state, and is_open is set to true, whatever the initial state declares. If the workflow cannot be found in the ticket's space, the ticket keeps the state it was sent. Example create record:
POST /managed/request
{
"space_name": "applications",
"request_type": "create",
"records": [
{
"resource_type": "ticket",
"subpath": "api/v1/tickets",
"shortname": "TCK_1042",
"attributes": {
"is_active": true,
"workflow_shortname": "channel",
"state": "new",
"is_open": true,
"payload": {
"content_type": "json",
"schema_shortname": "ticket",
"body": { "summary": "Activate new POS", "priority": "high" }
}
}
}
]
}
The workflow is looked up in the ticket's own space, so this example needs a channel workflow under /workflows in applications; the seed data ships channel in management.
The folder a ticket lives in can constrain which workflows are legal through its workflow_shortnames setting — see Folders & Rendering. A ticket that declares a workflow_shortname must use one the folder allows.
A ticket can also be filed without signing in, through POST /public/submit/{space}/ticket/{workflow}/{schema}/{subpath}, when ALLOWED_SUBMIT_MODELS lists that space.schema pair (see the API reference). The request body becomes the payload, checked against the schema. DMART picks the shortname, the anonymous user owns the ticket, it starts in the workflow's initial state (submitted if the workflow is not found), and it is inactive until someone switches it on.
Workflows Are Data
A workflow is not code. It is a content entry (resource type content) stored under a /workflows folder in the same space as the ticket. Its payload.body holds the state-machine definition. At transition time WorkflowEngine loads that entry — trying subpaths /workflows, /workflow, then / — deserialises the body, and evaluates the requested action against it. The entry's is_active is not consulted: an inactive workflow still governs its tickets, and the seeded channel workflow is inactive.
Anatomy of a workflow body
initial_state The state a new ticket starts in. May be a plain string, or an array of {name, roles} objects. In the array form the engine prefers the entry whose roles holds one of the roles on the user record of whoever creates the ticket (or puts it on the workflow with an update), then the one whose roles contains "default", then the first named entry. A ticket that enters the workflow through a move takes the "default" entry, or the first. The workflow schema in management/schema, a core schema every start creates when it is missing, takes only the array form, and no top-level keys besides name, illustration, initial_state and states; a workflow there whose payload names that schema is checked against it.
states[] The list of states. Each has a machine state key, an optional display name, an optional is_open, and a next array of outgoing transitions. A state with no next is terminal (closed). A state with "is_open": false is closed too, though its transitions can still move a ticket on (see Reopening a closed ticket).
next[] transition An action (the verb the caller invokes), a target state (the engine also accepts to for back-compat), an optional roles gate, and an optional resolution_required flag.
resolutions[] Optional per-state catalogue of resolution reasons (each a key plus localized labels). Presentational: the server does not check a resolution against it. The admin UI's ticket form offers the resolutions of the state a transition leads to.
Full workflow definition (payload.body)
This is the shape of a real seeded workflow (management/workflows/channel), trimmed to the essentials, with resolution_required added to reject to show the flag. Note that new, pending, approved, rejected and completed all have a next array (open states), while failed has none (terminal / closed):
{
"initial_state": [
{ "name": "new", "roles": ["default"] }
],
"states": [
{
"name": "New",
"state": "new",
"next": [
{ "action": "submit", "state": "pending", "roles": ["account_manager"] }
]
},
{
"name": "Pending",
"state": "pending",
"next": [
{ "action": "approve", "state": "approved", "roles": ["backoffice"] },
{ "action": "reject", "state": "rejected", "roles": ["backoffice"],
"resolution_required": true }
]
},
{
"name": "Approved",
"state": "approved",
"next": [
{ "action": "complete", "state": "completed", "roles": ["backoffice"] },
{ "action": "fail", "state": "failed", "roles": ["backoffice"] }
]
},
{
"name": "Rejected",
"state": "rejected",
"next": [
{ "action": "re_submit", "state": "pending", "roles": ["account_manager"] }
],
"resolutions": [
{ "key": "missing_documents", "en": "Missing Documents" },
{ "key": "Others", "en": "Others" }
]
},
{
"name": "Completed",
"state": "completed",
"next": [
{ "action": "update", "state": "pending", "roles": ["account_manager"] }
]
},
{ "name": "Failed", "state": "failed" }
]
}
Open vs. closed comes from the target state. The engine's CheckOpenState looks up the target state in states[] and reports it closed if it says "is_open": false; otherwise open if it has a next field, closed if it does not. Only false counts: "is_open": true on a state without transitions would leave a ticket open that nothing could move on, so it and any other value are ignored. That result becomes the ticket's is_open after the transition — so landing on failed above sets is_open = false, while landing on pending keeps it true.
The State Machine
The workflow above describes this machine. Solid arrows are transitions; each is labelled with the action that triggers it. failed is terminal (closed); every other state is open.
stateDiagram-v2
[*] --> new
new --> pending: submit
pending --> approved: approve
pending --> rejected: reject (resolution_required)
approved --> completed: complete
approved --> failed: fail
rejected --> pending: re_submit
completed --> pending: update
failed --> [*]
Because rejected and completed still have outgoing transitions, they are open states even though they read like end states — a rejected ticket can be re-submitted, a completed one re-opened. Only failed has no next, so it is the sole closed state here. To have completed read closed and keep its update transition, mark it "is_open": false.
Reopening a closed ticket
A state that says "is_open": false keeps a ticket entering it closed although transitions lead out of it, so a delivered or cancelled ticket reads closed and can still be reopened:
{ "name": "Delivered", "state": "delivered", "is_open": false,
"next": [{ "action": "reopen", "state": "pending", "roles": ["order_recovery"] }] }
A reopen is a transition like any other: PUT /managed/progress-ticket/{space}/{subpath}/{shortname}/reopen moves a delivered ticket back to pending, open again, for a caller holding progress_ticket on it and the role order_recovery. A caller without the role is refused with INVALID_TICKET_STATUS (300); one that may see the ticket but lacks progress_ticket on it gets NOT_ALLOWED. The ticket keeps its resolution_reason unless the call sends one.
- A new ticket starts open, whatever its initial state declares.
- Tickets already in a state when it is marked
"is_open": falsekeep theis_openthey have until they next move; nothing rewrites them. - The
workflowschema inmanagementdeclaresis_openon a state. So does the one catalog creates for a new space, which also allowsrolesandresolution_requiredon a transition andresolutionson a state. A space catalog set up earlier keeps its older schema, which refuses these keys: add them to that copy before using them there. - cxb's ticket form offers a closed ticket's transitions to a user who may take one of them, above a note that the ticket is closed; anyone else sees it closed. Its choices are listed by action, so two transitions to the same state are told apart, and Submit waits for a choice, and for a resolution when the transition requires one. The form reads the workflow from the ticket's space, and from management's when the space has none of that name, while the server looks only in the ticket's space. cxb's workflow editor has a "Closed" checkbox per state and a "Resolution required" checkbox per transition, opens an
initial_statewritten as a string, and its diagram ends a closed state's path. - Catalog's reports page shows a closed state that can be reopened as closed.
Advancing a Ticket: progress-ticket
Tickets are advanced through a dedicated endpoint that runs the workflow engine:
PUT /managed/progress-ticket/{space}/{subpath}/{shortname}/{action}
The trailing path segments are the ticket's location and the action to invoke; the JSON body carries the resolution and an optional comment:
PUT /managed/progress-ticket/applications/api/v1/tickets/TCK_1042/reject
{
"resolution": "missing_documents",
"comment": "ID scan unreadable"
}
The body accepts resolution or resolution_reason (both map to the ticket's resolution_reason column, and resolution_reason wins when both are sent), plus an optional comment. A missing/empty body is allowed for transitions that do not require a resolution.
The CLI's progress <subpath> <shortname> <action> progresses a ticket in its current space, but sends no body, so it cannot take a transition with resolution_required; use the endpoint for those.
The workflow is the only way to move a ticket. On a ticket whose workflow exists, an update (or patch, an MCP update, or an alteration) that changes state, is_open, resolution_reason or workflow_shortname is refused with INVALID_TICKET_STATUS (300), naming this endpoint; workflow_shortname counts too, so a ticket cannot be pointed at a missing workflow to have its state written directly. An update that sends those fields back unchanged, as a front end saving the whole record does, passes. A global admin may still write them, to repair a stuck ticket, and a ticket whose workflow is not found behaves as any other entry.
A ticket put on a workflow starts at its beginning. An update that sets workflow_shortname on a ticket whose stored workflow is unset or not found, to a workflow that exists, puts the ticket at that workflow's initial_state with is_open: true, as a new ticket starts; any state, is_open or resolution_reason in the same update is not written, whoever sends it. A workflow with no initial_state leaves the update as sent. Workflows resolve per space, so a move can do the same: a ticket moved to another space where its workflow_shortname resolves, from one where it did not, arrives at the workflow's initial_state, open, and so does every such ticket beneath a moved folder; the move's history row records the new state and is_open. A move within a space, or between two spaces where the name resolves in both, keeps the state. From then on the ticket moves through progress-ticket.
What the engine enforces
WorkflowService.ProgressAsync + WorkflowEngine.EvaluateAsync apply these checks, in order, before any write happens:
1 · Caller may know the ticket The ticket must exist, and the caller must hold view, progress_ticket or update on it, judged with the row filters of the permission that grants it. Otherwise the answer is the same "ticket not found" a missing ticket gets.
2 · Workflow exists The ticket must carry a non-empty workflow_shortname, and that workflow entry must load and contain a states array.
3 · Current state is known The ticket's state must match a state in the workflow, and that state must have a next array (not terminal). A state marked "is_open": false with a next array passes, which is how a closed ticket is reopened.
4 · Action is a valid transition Some entry in next must have action equal to the URL's action, with a non-empty target state.
5 · Actor holds a required role If the transition lists roles, the actor must hold at least one of them among the roles on their user record — otherwise the transition is refused.
6 · Resolution when required If the transition sets resolution_required: true, the body must carry a resolution_reason (or resolution) that is not empty or blank, else the request fails with MISSING_DATA. null, "" and whitespace count as none. The reason is stored trimmed.
When all checks pass, the service builds a patch — state = the new state, is_open = whether that state is open, and resolution_reason if supplied and not blank (otherwise the ticket keeps the one it has) — and applies it through EntryService.UpdateAsync with the action override progress_ticket (so the permission walk gates on the progress_ticket action, not update). The patch is written only if the ticket is still at the revision the transition was evaluated on: of two transitions racing on one ticket, one wins and the other answers CONFLICT, and the caller reloads the ticket before trying again. A non-empty comment is then stored as a comment attachment under the ticket, carrying the new state. The comment is written best effort, after the transition: if it fails, the transition stands and the request still succeeds, and it fires no plugin event of its own. Plugins hear of the transition as an update event whose attributes carry the new state and a history_diff of what changed, delivered at least once (see After-Hook Delivery). The success envelope echoes the outcome:
{
"status": "success",
"attributes": {
"state": "rejected",
"is_open": true
}
}
Failure responses
The rejections are explicit. A few representative errors (all returned as status: "failed"):
| Cause | Error |
|---|---|
| Ticket missing, or the caller may not view, progress or update it | SHORTNAME_DOES_NOT_EXIST — "ticket not found" |
Ticket has no workflow_shortname |
WORKFLOW_BODY_NOT_FOUND — "ticket has no workflow_shortname" |
| Workflow entry not found | INVALID_TICKET_STATUS — "workflow {name} not found" |
| Action isn't a valid transition from the current state | INVALID_TICKET_STATUS — "You can't progress from {state} using {action}" |
| Actor lacks a required role | INVALID_TICKET_STATUS — "You don't have the permission to progress this ticket with action {action}" |
| Resolution required but omitted | MISSING_DATA — "this transition requires a resolution_reason" |
Caller lacks the progress_ticket action on the ticket |
NOT_ALLOWED — "no progress_ticket access" |
| The ticket changed after the transition was evaluated, such as by a competing transition | CONFLICT (HTTP 409) — "entry changed; …" |
Assigning: the assign Request
Handing a ticket to a new owner is a first-class operation, distinct from a normal update. It goes through the standard /managed/request envelope with request_type: "assign" (one of the 7 request types) and is dispatched by DispatchAssignAsync:
POST /managed/request
{
"space_name": "applications",
"request_type": "assign",
"records": [
{
"resource_type": "ticket",
"subpath": "api/v1/tickets",
"shortname": "TCK_1042",
"attributes": {
"owner_shortname": "backoffice_agent_7"
}
}
]
}
Why it isn't a plain update. owner_shortname is a restricted field — a regular update silently preserves the creation-time owner and can never transfer it. The assign path opts into exactly that one restricted field (AssignRestrictedFields) and gates the permission check on the assign action (not update) via an action override. This lets an admin grant "may reassign ownership" separately from "may edit content".
The dispatcher's contract:
owner_shortname required Absent or empty → MISSING_DATA ("The owner_shortname is required").
The record answers first The caller's assign on the ticket is checked before the new owner is looked up, so a caller without it gets NOT_ALLOWED whether or not the ticket or the new owner exists (see Row filters).
Target must exist The new owner must be a real user in management/users → otherwise OBJECT_NOT_FOUND.
Only ownership changes The patch stores owner_shortname alone. Any other attribute in the record, collaborators included, is ignored, and every other field stays untouched; collaborators changes through an update.
Reporter & Provenance
Tickets often originate outside the system — a phone channel, a mobile app, an automated bot. The optional reporter block (Dmart.Models.Core.Reporter) records where a ticket came from without conflating it with the DMART owner_shortname:
"reporter": {
"type": "web",
"name": "Retail Front Desk",
"channel": "walk_in",
"distributor": "north_dist_04",
"governorate": "Baghdad",
"msisdn": "07701234567",
"channel_address": { "queue": "activation" }
}
| Field | Purpose |
|---|---|
type |
Origin kind, such as web, mobile or bot. |
name |
Human/agent name that filed the ticket. |
channel |
The channel it arrived through. |
distributor |
Distributor / partner reference. |
governorate |
Geographic province of origin. |
msisdn |
Optional phone number of the reporter. |
channel_address |
Optional free-form address bag for channel-specific routing. |
All reporter fields are optional strings (plus the channel_address map). The set above is exactly what the model exposes, and no field's value is checked against a list: type takes any string.
Locking Tickets
An agent working a ticket can place an entry lock so nobody else edits it underneath them. Locking is generic to all entries but is especially useful for tickets. Taking a lock needs the lock or the update action on the entry. The holder releases their own lock without any further grant. Anyone else needs lock, update or unlock on the entry to hear anything about its lock (without one, the answer is NOT_ALLOWED whether the entry is locked, unlocked or missing), and unlock to release another user's lock:
# take the lock
PUT /managed/lock/ticket/applications/api/v1/tickets/TCK_1042
# release it
DELETE /managed/lock/applications/api/v1/tickets/TCK_1042
A lock lasts LOCK_PERIOD seconds (300 by default); the holder locking again extends it, and anyone else gets LOCKED_ENTRY, naming the holder. Locking a ticket also sets collaborators.processed_by to the agent, keeping the ticket's other collaborators. When that changes the map (the first lock by a new agent), it is an update of the ticket, gated on lock: the history records collaborators.processed_by, and hooks hear an update. Every lock also writes a history row of its own (lock_type: lock or extend).
While a lock is held, a plain update, a delete or a move by anyone other than the lock holder is blocked. The permission gate runs first, so an unauthorized caller still gets a "not allowed" answer rather than a hint that the entry is merely locked.
Workflow actions bypass the lock. The lock block applies only to plain update, delete and move. The workflow-authorized overrides — progress_ticket and assign — carry their own action grant and keep working even when a lock is parked, so a supervisor can reassign or progress a locked ticket whose holder has gone offline.
Related Concepts
Access Control The progress_ticket, assign, lock and unlock action types are all defined and gated here alongside the other seven actions.
Folders & Rendering A folder's workflow_shortnames setting constrains which workflows tickets created there may use.
Entity Lifecycle Tickets are created, updated and deleted through the same /managed/request envelope as every other entry.