Module: Axn::Core::Contract

Defined in:
lib/axn/core/contract.rb,
lib/axn/core/contract/redaction.rb,
lib/axn/core/contract/shape_declaration.rb,
lib/axn/core/contract/subfield_contradictions.rb

Defined Under Namespace

Modules: ClassMethods, FieldOptionality, InstanceMethods, Redaction, ShapeDeclaration, SubfieldContradictions Classes: FieldConfig, ShapeBuilder, ShapeConfig

Constant Summary collapse

GENERATED_READER_SOURCE_PATH =

Every top-level reader and boolean predicate alias is defined in this file, so reflection can verify a Symbol condition still resolves to the framework-generated reader — an alias shares its source_location with the aliased definition, so a user method of the same name (a pre-existing predicate target that suppressed generation, or a plain reader redefined after expects) reports a different source and is rejected (declarative-emission would otherwise condition on the wire value while runtime evaluates the user method — the looser direction).

__FILE__
RESERVED_EXECUTION_CONTEXT_KEYS =

Keys the framework owns in the execution/exception-report context, so they can't be set via set_execution_context or the additional_execution_context hook: :inputs/:outputs are the structural pair, and :async/:ambient_context/:axn_stack/:tags/:dimensions are framework-populated in execution_context / Internal::ExceptionContext.build — reserving them here prevents a user value from being silently overwritten when they're assigned after merging the user's extra keys. :tags/:dimensions carry the resolved tag/dimension facets (PRO-2853).

%i[inputs outputs async ambient_context axn_stack tags dimensions].freeze

Class Method Summary collapse

Class Method Details

.canonical_name!(value, option:, names:, fix:, encoding_fix:) ⇒ Object

Canonicalize a declared name to the Symbol every consumer downstream reads, holding both rules first. THE single place a name becomes a Symbol, so the rules and the conversion cannot drift apart — a site that canonicalized on its own is how as:, prefix: and the field names each ended up with a different answer for the same bad value.

Scope, deliberately: these rules serve a name a developer actually WROTE — a Symbol, a String, or the nil/[]/123 that a variable holding the wrong thing produces. They do not try to survive a String subclass whose to_sym lies (answering with a wide Symbol, a non-Symbol, or by raising). Verifying that a caller's object BEHAVES is unbounded — every round of verification is defeated by the next case — and the honest boundary is that such a class is not a contract axn can be asked to hold. What IS guaranteed is that nothing here consults the value's own is_a?, inspect or encoding to reach a verdict, so an ordinary mistake is always diagnosed as one.



254
255
256
257
258
259
# File 'lib/axn/core/contract.rb', line 254

def self.canonical_name!(value, option:, names:, fix:, encoding_fix:)
  validate_name_option!(value, option:, names:, fix:)
  validate_name_encoding!(value, kind: option, fix: encoding_fix)

  value.to_sym
end

.included(base) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/axn/core/contract.rb', line 22

def self.included(base)
  base.class_eval do
    # Copy-on-write stores, frozen at every assignment: declaration replaces the array (`+`,
    # never `<<` — which would now raise FrozenError rather than silently mutating the
    # superclass's contract), so the per-class resolved-subfield cache can key on array
    # identity and concurrent readers always see an immutable snapshot.
    class_attribute :internal_field_configs, :external_field_configs, default: [].freeze

    extend ClassMethods
    include InstanceMethods
  end
end

.validate_name_encoding!(value, kind:, fix:) ⇒ Object

A name of the right TYPE can still be written in bytes no declaration can work with. Runs immediately after the type rule above, at every site that takes a name, and BEFORE anything compares the name to anything: the questions a declaration asks — is this path dotted, is this reader reserved — are asked against axn's own ASCII patterns, and a wide encoding (UTF-16, UTF-32) makes the comparison itself raise Encoding::CompatibilityError instead of answering. That was the whole diagnosis such a name got: an encoding error from "a.b".include?("."), naming neither the option nor what was wrong with it.

Rejected rather than accommodated, because there is no working declaration behind it. The name interns to a Symbol DISTINCT from its UTF-8 twin ("ab".encode("UTF-16LE").to_sym != :ab) while canonicalizing to the same JSON property, so the schema advertises "ab" and no caller can satisfy it: supplying that property, its Symbol, or the wide Symbol itself each raise from the read path. An ASCII-COMPATIBLE non-UTF-8 name (Latin-N) is a different case entirely and stays legal — it compares, it reads, and it renders as the property it canonicalizes to.

The encoding is read from bound base implementations (NativeMethods.ascii_compatible_name?) for the same reason the rest of this layer does — a dispatch inside a verdict is a dispatch the verdict did not need — and the encoding is named in the message from Encoding's own object rather than from anything the caller supplied.

Raises:

  • (ArgumentError)


232
233
234
235
236
237
238
239
240
# File 'lib/axn/core/contract.rb', line 232

def self.validate_name_encoding!(value, kind:, fix:)
  return if Axn::Internal::NativeMethods.ascii_compatible_name?(value)

  raise ArgumentError,
        "#{kind} must be written in an ASCII-compatible encoding (got one encoded as " \
        "#{Axn::Internal::NativeMethods.name_encoding(value).name}) — a name in a wide encoding interns to a " \
        "different Symbol than the UTF-8 property it renders as, so nothing a caller sends can match it, and " \
        "every check the declaration makes against it raises rather than answering. #{fix}"
end

.validate_name_option!(value, option:, names:, fix:) ⇒ Object

An option whose value NAMES something (Axn::Factory.build's expose_return_as:, a subfield's on:) is canonicalized to a Symbol the moment it is read, so the only values it can carry are the two that have a Symbol as their canonical spelling: a String and a Symbol. Anything else used to be left to the caller's own to_sym, and was therefore diagnosed as whatever that happened to raise — NoMethodError: undefined method 'to_sym' for an instance of Array, which names neither the option nor what was wrong with the value — the same non-diagnosis as a mistyped option key surfacing at call time as Unknown validator: 'TpyeValidator', and rejected for the same reason.

Worse, an exotic object that merely ANSWERS to_sym was not diagnosed at all: it silently named whatever its to_sym invented, independently of what the object renders as — the two-independent-conversions defect validate_shape_member_name! rejects for a member name.

Runs AFTER the absent check at each call site, so every spelling of "not supplied" (nil, false, an empty or whitespace-only String, the empty Symbol) still means the option was omitted rather than hitting a type error — see Internal::NativeMethods.absent_value?.

case/when decides the type through Module#=== (a C-level check) rather than is_a?, which a value can override to route around a guard, and the offender is named by CLASS through the renderer rather than by its own inspect, exactly as the sensitive:/user_facing: guards above — a declaration guard must not let the value it is judging raise INSTEAD of the verdict.

Raises:

  • (ArgumentError)


203
204
205
206
207
208
209
210
211
212
# File 'lib/axn/core/contract.rb', line 203

def self.validate_name_option!(value, option:, names:, fix:)
  case value
  when ::String, ::Symbol then return
  end

  raise ArgumentError,
        "#{option} must be a String or Symbol naming #{names} (got a value of class " \
        "#{Axn::Internal::Reflection::PropertyNames.renderable_class_name(value)}) — any other object has no single " \
        "name to canonicalize to. #{fix}"
end

.validate_sensitive!(sensitive) ⇒ Object

The grammar of a sensitive: value: true/false, a Symbol (an action method name), or a Proc — plus nil, which means false (so sensitive: some_flag reads naturally when the flag is unset). Anything else is rejected at declaration, because the runtime failure mode is a LEAK rather than an error: only those values are resolution rules, so sensitive: "yes" or sensitive: 1 left the field out of the name-based redaction set entirely and logged the secret in the clear, with no signal to the author.

Closing the value space is also what makes one predicate enough to decide whether redaction needs an action instance: over this grammar, "carries no Proc or Symbol" and "resolves to the same answer with and without an instance" are the same question (see _has_dynamic_sensitive_fields?).

case/when consults the real class through Module#=== (a C-level check) and compares the literals by identity, so nothing a caller-supplied value defines gets to answer whether it is a valid rule — and the offender is named by CLASS rather than by inspect, which would be running its code while its own error is being built. A Proc is the callable form the resolver actually implements (instance_exec(&sensitive)); a non-Proc callable would be truthiness-tested, i.e. always sensitive.

Enforced in FieldConfig's constructor, which every field, subfield and ambient subfield is built by, and in the DECLARATION WALK for every shape member, whatever its class — the walk reads the value once, on its way into the snapshot, so the check and the stored value are the same read (_snapshot_member_attributes!). ShapeConfig's constructor checks it too, for the block form.

Raises:

  • (ArgumentError)


141
142
143
144
145
146
147
148
149
150
151
# File 'lib/axn/core/contract.rb', line 141

def self.validate_sensitive!(sensitive)
  case sensitive
  when true, false, nil, ::Symbol, ::Proc then return
  end

  raise ArgumentError,
        "sensitive: must be true, false, a Symbol naming an action method, or a Proc (got a value of " \
        "class #{Axn::Internal::Reflection::PropertyNames.renderable_class_name(sensitive)}) — any other value is not a redaction rule, and " \
        "a truthy one would silently leave the value logged in the clear rather than raise. Use " \
        "`sensitive: true` to always redact, or a Symbol/Proc predicate to decide per call."
end

.validate_shape_member_name!(name) ⇒ Object

A shape member's name has to serve as TWO things: the JSON property it renders as (via to_s, which the declaration guard canonicalizes) and the schema property key (via to_sym, which Internal::Reflection::Schema#member_properties emits). A String and a Symbol are the only types for which those conversions are each other's inverse, so they are the only names that mean one property. Any other object defines the two independently, and one whose to_s and to_sym disagree makes the guard and the schema compare different property names for the same member: the guard sees no collision while the schema keys one property for two members and silently discards the first.

Rejected rather than reconciled, because there is nothing to reconcile — an object whose two renderings disagree has no single property name to be. Named by class rather than by inspect, which is the offender's own code running while its error is built.

Enforced in the DECLARATION WALK, unconditionally, for every member the class will store — the one point a member of any class passes through, and the point before the name is converted. Every STORED member is a ShapeConfig, whose constructor holds the same rule and normalizes a String to its Symbol, so a stored name is always the Symbol the schema keys by; that conversion is what keeps parallel to_s/to_sym readings of an unnormalized name from being two questions that can disagree.

Raises:

  • (ArgumentError)


170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/axn/core/contract.rb', line 170

def self.validate_shape_member_name!(name)
  case name
  when ::String, ::Symbol then return
  end

  raise ArgumentError,
        "a shape member name must be a String or a Symbol (got a name of class " \
        "#{Axn::Internal::Reflection::PropertyNames.renderable_class_name(name)}) — a member name is both the JSON " \
        "property it renders as " \
        "and the schema property key it is emitted under, and any other object converts to those two " \
        "independently. Declare the member under a String or Symbol name."
end

.validate_user_facing!(user_facing) ⇒ Object

The grammar of a user_facing: value: true/false, a String, a Symbol (an action method name), or a callable (Proc) — the full error/fail!/fails_on handler shape. Anything else is a programmer error, rejected at declaration. Single-sourced here so the expects/exposes field-level check, FieldConfig's and ShapeConfig's own construction, the declaration walk that reads every member's value on its way into the snapshot (_snapshot_member_attributes!), and ShapeValidator's read of a member axn never snapshotted hold members and fields to one grammar — a member built via the block form, via a raw shape: kwarg, or by the caller's own class is validated identically.

A value outside the grammar is not inert: the executor treats a truthy one as a resolution rule, so it RECLASSIFIES the violation as user-facing (the contract bug is never reported) and then renders as the caller's own error message — user_facing: 123 surfaced the literal "123". That is why the check lives at every point a value can enter, not only at the DSL.

The three literal arms are decided by case/when (Module#===, a C-level check) rather than by is_a?, and the offender is named by CLASS rather than by inspect, exactly as the sensitive: guard beside it — a declaration guard must not let the value being judged raise INSTEAD of the verdict, and outside StandardError that exception escapes every rescue above. It also closes a divergence: the executor picks the String arm with case/when too (_resolve_user_facing_override), so a value whose own is_a? claimed String passed this check and was then rendered as a literal — the failure the guard exists to prevent.

The CALLABLE arm cannot be decided that way, and deliberately is not narrowed to when ::Proc, ::Method. What may be declared here is what Handlers::Invoker will actually invoke, decided by the invoker's own callable? so there is no second, divergent predicate — and that set is open-ended duck typing (to_proc + arity). A case/when list would reject a custom callable that works today, and asking the method table instead would ACCEPT one whose respond_to? answers false, which the invoker would then treat as a literal value and render as the caller's error message. So the dispatch stays and is GUARDED instead: an object that raises while being asked cannot be established as invokable, so it is refused by axn's own error naming its class (see _invokable_user_facing?).

Raises:

  • (ArgumentError)


89
90
91
92
93
94
95
96
97
98
# File 'lib/axn/core/contract.rb', line 89

def self.validate_user_facing!(user_facing)
  case user_facing
  when true, false, ::String, ::Symbol then return
  end
  return if _invokable_user_facing?(user_facing)

  raise ArgumentError,
        "user_facing: must be true, a String, a Symbol, or a Proc (got a value of class " \
        "#{Axn::Internal::Reflection::PropertyNames.renderable_class_name(user_facing)})"
end