Is const-casting away const-ness of references to actual const objects permitted if they are never modified through them?

Per https://en.cppreference.com/w/cpp/language/const_cast:

const_cast makes it possible to form a reference or pointer to non-const type that is actually referring to a const object or a reference or pointer to non-volatile type that is actually referring to a volatile object. Modifying a const object through a non-const access path and referring to a volatile object through a non-volatile glvalue results in undefined behavior.

So, the const_cast itself is allowed (and well-defined), even though it would be undefined behavior to actually modify the object via the resulting non-const reference.


As the other answer is perfecly clear about the validity of const-casting in your situation, one (sub-)question remains: how make your wrapper const when you want it to actually behave as const? (your edit)

I suggest providing two distinct interfaces, thus two distinct wrappers, to prevent non-const accesses to the wrapped record when it is thought about as const.
The drawback of this solution is that, in order to avoid code duplication, you have to explicitely make the mutable wrapper rely on the const wrapper (then duplicate the call, not the actual code).

Here is a simple example based on yours:

/**
  g++ -std=c++17 -o prog_cpp prog_cpp.cpp \
      -pedantic -Wall -Wextra -Wconversion -Wno-sign-conversion \
      -g -O0 -UNDEBUG -fsanitize=address,undefined
**/

#include <iostream>
#include <cstdint>

struct BoundedFloat
{
  float f;
};

struct stored_record
{
  std::int16_t foo;
};

BoundedFloat
convert_from_int16(std::int16_t v)
{
  return {float(v/100.0)};
}

std::int16_t
convert_to_int16(BoundedFloat bf)
{
  return {std::int16_t(bf.f*100.0)};
}

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

class const_record_interface
{
public:
  virtual ~const_record_interface() = default;
  virtual BoundedFloat get_foo() const = 0;
};

class mutable_record_interface : public const_record_interface
{
public:
  virtual void set_foo(BoundedFloat) = 0;
};

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

class const_record_wrapper : public const_record_interface
{
public:
  const_record_wrapper(const stored_record &wrapped) : wrapped_{wrapped} {}
  BoundedFloat get_foo() const final { return convert_from_int16(wrapped_.foo); }
private:
  const stored_record &wrapped_;
};

const_record_wrapper
make_wrapper(const stored_record &wrapped)
{
  return {wrapped};
}

class mutable_record_wrapper : public mutable_record_interface
{
public:
  mutable_record_wrapper(stored_record &wrapped) : wrapped_{wrapped} {}
  auto as_const() const { return make_wrapper(this->wrapped_); }
  void set_foo(BoundedFloat value) final { wrapped_.foo=convert_to_int16(value); }
  BoundedFloat get_foo() const final { return as_const().get_foo(); }
private:
  stored_record &wrapped_;
};

mutable_record_wrapper
make_wrapper(stored_record &wrapped)
{
  return {wrapped};
}

//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

int
main()
{
  auto sr=stored_record{50};
  const auto &csr=sr;
  auto w1=make_wrapper(sr);
  auto w2=make_wrapper(csr);
  std::cout << "w1: " << w1.get_foo().f
            << "  w2: " << w2.get_foo().f << '\n';
  w1.set_foo({0.6f});
  // w2.set_foo({0.7f}); // rejected: no member named ‘set_foo'
  std::cout << "w1: " << w1.get_foo().f
            << "  w2: " << w2.get_foo().f << '\n';
  return 0;
}