Skip to content

Approvers and resolvers

A step’s approvers says who decides. Rungs does not keep users or an org chart; it asks your application through three callbacks you pass to createRungs:

const directory: Record<string, { manager?: string; roles: string[]; active: boolean }> = {
u_ana: { manager: "u_ben", roles: [], active: true },
u_ben: { roles: ["finance"], active: true },
u_cat: { roles: ["finance"], active: false }, // left the company
u_dan: { roles: ["finance"], active: true },
};
const rungs = createRungs({
store,
clock: () => new Date(),
ids: () => crypto.randomUUID(),
// A role name to the users who have it.
roles: (role) => Object.keys(directory).filter((id) => directory[id]?.roles.includes(role)),
// Named resolvers for anything else; `request` has the requester, subject and payload.
resolvers: {
directManager: ({ request }) => {
const manager = directory[request.requesterId]?.manager;
return manager ? [manager] : [];
},
},
// Inactive users are removed when a step resolves its approvers, and the reason is recorded.
isActiveUser: (userId) => directory[userId]?.active ?? false,
});

Runs in CI:examples/docs-snippets/src/concepts/approvers.ts

Approvers Resolves to
{ type: "users", ids: [...] } Exactly these users
{ type: "role", role } roles(role)
{ type: "resolver", name, params? } resolvers[name]({ request, stepId, params })
{ type: "union", of: [...] } Everyone the inner specs resolve to
{
id: "review",
approvers: {
type: "union",
of: [
{ type: "resolver", name: "directManager" },
{ type: "role", role: "finance" },
],
},
},

Runs in CI:examples/docs-snippets/src/concepts/approvers.ts

When a step starts, Rungs resolves its approvers and then removes, with the reason recorded on the step and in its audit event:

  • The requester, unless the flow sets allowSelfApproval (EC-01).
  • Inactive users, according to isActiveUser (EC-06).
  • Duplicates: a user reached twice gets one task (EC-44).

If nobody is left, Rungs does not skip the step silently. At submit, the submit is refused with NoApproversError (EC-43); later in the flow, the step is blocked until an admin acts (EC-42). rungs.blocked() lists such steps.

preview shows the requester who would approve, before anything is stored:

const preview = await rungs.preview({
flow: "expense",
requesterId: "u_ana",
subject: { type: "expense", id: "EXP-7" },
payload: { amount: 120 },
});
console.log(preview.steps[0]?.approvers); // ["u_ben", "u_dan"]: u_ben once, u_cat is inactive
console.log(preview.steps[0]?.removed); // [{ userId: "u_cat", reason: "inactive" }]

Runs in CI:examples/docs-snippets/src/concepts/approvers.ts

The callbacks run inside the command’s database transaction, while the request is locked. Read from memory or a cache where you can, and never use the command’s own database connection inside a callback: a database with a single connection, such as PGlite, would wait forever.

Resolvers that walk an organization chart (direct manager, head of department, acting holder of a vacant position) are planned. Until then, write your own resolver, as above.