Skip to content

6.8. Disposal Contract

VMx disposal is safe to call from independent teardown paths. If a public VMx-owned type exposes Dispose() / dispose(), the first call claims its terminal work and every later or re-entrant call is a no-op. Completion, terminal notifications, cancellation, and owned-resource cleanup happen at most once.

This rule removes the need for host-side “already disposed” flags. It does not make all later API calls behave alike: each type keeps its documented post-dispose behavior.

6.8.1. Modeled Assignment After Disposal

Modeled assignment has one portable terminal rule across all five flavors. If an assignment begins after the VM is disposed, it returns before candidate equality, retained-model or snapshot mutation, modeled-hint work, validation, command-state recomputation, consumer callbacks, local notifications, or hub messages. The last accepted model and every derived state value remain readable and unchanged.

This admission guard covers modeled components and FormVM. Swift's internal read-only modeled-component update and forwarding wrappers delegate to the same guarded path. Modeled composites do not expose a settable retained model—their model input configures a child factory—so they need no separate guard.

An assignment admitted before disposal keeps its ordinary completion and notification contract. Continue cancelling network requests, renderer work, tasks, and other application operations when their resources are no longer needed; the VM guard prevents late state admission but does not replace resource cancellation.

6.8.2. The Seven Disposal Families

Family Representative surfaces Cross-cutting ID Existing detailed coverage
VM and owner cascades Component, composite, group, aggregate, hierarchy, forwarding DISP-001 LIFE-004, LIFE-012, LIFE-013
Commands Relay, async relay, composite/decorator, modeled CRUD DISP-002 CMD-012, CMD-013, CMDD-010
Hubs and services Message hub, notification hub DISP-003 HUB-012, NOTIF-017
Interaction owners Form, modal, notification rendering VMs DISP-004 DIA-011, DIA-012, FORM-014, NOTIF-017
Reactive helpers Derived property, expansion/search state, discriminator DISP-005 DPROP-011, COMP-035, CVM-009
Collection/projection helpers Batch handles, paging, filtered projections, disposable collections DISP-006 COMP-013, GRP-006, COL-024..031, COMP-035
Async resource owners AsyncResourceVM loader, commands, and accepted value ARES-010..011 ARES-005, ARES-008, ARES-009

Only types already documented as thread-safe promise racing disposal. Those types atomically claim terminal work. Single-threaded flavors and helpers still guarantee repeated and re-entrant disposal on their supported execution model.

6.8.3. Ownership Rules

  • Every VM exposes its injected message hub as a read-only baseline member. The hub is shared infrastructure: VM disposal never disposes it.
  • Subclass-owned, disposal-lifetime resources can be registered through Own (C#), _own (Python), or own (TypeScript, Swift, Rust). Cleanup is exactly once in LIFO order after the subclass disposal hook. One failing cleanup is swallowed without blocking the rest.
  • Registration after disposal cleans the resource immediately once.
  • Reconstruct and destruct do not release disposal-lifetime registrations. Per-construct resources remain explicit in OnConstruct/OnDestruct.
  • Parent VMs dispose owned children depth-first. A child reached through several teardown paths has one observable terminal transition.
  • Terminal cascades do not stop at a failing child or subclass hook. Every sibling and the parent's base teardown still run; throwing/result-based flavors propagate the first failure only after mandatory cleanup completes.
  • Disposal deferred by an ownership transfer runs after commit or rollback. A committed transfer publishes both membership events before propagating a disposal failure; an earlier attachment failure stays first if rollback also triggers failing disposal.
  • Disposal requested from a current-changed callback is likewise deferred until selection publication finishes, without blocking the callback on its own membership transaction.
  • Disposal racing an already-admitted construct/destruct hook waits until the hook settles before terminal resource and stream teardown. Opposing hooks that cross-dispose use cycle-aware deferral, so neither thread deadlocks and no post-hook child work begins after the parent becomes terminal.
  • A forwarding VM delegates disposal to the wrapped VM; it does not introduce reference counting.
  • Command decorators and composites do not dispose caller-owned inner commands unless their type-specific contract explicitly says they own them.
  • ServicedObservableCollection and KeyedServicedObservableCollection never own or dispose their items and do not implement VM child-collection lifecycle interfaces.
  • Subscription and batch handles release only the registration or batch scope they represent.
  • AsyncResourceVM cancels active acquisition and releases its last accepted value on disposal. A loader that completes late cannot publish or become current; its acquired value is cleaned exactly once.

6.8.4. C# Inventory

Public disposable surface Second dispose Completion / terminal signal Owned teardown Permitted post-dispose behavior
IComponentVM implementations: component, readonly, composite, group, aggregate 1–6, hierarchy No-op One Disposed transition; VM-local streams complete Subclass hook, LIFO owned registrations, commands, and children per family ordering Status reads remain; lifecycle calls other than dispose raise; selection/property changes are inert
ForwardingComponentVM, ForwardingCompositeVM Delegated no-op Wrapped VM completes once Delegates to wrapped VM Wrapped VM contract
MessageHub No-op under its gate Messages completes once Pending transaction queue and subject Sends are ignored after disposal
RelayCommand, RelayCommand<T> No-op Optional final CanExecuteChanged; then terminal disabled state Trigger subscriptions CanExecute == false; execute is inert
AsyncRelayCommand No-op under its gate Error channel completes once Cancels one active token and trigger subscriptions Disabled; later execute is inert
CompositeCommand, DecoratorCommand, ConfirmationDecoratorCommand, ModeledCrudCommands No-op Owned error channel completes where present Event subscriptions and owned relay commands only Inert according to command type; caller-owned inner commands remain caller-owned
FormVM<T> No-op Approval/error/validation channels complete once Approve/deny commands Approve and deny are inert; readable model/snapshot state remains
NotificationHub No-op under its lock Pending completes once; waiters resolve Pending Pending queue and waiters Post resolves Pending; resolve is inert
NotificationVM, ConfirmationVM No-op Property-change surface stops Timers, pending subscription, commands Last render state remains readable; later resolution paths are inert
ModalVM<T> No-op through idempotent dismiss Completion resolves once Waiter completion First result remains readable
DerivedProperty<T> No-op ValueChanged completes once Source subscriptions Last value remains readable; writes follow the type-specific validator contract
ExpandableState No-op Expansion stream completes once Expansion subscribers Last expansion value remains readable; mutations are inert
SearchableState<T> No-op Search streams complete once Search/source subscriptions Search term reads empty; setters/search are inert
FilteredCompositeVM, ScoredFilteredCompositeVM No-op Projection stops changing Source collection subscription Frozen visible projection remains readable
PagedComposition<T> No-op No completion surface Source collection subscription Page reads remain available without source tracking
TokenPagedComposition<T,TToken> No-op under its gate Command stream completes once Load/refresh commands Late fetch results cannot mutate or publish
Public batch-return handles from list/composite/group operations No-op At most one reset at outer batch exit One batch depth claim Handle has no other behavior
DiscriminatorVM<TKey> No-op Active-change stream completes once Stream subject Reads remain; later mutations are inert

6.8.5. Python Inventory

Public disposable surface Second dispose Completion / terminal signal Owned teardown Permitted post-dispose behavior
Component/composite/group/aggregate/hierarchy VM families No-op under the lifecycle lock One DISPOSED transition; local streams complete Subclass hook, LIFO owned registrations, commands, children Status reads remain; illegal lifecycle calls raise; changes are inert
Forwarding component/composite VMs Delegated no-op Wrapped VM completes once Delegates to wrapped VM Wrapped VM contract
MessageHub No-op under its lock messages completes once Pending transaction queue and subject Sends are ignored
Relay, async relay, composite/decorator, confirmation-decorator, modeled-CRUD commands No-op Owned subjects complete once Trigger subscriptions; one in-flight async task; owned relays Disabled/inert after disposal; caller-owned inner commands remain caller-owned
FormVM No-op Approval/error/validation streams complete once Approve/deny commands Approve and deny are inert; model/snapshot remain readable
NotificationHub No-op under its lock Pending completes once; futures resolve PENDING Pending queue and futures Post resolves PENDING; resolve is inert
NotificationVM, ConfirmationVM No-op Property-change stream completes once Timers, subscriptions, commands Last render state remains readable
ModalVM No-op through idempotent dismiss Waiters resolve once Awaiting futures First result remains readable
DerivedProperty No-op value_changed completes once Source subscriptions Last value remains readable; no recompute emission
ExpandableState No-op Expansion stream completes once Expansion subscribers Last expansion value remains readable; mutations are inert
SearchableState No-op Search streams complete once Search/source subscriptions Search term reads empty; setters/search are inert
FilteredCompositeVM, ScoredFilteredCompositeVM No-op Projection stream completes once Source subscription Frozen projection remains readable
PagedComposition, TokenPagedComposition No-op Paging streams complete where exposed Source subscriptions and paging commands Reads remain; late token fetches cannot mutate
ObservableList, ObservableDictionary No-op Every VMx-owned collection subject completes once Collection subjects Stored contents remain readable; mutations emit nothing
BatchUpdateHandle No-op At most one reset at outer batch exit One batch depth claim Handle has no other behavior
DiscriminatorVM No-op Active-change stream completes once Stream subject Reads remain; later mutations are inert

6.8.6. TypeScript Inventory

Public disposable surface Second dispose Completion / terminal signal Owned teardown Permitted post-dispose behavior
Component/composite/group/aggregate/hierarchy VM families No-op One Disposed transition; local streams complete Subclass hook, LIFO owned registrations, commands, children Status reads remain; illegal lifecycle calls throw; changes are inert
Forwarding component/composite VMs Delegated no-op Wrapped VM completes once Delegates to wrapped VM Wrapped VM contract
MessageHub No-op messages completes once Pending transaction queue and subject Sends are ignored
Relay, async relay, composite/decorator, confirmation-decorator, modeled-CRUD commands No-op Owned subjects complete once Trigger subscriptions; one AbortController; owned relays Disabled/inert; caller-owned inner commands remain caller-owned
FormVM No-op Approval/error/validation streams complete once Approve/deny commands Approve and deny are inert; state remains readable
NotificationHub No-op Pending completes once; promises resolve Pending Pending queue and resolvers Post resolves Pending; resolve is inert
NotificationVM, ConfirmationVM No-op Property-change stream completes once Scheduler handles, subscriptions, commands Last render state remains readable
ModalVM No-op through idempotent dismiss Promise resolves once Completion resolver First result remains readable
DerivedProperty No-op valueChanged completes once Source subscription Last value remains readable; no recompute emission
ExpandableState No-op Expansion stream completes once Expansion subscribers Last expansion value remains readable; mutations are inert
SearchableState No-op Search streams complete once Search/source subscriptions Search term reads empty; setters/search are inert
FilteredCompositeVM, ScoredFilteredCompositeVM No-op Projection stream completes once Source subscription Frozen projection remains readable
PagedComposition, TokenPagedComposition No-op Paging streams complete where exposed Source subscriptions and paging commands Reads remain; late token fetches cannot mutate
BatchUpdateHandle / [Symbol.dispose] No-op At most one reset at outer batch exit One batch depth claim Handle has no other behavior
DiscriminatorVM No-op Active-change stream completes once Stream subject Reads remain; later mutations are inert

6.8.7. Swift Inventory

Public disposable surface Second dispose Completion / terminal signal Owned teardown Permitted post-dispose behavior
Component/composite/group/aggregate/hierarchy VM families No-op under the lifecycle lock One .disposed transition; local publishers finish Subclass hook, LIFO owned registrations, commands, children Status reads remain; illegal lifecycle calls throw; changes are inert
Forwarding component/composite VMs Delegated no-op Wrapped VM finishes once Delegates to wrapped VM Wrapped VM contract
MessageHub No-op under its lock messages finishes once Pending transaction queue Sends are ignored
Relay, async relay, composite/decorator, confirmation-decorator, modeled-CRUD commands No-op Owned publishers finish once Cancellables; one active task; owned relays Disabled/inert; caller-owned inner commands remain caller-owned
FormVM No-op Approval/error/validation publishers finish once Approve/deny commands Approve and deny are inert; state remains readable
NotificationHub No-op under its lock Pending finishes once; continuations resume .pending Pending queue and continuations Post returns .pending; resolve is inert
NotificationVM, ConfirmationVM No-op Property-change publisher finishes once Timer, tick, pending subscription, commands Last render state remains readable
BasicModalVM No-op through idempotent dismiss Continuations resume once Awaiting continuations First result remains readable
DerivedProperty No-op valueChanged finishes once Source cancellables Last value remains readable; no recompute emission
ExpandableState No-op Expansion publisher finishes once Expansion subscribers Last expansion value remains readable; mutations are inert
SearchableState No-op Search publishers finish once Search/source cancellables Search term reads empty; setters/search are inert
FilteredCompositeVM, ScoredFilteredCompositeVM No-op Projection publisher finishes once Source cancellables Frozen projection remains readable
PagedComposition No-op Property-change publisher finishes once Source-composite cancellable Reads and direct paging remain available without source tracking
TokenPagedComposition No-op on its state queue Collection/property/command publishers finish once Paging commands Late fetch results cannot mutate
BatchUpdateHandle No-op At most one reset at outer batch exit One batch depth claim Handle has no other behavior; deinit is a safety net
DiscriminatorVM No-op Active-change publisher finishes once Publisher subject Reads remain; later mutations are inert

ObservableList and ObservableDictionary do not expose a VMx disposal member in Swift; they are therefore outside this invariant's public-disposable inventory.

6.8.8. Rust Inventory

Public disposable surface Second dispose Completion / terminal signal Owned teardown Permitted post-dispose behavior
VmNode::dispose: component, composite, group, modeled composite, hierarchy, aggregate, forwarding families Ok(()) with no new work One Disposed message LIFO owned registrations, children, and property-change stream Status reads remain; illegal lifecycle calls return VmxError::Disposed; changes are inert
MessageHub No-op under its state mutex Subscribers are removed; queued messages are dropped Subscribers and pending transaction queue Sends are ignored
Subscription, PropertyChangedSubscription No-op No completion surface One subscriber registration Handle has no other behavior; Drop is a safety net
RelayCommand, RelayCommandOf No-op Terminal disabled state Command state can_execute == false; execute is inert
AsyncRelayCommand No-op through atomic disposed state One active token is cancelled Active cancellation token Disabled; later execute is inert
ConfirmationDecoratorCommand No-op through atomic disposed state Error hub is disposed once Error subscribers Confirmation and execution are inert
ValueStream, ValueSubscription No-op Stream subscribers complete once; subscriptions detach Subscribers Last value remains readable; later sends are inert
AggregateChangeStream, AggregateChangeSubscription No-op Observers complete once; subscriptions detach Membership and item subscriptions Later membership/item changes are ignored
FilteredCompositeVm No-op Frozen projection captured once Projection tracking state Frozen projection remains readable
DerivedProperty No-op Value-change hub is disposed once Value-change subscribers Last value remains readable; recompute is inert
ExpandableState No-op Expansion hub is disposed once Expansion subscribers Last expansion value remains readable; mutations are inert
SearchableState No-op Search hubs are disposed once Search/source subscriptions Search term reads empty; setters/search are inert
DiscriminatorVm No-op Active-change hub is disposed once Active-change subscribers Reads remain; later mutations are inert
TokenPagedComposition No-op through shared disposed gate Owned commands enter terminal disabled state Load-more and refresh commands Accumulated state remains readable; late work cannot commit pager state
NotificationHub No-op after an atomic claim One terminal pending snapshot; waiters read Pending Pending notifications Post returns a pending result; resolve cannot re-enqueue
FormVm No-op Terminal inert state Form command gate Approve/deny are inert; state remains readable
ModalVm No-op through idempotent dismiss Result is set once Result state First result remains readable

Rust types without a public disposal member—such as PagedComposition and rendering NotificationVm—are not silently treated as disposable. Adding such a surface is a separate API decision and would require its own inventory row and conformance coverage.

6.8.9. Practical Teardown Pattern

Register every real teardown path and call disposal unconditionally:

page unload ─┐
route switch ├─> root.dispose()
test cleanup ┘

Do not add a second consumer-side disposed flag merely to serialize those paths. Keep ownership explicit: dispose the root and any independently owned services, but do not dispose caller-owned objects through non-owning wrappers.

The owned-resource contract is covered by DISP-007..013 and ADR-0090. Inert modeled assignment is covered by DISP-014 and ADR-0091.