Docs › Reference

For everyone

Workflows, requests and change sets

The API calls behind workflows, requests and change sets.

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:

  1. 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.
  2. 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.
  3. Nothing is applied over a value that moved. was is 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=version puts it on the existing workflow of that code as its next version — what promoting a definition from one environment to the next means. as=separate mints 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_by is cleared, the created trigger fires, and links that were waiting for this asset are released (_release_links_to).
  • kind: "delete" with context.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 through set_attribute, edit_asset, add_relation, drop_relation, assign or unassign — "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.