Safe API Framework
Layered API framework for safety-related applications (ERTMS RBC reference targeting CENELEC EN 50128 SIL 4)
Loading...
Searching...
No Matches
ADR-030: sapi_notify - fixed-capacity registered-callback list shape

Status: Accepted Date: 2026-08-20 Applies to: new include/safeapi/notify/sapi_notify.h (header-only, no src/, no SAFEAPI_ENABLE_* option, no tests/ executable of its own - see §2.3/§5).

1. Context

safeAPIRBC2oo2 (downstream, separate repo) is introducing a GA/GP split inside its A/B redundant-channel application: a "GP" orchestrator module (the cyclic train-route connect sweep) needs to (a) ask its registered "GA" peers "is this proposed action OK" before proceeding - a veto gate, every registered peer must agree - and (b) separately tell its registered GA peers "this happened" once it has - a fan-out notification, no return value. Today it does neither: it #includes its peer module's header directly and calls its functions by name, the same tight compile-time coupling ADR-025's own §1 context flags for channel_ab_crosscompare.c before that ADR - "already reimplemented outside the framework because no equivalent primitive existed here." The same trigger applies again: no registered-callback list primitive exists in this framework, so a downstream project reaches for a hand-rolled one.

A repository-wide search confirmed this is a genuine gap, not a naming mismatch: sapi_channel_t/sapi_voter_t/sapi_cross_comparator_t each carry a single, concretely-typed callback field per config (sapi_voter_compare_fn, on_disagreement, ...) - none register a bounded LIST of N independently-registered {callback, context} pairs. sapi_ipc_pubsub is structurally the closest cousin (_topic_create/ _subscribe/_publish) but a different shape entirely - subscribers poll a bounded async queue (sapi_ipc_pubsub_receive with a timeout), not a synchronous callback invocation - wrong fit for a same-cycle "ask before proceeding" gate or an immediate "tell everyone now" notification, both of which need to resolve within the same call, not on a later poll.

docs/MISRA_COMPLIANCE_REPORT.md Rule 11.1 ("Conversions shall not be performed between a pointer to a function and any other type") rules out the obvious type-erased design (store callbacks as void *, cast back to the real signature at dispatch) outright - a genuinely generic, signature-agnostic dispatcher is not an option here the way it might be in a non-MISRA codebase.

2. Decision

2.1 Storage-shape macro only, no generated control flow

SAFEAPI_DECLARE_CALLBACK_LIST(list_type, callback_fn_type, max_subscribers) declares list_type_slot_t ({callback_fn_type fn; void *context;}) and list_type ({list_type_slot_t slots[max_subscribers]; uint32_t count;})

  • nothing else. Every existing macro in this framework (SAFEAPI_DECLARE_STORAGE, sapi_types.h) declares a TYPE only, never control flow; this stays consistent rather than introducing the framework's first macro-generated loop/conditional, which would need either __VA_ARGS__-based dispatch (poor MISRA posture - hidden control flow inside a function-like macro, awkward to trace/review) or the void * type-erasure §1 already rules out.

2.2 Init/register/dispatch are hand-written per call site, not generated

Each concrete instantiation gets its own small, ordinary list_type_init()/_register()/dispatch function, following the worked example in sapi_notify.h's own header doc. _register() calls sapi_lifecycle_check_setup_allowed() as its first check, mirroring every other setup-only constructor in this framework (sapi_timer_create(), sapi_voter_init()/_register_channel(), sapi_cross_comparator_init()/_register_channel(), sapi_watchdog_create()) - registering a callback is exactly the same kind of INIT-phase-only resource construction ADR-026 already gates. A veto-gate dispatch function returns false if ANY registered validator returns false, and if zero validators are registered (fail-safe default-deny: nothing is authorized without an explicit, registered authority - the safety-appropriate default for code that will run in a SIL2/SIL3 context). A fan-out notify dispatch function NULL-guards every slot before calling it, the same "same function pointer type, same call site, same NULL-guard" precedent sapi_watchdog_create() already established (per docs/MISRA_COMPLIANCE_REPORT.md's own note on that function).

2.3 Header-only, no SAFEAPI_ENABLE_* option

Unlike sapi_voter/sapi_cross_comparator (full runtime modules with their own .c, own enable flag, own dependency-graph entry per ADR-024), this is a single header-only macro with zero runtime code of its own - the actual logic lives in each caller's own hand-written functions (§2.2), which compile as part of that caller's own translation unit. It belongs alongside sapi_buffer/sapi_types as an always-available foundational header, not as an optional subsystem a consuming project opts into.

3. Consequences

  • Positive: gives safeAPIRBC2oo2 (and any future integrator with the same "ask before proceeding" / "tell everyone after" need) a shared, reviewed storage shape instead of a fully hand-rolled one, without introducing macro-generated control flow or MISRA Rule 11.1-violating type erasure into this framework.
  • Positive: the worked example in sapi_notify.h's own header doc is itself the primary documentation - a downstream integrator copies the three-function pattern once per callback signature, the same per-signature boilerplate C's lack of generics always requires, not a new concept to learn.
  • Negative: less "free" than a fully-generated dispatcher would be - every call site still hand-writes ~15 lines of init/register/dispatch. Judged acceptable (§2.1) given the MISRA constraint leaves no cleaner alternative; the shared storage shape is still real, non-trivial value (bounds-checked capacity, consistent NULL-guard/lifecycle-lock posture across every instantiation).
  • Neutral: this ADR does not itself change any existing module - sapi_channel/sapi_voter/sapi_cross_comparator's own single-typed- callback-per-config shape is unaffected and remains the right choice for their own conceptually-single-slot use cases (ADR-025 §2.3's own "keep conceptually distinct relationships as separate types" applies here too: a registered-callback LIST is a different concept from a single configured callback, not a generalization that should replace it).

4. Verification

  • Standalone compile check (gcc -std=c99 -Wall -Wextra -Wpedantic, header include path only) of a worked instantiation matching sapi_notify.h's own doc example: clean, zero warnings.
  • safeAPIRBC2oo2 (downstream consumer, separate repo): full clean rebuild after wiring its new GA/GP callback lists against this header, plus .claude/skills/run-safeAPIRBC2oo2/smoke.sh matching its established baseline (no behavior change - the GA/GP notification path is additive/logging-only this pass, per that project's own design decision) - lesson from ADR-025 §3 applied directly: verify the downstream repo's own build, not just this repo's.

5. Location

  • include/safeapi/notify/sapi_notify.h (new, header-only).
  • No src/, no tests/, no SAFEAPI_ENABLE_* CMake option - see §2.3.