Skip to content

Assignee sources

Your directory group changed Change event signed webhook Resolver your hooks file Waiting dialog new assignees sends calls again replaces assignees
Your directory group changed Change event signed webhook Resolver your hooks file Waiting dialog new assignees sends calls again replaces assignees
When your directory reports a change, IdentityFlow calls your resolver again and writes its answer onto every pending dialog with that source as the new assignees.

A dialog always says who may answer it. When that group lives in another system and can change while the dialog waits, add an assignee source: a reference that IdentityFlow resolves again each time your directory reports a change. Afterwards, a waiting dialog follows the group without anyone reassigning it.

Decide first: most groups need no assignee source; a stable principal such as role:vacation-approver is enough (Start with stable principals). Before you start: your directory can send change events. First step: add a source to a dialog, then register the resolver. When assignees are recomputed, duplicates, evidence and the test follow below.

An assignee is a principal, { providerId, subject }, that may answer a dialog. A change event is a signed webhook call from your directory. Both terms are also in Concepts.

Most groups do not need an assignee source. If your provider can give the group a stable name, use that name as the assignee:

  • role:vacation-approver is reported by the provider for whoever holds the role today;
  • manager-of:p-2041 is reported for whoever manages person p-2041 today.

When someone joins or leaves, only the principals the provider reports for their account change. The dialog is never rewritten, and nothing needs to be resolved. The dialog with a role assignee recipe works this way.

Use an assignee source only when your directory cannot name the group and can only list its current members, for example the people standing in for an absent manager.

In a dialog, assignees is required and assigneeSource is optional:

const decision = await flow.dialog('approve vacation', { schema: Decision }, () => ({
params: { employeeId, days },
// Who may answer now: the manager's stable principal.
assignees: [{ providerId: 'acme', subject: `manager-of:${employeeId}` }],
// Who may answer later: the resolver replaces the set when the deputies change.
assigneeSource: {
providerId: 'acme',
sourceKey: `deputies-of:${managerId}`,
context: { employeeId, managerId },
},
}));
  • assignees is who may answer from the moment the dialog is created: one principal or a non-empty list of up to 1,024, all from the same provider.
  • assigneeSource must use the same provider. sourceKey (1–512 characters) names the group; change events refer to it. context is JSON, at most 8,192 bytes, that IdentityFlow stores with the dialog and hands to your resolver. sourceVersion is optional; IdentityFlow updates it from the resolver’s answer.

The source is stored in the dialog’s event, so replaying the workflow never calls your directory.

The resolver lives in your hooks file, next to the identity provider. There is one resolver per provider:

hooks.assignees.resolver(PROVIDER_ID, {
context: v.strictObject({ employeeId: v.string(), managerId: v.string() }),
async resolve({ source, signal }) {
const { employeeId, managerId } = source.context;
const { personIds, version } = await directory.deputiesOf(managerId, signal);
// The answer replaces the whole assignee set, so it names the manager as well.
const assignees = [managerOf(employeeId), ...personIds.map(person)];
return { assignees, sourceVersion: version };
},
});
return { assignees, sourceVersion: version };
  1. replaces the whole set
  2. stored with the source

The contract:

  • context is a schema. IdentityFlow validates the stored context with it before it calls resolve.
  • resolve({ source, signal }) receives the stored source and an AbortSignal. Pass the signal to your I/O so a slow directory call can be cancelled.
  • It returns { assignees, sourceVersion? }. assignees is the complete new set: non-empty, at most 1,024, every principal with this provider’s ID. It replaces the dialog’s current assignees, which is why the example includes the manager and not only the deputies.
  • resolve must answer within 3 seconds. An empty or invalid answer, a thrown error or a timeout fails the refresh. IdentityFlow retries it and finally lists it under Failed work, where an account with operator access can replay it. The dialog keeps its previous assignees until a refresh succeeds.

IdentityFlow calls your resolver for a dialog in exactly these cases:

  1. A change event arrives. A webhook maps it to hooks.commands.assignees.sourceChanged({ providerId, sourceKey }). IdentityFlow stores the event and answers 202 first. Then, asynchronously, a background worker calls your resolver for every pending dialog with that provider and source key.
  2. A dialog is created after a change was already received. Creating a dialog does not call the resolver: it starts with the assignees the workflow names. If IdentityFlow has ever received a change event for the same provider and source key, it schedules a refresh for the new dialog straight away. From then on the resolver’s answer, not the workflow’s assignees, decides who may answer.
  3. An operator replays a failed refresh from Failed work. That runs the resolver again.

Nothing else triggers the resolver: not a sign-in, not a page view, and not workflow replay. Answered, cancelled or failed dialogs are never refreshed.

When a refresh changes the assignee set, IdentityFlow records a STEP_ASSIGNEES_UPDATED event on the instance with the new set and the change-event details. An unchanged set adds no instance event.

An operator can also replace the assignee set directly, without a change event or the resolver; see Reassign a pending dialog. Reassignment removes the dialog’s assignee source, so later change events do not refresh that dialog.

Who may answer is decided at the moment someone tries. IdentityFlow asks the provider for that account’s principals, fresh, and compares them with the dialog’s current assignees. The provider ID must match exactly and the subject without regard to letter case (see Write the hooks file); one match is enough. If the provider lookup fails, the answer is refused.

This gives two independent ways for access to change:

  • A change in who holds a principal, for example a new manager for p-2041, counts at the next attempt to answer. No event and no refresh are needed.
  • A change in the assignee set itself is written onto the pending dialog: by a resolver refresh after a change event, or by an operator’s reassignment.
WHO HOLDS A PRINCIPAL THE ASSIGNEE SET Holder changes no event needed Provider reports holders Change event stored, 202 Resolver your hooks file Operator reassigns dialog Assignee set on pending dialog Answer attempt one match is enough next lookup asked fresh worker calls replaces set replaces set compared
WHO HOLDS A PRINCIPAL THE ASSIGNEE SET Holder changes no event needed Provider reports holders Change event stored, 202 Resolver your hooks file Operator reassigns dialog Assignee set on pending dialog Answer attempt one match is enough next lookup asked fresh worker calls replaces set replaces set compared
A new holder of a principal counts at the next answer attempt; a new assignee set is written onto the pending dialog, by the resolver after a change event or by an operator's reassignment.

An account that is refused sees the dialog stay pending; nothing is recorded on the instance.

Your directory will sometimes send the same change twice, for example after a timeout. A repeated eventId with the same content is stored once and reported as duplicate. And because a refresh replaces the assignee set with the resolver’s current answer, running it twice gives the same result. Reuse an eventId only for a retry of the same change.

IdentityFlow records every refresh that changes the assignee set as a STEP_ASSIGNEES_UPDATED event that stores the new set, the source key, its context and version, the source and eventId of the change event, and the name and digest of the hooks file. In 0.3.0 the instance’s Audit tab, which needs auditor, lists the event with its time but does not show these fields, and GraphQL audit does not show them either; see Audit trail. Keep the eventId and version you sent with each change on your side.

An event that was recorded before a change stays as it was: an answer given last week still shows who gave it.

The Workflow Test below checks that the dialog records its source next to its starting assignees, and that only those assignees can answer until a change arrives:

import { WorkflowTestError, workflowTest } from '@identity-flow/testing';
import { expect } from 'vitest';
import { vacationCoverage } from './w2-coverage.workflow';
const test = workflowTest({
workflow: vacationCoverage,
accounts: {
requester: { providerId: 'acme', principals: ['person:p-2041'] },
manager: { providerId: 'acme', principals: ['person:p-1001', 'manager-of:p-2041'] },
deputy: { providerId: 'acme', principals: ['person:p-2050'] },
},
});
test('records the source next to the assignees it starts with', async ({ flow, accounts }) => {
const instance = await flow
.actAs(accounts.requester)
.start({ employeeId: 'p-2041', managerId: 'p-1001', days: 3 });
const dialog = await flow.waitFor(instance).toHaveDialog('approve vacation');
expect(dialog.assignees).toEqual([{ providerId: 'acme', subject: 'manager-of:p-2041' }]);
expect(dialog.assigneeSource).toEqual({
providerId: 'acme',
sourceKey: 'deputies-of:p-1001',
context: { employeeId: 'p-2041', managerId: 'p-1001' },
});
// Until a change event arrives, only the assignees the dialog started with count.
await expect(
flow.actAs(accounts.deputy).completeActivity(dialog, { outcome: 'approved' }),
).rejects.toBeInstanceOf(WorkflowTestError);
await flow.actAs(accounts.manager).completeActivity(dialog, { outcome: 'approved' });
await expect(flow.waitFor(instance).toBeCompleted()).resolves.toMatchObject({
data: { employeeId: 'p-2041', outcome: 'approved' },
});
});

Workflow Tests in 0.3.0 cannot deliver a change event, so test the resolver on its own with createHookTestHost(), as in Test the hooks file.