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.
Two worlds: operational and draft
Section titled “Two worlds: operational and draft”The status lives behind two sibling interfaces, and which one you hold decides what you can see.
BusinessObjectManager— the operational world. It reads and writesactiveandarchivedrecords 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; nolistorcount(drafts don’t browse), norun(no actions on drafts), noarchive/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.
Every business object has drafts
Section titled “Every business object has drafts”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.
One flag: archivable
Section titled “One flag: archivable”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.
Locking is a separate concern
Section titled “Locking is a separate concern”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 draftisLocked: (invoice) => invoice.status === "active";
// Seal once a domain field reaches a terminal state, independent of lifecycle statusisLocked: (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.
The operations that move status
Section titled “The operations that move status”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 → 200POST /api/business-object/master/Product/{id}/restore # bodiless → 200DELETE /api/business-object/master/Product/{id} # → 200POST /api/drafts/sales/SalesOrder # the fields → 200POST /api/drafts/sales/SalesOrder/{id}/activate # bodiless → 200DELETE /api/drafts/sales/SalesOrder/{id} # discard → 204Staging 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.
Where a rule lives
Section titled “Where a rule lives”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).
Drafts are silent, from birth to death
Section titled “Drafts are silent, from birth to death”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.
Deletion
Section titled “Deletion”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.
Reads: browse vs. lookup
Section titled “Reads: browse vs. lookup”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 draftbom.list(ProductSchema); // active only (the default); same for count()bom.list(ProductSchema, { includeArchived: true }); // active + archiveddraftWorkspace.get(ProductSchema, id); // a draft, by id — the only way to read oneStatus 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.
What this drives in the UI
Section titled “What this drives in the UI”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.