1. Today's topic

Design by Contract formalises system rules directly in code:

text
precondition: what the caller must satisfy
postcondition: what the function guarantees after success
invariant: what is always true in a stable object state

The main idea: an assertion does not check the outside world. It catches a violation of an internal promise made by the program.

2. Why this matters

For EC25, an invariant:

text
transaction_active == false -> active_command == NONE
transaction_active == true -> transaction_id != 0

For the phase FSM:

text
RED && GREEN -> quality == CONFLICT
input_stale -> quality != VALID
off_threshold < on_threshold

For DMA:

text
CPU_OWNED and DMA_OWNED cannot both be true at the same time

The same invariant checker is used in production, unit tests, fuzzing and replay.

3. Theory

Four classes of errors

A. Untrusted external input:

text
invalid MQTT payload, CAN CRC error, an excessively long AT line

Response: reject/error/resynchronise, not assert. B. Expected runtime failure:

text
EC25 failed to register, DNS timeout, CAN bus-off

Response: FSM recovery, retry/backoff. C. Internal contract violation:

text
double free buffer, ONLINE without registration, retry_count > limit

Response: contract fault. D. Loss of a safe state:

text
the output state or correctness of phase data cannot be guaranteed

Response: enter a safe state, latch the fault, then perform controlled recovery/reboot.

Invariant checker

c
typedef enum {
    MODEM_INV_OK = 0,
    MODEM_INV_NULL,
    MODEM_INV_COMMAND_WITHOUT_TRANSACTION,
    MODEM_INV_MQTT_WITHOUT_ONLINE_MODEM,
    MODEM_INV_RETRY_OVERFLOW,
} modem_invariant_result_t;
static modem_invariant_result_t modem_invariants_check(const modem_core_t *m)
{
    if (m == NULL) return MODEM_INV_NULL;
    if (!m->transaction_active && m->active_command != MODEM_COMMAND_NONE)
        return MODEM_INV_COMMAND_WITHOUT_TRANSACTION;
    if (m->mqtt_state == MQTT_STATE_CONNECTED && m->state != MODEM_STATE_ONLINE)
        return MODEM_INV_MQTT_WITHOUT_ONLINE_MODEM;
    if (m->retry_count > m->policy.retry_limit)
        return MODEM_INV_RETRY_OVERFLOW;
    return MODEM_INV_OK;
}

Check before and after handle_event().

Compile-time contracts

c
_Static_assert(SPSC_CAPACITY != 0U &&
               (SPSC_CAPACITY & (SPSC_CAPACITY - 1U)) == 0U,
               "SPSC capacity must be a nonzero power of two");
c
_Static_assert((SPSC_CAPACITY & (SPSC_CAPACITY - 1U)) == 0U,
               "SPSC capacity must be power of two");

Assertions without side effects

Incorrect:

c
assert(queue_pop(&queue, &item));

Correct:

c
bool popped = queue_pop(&queue, &item);
APP_INVARIANT(popped);

Fail-fast does not mean an instant reboot

The correct path:

text
contract violation -> capture minimal state -> safe-state -> fault record -> controlled abort/reboot or local recovery

4. Common mistakes

  1. Using an assertion on external MQTT/UART input.
  2. Using ESP_ERROR_CHECK() for a network timeout.
  3. Continuing execution after a “should never happen” condition.
  4. Using assertions with side effects.
  5. Checking only ptr != NULL, without logical invariants.
  6. Using different invariant functions for production/fuzz/replay.
  7. Using one giant invariant without a specific violation ID.
  8. Taking a mutex or writing NVS from an ISR contract handler.
  9. Checking an expensive O(N) invariant for every ADC sample.
  10. Disabling all critical checks in a release.
  11. Treating the watchdog as a replacement for assertions.
  12. Treating assertions as a replacement for normal error handling.
  13. Using default: break for an impossible enum state.

5. Practical task

Implement modem_invariants_check() and call it on entry to and exit from the modem event handler. Add an intentional bug: on AT_OK, clear transaction_active=false but do not clear active_command. The invariant should immediately report MODEM_INV_COMMAND_WITHOUT_TX. Add _Static_assert for buffer sizes:

c
_Static_assert(MODEM_MAX_AT_COMMAND_LENGTH < MODEM_TX_BUFFER_SIZE,
               "AT command can overflow TX buffer");

Then connect the same checker to the fuzz target and replay runner.

6. What to try next

  • Separate contract levels: critical always-on, normal debug and deep audit.
  • Store contract ID, state, transaction ID, configuration generation and boot ID in the production fault record.
  • Use separate types for remote commands: raw_command_t, verified_command_t and authorized_command_t.
Which case is an internal contract violation rather than ordinary untrusted input or an expected runtime failure?

Exercise

An AT_OK handler clears transaction_active but leaves active_command set. Name the checker outcome expected by the lesson and outline a fault path that does not rely on an assertion expression with side effects.

Self-check criteria: Name the violation and separate diagnostic/safe-state actions from a side-effect-free check.

Show the supplied answer

The checker reports MODEM_INV_COMMAND_WITHOUT_TX. Capture minimal diagnostic state, enter the safe state, record the fault, then perform controlled abort/reboot or local recovery according to the fault policy. State-changing work belongs outside the assertion expression, and the same invariant checker should serve production, fuzzing and replay.