Question: Why sometimes local class cannot access constexpr variables defined in function scope

This c++ code cannot compile:
#include <iostream>

int main()
{
    constexpr int kInt = 123;
    struct LocalClass {
        void func(){
            const int b = std::max(kInt, 12); 
            //                     ^~~~  
            // error: use of local variable with automatic storage from containing function
            std::cout << b;
        }
    };
    LocalClass a;
    a.func();
    return 0;
}

But this works:
#include <iostream>
#include <vector>

int main()
{
    constexpr int kInt = 123;
    struct LocalClass {
        void func(){
            const int b = std::max((int)kInt, 12); // added an extra conversion "(int)"
            std::cout << b;
            const int c = kInt; // this is also ok
            std::cout << c;
            const auto d = std::vector{kInt}; // also works
            std::cout << d[0];
        }
    };
    LocalClass a;
    a.func();
    return 0;
}

Tested under C++17 and C++20, same behaviour.


Answer:
1. odr-using local entities from nested function scopes
Note that kInt still has automatic storage duration - so it is a local entity as per:

6.1 Preamble [basic.pre]
(7) A local entity is a variable with automatic storage duration, [...]


In general local entities cannot be odr-used from nested function definitions (as in your LocalClass example)
This is given by:

6.3 One-definition rule [basic.def.odr]
(10) A local entity is odr-usable in a scope if:
[...]
(10.2) for each intervening scope between the point at which the entity is introduced and the scope (where *this is considered to be introduced within the innermost enclosing class or non-lambda function definition scope), either:

the intervening scope is a block scope, or
the intervening scope is the function parameter scope of a lambda-expression that has a simple-capture naming the entity or has a capture-default, and the block scope of the lambda-expression is also an intervening scope.

If a local entity is odr-used in a scope in which it is not odr-usable, the program is ill-formed.

So the only times you can odr-use a local variable within a nested scope are nested block scopes and lambdas which capture the local variable.
i.e.:
void foobar() {
    int x = 0;

    {
        // OK: x is odr-usable here because there is only an intervening block scope
        std::cout << x << std::endl;
    }

    // OK: x is odr-usable here because it is captured by the lambda
    auto l = [&]() { std::cout << x << std::endl; };

    // NOT OK: There is an intervening function definition scope
    struct K {
      int bar() { return x; }
    };
}

11.6 Local class declarations [class.local] contains a few examples of what is and is not allowed, if you're interested.

So if use of kInt constitutes an odr-use, your program is automatically ill-formed.
2. Is naming kInt always an odr-use?
In general naming a variable constitutes an odr-use of that variable:

6.3 One-definition rule [basic.def.odr]
(5) A variable is named by an expression if the expression is an id-expression that denotes it. A variable x that is named by a potentially-evaluated expression E is odr-used by E unless [...]

But because kInt is a constant expression the special exception (5.2) could apply:

6.3 One-definition rule [basic.def.odr]
(5.2) x is a variable of non-reference type that is usable in constant expressions and has no mutable subobjects, and E is an element of the set of potential results of an expression of non-volatile-qualified non-class type to which the lvalue-to-rvalue conversion is applied, or

So naming kInt is not deemed an odr-use as long as it ...

is of non-reference type (✓)
is usable in constant expressions (✓)
does not contain mutable members (✓)

and the expression that contains kInt ...

must produce a non-volatile-qualified non-class type (✓)
must apply the lvalue-to-rvalue conversion (?)

So we pass almost all the checks for the naming of kInt to not be an odr-use, and therefore be well-formed.
The only condition that is not always true in your example is the lvalue-to-rvalue conversion that must happen.
If the lvalue-to-rvalue conversion does not happen (i.e. no temporary is introduced), then your program is ill-formed - if it does happen then it is well-formed.
// lvalue-to-rvalue conversion will be applied to kInt:
// (well-formed)
const int c = kInt;  
std::vector v{kInt}; // vector constructor takes a std::size_t

// lvalue-to-rvalue conversion will NOT be applied to kInt:
// (it is passed by reference to std::max)
// (ill-formed)
std::max(kInt, 12); // std::max takes arguments by const reference (!)

This is also the reason why std::max((int)kInt, 12); is well-formed - the explicit cast introduces a temporary variable due to the lvalue-to-rvalue conversion being applied.