Diagram
100%

Drag to pan · Scroll to zoom · + / − to zoom · 0 to fit · Esc to close

Skip to content

5. Actions, Rules, and Lifecycles ​

Fields describe state. Actions describe intent to change state. A lifecycle declares which state changes are legal.

Declare a lifecycle on its owning facet ​

dart
enum AreaStatus { draft, active, retired }

abstract final class AreaFields {
  static const id = UuidField('id', nullable: false);
  static const status = StatusField(
    'status',
    AreaStatus.values,
    dbEnumName: 'area_status',
  );
}

final class ActivateArea extends Action {
  const ActivateArea() : super(name: 'activate');
}

final class RetireArea extends Action {
  const RetireArea() : super(name: 'retire');
}

abstract final class AreaActions {
  static const activate = ActivateArea();
  static const retire = RetireArea();
}

final governanceFacet = Facet(
  name: 'governance',
  title: 'Governance',
  lifecycle: Lifecycle<AreaStatus>.typed(
    stateField: AreaFields.status,
    initialState: AreaStatus.draft,
    transitions: const [
      Transition.typed(
        from: AreaStatus.draft,
        to: AreaStatus.active,
        action: AreaActions.activate,
      ),
      Transition.typed(
        from: AreaStatus.active,
        to: AreaStatus.retired,
        action: AreaActions.retire,
      ),
    ],
  ),
  actions: const [
    AreaActions.activate,
    AreaActions.retire,
  ],
);

A typed transition retains the actual action declaration. The validator requires that exact declaration object to be exposed by the same facet; assembly then derives its qualified ActionRef. This keeps reusable lifecycles owner-neutral without falling back to action-name strings. Transition.fromAny is available for deliberately global transitions.

Put the complete decision surface on the action ​

dart
final class ActivateArea extends Action {
  const ActivateArea()
    : super(
        name: 'activate',
        title: 'Activate',
        description: 'Make the area available for controlled operations.',
        scope: ActionScope.record,
        consistency: ConsistencyMode.strict,
        rules: [
          Rule(
            id: 'ops.area.activate.grant',
            kind: RuleKind.access,
            phase: RulePhase.availability,
            test: HostFact<bool>('actor.holds', [
              'action',
              'ops.area.governance.activate',
            ]),
            error: ErrorDefinition(
              name: 'activate_not_allowed',
              title: 'Activation not allowed',
              code: 'ops.area.activate.not_allowed',
              description: 'The actor cannot activate areas in this scope.',
              kind: BlueprintErrorKind.access,
              httpStatus: 403,
            ),
          ),
          Rule(
            id: 'ops.area.activate.lifecycle',
            kind: RuleKind.lifecycle,
            test: StateIn('governance', {'draft'}),
          ),
        ],
        capture: ActionCapture.signedStandard,
        emits: [
          ActionEvent(
            name: 'ops.area.activated',
            kind: ActionEventKind.stateChanged,
            facet: 'governance',
          ),
        ],
        ui: ActionUI(
          icon: UIIcon('activate'),
          confirmationMessage: 'Activate this area?',
        ),
      );
}

This is the complete version of the shortened ActivateArea declaration in the preceding example. The named Dart type remains the reusable atom attached through AreaActions.activate; an action belongs to the facet whose state and behavior it governs.

A rule's error and an action's raises hold ErrorDefinitions directly. Assembly adds each one to the owning module's error catalogue, so clients see one catalogue per module. Two different definitions with one code are rejected as blueprint.validation.error_code_conflict.

A rule's test is a typed expression ​

A Rule keeps its phase, severity, structured error and provenance. What it checks is its test, an Expr<bool> from the typed expression language in vyuh_expression (import package:vyuh_expression/vyuh_expression.dart next to vyuh_blueprint). The nodes an author reaches for:

  • constants: Literal<bool>(true) (the default test) and Literal<bool>(false);
  • row comparisons: Comparison<T>, IsNull<T>, OneOf<T> over FieldValue<T>(FieldOf<T>(MyFields.status)), where T is String, int, bool, DateTime or Rational;
  • lifecycle: StateIn('governance', {'draft'});
  • actors: Actors.role(...), Actors.group(...), Actors.initiator and the rest of the Actors builders, see Actors, work, and policy;
  • host facts: Facts.grant(...), Facts.qualified(...), Facts.policyAllows(...), Facts.evidencePresent(...) and Facts.evaluator(HostEvaluatorRef('ops.area.release'));
  • boolean composition: And, Or, Not.

Prefer the built-in nodes. A registered host evaluator remains explainable only when its reference and implementation version are captured at runtime.

A builder such as Actors.role(...) or Facts.grant(...) reads a reference when it is called, so its result is not const, and a declaration that uses one is final. When every argument is a literal, write the fact directly and keep const: const HostFact<bool>('actor.holds', ['role', 'qa']).

Rules, tests and facts ​

A test answers one of three things: known true, known false or unknown. And and Or use Kleene logic, so the order of their operands never changes the answer: And is false when any operand is known false, even if another is unknown; Or is true when any operand is known true. A rule passes only on a known true. Unknown fails closed, also under Not: Not(unknown) is still unknown, so a gate on a missing fact blocks instead of opening.

Most of what a test reads is in the record: a field, a lifecycle state, the actor id. Some of it is not. Whether the actor holds a role, whether a policy allows the action, whether the command carries evidence: these are host facts, and an expression names them as HostFacts. The expression never resolves a fact itself. Before it runs, the runtime walks the expression (ExpressionShape.of(test).facts), resolves each distinct fact once and concurrently on the action's session, pins the answers in FactPins, and then evaluates synchronously against the pins. A fact with no resolver, or one the host could not answer, is unknown, so the rule fails closed. A definite no, such as an anonymous actor, is a known false.

dart
// Only a QA actor, only while the area is a draft, and only with sign-off.
final releaseGate = And([
  Actors.role(const RoleRef('qa')),
  StateIn('governance', {'draft'}),
  Facts.evidencePresent('qa_signoff'),
]);

The Actors builders build the same kind of expression over the acting user. Actors.role, .group, .department, .qualified, .allowedAction, .initiator and .inField are facts; Actors.user compares the actor id; Actors.anyone and Actors.noOne are the constants; Actors.allOf, .anyOf and .noneOf compose. One actor expression serves a rule, an assignment and an IAM access check.

When a blocking rule fails, the action keeps the rule's structured error. The diagnostic is the evaluation trace: the runtime stores it as expression_trace in the rule record's metadata, with the value or the unknown reason of every leaf and every fact. There is no per-leaf error code to look up; read the trace to see which fact decided.

Rule phase and severity are independent ​

dart
Rule(
  id: 'ops.area.capacity.warning',
  kind: RuleKind.data,
  phase: RulePhase.beforeCommit,
  severity: RuleSeverity.warning,
  test: const Not(IsNull<int>(FieldValue<int>(FieldOf<int>(AreaFields.capacity)))),
)
  • availability powers disabled/hidden action affordances and preflight.
  • beforeCommit guards the transaction.
  • afterCommit verifies the committed outcome.
  • async schedules deferred evaluation.

info and warning findings are still captured. blocking prevents commit.

Controls gate effects without creating another action model ​

Use an ActionControl when an authorized action requires durable work, such as an approval, before its ordered effects may apply. A control is a product subclass, and the product registers an ActionControlResolver that decides whether it is satisfied:

dart
final class QualityApproval extends ActionControl {
  const QualityApproval() : super(name: 'quality_approval');
}

Action(name: 'activate', controls: const [QualityApproval()])

Rules decide whether the request is allowed. Controls represent required work that may remain pending. The runtime applies the action effects only after all required controls are satisfied.

Approval of a governed record's revisions is not a control. It is declared on the entity's history, History.revisions(approval: RevisionApprovalPolicy()); see Governed revisions.

Events are facts; effects are consequences ​

dart
Action(
  name: 'activate',
  emits: const [
    ActionEvent(
      name: 'ops.area.activated',
      kind: ActionEventKind.stateChanged,
      required: true,
    ),
  ],
  effects: const [
    Effect.action(
      targetEntity: 'equipment',
      targetFacet: 'availability',
      targetTrigger: 'refresh',
      consistencyOverride: ConsistencyMode.eventual,
    ),
    Effect.workflow(
      workflow: WorkflowRef(
        module: ModuleRef('quality'),
        name: 'area_activation_approval',
      ),
      inputMapping: {'area_id': AreaFields.id},
      correlationIdFrom: AreaFields.id,
    ),
    Effect.task(
      templateRef: TaskTemplateRef('ops.area_activation_review'),
      consistencyOverride: ConsistencyMode.eventual,
    ),
  ],
)

The event says what happened. Every item in Action.effects requests a consequence, and the concrete effect type owns the target contract:

EffectMeaningRuntime boundary
Effect.action (ActionEffect)Invoke another Blueprint action.Strict effects expand into the same request and transaction; eventual effects use the outbox.
Effect.workflow (WorkflowEffect)Start a registered immutable durable workflow with typed input and correlation mappings.The source transaction commits its outbox intent; workflow execution is external.
Effect.task (TaskEffect)Create a task from a declared TaskTemplateRef.The ordinary action-effect path; it specializes ActionEffect.

All three variants retain their declared order, lower to planned runtime effects, and produce durable ActionEffectRecord entries. Workflow effects are not a separate authoring channel.

Checkpoint ​

Add a retire action that:

  1. requires ops.area.approve;
  2. is legal only from active;
  3. requires a reason and signature;
  4. emits ops.area.retired;
  5. produces an eventual refresh effect.

Validate that the transition names match actions on the facet.

Next: Modules and assembly.