Skip to content

Lifecycle — draft, active, archived

Every business object carries a status — one of exactly three: draft, active, archived. Status is system metadata: it’s not a field you write, and it moves only through the named operations below.

  • draft — tentative. Nothing else references it, and it emits no side effects (no events, no number assigned yet).
  • active — operational, in use.
  • archived — retired, but kept for the record.

The status lives behind two sibling interfaces, and which one you hold decides what you can see.

  • BusinessObjectManager — the operational world. It reads and writes active and archived records and never returns a draft.
  • DraftWorkspace — the draft world, where a record is authored before it is real. A deliberate subset: create / get / update / activate / discard; no list or count (drafts don’t browse), no run (no actions on drafts), no archive / restore (operational moves).

The type vocabulary follows the same boundary. BusinessObjectDef is the schema definition; BusinessObject is an instance of that definition. Its boolean type parameter controls only required-field nullability:

type BusinessObject<S extends Fieldset, RequiredFieldsFilled extends boolean = true> =
InferFields<S["fields"], RequiredFieldsFilled> & {
readonly id: Identifier;
readonly status: LifecycleState;
};

The operational manager returns BusinessObject<S>, with required fields filled. DraftWorkspace.create, get and update return BusinessObject<S, false>; activate returns BusinessObject<S>. The boolean changes only required-field nullability; determination and validator consumers receive incomplete objects as readonly views, and the engine includes the target lifecycle status when it evaluates a rule.

Both are ordinary injectable providers, and business logic reaches both. What is operational-only is what a rule may read and what an operation runs on: a determination reads through BusinessObjectManager inside a read-only unit of work, so it cannot resolve a half-finished record or stage a write, and an action’s subject is loaded through BusinessObjectManager.get. An action’s run, though, may inject DraftWorkspace and stage one, the way to spawn a follow-up document a person still finishes. See Follow-up documents.

The two worlds meet at DraftWorkspace.activate, the one call that moves a record between them. One thing reaches across without moving anything: a treeParent. A draft may name an active parent or a co-draft, while an operational write may only parent onto an operational record. That asymmetry is why parent existence is guarded per world rather than by the ordinary reference-target check, which is world-blind and rejects a draft target outright.

A draft may be incomplete — activation is the enforcement boundary

Section titled “A draft may be incomplete — activation is the enforcement boundary”

Determinations and the reference-target check run on every write, drafts included — the figures you see stay honest and a write naming a draft or missing record is always rejected. What a draft relaxes is the active-entity bar: required fields and validators are not enforced while a record is a draft, so you can save a half-filled order, or one that fails a validation rule, and come back to it.

That bar is enforced only at the moment the record becomes active — on BusinessObjectManager.create / update (records born active) and on DraftWorkspace.activate, which throws core.validation_failed for a draft still missing a required field or tripping a validator.

There is nothing to declare — every BO reaches draft and active. Whether a BO’s user interface offers drafts is a frontend decision, made once per resource with drafts: true on defineResource, and it controls two things: the create form authors in the draft world (saving freely, then offering Release and Discard) instead of creating a record born active, and the resumable /<ref>/draft/:id route exists. Without that route a draft has no address: the first save could not re-key the URL, and a reload would orphan the row. Everything else — the status badge, Archive / Restore / Delete — derives from the record’s own status.

Drafts are their own world, so they are never listed, whether or not the resource’s form authors them; a draft is reached only by id.

defineBusinessObject("inventory/GoodsMovement", { … }, {
archivable: false, // no `archived` state — once active, permanent
});

archivable defaults to true, giving the master-data lifecycle draft → active ↔ archived with zero configuration. Setting it to false removes the archived state, and since an active record is never deletable (see below), a non-archivable record is permanent once activated — deliberate for immutable audit-trail data and for posted documents. Its drafts remain freely discardable.

Whether a record is editable is not part of the lifecycle — it’s a standalone isLocked?(record): boolean predicate that makes the record’s input fields read-only when it returns true. It is ordinary TypeScript over the record, and a domain field works exactly like status:

// Seal a posted document the moment it leaves draft
isLocked: (invoice) => invoice.status === "active";
// Seal once a domain field reaches a terminal state, independent of lifecycle status
isLocked: (order) => order.execution !== "open";

It is a pure predicate: no services, no database, no other records — cheap enough to evaluate on every serialized row, and honest, since a rule that could ask the database would be a rule nobody could read off the schema. Read only status and access: "system" fields: the verdict is computed at serialization, so keying it on an input field would make the form stop locking as the user edits. There is no sealed state and no whole-BO locked: "always" flag — both dissolve into isLocked — and a record is read-only whenever it is archived anyway, which is the framework’s own rule, never one a business object repeats.

The predicate runs only on the server. It is a function, so it cannot cross the wire: the server evaluates it and ships the answer as the record’s locked flag, carried by every read beside status. locked is one of exactly three reasons a form goes read-only — the other two are the caller lacking create/update on the resource, and the business object being access: "readonly".

What bypasses the lock is the delta, not the caller. ActionService.run calls bom.update like anyone else, and the guard fires on any delta touching an input field, throwing core.record_locked (422). A delta of access: "system" fields only passes whatever the lock says, which is how CancelOrder moves a sealed order’s execution forward while its authored content stays frozen. A system struct or hasMany is system all the way down: its members are stamped system when the business object is defined, whatever they declare, so a shared fieldset such as a postal address reads as input in the sold-to address and as system in the seller address. Archive and restore write the status column directly and activate applies an empty delta, so the lock never applies to a lifecycle move.

The draft world is exempt. DraftWorkspace.update runs no lock guard at all — a draft is never locked, which is what makes the status === "active" example mean what it reads as: free while authoring, frozen the moment it is released.

Status is written on create, and after that only by the operations below, each in its own world. There is no transition graph to declare and no generic transition(to) verb.

stateDiagram-v2
    [*] --> draft: draft.create
    [*] --> active: manager.create
    draft --> active: draft.activate
    draft --> [*]: draft.discard
    active --> archived: manager.archive
    archived --> active: manager.restore
    archived --> [*]: manager.delete

archivable: false cuts the archived state out of that picture and the two edges through it: no exit from active, and no delete behind it, so an active record is permanent. Which is why a domain that needs a reversible “switch this off” reaches for archive rather than inventing a status field of its own — inventory/Reservation is archivable precisely so that releasing a hold and taking it back are the pair of verbs the framework already has.

What each edge emits is in the table below; what those events carry is in Events.

operation world from to event
manager.create(bo, data) operational — active created
manager.archive(bo, id) operational active archived archived
manager.restore(bo, id) operational archived active restored
manager.delete(bo, id) operational archived (gone) deleted
draft.create(bo, data) draft — draft (silent)
draft.activate(bo, id) draft → operational draft active created
draft.discard(bo, id) draft draft (gone) (silent)

A draft is born only in the DraftWorkspace — there is no { draft: true } flag on manager.create, which always mints an active record — and archived is likewise not a birth state. Each operational verb refuses loudly when it doesn’t apply: core.not_archivable (which also covers archivable: false), core.not_restorable and core.not_deletable, all 422. Activation carries no dedicated refusal — it lives in the draft world, where an already-active id simply doesn’t resolve, so it is a plain core.not_found (404).

Over the wire the operational verbs sit under /api/business-object and the draft verbs under /api/drafts:

POST /api/business-object/master/Product/{id}/archive # bodiless → 200
POST /api/business-object/master/Product/{id}/restore # bodiless → 200
DELETE /api/business-object/master/Product/{id} # → 200
POST /api/drafts/sales/SalesOrder # the fields → 200
POST /api/drafts/sales/SalesOrder/{id}/activate # bodiless → 200
DELETE /api/drafts/sales/SalesOrder/{id} # discard → 204

Staging a draft takes the same body as an operational create — the record’s fields, bare — because birth-as-draft is a routing choice, not a body flag; delete answers with the pre-deletion snapshot, discard with nothing.

The AI agent reaches all of these as approval-gated tools, each gating on the same permission as its HTTP route: archive_business_object, restore_business_object, draft_create_business_object, and activate_business_object — that last without the draft_ prefix, because it is the crossing rather than an operation within either world.

A validator is a total invariant: it runs once the determinations have converged, against the final state, on every save (the order is in One save, in order). What changes with the lifecycle is only whether its issues block — held to the active-entity bar above, so a draft saves with them and an activation is refused by them. There is no way to run a validator once, at one crossing, and there should not be: a rule that holds “at this moment” is a precondition of whatever causes the moment, and that is an action.

The rule says It is a Lives in
this field follows from those determination the business object’s behavior
this record is never saved like that validator the business object’s behavior
this action applies to this record, read off the record alone precondition the action’s isAvailable — pure, no I/O, drives the menu
this action applies, but only a read or a computed result can tell precondition a throw in the action’s run, which rolls the unit back
this number equals what other records add rollup its own class on the holder; it keeps the number, never guards it

The credits on a sales invoice show all five at once: line totals are determinations, the currency lock is a validator, whether Cancel and Correct are offered is read off amountCredited in each action’s isAvailable, the over-credit bound is a throw in correct’s run after the correction’s totals exist, and amountCredited itself is the rollup the two credits feed.

archive and restore write the status column directly and never call the shared mutate/apply path, so no validator runs on them. React to the emitted archived / restored event instead (see Events).

A draft emits nothing. Not on create, not on update, and not on discard — an audit timeline that opened with deleted would describe a record that never existed operationally. activate is the moment a record first announces itself, and it emits created, so deleted only ever fires for an archived record.

Delete is a hard, RESTRICT-guarded physical delete — never a soft delete, no deleted_at. isLocked has no say: it governs editability only. manager.delete runs three guards, in order, each with its own refusal:

guard rule refusal
lifecycle the record must be archived core.not_deletable (422)
tree children nothing operational may name it as its treeParent core.tree_children (422)
references no other business object may reference it core.cross_bo_reference (422)

An active record is therefore never hard-deletable, locked or not, referenced or not: retire it first — archive it, or move a document to its own domain terminal state if it doesn’t archive (e.g. SalesOrder.cancel). An archived record is not editable either; restore it to edit. The tree guard counts active and archived children — an archived child is still a child — but a draft child does not block, since its dangling parent reference will simply fail its own activation later.

DraftWorkspace.discard runs none of the three. It removes the row directly, and needs no reference guard: the reference-target check rejects any write naming a draft, so nothing can be referencing one. Discard is the draft world’s own exit, not the operational delete under another label.

Referential integrity is described in References between objects.

Browsing has a status policy; an identity lookup does not. Every read names the business object by its schema value — ProductSchema, never the Product record type declared beside it — or by its name string.

bom.get(ProductSchema, id); // one operational row — active or archived, never a draft
bom.list(ProductSchema); // active only (the default); same for count()
bom.list(ProductSchema, { includeArchived: true }); // active + archived
draftWorkspace.get(ProductSchema, id); // a draft, by id — the only way to read one

Status is a world boundary, not a filter. You cannot name it in ?filter=: the wire schema rejects a status key, because an operational browse never spans the draft world.

Release promotes a draft (the activate verb, in the ERP term of art), sitting beside Save at the top of the action bar — the commit step of authoring, not a record-level action — enabled once the active bar holds client-side. Until then, unmet required fields and validator issues render as muted amber, non-blocking markers, and become blocking errors at Release. Discard removes a draft, never gated on unsaved changes; Delete is its operational counterpart on an archived record, and on a referenced record it surfaces the reference-block dialog naming the blockers. Archive, Restore and the list’s Show archived toggle render automatically from the record’s status and the BO’s archivable flag, so a non-archivable BO gets no toggle and no list anywhere offers a draft option. Every move lands on the record’s activity timeline, activate as a created entry with the born-state snapshot.