9. Actors, Work, and Policy
Identity answers “who are you?” Access answers “what may you do here?” Work assignment answers “who should receive this task now?”
Blueprint keeps these questions separate.
An action control is a fourth, related question: “what durable approval must finish before this already-authorized action may apply its effects?” Controls do not replace grants, qualifications, task assignment, or the action itself.
Actor/task declarations come from vyuh_blueprint; the product action catalog comes from package:vyuh_iam/vyuh_iam.dart.
An actor expression is boolean algebra
final eligibleReviewers = Actors.allOf([
Actors.role(const RoleRef('access.quality_reviewer')),
Actors.qualified(const QualificationRef('training.gmp_current')),
Actors.noneOf([
Actors.initiator,
]),
]);An actor expression is one Expr<bool> over the acting user and the actor facts. Build it with the Actors builders:
Actors.anyone,.noOne,.initiator,.user(id),.inField(field);Actors.role(ref),.group(ref),.department(ref),.allowedAction(action),.qualified(ref);Actors.allOf([...]),.anyOf([...]),.noneOf([...]).
Each builder returns a plain And, Or, Not, Comparison or HostFact from vyuh_expression; the facts are named by blueprintFactPorts (actor.holds, actor.in_group, actor.in_department, actor.qualified, actor.initiator, actor.in_field). A builder reads a reference when it is called, so a declaration that uses one is final, not const. A fact with literal arguments can stay const: const HostFact<bool>('actor.holds', ['role', 'access.quality_reviewer']).
Runtimes can enumerate the eligible actors (ActorTargets.of(expr)) or test one actor against the expression. The test is three-valued: a fact the host cannot answer is unknown, and a gate passes only on a known true, so it fails closed, also under Not. A policy lock (a scoped configuration may narrow but not broaden) is a policy concern, not part of the actor expression.
Action access is not task assignment
An action gate:
Rule(
id: 'ops.area.activate.actor',
kind: RuleKind.access,
test: eligibleReviewers,
)A task assignment:
final reviewAssignment = AssignmentSpec(
claimMode: ClaimMode.pool,
actors: Actors.anyOf([
Actors.role(const RoleRef('access.quality_reviewer')),
Actors.group(const GroupRef('access.hyderabad_quality')),
]),
);An assignment uses the same actor expression as a gate, but it must be enumerable: Actors.user, .group, .role, .inField, or Actors.anyOf over them (ActorTargets.of returns null for anything else). Validation rejects anything else with blueprint.validation.assignment_not_enumerable.
Role and group membership are expanded late at claim time, so staffing changes do not strand pooled tasks. Actors.user freezes a concrete user at spawn. Actors.inField(ProjectedFieldRef('self.identity.owner_id')) resolves from context.
Declare a task template
final qualityReviewTask = TaskTemplate(
name: 'area_activation_review',
title: 'Review area activation',
kind: 'approval',
completionActionRef: ActionRef(
facet: FacetRef(
entity: EntityRef(module: ModuleRef('ops'), name: 'area'),
name: 'governance',
),
name: 'activate',
),
defaultAssignment: reviewAssignment,
completion: const TaskCompletionStrategy.claimSingle(),
expiry: const Duration(hours: 24),
escalations: const [
TaskEscalation(
after: Duration(hours: 8),
assignment: AssignmentSpec(
actors: HostFact<bool>('actor.holds', [
'role',
'access.quality_manager',
]),
),
),
],
separationOfDuties: const [
SeparationOfDuties(
subject: SodSubject.completer,
notEqualTo: SodInitiator(),
locked: true,
),
],
);Register templates at module scope:
Module(
// ...
taskTemplates: [qualityReviewTask],
)Completion always names an action. There is no task-completion path that bypasses the action journal.
Spawn work through the effect channel
Action(
name: 'request_activation',
effects: const [
Effect.task(
templateRef: TaskTemplateRef('ops.area_activation_review'),
consistencyOverride: ConsistencyMode.eventual,
),
],
)Effect.task targets workflow.task.lifecycle.create. The spawning module must depend on a workflow module that exports that trigger. Outbox delivery, causality, recursion limits, and retry behavior remain common runtime machinery.
Declaration, configuration, and resolution
| Plane | Example |
|---|---|
| Declaration | role ref, actor expression, task defaults, SoD lock |
| Configuration | tenant/site assignment override, membership, grant, expiry |
| Resolution | effective policies, concrete eligible users, disabled reason |
| Execution | claim, delegate, complete, expire action records |
Do not put concrete tenant membership into the product Blueprint. Do not let a site override weaken a locked separation-of-duties constraint.
Effective actions
Assembly gathers facet actions into an EffectiveActionCatalog. At request time, EffectiveActionResolver evaluates placement, access, policies, and context into EffectiveActionSets:
The UI renders this result. It must not replicate authorization logic with ad-hoc button conditions.
Product action catalogs
IAM administration uses the same fully qualified action identities as the runtime:
const opsPermissions = IamProductDescriptor(
product: 'ops',
title: 'Operations',
actions: [
IamActionDescriptor(
action: 'ops.area.governance.activate',
title: 'Activate Area',
entityName: 'area',
facetName: 'governance',
),
IamActionDescriptor(
action: 'ops.session.refresh',
title: 'Refresh Session',
resourceType: 'session',
grantEditable: false,
),
],
);editableActions includes only actions administrators may grant. Public, self-service, and system actions remain visible in the complete inventory with grantEditable: false. validate() catches duplicate or invalid entries, and validateEntityCoverage() reports entities missing from the product catalog.
Checkpoint
Model dual-control retirement:
- the requester can submit retirement;
- a qualified quality reviewer completes it;
- the completer cannot be the initiator;
- the task escalates to a quality manager;
- the completion action still evaluates its own grant and lifecycle rules.
Next: Runtime execution.