Example: Shopping cart

This example models a shopping cart that supports adding items, applying discounts, checking out, and cancelling. It demonstrates variant types for cart status, multiple transitions, and scenarios that capture both happy and rejection paths.


The system

  • Items can be added to the cart (each with a quantity)
  • A percentage discount can be applied once
  • The cart can be checked out if the total is positive
  • The cart can be cancelled at any time before checkout
  • Once checked out or cancelled, no further modifications are allowed

Domain base

type cart_status = Open | CheckedOut | Cancelled
 
type op = AddItem | ApplyDiscount | Checkout | Cancel
 
type state = {
  total : int;
  item_count : int;
  discount_applied : int;
  status : cart_status
}
 
type action = {
  op : op;
  amount : int
}

Design notes:

  • cart_status is a variant — three distinct lifecycle states with no meaningless values.
  • discount_applied is an int used as a boolean flag (0 or 1). IML doesn't have a native boolean type, so this is the idiomatic pattern. Using an int here is appropriate because it tracks a count, not a category.
  • amount serves double duty: it's the item quantity for AddItem and the discount percentage for ApplyDiscount. For Checkout and Cancel, it's unused. This keeps the action type simple — in a larger model you might use nested variant payloads instead.

Predicates

State predicates

NameIML bodyMeaning
is_opens.status = OpenCart accepts modifications
is_checked_outs.status = CheckedOutCart has been finalized
is_cancelleds.status = CancelledCart has been cancelled
has_itemss.item_count > 0Cart is non-empty
no_discount_yets.discount_applied = 0Discount hasn't been applied
has_positive_totals.total > 0Total is positive (can checkout)

Action predicates

NameIML bodyMeaning
is_add_itema.op = AddItemAdding an item
is_apply_discounta.op = ApplyDiscountApplying a discount
is_checkouta.op = CheckoutChecking out
is_cancela.op = CancelCancelling the cart

Transitions

Each transition returns a new state. Every field must be assigned.

add_item

{ total = s.total + a.amount;
  item_count = s.item_count + 1;
  discount_applied = s.discount_applied;
  status = s.status }

Adds the item's value to the total and increments the count. Everything else is unchanged.

apply_discount

{ total = s.total - (s.total * a.amount / 100);
  item_count = s.item_count;
  discount_applied = 1;
  status = s.status }

Reduces the total by the discount percentage. Marks the discount as applied.

do_checkout

{ total = s.total;
  item_count = s.item_count;
  discount_applied = s.discount_applied;
  status = CheckedOut }

Finalizes the cart. Only the status changes.

do_cancel

{ total = 0;
  item_count = 0;
  discount_applied = s.discount_applied;
  status = Cancelled }

Zeros out the cart and marks it cancelled.

noop

{ total = s.total;
  item_count = s.item_count;
  discount_applied = s.discount_applied;
  status = s.status }

Nothing changes — used for rejected operations.


Scenarios

Happy paths

ScenarioGivenWhenThenFlags
add_first_itemis_openis_add_itemadd_iteminitial
add_more_itemsis_open, has_itemsis_add_itemadd_item
discount_on_cartis_open, has_items, no_discount_yetis_apply_discountapply_discount
checkout_cartis_open, has_items, has_positive_totalis_checkoutdo_checkoutgoal
cancel_cartis_openis_canceldo_cancel

Rejection paths

ScenarioGivenWhenThen
checkout_empty_cartis_openis_checkoutnoop
double_discountis_openis_apply_discountnoop
modify_after_checkoutis_checked_outis_add_itemnoop
modify_after_cancelis_cancelledis_add_itemnoop

Notes on the scenarios:

  • add_first_item is marked initial — the system starts with an open, empty cart.
  • checkout_cart is marked goal — we want to verify that checkout is always reachable from the initial state.
  • checkout_empty_cart guards against checking out with no items. The given has is_open but not has_items, so it fires when the cart is open but empty.
  • double_discount fires when the no_discount_yet predicate is not in the given — meaning a discount was already applied. Note that this scenario's given only requires is_open, not no_discount_yet, so it covers the case where a discount was already applied.

Reachability

Starting from add_first_item:

[add_first_item] ──▶ [add_more_items] ──▶ [discount_on_cart]
       │                    │                      │
       │                    │                      │
       ▼                    ▼                      ▼
 [cancel_cart]       [checkout_cart]         [checkout_cart]
                          (goal)                 (goal)

All scenarios are reachable from the initial. The goal scenario checkout_cart is reachable via multiple paths — adding items then checking out, or adding items, applying a discount, then checking out.

The rejection scenarios are also reachable:

  • checkout_empty_cart — reachable at the initial state (open, empty cart)
  • double_discount — reachable after discount_on_cart fires
  • modify_after_checkout — reachable after checkout_cart
  • modify_after_cancel — reachable after cancel_cart

Complement analysis

Even with 9 scenarios, there are uncovered regions. For example:

  • Cancel after checkout — what happens if is_checked_out + is_cancel? Not modeled. You might add a noop scenario or decide that checkout is terminal.
  • Discount on empty cartis_open + no_discount_yet + is_apply_discount without has_items. Should this be allowed? The model doesn't say.
  • Checkout after cancelis_cancelled + is_checkout. Probably should be a noop.

The complement tells you exactly which combinations of predicates lack a matching scenario. This is how SpecLogician guides you toward completeness — not by demanding you model everything upfront, but by showing you what's missing after each iteration.


Key observations

  1. Scenarios partition behavior. Each scenario covers one path. Together, they describe the system. The complement shows what's left.
  2. Predicates are reusable. is_open appears in many scenarios. has_items appears in both happy and rejection paths. You define a predicate once and reference it everywhere.
  3. Transitions are shared too. noop is used by all four rejection scenarios. add_item is used by both add_first_item and add_more_items.
  4. The model is incremental. You don't need all 9 scenarios at once. Start with the happy path, check the complement, then add rejection scenarios to cover the gaps.

Building this model with the CLI

Define the model

# Domain base
speclogician ch base-edit --src "type cart_status = Open | CheckedOut | Cancelled
type op = AddItem | ApplyDiscount | Checkout | Cancel
type state = { total : int; item_count : int; discount_applied : int; status : cart_status }
type action = { op : op; amount : int }" --json
 
# State predicates
speclogician ch pred-add --pred-type state --pred-name is_open \
  --pred-src "s.status = Open" --json
speclogician ch pred-add --pred-type state --pred-name is_checked_out \
  --pred-src "s.status = CheckedOut" --json
speclogician ch pred-add --pred-type state --pred-name is_cancelled \
  --pred-src "s.status = Cancelled" --json
speclogician ch pred-add --pred-type state --pred-name has_items \
  --pred-src "s.item_count > 0" --json
speclogician ch pred-add --pred-type state --pred-name no_discount_yet \
  --pred-src "s.discount_applied = 0" --json
speclogician ch pred-add --pred-type state --pred-name has_positive_total \
  --pred-src "s.total > 0" --json
 
# Action predicates
speclogician ch pred-add --pred-type action --pred-name is_add_item \
  --pred-src "a.op = AddItem" --json
speclogician ch pred-add --pred-type action --pred-name is_apply_discount \
  --pred-src "a.op = ApplyDiscount" --json
speclogician ch pred-add --pred-type action --pred-name is_checkout \
  --pred-src "a.op = Checkout" --json
speclogician ch pred-add --pred-type action --pred-name is_cancel \
  --pred-src "a.op = Cancel" --json
 
# Transitions
speclogician ch trans-add --trans-name add_item \
  --trans-src "{ total = s.total + a.amount; item_count = s.item_count + 1; discount_applied = s.discount_applied; status = s.status }" --json
speclogician ch trans-add --trans-name apply_discount \
  --trans-src "{ total = s.total - (s.total * a.amount / 100); item_count = s.item_count; discount_applied = 1; status = s.status }" --json
speclogician ch trans-add --trans-name do_checkout \
  --trans-src "{ total = s.total; item_count = s.item_count; discount_applied = s.discount_applied; status = CheckedOut }" --json
speclogician ch trans-add --trans-name do_cancel \
  --trans-src "{ total = 0; item_count = 0; discount_applied = s.discount_applied; status = Cancelled }" --json
speclogician ch trans-add --trans-name noop \
  --trans-src "{ total = s.total; item_count = s.item_count; discount_applied = s.discount_applied; status = s.status }" --json
 
# Happy path scenarios
speclogician ch scenario-add add_first_item \
  --given is_open --when is_add_item --then add_item --initial --json
speclogician ch scenario-add add_more_items \
  --given is_open --given has_items --when is_add_item --then add_item --json
speclogician ch scenario-add discount_on_cart \
  --given is_open --given has_items --given no_discount_yet \
  --when is_apply_discount --then apply_discount --json
speclogician ch scenario-add checkout_cart \
  --given is_open --given has_items --given has_positive_total \
  --when is_checkout --then do_checkout --goal --json
speclogician ch scenario-add cancel_cart \
  --given is_open --when is_cancel --then do_cancel --json
 
# Rejection path scenarios
speclogician ch scenario-add checkout_empty_cart \
  --given is_open --when is_checkout --then noop --json
speclogician ch scenario-add double_discount \
  --given is_open --when is_apply_discount --then noop --json
speclogician ch scenario-add modify_after_checkout \
  --given is_checked_out --when is_add_item --then noop --json
speclogician ch scenario-add modify_after_cancel \
  --given is_cancelled --when is_add_item --then noop --json

Inspect and verify

speclogician view state --compact
speclogician reach summary --json

Using batch instead

[
  {
    "kind": "DomainModelBaseEdit",
    "new_base_src": "type cart_status = Open | CheckedOut | Cancelled\ntype op = AddItem | ApplyDiscount | Checkout | Cancel\ntype state = { total : int; item_count : int; discount_applied : int; status : cart_status }\ntype action = { op : op; amount : int }"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "state",
    "pred_name": "is_open",
    "pred_src": "s.status = Open"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "state",
    "pred_name": "is_checked_out",
    "pred_src": "s.status = CheckedOut"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "state",
    "pred_name": "is_cancelled",
    "pred_src": "s.status = Cancelled"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "state",
    "pred_name": "has_items",
    "pred_src": "s.item_count > 0"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "state",
    "pred_name": "no_discount_yet",
    "pred_src": "s.discount_applied = 0"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "state",
    "pred_name": "has_positive_total",
    "pred_src": "s.total > 0"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "action",
    "pred_name": "is_add_item",
    "pred_src": "a.op = AddItem"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "action",
    "pred_name": "is_apply_discount",
    "pred_src": "a.op = ApplyDiscount"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "action",
    "pred_name": "is_checkout",
    "pred_src": "a.op = Checkout"
  },
  {
    "kind": "PredicateAdd",
    "pred_type": "action",
    "pred_name": "is_cancel",
    "pred_src": "a.op = Cancel"
  },
  {
    "kind": "TransitionAdd",
    "trans_name": "add_item",
    "trans_src": "{ total = s.total + a.amount; item_count = s.item_count + 1; discount_applied = s.discount_applied; status = s.status }"
  },
  {
    "kind": "TransitionAdd",
    "trans_name": "apply_discount",
    "trans_src": "{ total = s.total - (s.total * a.amount / 100); item_count = s.item_count; discount_applied = 1; status = s.status }"
  },
  {
    "kind": "TransitionAdd",
    "trans_name": "do_checkout",
    "trans_src": "{ total = s.total; item_count = s.item_count; discount_applied = s.discount_applied; status = CheckedOut }"
  },
  {
    "kind": "TransitionAdd",
    "trans_name": "do_cancel",
    "trans_src": "{ total = 0; item_count = 0; discount_applied = s.discount_applied; status = Cancelled }"
  },
  {
    "kind": "TransitionAdd",
    "trans_name": "noop",
    "trans_src": "{ total = s.total; item_count = s.item_count; discount_applied = s.discount_applied; status = s.status }"
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "add_first_item",
    "given": { "add": ["is_open"] },
    "when": { "add": ["is_add_item"] },
    "then": { "add": ["add_item"] },
    "is_initial": { "set": true }
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "add_more_items",
    "given": { "add": ["is_open", "has_items"] },
    "when": { "add": ["is_add_item"] },
    "then": { "add": ["add_item"] }
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "discount_on_cart",
    "given": { "add": ["is_open", "has_items", "no_discount_yet"] },
    "when": { "add": ["is_apply_discount"] },
    "then": { "add": ["apply_discount"] }
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "checkout_cart",
    "given": { "add": ["is_open", "has_items", "has_positive_total"] },
    "when": { "add": ["is_checkout"] },
    "then": { "add": ["do_checkout"] },
    "is_goal": { "set": true }
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "cancel_cart",
    "given": { "add": ["is_open"] },
    "when": { "add": ["is_cancel"] },
    "then": { "add": ["do_cancel"] }
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "checkout_empty_cart",
    "given": { "add": ["is_open"] },
    "when": { "add": ["is_checkout"] },
    "then": { "add": ["noop"] }
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "double_discount",
    "given": { "add": ["is_open"] },
    "when": { "add": ["is_apply_discount"] },
    "then": { "add": ["noop"] }
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "modify_after_checkout",
    "given": { "add": ["is_checked_out"] },
    "when": { "add": ["is_add_item"] },
    "then": { "add": ["noop"] }
  },
  {
    "kind": "ScenarioAdd",
    "scenario_name": "modify_after_cancel",
    "given": { "add": ["is_cancelled"] },
    "when": { "add": ["is_add_item"] },
    "then": { "add": ["noop"] }
  }
]
speclogician ch batch --file changes.json --json