Assignee sources
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-approveris reported by the provider for whoever holds the role today;manager-of:p-2041is reported for whoever manages personp-2041today.
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 }, },}));assigneesis 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.assigneeSourcemust use the same provider.sourceKey(1–512 characters) names the group; change events refer to it.contextis JSON, at most 8,192 bytes, that IdentityFlow stores with the dialog and hands to your resolver.sourceVersionis 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 };- replaces the whole set
- stored with the source
The contract:
contextis a schema. IdentityFlow validates the stored context with it before it callsresolve.resolve({ source, signal })receives the stored source and anAbortSignal. Pass the signal to your I/O so a slow directory call can be cancelled.- It returns
{ assignees, sourceVersion? }.assigneesis 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. resolvemust 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 withoperatoraccess can replay it. The dialog keeps its previous assignees until a refresh succeeds.
IdentityFlow calls your resolver for a dialog in exactly these cases:
- 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. - A dialog is created after a change was already received. Creating a
dialog does not call the resolver: it starts with the
assigneesthe 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’sassignees, decides who may answer. - 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.
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.