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:
EntityBaseprovides identity (id,name)AuditableaddscreatedAt,updatedAt,createdBy,updatedByVersionableaddsversionNumberandisActiveDraftableaddsdraftMetadatafor the approval workflow
Versionable is the prerequisite for Draftable. A typical regulated entity composes all four: extends EntityBase with Auditable, Versionable, Draftable.
Versionable Mixin
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
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
| Status | Description | Terminal? |
|---|---|---|
draft | Initial state -- user is editing | No |
submitted | Submitted by the user, awaiting workflow-engine intake | No |
underReview | Submitted for approval | No |
revisionRequested | Approver requested changes; submitter can edit and resubmit | No |
approved | All approvals complete, awaiting effectivity | No |
effective | Changes applied to the main entity table | Yes |
rejected | Rejected by an approver | Yes |
cancelled | Cancelled by the user | Yes |
deleted | Draft deleted | Yes |
failed | Apply to main table failed | Yes |
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:
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:
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 changesDraft 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:
| Operation | Purpose |
|---|---|
registration | New entity being created through the draft process |
modification | Existing entity being modified |
activation | Entity being activated |
deactivation | Entity being deactivated |
restoration | Entity version being restored |
unknown | Unknown or future operation type |
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:
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 usercountDrafts()-- 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:
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:
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:
@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:
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:
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
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/restartAudit Trails
The audit trail records every action performed on an entity:
EntityAudit Model
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:
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:
| Formatter | Purpose |
|---|---|
TextFieldFormatter | Plain text values |
DateFieldFormatter | Date values with locale formatting |
TimeFieldFormatter | Time-only values |
BooleanFieldFormatter | Yes/No or Active/Inactive |
NumberFieldFormatter | Numeric values with units |
CurrencyFieldFormatter | Currency values |
PercentageFieldFormatter | Percentage values |
UserIdFieldFormatter | Resolves user IDs to display names |
EntityFieldFormatter | Resolves entity IDs to display names |
UuidFieldFormatter | Formats UUID values |
JsonFieldFormatter | Formatted JSON display |
ListFieldFormatter | List/array values |
StatusFieldFormatter | Status values with color coding |
Register field metadata for your entity to get proper formatting in the audit trail:
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
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
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:
| Role | Purpose |
|---|---|
doer | The person who performed the action (default) |
witness | An independent observer (triggers multi-step flow) |
reviewer | A 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:
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
// 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
SignatureDrivenEditorandLifecycleResolverhandle verification - Permissions -- controlling who can approve drafts
- Data Flow -- how draft data flows through caching and UI