Workflows, requests and change sets
The API calls behind workflows, requests and change sets.
On this page
- The order of calls
GET /api/workflows— the listGET /api/workflows/templatesGET /api/workflows/palette— what a designer may put on the canvasPOST /api/workflows— a new draft from a templateGET /api/workflows/{uid}— one workflow, as the canvas draws itPUT /api/workflows/{uid}— the whole draftPATCH /api/workflows/{uid}— rename itPOST /api/workflows/{uid}/publishPOST /api/workflows/{uid}/retirePOST /api/workflows/{uid}/copyPOST /api/workflows/{uid}/try— walk it without writingGET /api/workflow-scope/preview— how many the scope takes inGET /api/working-daysPUT /api/working-daysPOST /api/workflows/import/previewPOST /api/workflows/importGET /api/workflows/{uid}/exportGET /api/requests— open requests, and which are mineGET /api/requests/{uid}— one requestPOST /api/requests/{uid}/take/{transition_uid}— move it alongGET /api/notificationsPOST /api/notifications/seenPOST /api/runs/{uid}/retry— try an outbound action againGET /api/change-sets— my draftsPOST /api/change-sets/items— add an edit to my draftDELETE /api/change-sets/{uid}/items/{n}DELETE /api/change-sets/{uid}— throw the draft awayPOST /api/change-sets/{uid}/submit
Part of the HTTP API reference.
Routes:
| Route | Draw function |
|---|---|
#/workflows |
drawWorkflows |
#/workflow/<uuid> |
drawDesigner(uid) |
#/requests |
drawRequests |
#/request/<uuid> |
drawRequest(uid) |
The three rules the whole section exists to keep:
- A published definition is frozen. Requests in flight keep the version they started on, so changing the rules cannot move somebody's request under them. Editing a published workflow means publishing a new version.
- A proposal is not true until it is approved, and on approval it is applied through the same endpoints the screen uses — so every metamodel refusal fires again at that moment.
- Nothing is applied over a value that moved.
wasis what the repository said when the request was raised; if it no longer says that, the approver is about to apply a decision they did not make, and it is refused.
And the claim the designer rests on: "Nothing here can draw a step that the engine cannot run, because there is nothing to draw except what the engine understands. That is a smaller promise than a BPMN palette makes, and one that is kept."
The order of calls
The Workflows list: GET /api/workflows, then on opening the New form
GET /api/workflows/templates and GET /api/workflows/palette in parallel, then GET /api/workflows/palette?types=<CODES> again as the
type picker changes. The import form calls
POST /api/workflows/import/preview then POST /api/workflows/import?as=…
.
The designer: GET /api/workflows/{uid}, then
GET /api/workflows/palette?…, then on demand
GET /api/working-days,
GET /api/workflow-scope/preview?…,
GET /api/facets?types=…,
GET /api/people and GET /api/connections for the action adder
. Writes: PUT, PATCH, publish, copy, retire,
try, and PUT /api/working-days.
Requests: GET /api/requests?open=1,
GET /api/change-sets, GET /api/notifications (–12913), then
POST /api/notifications/seen.
One request: GET /api/requests/{uid}, then
POST /api/requests/{uid}/take/{transition_uid} and
POST /api/runs/{uid}/retry.
GET /api/workflows — the list
No parameters, no permission check.
Response
{"workflows": [ // ORDER BY status, lower(name)
{"uid": "…", "code": "TERM_APPROVAL", "name": "Term approval",
"description": "…",
"status": "published", // draft | published | retired
"version": 3,
"trigger_event": "manual", // manual | created | changed | deleted
"priority": 100, // lower is tried first
"scope_groups": 2, // how many OR-groups the scope has
"type_name": "Business Terms, Data Entities", // the plurals, or null for every type
"type_code": "BUSINESS_TERM", // ONLY when there is exactly one
"updated_by": "r.iyer", "updated_at": "…",
"states": 4,
"open_requests": 7}
]}
GET /api/workflows/templates
No parameters, no permission check, no database access.
Response: {"templates": [ … ]} — the TEMPLATES list with
an ends field added, "so the form can show the chain it is about to draw in
that template's own words — 'Certified', not a generic 'Approved'."
{"code": "APPROVAL", "name": "Approval",
"what": "Somebody proposes a change and it is decided — by one role, or by several in turn. A rejection at any level closes it.",
"trigger": "manual",
"params": [
{"code": "levels", "name": "How many levels (1–6)", "kind": "number", "default": 1},
{"code": "roles", "name": "Which role decides at each level", "kind": "roles",
"hint": "A role, not a person. Whoever holds it over the asset in question — including through the domain that owns it, and including a team. Nobody is chosen now; it is worked out per request, so it follows the access model when responsibilities move.",
"default": ["OWNER"]}
],
"ends": "Approved"}
The six templates
code |
trigger |
What it does |
|---|---|---|
APPROVAL |
manual |
one or several levels decide; rejection at any level closes it |
CERTIFY |
created |
checked and certified when an asset is created; approval sets an attribute |
REVIEW_CHANGE |
changed |
the change has already happened; this is the record that somebody looked |
DELETION |
deleted |
approval performs the deletion; rejection leaves the asset alone |
BLANK |
manual |
one starting state and an empty canvas |
NOTIFY |
changed |
no decision at all: something happens and the people who care are told |
LEVELS is kept as a code for anything already referring to it and generates
exactly what APPROVAL does — "'Approval in levels' was a second entry on the
gallery that generated exactly what Approval generates once levels became a
question every template asks."
Why levels belong to all of them:
Every template below except "Tell somebody" is the same deciding machinery with a different beginning and a different ending. So levels belong to all of them, not only to the one with "levels" in its name: certifying a new data product may well need the steward and then the owner, and a deletion in a bank needs two people more often than one.
SHAPES says per template: the single-level state name, the
approve label, the reject label, the approving state's name, the closing
state's name, and whether the raiser is barred from deciding. Four eyes is on
where approving makes something true and not on a review:
there the change has already happened and the request is the record that somebody looked at it. Barring the person who made the change would leave a stale request open in every estate small enough for one person to hold both roles — which is most of them.
GET /api/workflows/palette — what a designer may put on the canvas
Query parameters
| Name | Type | Notes |
|---|---|---|
type |
string | one asset type code |
types |
string | comma-separated codes; this is the one the client sends |
Either is read; types wins. Codes are stripped and upper-cased.
Docstring: "the roles and permissions that exist, and the columns a condition can be built from. All from the model."
Response
{
"roles": [ // every ACTIVE role, in display order
{"code": "OWNER", "name": "Owner", "scope": "resource", "description": "…"}
],
"permissions": [{"code": "ASSET_VIEW", "name": "View assets", "category": "Assets"}],
"types": [ // AUTHORED types only
{"code": "BUSINESS_TERM", "name": "Business Term", "plural": "Business Terms"}
],
"columns": { // what a condition can be built from
"attributes": [{"code","name","data_type","allowed_values","is_mandatory"}],
"roles": [{"code","name"}],
"relations": [{"code","dir","label","other","other_type_code","cardinality"}],
"fields": [{"code","name"}]
},
"settable": [ // what a "Set a characteristic" step may set
{"code": "STATUS", "name": "Status", "data_type": "choice",
"allowed_values": ["Draft","Approved"],
"on_types": ["Business Term", "Data Entity"]}
]
}
With no types named, columns.attributes is every attribute any type
declares, columns.relations is empty, and columns.roles is every active
resource role. With types named, each type's columns are merged in, first
occurrence winning, de-duplicated on (code, dir) — "a condition on a
characteristic one of them lacks simply does not hold for that one."
settable never includes a harvested attribute: "those are the source's,
and the database refuses the write." With no types named it is every
non-harvested attribute any type carries, saying which types via on_types.
POST /api/workflows — a new draft from a template
Permission: WORKFLOW_DESIGN — "design workflows".
Body
| Field | Type | Default | Notes |
|---|---|---|---|
name |
string | — | required, stripped |
type_codes |
list | [] |
upper-cased, de-duplicated; empty means every type |
type_code |
string | — | the older single form, used when type_codes is absent |
template |
string | "APPROVAL" |
upper-cased; an unknown code falls back to TEMPLATES[0] |
params |
object | {} |
the template's own parameters |
trigger_event |
string | the template's | |
trigger_config |
object | {} |
merged with what params implies |
description |
string | the template's what |
|
scope |
object | — | only applied when present |
priority |
int | — | only applied when present |
Refusals: 400 "A workflow needs a name."; 400 "There is no asset type
called <C>." per unknown code.
params.kinds, params.attributes and params.relations are folded into
trigger_config (upper-cased for the last two).
Docstring: "Every part of what a template produces is an ordinary state or step, so the first thing anybody does after generating one is edit it — which is the point. A template that produced something you could not take apart would be a feature pretending to be a product."
The code is derived from the name (_wf_code): non-alphanumerics
to _, upper-cased, "WORKFLOW" as a fallback, then _2, _3 … until
unique. A workflow's code never changes afterwards.
A generated state is named for the role's display name, not its code: "A state called 'Level 3: Sme' is the role's CODE leaking onto the screen." Positions are generated too — "a generated workflow that lands in a heap in the corner reads as broken." Levels are clamped to 1…6.
Response: {"uid": "…", "code": "TERM_APPROVAL", "template": "APPROVAL"}.
GET /api/workflows/{uid} — one workflow, as the canvas draws it
No permission check.
Refusal: 404 "There is no workflow with that id."
Response
{
"workflow": {
"uid": "…", "code": "TERM_APPROVAL", "name": "Term approval",
"description": "…", "status": "published", "version": 3,
"type_code": "BUSINESS_TERM", // null when several types or none
"type_name": "Business Term", // null likewise
"types": [{"code","name","plural"}],
"updated_by": "…", "updated_at": "…",
"trigger_event": "manual",
"trigger_config": {"kinds": ["attribute"], "attributes": ["DEFINITION"], …},
"scope": {"groups": [[{"facet","op","values"}]]},
"priority": 100
},
"states": [
{"id": 41, "uid": "…", "code": "LEVEL_1", "name": "Submitted",
"kind": "start", // start | normal | approved | closed
"x": 40, "y": 40,
"decides": false, // a condition box: its arrows decide, not people
"task": { … }} // see below; {} on a state that waits on nobody
],
"transitions": [
{"id": 88, "uid": "…", "name": "Approve",
"conditions": [{"key": "a:STATUS", "op": "is", "value": "Draft"}],
"needs_comment": false,
"not_the_raiser": true, // four eyes
"display_order": 0,
"needs_all": false, // the step everyone takes
"on_expiry": false, // the step the workflow takes when time runs out
"from_uid": "…", "from_code": "LEVEL_1", "from_name": "Submitted",
"to_uid": "…", "to_code": "APPROVED", "to_name": "Approved",
"to_kind": "approved",
"role_code": "OWNER", "role_name": "Owner",
"permission_code": null, "permission_name": null}
],
"actions": [
{"uid": "…", "kind": "notify", // notify | set_attribute | assign_role | webhook
"config": {"to": "role:OWNER", "text": "{title} is waiting for you."},
"display_order": 0, "state_uid": "…"}
],
"problems": [ … ] // see below
}
A state's task (_clean_task) — only on a state that waits
on somebody:
{"mode": "any", // TASK_MODES: any | all | all_wait
"email": {"on": true, "subject": "…", "body": "…"}, // subject ≤300, body ≤8000
"sla": {"amount": 2, "unit": "days", "working_days": true},
"remind": {"every": {"amount": 1, "unit": "days"},
"before": {"amount": 4, "unit": "hours"},
"subject": "…", "body": "…"}}
Units are minutes, hours, days (_UNITS) — "Minutes are
here because an approval that has to happen inside half an hour is an ordinary
thing to ask for, and '0.5 hours' is not how anybody says it." An amount of
zero or less, or an unparseable one, means no limit. An unrecognised unit
becomes days.
TASK_MODES:
mode |
Words |
|---|---|
any |
"Any one of them" |
all |
"Every one of them — one other answer decides" |
all_wait |
"Every one of them — wait for every answer" |
problems (_wf_problems) — "What would stop this being
publishable, in sentences. Said the same way in the designer and at publish
time, because a rule that only appears when you press the button is a rule
nobody designs around."
The full list of sentences it can produce:
| Condition | Sentence |
|---|---|
| no start state | "Nothing starts this workflow. Mark one state as the start." |
| more than one | "A workflow needs exactly one starting state — this has <n>." |
no approved state |
"No state applies the change. Mark one as Approved." |
nothing approved or closed |
"Nothing ends this workflow." |
| a step out of a waiting state with no role and no permission | "“<step>” says who it goes to but not who may take it. Give it a role or a permission." |
| a state nothing reaches from the start | "Nothing reaches “<state>”." |
a start/normal state with no step out |
"Nothing leaves “<state>” — a request there would be stuck." |
| a condition box without exactly one unconditional arrow | "The condition box “<state>” needs exactly one arrow with no conditions — where a request goes otherwise. It has <n>." |
| a condition box with no conditional arrow | "The condition box “<state>” has no arrow with conditions, so it decides nothing." |
needs_all or on_expiry on a state that asks nobody |
"“<state>” asks nobody anything, so none of its arrows can be for everyone or for when time runs out." |
mode != any without exactly one needs_all arrow |
"“<state>” asks every one of them, so exactly one arrow out of it must be the step everyone takes. It has <n>." |
| that arrow has no role or permission | "“<step>” is the step everyone takes, but says nobody — give it a role, so there is a list of people to ask." |
mode != any with no other answer |
"“<state>” asks every one of them but offers no other answer — add a step for saying no." |
a needs_all arrow on an any state |
"“<step>” is marked for everyone, but “<state>” asks any one of them." |
more than one on_expiry arrow |
"“<state>” has <n> arrows for when time runs out; it can take only one." |
an on_expiry arrow with no time limit |
"“<step>” is for when time runs out, but “<state>” has no time limit." |
| an arrow that is both | "“<step>” cannot be both the step everyone takes and the one for when time runs out." |
every arrow is on_expiry |
"Nobody can decide at “<state>” — its only way out is when time runs out." |
Note what is allowed: a time limit with no arrow for it. "It is allowed now, and means what people asked for: when it runs out the request stays where it is, marked overdue, and everybody still to answer is told. Moving it is the other choice, and it is still a choice somebody makes on purpose rather than one the designer forces."
PUT /api/workflows/{uid} — the whole draft
Permission: WORKFLOW_DESIGN.
Docstring: "One call rather than a call per node: on a canvas the states and the transitions between them are one picture, and half-saving it would leave a transition pointing at a state that is no longer there."
Refusal: 409
"“<Name>” is published. A published workflow is what requests already in flight
are running on, so it is not edited in place — make a new version instead."
Body — all optional, and each clause is independent:
| Field | Effect |
|---|---|
name (truthy) |
sets name and description together |
description |
written with name |
type_codes / type_code |
replaces the whole type list |
scope |
cleaned by _clean_scope and replaced |
priority |
clamped to max(1, min(n, 9999)) |
trigger_event (truthy) |
sets event and trigger_config together |
states |
replaces the whole shape: actions, transitions and states are deleted and rewritten from states, transitions, actions |
The states guard is a fixed bug: "The shape is replaced
only when one is sent. A save of the scope or the order alone used to arrive
with no states and leave the workflow with none."
Refusals from the scope (_clean_scope): 400 "A condition in
the scope: <facets.Bad>". Empty groups are dropped. "Each condition checked
by the compiler the list filters use — so what is saved is what will run, and a
condition it cannot read is refused in words."
priority: 400 "The order is a whole number; lower is tried first."
How the shape is written (_write_shape): states, then steps,
then actions — "the order they depend on each other. Used by both the template
generator and the canvas save, so a hand-drawn workflow and a generated one are
the same rows written the same way." A transition whose ends are missing or
identical is silently skipped; a role or permission code that does not exist
becomes NULL.
Response: {"saved": true, "problems": [ … ]}.
PATCH /api/workflows/{uid} — rename it
Permission: WORKFLOW_DESIGN.
Docstring: "Its name and description, in any state. Renaming changes no rule, so a published workflow can be renamed without a new version; its code does not change, and neither does anything that runs on it."
Body: name, description. code may be sent only if it equals the
current one.
Refusals: 400 "A workflow needs a name."; 400 "A workflow's code is
fixed. <CODE> stays <CODE>; change its name instead."
Response: {"uid": "…", "code": "TERM_APPROVAL", "name": "…"}.
POST /api/workflows/{uid}/publish
Permission: WORKFLOW_DESIGN. No body.
Refusal: 400 with " ".join(problems) when anything in
_wf_problems fires.
Publishing a workflow of the same code retires the one before it — "a new version of the same workflow — its code — replaces the one before: retired, not deleted, so what it started finishes. Other workflows on the same types and event sit beside it, tried in order, each for the assets its scope takes in."
Already published → {"status": "published", "version": <n>} with no change.
Response
{"status": "published", "version": 4, "replaced": 1,
"notes": ["“B” is never reached: “A” comes first for the same types and leaves nothing out. Give “A” a scope, or put “B” first."]}
notes comes from _shadowing — "Published workflows on this
event that can never start: an earlier one covers every type they do and leaves
nothing out. Said, in sentences." Said, not refused — "One that can never
be reached is said, not refused."
POST /api/workflows/{uid}/retire
Permission: WORKFLOW_DESIGN. No body.
Sets status = 'retired'. Requests already running are unaffected.
Response: {"status": "retired"}.
POST /api/workflows/{uid}/copy
Permission: WORKFLOW_DESIGN. No body.
Docstring: "A new draft from an existing workflow — which is how a published one is changed, since it cannot be edited in place."
The copy keeps the same code and takes max(version) + 1. States,
transitions and actions are copied in SQL, matched by state code.
Response: {"uid": "<the new draft's uid>"}.
POST /api/workflows/{uid}/try — walk it without writing
Permission: WORKFLOW_VIEW — "try a workflow".
Docstring:
A draft cannot be run, and running a published one to see what it does means raising a real request somebody then has to decide. So this answers the same questions the engine would — is this asset in scope, where does it start, who would be asked, when would it be due, which path does a condition box take, what would fire — for a route the caller chooses, step by step, and writes nothing at all.
Body
| Field | Type | Notes |
|---|---|---|
asset_uid |
uid | required |
steps |
list of strings | the names of the steps taken so far, from the start; first 40 only |
Refusal: 400 "Choose an asset to try it against."
Response
{
"asset": {"uid": "…", "name": "Net Revenue", "type_name": "Business Term"},
"status": "published",
"in_types": true, // this workflow starts on this type (or on every type)
"in_scope": true, // the scope takes this asset in
"governs": true, // null for a draft; else whether THIS one would actually take it
"instead": null, // when governs is false: {"name", "priority"} of the one that would
"walk": [
{"state": "Submitted", "code": "LEVEL_1", "kind": "start",
"decides": false,
"asked": ["a.khan"], // who would be asked, by username, sorted and de-duplicated
"due_at": "2026-10-03T09:00:00Z",
"time_allowed": "2 working days",
"reminds": "every day",
"emails": true,
"actions": [{"kind": "notify", "config": { … }}],
"took": "Approve", // the step taken out of here, or null
"why": "its conditions hold"} // on a condition box, or "nobody decided in time"
],
"at": { … }, // the step in `walk` where it is waiting, or null
"next": [ // the answers the people asked could give
{"name": "Approve", "to": "Approved", "role_name": "Owner",
"permission_code": null, "on_expiry": false, "needs_all": false}
],
"ended": null // the sentence saying why the walk stopped, or null
}
The ended sentences
| Condition | Sentence |
|---|---|
| no start state | "This workflow has no starting state." |
| not this type | "Nothing would start: this workflow does not start on <Type>s." |
| out of scope | "Nothing would start: the scope leaves this asset out." |
reached an approved state |
"It ends approved, and the change is applied." |
reached a closed state |
"It ends here, applying nothing." |
| a condition box with no path and no otherwise | "Nothing takes it out of this box: no path holds and there is no otherwise." |
| a step name that is not there | "There is no step called “<step>” here." |
governs is the honest part: "so 'it is in scope' is never mistaken for 'it
is the one that runs': another workflow, earlier in the order, may take the
asset first."
A condition box answers itself and does not use up one of the steps the caller chose. The walk is bounded at 40 iterations.
Who would be asked is resolved the way the engine resolves it: for a role,
everybody holding it over the asset through responsibility_for, team
members included; for a permission, every active user for whom
may(username, perm, asset_id) is true.
GET /api/workflow-scope/preview — how many the scope takes in
No permission check.
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
types |
string | "" |
comma-separated codes; empty means every type |
scope |
JSON string | "{}" |
{"groups": [[cond, …], …]} |
Refusal: 400 "The scope is not readable." for bad JSON; 400 "A
condition in the scope: <…>" from _clean_scope.
Response
{"types": [
{"code": "BUSINESS_TERM", "plural": "Business Terms",
"total": 412, "in_scope": 44}
]}
With no types given, a type with no assets at all is left out; with types
given, every named type appears even at zero.
Groups are OR-ed; conditions within a group are AND-ed. Each group's compiled
parameters are re-prefixed g<N>_ so two groups' placeholders cannot collide.
GET /api/working-days
No permission check beyond a session.
Docstring: "Which days do not count, and the dates this repository takes off."
Response
{"weekend": [5, 6], // 0 = Monday … 6 = Sunday; from `weekend_days`, default "5,6"
"holidays": [ // on_date >= one year ago, ORDER BY on_date
{"uid": "…", "on_date": "2026-12-25", "name": "Christmas Day",
"created_by": "r.iyer"}
],
"may_manage": true} // _may_bool(WORKFLOW_DESIGN)
"Nothing here knows that Saturday is special, only that this repository said 5
and 6 are" (_days_off). The calendar is cached for one minute,
and PUT resets that cache.
PUT /api/working-days
Permission: WORKFLOW_DESIGN — "say which days do not count".
Docstring: "The weekend, and the whole list of dates at once. Whole, like every other list this product keeps: what somebody decides is 'these are our days off', and saving a day at a time leaves half a calendar behind whenever one row is refused."
Body — either or both:
| Field | Type | Notes |
|---|---|---|
weekend |
list of int | each 0…6; de-duplicated and sorted |
holidays |
list | at most 1000; each {on_date: "YYYY-MM-DD", name}; replaces the whole table |
Refusals
| Condition | Status | Message |
|---|---|---|
| a non-int or out-of-range day | 400 | "The weekend is days 0 (Monday) to 6 (Sunday)." |
| more than 6 distinct days | 400 | "A week with no working day in it would mean nothing is ever due." |
holidays not a list |
400 | "Send the days off as a list." |
| a bad date | 400 | "'<x>' is not a date. Write it as 2026-10-02." |
| a date with no name | 400 | "The day off on <date> needs a name." |
| the same date twice | 400 | "<date> is in the list twice." |
A row with neither a date nor a name is skipped. Names are truncated to 120 characters.
Response: the same shape as the GET.
POST /api/workflows/import/preview
Permission: WORKFLOW_DESIGN — "import a workflow".
multipart/form-data with file. .xlsx/.xlsm are read as a workbook;
anything else as JSON (_read_workflow_file).
Refusal: 400 with workflow_io.Bad's own sentence.
Docstring: "What the file would make here, and what it names that is not here. Answered before anything is written, the way a metamodel workbook is: a definition drawn somewhere else can name a role, a type, a characteristic or a connection this repository has never heard of, and importing it silently would produce a workflow that asks nobody and fires nothing."
Response (_import_plan)
{
"workflow": {"code","name","description","trigger_event","priority","types"},
"states": 4, "transitions": 6, "actions": 3,
"problems": [ … ], // _wf_problems against the FILE's shape
"missing": [ // sorted and de-duplicated
"asset type DATA_PRODUCT", "role SME", "permission FOO",
"characteristic CLASSIFICATION", "connection SLACK"
],
"notes": [ // sorted and de-duplicated; not blocking
"no DATA_DOMAIN called “Retail” — the workflow will govern nothing in it until there is one"
],
"here": { // the highest-versioned workflow of that code, or null
"uid": "…", "name": "…", "status": "published", "version": 3
}
}
What is checked for missing: every types entry; every transition's
role_code and permission_code; every condition key beginning a:; a
set_attribute action's code; an assign_role action's role_code; a
webhook action's connection; a notify action's to when it begins
role:; and every scope condition key beginning a:. A scope condition key
beginning g: whose named grouping does not exist is a note, not a
blocker.
POST /api/workflows/import
Permission: WORKFLOW_DESIGN.
multipart/form-data with file.
Query parameters
| Name | Type | Default | Allowed |
|---|---|---|---|
as |
string | "version" |
version, separate |
Docstring:
as=versionputs it on the existing workflow of that code as its next version — what promoting a definition from one environment to the next means.as=separatemints a new code, so nothing here is touched. Either way it arrives as a draft: an import that published itself would be a change to what people are approving, made by a file.
Refusals: 400 from the reader; 409
"This file names things this repository does not have: asset type DATA_PRODUCT,
role SME. Add them, or change the file."
Response
{"uid": "…", "code": "TERM_APPROVAL", "version": 4,
"notes": [ … ], "problems": [ … ]}
A system logbook line records the import.
GET /api/workflows/{uid}/export
No permission check beyond a session.
Query parameters
| Name | Default | Notes |
|---|---|---|
fmt |
"json" |
xlsx, excel or workbook → a workbook; anything else → JSON |
Docstring: "One workflow as a file: JSON to move it, a workbook to read it."
The designer offers both unconditionally, draft or published: "a definition is worth keeping and worth reviewing, and neither of those is an editing right".
Response: application/json or the xlsx media type, both with
Content-Disposition: attachment; filename="krisid-workflow-<code-slug>.json|xlsx".
The shape is workflow_io.to_shape(...) and is [not documented] here; its
round trip is the import above.
GET /api/requests — open requests, and which are mine
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
mine |
int | 0 |
non-zero drops every request not waiting on you |
open |
int | 1 |
non-zero adds outcome IS NULL |
asset |
uid | null |
one asset's requests |
Hard cap: LIMIT 500, newest first.
Docstring:
"Waiting on me" is not a column anywhere: it is the same guard the write path checks, asked of every open request. Storing an assignee would be a second answer to a question the access model already answers, and the two would drift the first time somebody's role changed.
Response
{
"requests": [
{"uid": "…", "title": "Definition changed on Net Revenue",
"created_by": "r.iyer", "created_at": "…",
"outcome": null, // null | "applied" | "closed"
"asset_id": 4120, // the internal id, exposed here
"state_id": 41,
"kind": "change", // change | create | delete | review
"context": { … }, // what started it
"asset_uid": "…", "asset_name": "Net Revenue",
"type_code": "BUSINESS_TERM", "type_name": "Business Term",
"state_name": "Submitted", "state_kind": "start",
"workflow_name": "Term approval",
"due_at": "…", // the open task's due time, or null
"changes": 3, // change_request_item count
"waiting_on_me": true,
"can_take": ["Approve", "Reject"]}
],
"acting_as": "r.iyer" // or null with no session
}
waiting_on_me and can_take are computed per request by
_open_transitions — so a list of 500 open requests is 500 of those walks.
GET /api/requests/{uid} — one request
No permission check to read it.
Refusal: 404 "There is no change request with that id."
Response
{
"task": { … } | null, // see below
"request": {
"uid": "…", "title": "…", "note": "…",
"outcome": null, "applied_at": null,
"created_by": "r.iyer", "created_at": "…",
"asset_uid": "…", "asset_name": "Net Revenue",
"type_code": "…", "type_name": "…",
"state_code": "LEVEL_1", "state_name": "Submitted", "state_kind": "start",
"workflow_name": "Term approval", "version": 3,
"kind": "change", "context": { … }
},
"items": [
// an attribute or field: the value, and whether it has MOVED since
{"kind": "attribute", "code": "DEFINITION",
"was": "the old text", // what the repository said when it was raised
"proposed": "the new text",
"payload": { … },
"status": null, // null | "applied" | "refused" | "waiting"
"now": "the old text", // what it says NOW
"stale": false, // now != was
"label": "Definition"},
// a link or a responsibility: said in words, never stale
{"kind": "relation_add", "code": "TERM_OWNED_BY_DOMAIN",
"label": "Link: owned by", "now": null, "stale": false,
"other_uid": "…",
"pair": "<the paired request's uid>"} // when the far end must approve too
],
"decisions": [ // ORDER BY decided_at, id
{"from_state": "Submitted", "to_state": "Approved",
"actor": "a.khan", "comment": "…", "decided_at": "…"}
],
"transitions": [ … ], // _open_transitions for whoever is asking; [] when closed
"runs": [ // every action run on this request
{"uid": "…", "kind": "webhook", "state_name": "Approved",
"status": "given up", // queued | done | failed | given up
"attempts": 5, "detail": "after 5 tries — HTTP 503: …",
"created_at": "…", "updated_at": "…"}
]
}
stale is the whole point of was: "if the repository no longer says what
it said when this was raised, approving it would apply a decision nobody made
about the value that is actually there." A link or a responsibility is never
stale, "it is added or taken away, not overwritten."
The item labels: "Link: <word>", "Unlink: <word>",
"Give: <role>", "Take away: <role>", or an attribute's name, or
FIELD_COLUMNS[code].
A transition in transitions (_open_transitions) — "the
same function the write path uses to decide, so a button that appears is a
button that works":
{"uid": "…", "name": "Approve", "needs_all": false,
"to": "Approved", "to_kind": "approved",
"needs_comment": false, "not_the_raiser": true,
"role": "Owner", "permission": null,
"may": false,
"why_not": "only the Owner of “Net Revenue” may take this"}
Every why_not sentence
| Condition | why_not |
|---|---|
| a permission step they lack | "needs <PERM>" |
| a role step they lack | "only the <Role> of “<asset>” may take this" |
| four eyes, and they raised it | "this step asks for a second pair of eyes, and you raised the request" |
| the step's own conditions do not hold | "the conditions on this step do not hold" |
| everyone is asked and they were not on the list | "every one of the people asked when this arrived must answer, and you were not one of them" |
| they have already answered | "you have answered “<choice>”" |
A condition box's arrows return [] — "a condition box's arrows are not
people's." An on_expiry arrow is never offered — "the workflow takes it when
time runs out."
task (_task_view) — "who was asked, who has answered, and
by when":
{
"state": "Submitted", "mode": "all_wait",
"mode_words": "Every one of them — wait for every answer",
"asked": [{"username": "a.khan", "display_name": "A. Khan", "answered": true}],
"votes": [{"username": "a.khan", "display_name": "A. Khan",
"choice": "Approve", "comment": "…", "at": "…"}],
"entered_at": "…", "due_at": "…",
"time_allowed": "2 working days",
"overdue": false,
"expired_at": null,
"reminders_sent": 1,
"next_reminder_at": "…" // null when it equals due_at
}
null when there is no task, or when the task is closed and belongs to a state
the request has since left.
Who was asked is fixed when the request arrives: "Each
arrival is a request_task row — who was asked is fixed then, so the list
cannot change under people halfway through answering."
POST /api/requests/{uid}/take/{transition_uid} — move it along
Permission: whatever the transition says — its role_code resolved against
the asset, or its permission_code, plus the four-eyes and conditions checks.
Checked here, not trusted from the screen.
Body: {"comment": "…"} — optional unless the step asks for one.
Docstring: "The guard is checked here rather than trusted from the screen, and the change is applied through the same endpoints an ordinary edit uses — so a rule added since the request was raised still refuses it, and the History entry is the same shape as every other edit."
Refusals
| Condition | Status | Message |
|---|---|---|
| already closed | 409 | "That request is already closed." |
| the step is not on this request | 404 | "That step is not on this request." |
| they may not take it | 403 | "<Name> may not take “<step>” — <why_not>." |
| the step needs a comment and there is none | 400 | "“<step>” asks for a reason. Say why, so the person who raised this knows what to change." |
landing on approved with a stale item |
409 | "“<CODE>” has changed since this was raised, so approving it would apply a decision about a value that is no longer there. Ask for the request to be raised again against what the repository says now." |
The request row is locked FOR UPDATE first: "two approvers answering at
once, or the clock running out while somebody approves, must not both move."
Response — moved
{"state": "Approved",
"applied": 3, // items made true
"refused": ["DEFINITION: Definition is required for a Business Term …"],
"deleted": false, // a delete request that performed its deletion
"actions": 1, // outbound action runs queued by the state entered
"decided": true} // present when a condition box then moved it on by itself
Response — one vote recorded, while answers are still due (mode != any):
{"state": "Submitted", "voted": true,
"waiting_for": ["b.osei"],
"applied": 0, "refused": [], "actions": 0}
How voting resolves (_vote):
mode |
Behaviour |
|---|---|
all |
the agreeing step waits for everyone; any other answer, from any one of them, decides at once |
all_wait |
everyone answers first; all agreeing takes the agreeing step, otherwise the first other answer given |
The recorded comment on a unanimous approval is
"Everyone asked agreed: a.khan, b.osei" plus the approver's own comment; on
a split all_wait, the note reads "Everyone answered (a.khan: Approve;
b.osei: Reject) — not all agreed, so Reject." followed by their own comment.
What approval does — applied outside the transaction, under
_no_triggers and _as_the_workflow:
kind: "create"→proposed_byis cleared, thecreatedtrigger fires, and links that were waiting for this asset are released (_release_links_to).kind: "delete"withcontext.clear_gone→ the ids the raiser was allowed are deleted (_clear_ids). Not re-derived now — "an approval must apply the act that was put to the approver and not a larger one that has become true since."kind: "delete"otherwise →delete_asset(...).- Every item →
_apply_item, which goes throughset_attribute,edit_asset,add_relation,drop_relation,assignorunassign— "made true through the endpoint an edit uses."
A link whose far end must approve too waits: it is marked
status = "waiting" and made when the second request is approved. A link
pointing at an asset that is itself still a proposal waits the same way — "
written now it would be a link to something that does not exist for anybody
else, and refused now it would throw away a decision that was taken."
What a rejection does to a create request: the asset stays hidden, keeps
its request as the record, and gives its key back by appending
" (declined <8 hex>)" to external_key "so it can be proposed again";
every link held for it is refused with the reason
"<Name> was not approved, so this link has nothing to point at."
Condition boxes move by themselves (_decide): the first
arrow out whose conditions all hold, or the arrow with none — "otherwise".
Bounded at 20 hops: "a loop of boxes; the designer flags it." Strict:
"a condition naming a characteristic this asset's type does not have does not
hold, so a Report is never sent down the 'Restricted' path for want of a
Classification to read." The decision is recorded with the conditions in
words, e.g. "Decided: Classification is Restricted".
What a condition can see beyond the asset (CONTEXT_COLUMNS) — keys beginning q::
| Key | Label |
|---|---|
q:trigger |
What started it |
q:field |
What is changing |
q:old |
Its old value |
q:new |
Its new value |
q:raised_by |
Who raised it |
q:kind |
Kind of request |
q:items |
How many changes |
Without these a workflow can only route on what an asset already says, which cannot express "a change to the definition needs two levels" — and that is most of what anybody actually wants to write.
GET /api/notifications
No parameters. LIMIT 50, newest first, for whoever is asking.
Response
{"notifications": [
{"uid": "…", "text": "Net Revenue is waiting for you.",
"created_at": "…", "seen_at": null,
"request_uid": "…"} // null when the request has gone
]}
POST /api/notifications/seen
No body. Marks every unseen notification for this person as seen.
Response: {"seen": 4}.
POST /api/runs/{uid}/retry — try an outbound action again
Permission: the request's raiser may always; anybody else needs
WORKFLOW_DESIGN — "Anybody who could act on the request can retry: the
person watching a notification that never arrived is the person who knows it
never arrived."
{uid} is an action_run.uid.
Refusals
| Condition | Status | Message |
|---|---|---|
| no such run | 404 | "There is no such run." |
the kind is not webhook or email |
400 | "Only an outbound call or an email can be retried; the rest either happened or were refused." |
| an email run and nobody it was for has an address | 400 | "Nobody it was for has an email address, so there is nothing to send. Add their addresses under People first." |
For an email run the addresses are re-read as they are now — "'no email address for r.iyer' is fixed by giving r.iyer one and pressing Try again."
The run is reset to status = 'queued', attempts = 0, next_try_at = now,
and the outbox is drained once immediately.
Response: {"queued": true}.
How the outbox behaves (_drain_outbox):
BACKOFF = [30, 120, 600, 1800, 7200] seconds — five attempts, then status =
'given up'. "Backoff is fixed and short at first — most webhook failures are a
restart or a rate limit, and the useful thing is to try again in half a minute
rather than tomorrow… a queue that retries for ever is a queue nobody notices
is broken." An email run that fails because there is no mail server does not
retry at all: "trying again in half a minute changes nothing, so it says why
and stops."
Rows are claimed with a LEASE_SECONDS = 300 lease rather than held under
FOR UPDATE SKIP LOCKED across the send: "This used to hold
FOR UPDATE SKIP LOCKED on five rows across the sending — a webhook POST is
ten seconds, an SMTP send is fifteen PER RECIPIENT — on a loop that ticks every
five seconds. So the ticks piled up behind each other on rows that were already
claimed, and anything else touching action_run waited behind a socket."
GET /api/change-sets — my drafts
Query parameters: asset — a uid, optional.
Docstring: "My drafts — all of them, or the one on this asset." Always
scoped to created_by = <you>.
Response
{"drafts": [
{"uid": "…", "asset_uid": "…", "asset_name": "Net Revenue",
"type_name": "Business Term",
"items": [ … ], // the cleaned items, as stored
"note": null, "updated_at": "…",
"proposed_asset": true, // the asset itself is still a proposal
"workflow": "Term approval"} // the workflow that governs it now, or null
]}
Why change sets exist:
Every kind of edit can be proposed now — characteristics, the name and key, links added and removed, responsibilities given and taken away — and a new asset, which waits as a pending asset until it is approved. A link joins two assets; where the other end is governed too, its workflow must approve as well, so the link is a paired item on two requests and is made when the second is approved.
POST /api/change-sets/items — add an edit to my draft
Permission: WORKFLOW_RAISE on the asset — "propose changes to".
Body
| Field | Type | Notes |
|---|---|---|
asset_uid |
uid | required |
item |
object | one edit; see the kinds below |
Docstring: "Add an edit to my draft on this asset, starting the draft if
there is none. The same edit again replaces the one before." Sameness is
_item_key:
kind |
Key |
|---|---|
relation_add |
(kind, code, other_uid, as_source) |
relation_remove |
(kind, relation_id) |
responsibility_add |
(kind, code, holder) |
responsibility_remove |
(kind, ruid) |
attribute, field |
(kind, code) |
The six item kinds (CS_KINDS), each checked and described in
words by _clean_item:
kind |
Body | Refusals |
|---|---|---|
attribute |
{code, proposed} |
400 "There is no characteristic called <code>." |
field |
{code, proposed}, code ∈ name, external_key |
400 "<code> is not a field of an asset." |
relation_add |
{code, payload: {other_uid, as_source?}} |
400 "There is no relation type called <CODE>."; 400 "An asset cannot be linked to itself." |
relation_remove |
{payload: {relation_id}} |
404 "That link is not on this asset." |
responsibility_add |
{code, payload: {holder, holder_kind}} |
400 "There is no role called <CODE>."; 400 "There is no <kind> called '<holder>'." |
responsibility_remove |
{payload: {ruid}} |
404 "That responsibility is not held on this asset. An inherited one is changed where it was assigned." |
Anything else → 400 "'<kind>' is not something a change set can hold."
Response: the _cs_view shape above.
DELETE /api/change-sets/{uid}/items/{n}
Permission: the draft's creator only (the SELECT is scoped to them).
{n} is a zero-based index into items.
Refusals: 404 "There is no draft of yours with that id."; 404 "That
change is not in the draft."
Response: the _cs_view shape.
DELETE /api/change-sets/{uid} — throw the draft away
Permission: the creator only.
Docstring: "Throw the draft away. A proposed asset nobody submitted goes with it." The asset is deleted only if you proposed it and no change request refers to it.
Refusal: 404 "There is no draft of yours with that id."
Response: {"discarded": true, "asset_removed": true}.
POST /api/change-sets/{uid}/submit
Permission: the creator only.
Body: {"title": "…", "note": "…"} — both optional; the note falls back to
the draft's own.
Refusals
| Condition | Status | Message |
|---|---|---|
| not yours, or no such draft | 404 | "There is no draft of yours with that id." |
| empty, and not a new asset | 400 | "The draft is empty — there is nothing to submit." |
| nothing governs the asset any more | 409 | "No published workflow governs this asset any more, so there is nothing to submit to. Make the changes directly." |
The draft is deleted as part of the same transaction.
Response: {"uid": "<the request's uid>", "paired": ["<uid>", …]} — the
paired requests raised on the far end of any governed link.