Safety Patterns
This page is the short path from a blank file to Arc's compile-time safety surface. It does not replace the safety case or product hazard analysis; it shows the code shapes that make ownership and lifetime visible before runtime. The core snippets here are compile-checked in tests/host/safety_patterns_compile.cpp.
What Arc Can Prove
Arc can make several local mistakes fail during C++ template checking:
- the wrong core touching a
CoreLocalvalue; - mutable access to const static state;
- a mutable static loan packed beside another loan to the same object;
- queues, fan-in lanes, and RPC lanes exposing the wrong role endpoint;
- public headers missing their ESP-IDF component dependency.
Arc does not prove whole-program C++ lifetime safety. Raw pointers, raw std::span lifetimes, vendor driver handles, board wiring, and product failsafes still need ordinary engineering review and runtime evidence.
Smallest Safe Shape
Start by declaring long-lived state once. Give it a core owner in the type.
#include "arc/borrow.hpp"
#include "arc/task.hpp"
struct ControlState {
std::uint32_t tick{};
};
constinit ControlState control_state{};
using ControlCell = arc::StaticRef<&control_state, arc::Core::core1>;
using ControlRead = ControlCell::Read;
using ControlWrite = ControlCell::Write;
static_assert(ControlCell::can_write<arc::Core::core1>());
static_assert(!ControlCell::can_write<arc::Core::core0>());
static_assert(arc::StaticLoanType<ControlRead>);
static_assert(arc::StaticWritable<ControlCell, arc::Core::core1>);
static_assert(!arc::StaticWritable<ControlCell, arc::Core::core0>);
static_assert(arc::loans_ok<ControlRead, ControlRead>());
static_assert(!arc::loans_ok<ControlRead, ControlWrite>());Use the loans at the owner boundary:
void control_tick()
{
const auto before = ControlCell::snapshot();
ControlCell::set(ControlState{.tick = before.tick + 1U});
arc::set<ControlCell>(ControlState{.tick = before.tick + 2U});
ControlCell::with_write([](ControlState& state) {
state.tick += 1U;
});
}That is the basic Arc pattern: name the owner, make access mode explicit, and let the wrong core or wrong access mode disappear from the overload set. The free arc::with_read<...> and arc::with_write<...> helpers remain available when code prefers the helper at the call site instead of on the owner type. For simple reads, StaticRef::snapshot() copies out a value without exposing a borrowed reference. Scoped callbacks can return void or a copied value; returning a reference, raw pointer, std::reference_wrapper, common non-owning view such as std::span / std::string_view, standard/result wrapper such as std::tuple / std::pair / std::array / std::optional / std::variant / std::expected / arc::Result containing those, or StaticLoan fails the build so the borrow token cannot escape the callback. StaticRef::set(value) and arc::set<Ref>(value) cover whole-value assignment when a callback would only expose a mutable reference for one store.
Shared Task Contract
When a task needs several static resources, name the expected loan set. Repeated readonly loans are valid. A mutable loan cannot be grouped with another loan to the same object.
constinit ControlState telemetry_state{};
using TelemetryCell = arc::StaticRef<&telemetry_state, arc::Core::core1>;
using TelemetryRead = TelemetryCell::Read;
using TelemetryInputs = arc::StaticReads<ControlCell, TelemetryCell>;
using ControlStep = arc::StaticEdit<ControlCell, TelemetryCell>;
using OwnerTelemetryInputs = ControlCell::Reads<TelemetryCell>;
using OwnerControlStep = ControlCell::Edit<TelemetryCell>;
static_assert(TelemetryInputs::count == 2U);
static_assert(OwnerTelemetryInputs::count == 2U);
static_assert(OwnerControlStep::count == 2U);
static_assert(TelemetryInputs::contains<ControlRead>());
static_assert(OwnerTelemetryInputs::contains<ControlRead>());
static_assert(TelemetryInputs::reads<ControlCell>());
static_assert(TelemetryInputs::reads<TelemetryCell>());
static_assert(!TelemetryInputs::writes<ControlCell>());
static_assert(arc::HasStaticRefRead<TelemetryInputs, TelemetryCell>);
static_assert(ControlStep::contains<ControlWrite>());
static_assert(OwnerControlStep::contains<ControlWrite>());
static_assert(ControlStep::contains<TelemetryRead>());
static_assert(ControlStep::can_access<arc::Core::core1>());
static_assert(!ControlStep::can_access<arc::Core::core0>());StaticEdit<WriteRef, ReadRefs...> is the common one-writer, many-reader shape. If the task later tries to add the writer as one of its readers, LoanPack fails the build. This is not full alias analysis; it is a deliberate contract for the static objects listed in the task type.
Run code through the same contract when the callback should receive only the references named by the loan pack:
void control_step()
{
arc::with_reads<ControlCell, TelemetryCell>([](const ControlState& control, const ControlState& telemetry) {
static_cast<void>(control.tick + telemetry.tick);
});
ControlCell::with_reads<TelemetryCell>([](const ControlState& control, const ControlState& telemetry) {
static_cast<void>(control.tick + telemetry.tick);
});
auto member_copies = ControlCell::snapshots<TelemetryCell>();
static_cast<void>(member_copies);
auto copies = arc::snapshots<ControlCell, TelemetryCell>();
static_cast<void>(copies);
ControlCell::with_edit<TelemetryCell>([](ControlState& control, const ControlState& telemetry) {
control.tick += telemetry.tick;
});
arc::with_edit<ControlCell, TelemetryCell>([](ControlState& control, const ControlState& telemetry) {
control.tick += telemetry.tick;
});
}Cross-Core Handoff
Use CoreLocal and CoreMsg when the value itself should remember which core may touch it.
#include "arc/task.hpp"
using Core1Counter = arc::CoreLocal<std::uint32_t, arc::Core::core1>;
static_assert(arc::CoreLocalType<Core1Counter>);
Core1Counter counter{41U};
using CounterMsg = Core1Counter::Msg<arc::Core::core0>;
static_assert(arc::CoreMsgType<CounterMsg>);
static_assert(CounterMsg::from == arc::Core::core1);
static_assert(CounterMsg::to == arc::Core::core0);
counter.set<arc::Core::core1>(42U);
counter.set(42U);
auto current = counter.snapshot();
counter.with([](std::uint32_t& value) {
value += 1U;
return value;
});
auto msg = counter.msg<arc::Core::core0>();
static_assert(decltype(msg)::from == arc::Core::core1);
static_assert(decltype(msg)::to == arc::Core::core0);
static_cast<void>(msg.get());
static_cast<void>(msg.with([](const std::uint32_t& value) {
return value;
}));
auto delivered = msg.snapshot();For simple reads, snapshot() copies out a value instead of handing out a reference. The message carries its destination in the type, so msg.get() reads through that encoded destination, while msg.with(fn) scopes the read to one callback. Core-local state and message payloads must be copied values, stable IDs, or fixed arrays; direct pointers and non-owning views fail before the value can cross a core boundary. Explicit get<Core>() and with<Core>(fn) are still available when a boundary should name the core directly. Like static-borrow helpers, CoreLocal::with and CoreMsg::with callbacks cannot return references, raw pointers, std::reference_wrapper, or common non-owning views such as std::span / std::string_view, or standard/result wrappers such as std::tuple / std::pair / std::array / std::optional / std::variant / std::expected / arc::Result containing those. Explicit forms such as CoreLocal::with<Core>(fn) and msg<Core, To>() remain available when a boundary should spell the access core directly.
Role Boundary
Use Roles<Lane> when setup code should hand out queue or RPC endpoints without keeping the root lane API visible.
#include "arc/roles.hpp"
#include "arc/spsc.hpp"
arc::Roles<arc::Spsc<std::uint32_t, 4>> events;
using EventProducer = decltype(events.producer());
using EventConsumer = decltype(events.consumer());
static_assert(arc::PushRole<EventProducer, std::uint32_t>);
static_assert(arc::PopRole<EventConsumer, std::uint32_t>);
static_assert(!arc::PopRole<EventProducer, std::uint32_t>);
void route_event()
{
events.with_producer([](auto& producer) {
return producer.try_push(7U);
});
events.with_consumer([](auto& consumer) {
std::uint32_t event{};
return consumer.try_pop(event);
});
}The with_producer, with_consumer, with_split, with_client, and with_server helpers scope endpoint use to one callback. The callback may return void or an ordinary copied value; returning an endpoint, reference, or raw pointer fails the build, including when the endpoint or borrow hides inside a standard/result wrapper. PushRole, PopRole, RpcClientRole, and RpcServerRole let template boundaries accept only the endpoint side they actually use. The scoped callback check rejects captured endpoint values too, so a producer callback cannot smuggle out a consumer endpoint.
Lane, fan-in, RPC, and Flow payloads are also checked at the type boundary. They must be copied values, stable IDs, or fixed arrays; direct pointers, std::reference_wrapper, std::span, std::string_view, and standard/result wrappers containing those fail the build before they can cross a task or core boundary.
Static Plane Launch
When a workload owns static state, bind the task to the same StaticRef instead of repeating the state type and object address in separate places.
#include "arc/plane.hpp"
struct ControlLoop {
static void setup(ControlState&) {}
static void run(ControlState&) noexcept {}
};
using ControlPlane = arc::StaticPlane<ControlLoop, ControlCell, 2048>;
static_assert(ControlPlane::core == arc::Core::core1);
static_assert(requires { ControlPlane::boot("control"); });StaticPlane still boots through the same static-address contract as Plane<...>::boot<&state>(); it only removes duplicated type plumbing.
Proof Metadata
Attach small proof metadata to workloads when release tooling or review needs a source-visible claim beside a measured cycle budget.
#include "arc/proof.hpp"
using ControlProof = arc::proof::Pack<
10'000U,
arc::proof::Deadline<17U, 10'000U>,
arc::proof::NoHeap<17U>,
arc::proof::StaticLife<17U>>;
static_assert(ControlProof::has<arc::proof::Kind::deadline>());
static_assert(ControlProof::has<arc::proof::Kind::deadline, 17U>());
static_assert(ControlProof::bound<arc::proof::Kind::deadline>() == 10'000U);
static_assert(ControlProof::bound<arc::proof::Kind::deadline, 17U>() == 10'000U);The proof pack is not a certificate. It is a compact artifact that keeps the reviewed claim, subject id, and bound near the code that depends on it. Prefer the subject-specific checks when a pack carries facts for more than one workload.
Verification Loop
Use host checks while shaping contracts:
./tools/host-tests.sh
./tools/format.sh --check
git diff --check
python3 tools/docs_module_pages_test.pyUse repo policy and public-header validation before publishing a safety-relevant change:
./tools/check-repo.sh
./tools/clangd-compile-commands.py --validate-arc-headers -o /tmp/arc_compile_commands.jsonFor timing, wiring, control loops, RF, optics, motors, or actuators, host checks are not enough. Build the closest firmware example and capture board or HIL evidence that matches the claim.