Skip to content

Drafts and Versioning

This guide covers the draft workflow and version management system. Entities can go through a multi-step approval process before changes become effective, with full version history and audit trails.

Mixin Chain

Draft and versioning support is added through a mixin chain:

  • EntityBase provides identity (id, name)
  • Auditable adds createdAt, updatedAt, createdBy, updatedBy
  • Versionable adds versionNumber and isActive
  • Draftable adds draftMetadata for the approval workflow

Versionable is the prerequisite for Draftable. A typical regulated entity composes all four: extends EntityBase with Auditable, Versionable, Draftable.

Versionable Mixin

dart
mixin Versionable on EntityBase {
  /// Current version number (managed by the database)
  abstract final int versionNumber;

  /// Whether this entity is currently active
  abstract final bool isActive;
}

Entities with Versionable support:

  • Version numbering (auto-incremented on save)
  • Active/inactive status toggle
  • Version history browsing
  • Version comparison (diffing)
  • Version restoration

Draftable Mixin

dart
mixin Draftable on Versionable {
  /// Draft metadata from the server (never sent back in toJson)
  @JsonKey(includeToJson: false)
  DraftMetadata? draftMetadata;

  /// Is this a registration draft (new entity not yet approved)?
  bool get isEntityDraft => draftMetadata?.isEntityDraft ?? false;

  /// Does this approved entity have a pending modification draft?
  bool get hasModificationDraft =>
      draftMetadata?.hasModificationDraft ?? false;

  /// Should the UI show a draft indicator?
  bool get showDraftIndicator => draftMetadata != null;
}

DraftStatus

Every draft has a status that moves through a state machine:

All 10 Status Values

StatusDescriptionTerminal?
draftInitial state -- user is editingNo
submittedSubmitted by the user, awaiting workflow-engine intakeNo
underReviewSubmitted for approvalNo
revisionRequestedApprover requested changes; submitter can edit and resubmitNo
approvedAll approvals complete, awaiting effectivityNo
effectiveChanges applied to the main entity tableYes
rejectedRejected by an approverYes
cancelledCancelled by the userYes
deletedDraft deletedYes
failedApply to main table failedYes

Terminal states have no further transitions. The isTerminal property on DraftStatus checks this. The server's draft-list/count/position pipeline uses DraftStatus.visibleForListing: draft, underReview, revisionRequested, cancelled, and failed.

DraftMetadata

When an entity has a draft, the server includes DraftMetadata in the response:

dart
class DraftMetadata {
  final String draftId;           // Draft record ID (different from entity ID)
  final WorkflowOperation operation; // registration, modification, etc.
  final DraftStatus status;       // Current draft status
  final int? versionNumber;       // Upcoming version number
  final String? createdBy;        // Who created the draft
  final DateTime? createdAt;      // When the draft was created
  final String? workflowInstanceId; // Workflow ID when under review
  final bool? canEditDraft;       // Whether current user can edit
  final String? revisionComments; // Feedback from approver
}

Key helpers:

dart
final draft = entity.draftMetadata!;

draft.isDraft         // Status is 'draft' (editable)
draft.isUnderReview   // Status is 'under_review'
draft.isRejected      // Status is 'rejected'
draft.canEdit         // Server says this user can edit
draft.canCancel       // Can be cancelled (draft, under_review, revision_requested)
draft.hasRevisionRequest // Approver has requested changes

Draft metadata is not draft identity

An approved row can carry draftMetadata when it has a pending modification draft. Use showDraftIndicator, isEntityDraft, or hasModificationDraft for badges and affordances. Use entity.isDraftRecord / entity.isDraft for API routing: it is true only when the row id equals draftMetadata.draftId, which means the object came from the drafts partition.

WorkflowOperation

Each draft is associated with a workflow operation type:

OperationPurpose
registrationNew entity being created through the draft process
modificationExisting entity being modified
activationEntity being activated
deactivationEntity being deactivated
restorationEntity version being restored
unknownUnknown or future operation type
dart
enum WorkflowOperation {
  registration,
  modification,
  activation,
  deactivation,
  restoration,
  unknown;
}

The type is exported from vyuh_entity_crud_types and re-exported through vyuh_entity_system's draft models so client and server agree on the same wire vocabulary.

Enabling Drafts

On the API

Set hasDrafts: true on the entity API and use VersionedEndpointBuilder:

dart
final certificationApi = HttpEntityApi<Certification>(
  entityType: 'certifications',
  schemaType: 'lms.certification',
  pathBuilder: VersionedEndpointBuilder(prefix: 'certifications'),
  fromJson: Certification.fromJson,
  defaultSortField: 'name',
  hasDrafts: true, // Enable draft support
);

This enables:

  • getDrafts() -- fetches pending drafts visible to the current user
  • countDrafts() -- counts pending drafts (for the badge on the Drafts toggle)
  • The list view shows a "Drafts" toggle in the header

On the Editor

Set allowDrafts: true on the entity editor. For regulated entities that need e-signature verification, use SignatureDrivenEditor. The verification behavior is now decided at runtime by a LifecycleResolver, so the editor itself no longer takes verifyCreate/verifyUpdate/verifyDraft/remarks* parameters:

dart
editor: SignatureDrivenEditor<Certification>(
  transformer: certificationTransformer,
  parts: () => [
    FormEditorPart<Certification>(
      title: 'Details',
      icon: FluentIcons.edit_24_regular,
      identifier: 'details',
      getForm: _getCertificationForm,
    ),
  ],
  allowDrafts: true, // Show "Save as Draft" button
  lifecycleResolver: appLifecycleResolver<Certification>(),
)

For non-regulated entities that still want drafts, use StandardEntityEditor:

dart
editor: StandardEntityEditor<Certification>(
  transformer: certificationTransformer,
  parts: () => [
    FormEditorPart<Certification>(
      title: 'Details',
      icon: FluentIcons.edit_24_regular,
      identifier: 'details',
      getForm: _getCertificationForm,
    ),
  ],
  allowDrafts: true,
)

On the Entity Model

The entity class composes the mixins explicitly. The version columns and audit columns are materialized with @JsonKey(readValue: readFieldValue) so they accept both camelCase and snake_case payloads:

dart
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Certification extends EntityBase
    with Auditable, Versionable, Draftable {
  // ── Auditable ─────────────────────────────────────────
  @override
  @JsonKey(readValue: readFieldValue)
  final DateTime? createdAt;
  @override
  @JsonKey(readValue: readFieldValue)
  final DateTime? updatedAt;
  @override
  @JsonKey(readValue: readFieldValue)
  final String? createdBy;
  @override
  @JsonKey(readValue: readFieldValue)
  final String? updatedBy;

  // ── Versionable ───────────────────────────────────────
  @override
  @JsonKey(readValue: readFieldValue, defaultValue: 1, includeToJson: false)
  final int versionNumber;
  @override
  @JsonKey(readValue: readFieldValue, defaultValue: true, includeToJson: false)
  final bool isActive;

  // ── Domain ────────────────────────────────────────────
  final String certificationType;
  final DateTime? issuedAt;
  final DateTime? expiresAt;
  final String certificationStatus; // valid, expired, revoked

  Certification({
    required super.id,
    required super.name,
    this.versionNumber = 1,
    this.isActive = true,
    required this.certificationType,
    this.issuedAt,
    this.expiresAt,
    this.certificationStatus = 'valid',
    this.createdAt,
    this.updatedAt,
    this.createdBy,
    this.updatedBy,
  }) : super(
          schemaType: 'lms.certification',
          layout: null,
          modifiers: null,
        );

  factory Certification.fromJson(Map<String, dynamic> json) {
    final entity = _$CertificationFromJson(json);
    // Hydrate draft metadata from the same payload (server-side projection).
    if (json['draft_id'] != null) {
      entity.draftMetadata = DraftMetadata.fromJson(json);
    }
    return entity;
  }

  @override
  Map<String, dynamic> toJson() => _$CertificationToJson(this);
}

versionNumber and isActive MUST have includeToJson: false — they are managed by database triggers, never sent on writes.

Draft row identity

draftMetadata alone does not mean the current row should be fetched through the drafts API. Approved rows can carry metadata for a pending modification draft. Use entity.isDraftRecord / entity.isDraft for route and API identity; they are true only when the row id equals draftMetadata.draftId.

Annotation path equivalent

With @Entity(capabilities: {EntityCapability.draftable()}), the generator emits a $CertificationBase that mixes in Auditable, Versionable, Draftable and declares all the capability fields with the right @JsonKeys. The hand-written boilerplate above collapses into class Certification extends $CertificationBase. See Annotations.

Version History

For entities with Versionable, add an EntityVersionLayout tab to show version history:

dart
final certLayouts = EntityLayouts<Certification>(
  list: [certTableLayout],
  details: [
    certDetailsTab,
    EntityVersionLayout<Certification>(
      schemaType: 'certification.layout.versions',
      identifier: 'versions',
      title: 'Version History',
      icon: Icons.history,
    ),
    EntityAuditLayout<Certification>(
      schemaType: 'certification.layout.audit',
      identifier: 'audit',
      title: 'Audit Trail',
      icon: Icons.receipt_long,
    ),
  ],
);

EntityVersion Model

Each version record contains:

dart
class EntityVersion {
  final String id;
  final String entityType;
  final String entityId;
  final int versionNumber;
  final Map<String, dynamic> entityData; // Full snapshot of entity at this version
  final DateTime createdAt;
  final String? createdBy;
  final bool isActive;
  final String? changeSummary;
}

The version history layout shows a timeline of versions, with the ability to compare any two versions side-by-side.

VersionedEndpointBuilder Operations

dart
final builder = VersionedEndpointBuilder(prefix: 'certifications');

// Version operations
builder.versions('entity-123')                    // GET .../entity-123/versions
builder.byVersion('entity-123', 2)                // GET .../entity-123/versions/2
builder.compareVersions('entity-123', 1, 2)       // GET .../entity-123/compare/1/2
builder.restoreVersion('entity-123', 2)            // POST .../entity-123/restore/2

// Audit operations
builder.audit('entity-123')                        // GET .../entity-123/audit

// Activation/deactivation
builder.activate('entity-123')                     // POST .../entity-123/activate
builder.deactivate('entity-123')                   // POST .../entity-123/deactivate

// Draft operations
builder.getDraft('draft-456')                      // GET .../drafts/certifications/draft-456
builder.updateDraft('draft-456')                   // PUT .../drafts/certifications/draft-456
builder.submitDraft('draft-456')                   // POST .../drafts/certifications/draft-456/submit
builder.cancelDraft('draft-456')                   // POST .../drafts/certifications/draft-456/cancel
builder.resubmitDraft('draft-456')                 // POST .../drafts/certifications/draft-456/resubmit
builder.getPendingDraftForEntity('entity-123')     // GET .../drafts/certifications/entity-123/pending
builder.reactivateDraft('draft-456')               // POST .../drafts/certifications/draft-456/restart

Audit Trails

The audit trail records every action performed on an entity:

EntityAudit Model

dart
class EntityAudit {
  final String id;
  final String entityType;
  final String entityId;
  final String action;          // create, update, delete, activate, deactivate
  final String? performedBy;    // User ID
  final DateTime performedAt;
  final Map<String, dynamic>? beforeState;
  final Map<String, dynamic>? afterState;
  final List<FieldChangeRecord>? fieldChanges;
  final String? ipAddress;
  final String? userAgent;
  final String? reason;         // Remarks entered by the user
  final int? versionNumber;
}

FieldChangeRecord

Each audit entry can include granular field-level changes:

dart
class FieldChangeRecord {
  final String field;        // Field name (e.g., 'status')
  final dynamic oldValue;    // Previous value
  final dynamic newValue;    // New value
  final String changeType;   // 'modified', 'added', 'removed'
}

Field Formatters

The audit trail UI uses FieldFormatter implementations to display values in a human-readable format:

FormatterPurpose
TextFieldFormatterPlain text values
DateFieldFormatterDate values with locale formatting
TimeFieldFormatterTime-only values
BooleanFieldFormatterYes/No or Active/Inactive
NumberFieldFormatterNumeric values with units
CurrencyFieldFormatterCurrency values
PercentageFieldFormatterPercentage values
UserIdFieldFormatterResolves user IDs to display names
EntityFieldFormatterResolves entity IDs to display names
UuidFieldFormatterFormats UUID values
JsonFieldFormatterFormatted JSON display
ListFieldFormatterList/array values
StatusFieldFormatterStatus values with color coding

Register field metadata for your entity to get proper formatting in the audit trail:

dart
final certFieldMetadata = FieldMetadataBuilder()
  ..add('name', FieldMetadata(type: FieldType.text, displayName: 'Name'))
  ..add('certification_type', FieldMetadata(
    type: FieldType.text,
    displayName: 'Type',
  ))
  ..add('issued_at', FieldMetadata(
    type: FieldType.date,
    displayName: 'Issued Date',
  ))
  ..add('expires_at', FieldMetadata(
    type: FieldType.date,
    displayName: 'Expiry Date',
  ))
  ..add('is_active', FieldMetadata(
    type: FieldType.booleanActive,
    displayName: 'Status',
  ));

Signature Verification

For regulated environments, the entity system supports e-signature verification through SignatureVerificationProvider.

SignatureVerificationProvider

dart
abstract class SignatureVerificationProvider {
  /// Show a signature verification dialog
  Future<SignatureVerificationResult?> showSignatureVerificationDialog({
    required BuildContext context,
    SignatureEntity? entity,
    required SignatureAction action,
    bool requireSignature = true,
    bool showRemarksField = false,
    bool requireRemarks = true,
    SignatureRole? role,
  });

  /// Verify user credentials
  Future<VerifiedIdentity?> verifyCredentials({
    required String email,
    required String password,
  });
}

SignatureVerificationResult

dart
class SignatureVerificationResult {
  final bool verified;
  final String signatureId;  // Server-assigned ID of the signature record
  final String? remarks;
}

SignatureRole

Different signer roles have different responsibilities:

RolePurpose
doerThe person who performed the action (default)
witnessAn independent observer (triggers multi-step flow)
reviewerA qualified person who reviewed/approved the action

When SignatureRole.witness is configured on an EntityEditor, the save flow becomes multi-step: the witness signs first, then the doer signs.

Configuring Verification on Entities

Verification policy lives in the LifecycleResolver (and the lifecycle guards / policies it returns), not on the editor. The editor only needs to know which resolver to ask:

dart
editor: SignatureDrivenEditor<Certification>(
  transformer: certTransformer,
  parts: () => [
    FormEditorPart<Certification>(
      title: 'Details',
      icon: FluentIcons.edit_24_regular,
      identifier: 'details',
      getForm: _getCertForm,
    ),
  ],
  lifecycleResolver: appLifecycleResolver<Certification>(),
)

The resolver decides at runtime whether each transition (create, update, status change, draft submit) requires a signature, remarks, witness, etc., based on the current entity state and operator role.

LMS Example: Certification with Draft Approval

dart
// Entity model — see "On the Entity Model" above for the full declaration.
// (Auditable + Versionable + Draftable composed on EntityBase.)

// Configuration
final certConfig = EntityConfiguration<Certification>(
  metadata: EntityMetadata(
    identifier: 'certifications',
    name: 'Certification',
    pluralName: 'Certifications',
    icon: Icons.verified,
  ),
  api: HttpEntityApi<Certification>(
    entityType: 'certifications',
    schemaType: 'lms.certification',
    pathBuilder: VersionedEndpointBuilder(prefix: 'certifications'),
    fromJson: Certification.fromJson,
    defaultSortField: 'name',
    hasDrafts: true,
  ),
  routing: EntityRouting(
    path: NavigationPathBuilder.collection(prefix: '/lms/certifications'),
    builder: StandardRouteBuilder<Certification>(),
    permissions: EntityRoutePermissions(
      list: ['certifications.view'],
      create: ['certifications.create'],
      edit: ['certifications.edit'],
    ),
  ),
  layouts: EntityLayouts(
    list: [certTableLayout],
    details: [
      certDetailsTab,
      EntityVersionLayout<Certification>(
        schemaType: 'cert.layout.versions',
        identifier: 'versions',
        title: 'Versions',
        icon: Icons.history,
      ),
      EntityAuditLayout<Certification>(
        schemaType: 'cert.layout.audit',
        identifier: 'audit',
        title: 'Audit',
        icon: Icons.receipt_long,
      ),
    ],
  ),
  actions: EntityActions<Certification>(
    inline: StandardEntityActions.inline<Certification>(),
  ),
  editor: SignatureDrivenEditor<Certification>(
    transformer: DefaultEntityTransformer<Certification>(
      fromJson: Certification.fromJson,
    ),
    parts: () => [
      FormEditorPart<Certification>(
        title: 'Details',
        icon: FluentIcons.edit_24_regular,
        identifier: 'details',
        getForm: _getCertForm,
      ),
    ],
    allowDrafts: true,
    lifecycleResolver: appLifecycleResolver<Certification>(),
  ),
);

This configuration provides:

  • A list with a "Drafts" toggle showing pending approvals
  • A detail page with Details, Versions, and Audit tabs
  • A form editor with "Save as Draft" option
  • E-signature verification on create and update
  • Required remarks for audit trail
  • Full version history and audit trail

Next Steps

  • Forms and Editors -- editor configuration, including how SignatureDrivenEditor and LifecycleResolver handle verification
  • Permissions -- controlling who can approve drafts
  • Data Flow -- how draft data flows through caching and UI