1. Today's topic
Design by Contract formalises system rules directly in code:
precondition: what the caller must satisfy
postcondition: what the function guarantees after success
invariant: what is always true in a stable object stateThe 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:
transaction_active == false -> active_command == NONE
transaction_active == true -> transaction_id != 0For the phase FSM:
RED && GREEN -> quality == CONFLICT
input_stale -> quality != VALID
off_threshold < on_thresholdFor DMA:
CPU_OWNED and DMA_OWNED cannot both be true at the same timeThe same invariant checker is used in production, unit tests, fuzzing and replay.
3. Theory
Four classes of errors
A. Untrusted external input:
invalid MQTT payload, CAN CRC error, an excessively long AT lineResponse: reject/error/resynchronise, not assert. B. Expected runtime failure:
EC25 failed to register, DNS timeout, CAN bus-offResponse: FSM recovery, retry/backoff. C. Internal contract violation:
double free buffer, ONLINE without registration, retry_count > limitResponse: contract fault. D. Loss of a safe state:
the output state or correctness of phase data cannot be guaranteedResponse: enter a safe state, latch the fault, then perform controlled recovery/reboot.
Invariant checker
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
_Static_assert(SPSC_CAPACITY != 0U &&
(SPSC_CAPACITY & (SPSC_CAPACITY - 1U)) == 0U,
"SPSC capacity must be a nonzero power of two");_Static_assert((SPSC_CAPACITY & (SPSC_CAPACITY - 1U)) == 0U,
"SPSC capacity must be power of two");Assertions without side effects
Incorrect:
assert(queue_pop(&queue, &item));Correct:
bool popped = queue_pop(&queue, &item);
APP_INVARIANT(popped);Fail-fast does not mean an instant reboot
The correct path:
contract violation -> capture minimal state -> safe-state -> fault record -> controlled abort/reboot or local recovery4. Common mistakes
- Using an assertion on external MQTT/UART input.
- Using ESP_ERROR_CHECK() for a network timeout.
- Continuing execution after a “should never happen” condition.
- Using assertions with side effects.
- Checking only ptr != NULL, without logical invariants.
- Using different invariant functions for production/fuzz/replay.
- Using one giant invariant without a specific violation ID.
- Taking a mutex or writing NVS from an ISR contract handler.
- Checking an expensive O(N) invariant for every ADC sample.
- Disabling all critical checks in a release.
- Treating the watchdog as a replacement for assertions.
- Treating assertions as a replacement for normal error handling.
- 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:
_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.
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.