axn 0.1.0.pre.alpha.5 → 0.1.0.pre.alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS-consuming.md +78 -17
  3. data/AGENTS-tool-adapters.md +57 -7
  4. data/CHANGELOG.md +131 -0
  5. data/lib/axn/async/adapters/active_job.rb +8 -2
  6. data/lib/axn/async/adapters/sidekiq.rb +14 -3
  7. data/lib/axn/async/batch_enqueue.rb +1 -1
  8. data/lib/axn/async/enqueue_all_orchestrator.rb +5 -1
  9. data/lib/axn/async/exception_reporting.rb +16 -3
  10. data/lib/axn/async.rb +3 -3
  11. data/lib/axn/configurable.rb +13 -5
  12. data/lib/axn/configuration.rb +99 -9
  13. data/lib/axn/core/ambient_context.rb +74 -22
  14. data/lib/axn/core/automatic_logging.rb +1 -0
  15. data/lib/axn/core/context/facade.rb +23 -8
  16. data/lib/axn/core/context/facade_inspector.rb +23 -8
  17. data/lib/axn/core/context/internal.rb +3 -3
  18. data/lib/axn/core/context.rb +8 -1
  19. data/lib/axn/core/contract/redaction.rb +351 -52
  20. data/lib/axn/core/contract/shape_declaration.rb +514 -68
  21. data/lib/axn/core/contract/subfield_contradictions.rb +112 -53
  22. data/lib/axn/core/contract/validator_class_cache.rb +72 -0
  23. data/lib/axn/core/contract.rb +4183 -292
  24. data/lib/axn/core/contract_for_subfields.rb +184 -63
  25. data/lib/axn/core/default_call.rb +1 -1
  26. data/lib/axn/core/executor.rb +293 -47
  27. data/lib/axn/core/flow/callbacks.rb +1 -1
  28. data/lib/axn/core/flow/fails_on.rb +149 -10
  29. data/lib/axn/core/flow/handlers/invoker.rb +16 -2
  30. data/lib/axn/core/flow/handlers/matcher.rb +70 -12
  31. data/lib/axn/core/flow/handlers/resolvers/callback_resolver.rb +8 -2
  32. data/lib/axn/core/flow/handlers/resolvers/message_resolver.rb +63 -14
  33. data/lib/axn/core/flow/messages.rb +1 -1
  34. data/lib/axn/core/hooks.rb +3 -3
  35. data/lib/axn/core/instance_deferral.rb +461 -0
  36. data/lib/axn/core/logging.rb +8 -1
  37. data/lib/axn/core/method_shadowing.rb +195 -9
  38. data/lib/axn/core/nesting_tracking.rb +53 -3
  39. data/lib/axn/core/tagging.rb +16 -4
  40. data/lib/axn/core/validation/base.rb +465 -65
  41. data/lib/axn/core/validation/clusivity_sets.rb +304 -0
  42. data/lib/axn/core/validation/container_contents.rb +32 -0
  43. data/lib/axn/core/validation/fields.rb +39 -23
  44. data/lib/axn/core/validation/validators/exclusion_validator.rb +16 -0
  45. data/lib/axn/core/validation/validators/inclusion_validator.rb +16 -0
  46. data/lib/axn/core/validation/validators/non_emptiness_validator.rb +4 -1
  47. data/lib/axn/core/validation/validators/of_validator.rb +300 -10
  48. data/lib/axn/core/validation/validators/shape_validator.rb +29 -12
  49. data/lib/axn/core/validation/validators/type_validator.rb +23 -3
  50. data/lib/axn/core/validation/validators/validate_validator.rb +24 -4
  51. data/lib/axn/core/validation/validators/whole_value_clusivity.rb +33 -0
  52. data/lib/axn/core.rb +77 -19
  53. data/lib/axn/exceptions.rb +199 -6
  54. data/lib/axn/extensions/config.rb +30 -1
  55. data/lib/axn/extensions/invoked_via.rb +64 -0
  56. data/lib/axn/extensions/serialization.rb +10 -1
  57. data/lib/axn/extensions/tracing.rb +59 -0
  58. data/lib/axn/extensions.rb +126 -9
  59. data/lib/axn/extras/strategies/client.rb +18 -10
  60. data/lib/axn/extras/strategies/vernier.rb +3 -3
  61. data/lib/axn/factory.rb +23 -1
  62. data/lib/axn/form_object.rb +11 -2
  63. data/lib/axn/internal/action_state.rb +114 -0
  64. data/lib/axn/internal/call_logger.rb +71 -9
  65. data/lib/axn/internal/coercion.rb +5 -1
  66. data/lib/axn/internal/current_entry_point.rb +35 -0
  67. data/lib/axn/internal/cycle_guard.rb +14 -0
  68. data/lib/axn/internal/exception_context.rb +1 -1
  69. data/lib/axn/internal/fails_on_verdicts.rb +49 -0
  70. data/lib/axn/internal/field_config.rb +34 -0
  71. data/lib/axn/internal/name_ownership.rb +169 -0
  72. data/lib/axn/internal/native_methods.rb +306 -26
  73. data/lib/axn/internal/reflection/pattern.rb +219 -0
  74. data/lib/axn/internal/reflection/property_names.rb +53 -9
  75. data/lib/axn/internal/reflection/schema.rb +1809 -153
  76. data/lib/axn/internal/reflection/values.rb +17 -19
  77. data/lib/axn/internal/reflection.rb +6 -2
  78. data/lib/axn/internal/rendering.rb +37 -2
  79. data/lib/axn/internal/resolved_subfields.rb +4 -0
  80. data/lib/axn/internal/shape_graph.rb +169 -34
  81. data/lib/axn/internal/subfield_tree.rb +156 -23
  82. data/lib/axn/internal/tracing.rb +61 -0
  83. data/lib/axn/internal/transparent_bubbling.rb +35 -0
  84. data/lib/axn/mountable/helpers/class_builder.rb +9 -2
  85. data/lib/axn/mountable/mounting_strategies/step.rb +31 -21
  86. data/lib/axn/mountable.rb +1 -1
  87. data/lib/axn/result.rb +96 -39
  88. data/lib/axn/strategies/form.rb +21 -3
  89. data/lib/axn/testing.rb +9 -1
  90. data/lib/axn/tools/adapter_roots.rb +161 -6
  91. data/lib/axn/tools/adapter_serialization.rb +159 -0
  92. data/lib/axn/tools/invoker.rb +31 -7
  93. data/lib/axn/tools.rb +13 -4
  94. data/lib/axn/version.rb +1 -1
  95. data/lib/axn.rb +33 -2
  96. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7c950f2b3d95c1f76aa0d963531b635e08c57058a2c3e2c8d07ccd40355faa5
4
- data.tar.gz: 459ab003e2d8802c4c547ff6b89f6de3cc673e7e50072a66928d6a1b78444db7
3
+ metadata.gz: 96629802248edd18b5c1d3607b7aa3ad6775c9185a8667808f4d81ae98cfb69e
4
+ data.tar.gz: 66185b1f3f784c7a4c57680ed7b1fdcda1ae958904b69d53dfa003fb50c88286
5
5
  SHA512:
6
- metadata.gz: b8917cfa50c90daf33a41ebc40c90e5bec9bafe926da10a70ac4b73c34d5883185569af921d8f7693dd4f0b38074d07d675ecc8d3f7b4808be4b69ef8e52c6b9
7
- data.tar.gz: 96a0838272f6f800206d2c85beae9c82f1e1ef954db7d940d856c431719870639bc4e63f1ad1883621665ac67eaa676bbc270c1072c0bf517d458e268d6790ef
6
+ metadata.gz: bc8ffc522d6226bcb15f401d42f23560cec3fd0497d30f710456d2b68f12c1238c5ca6453f151aec00baddebfe707b619a649177c62779f6db0cf44eb43e84fb
7
+ data.tar.gz: 8bd82a4adf9fd10a612ae70ff4abfc7b37284970dbed31a6e615c5d842b8384e51ad9ff17b1a6914738257fc2bc2d86ce374fa8d18a21019f069e19e300653dd
data/AGENTS-consuming.md CHANGED
@@ -60,13 +60,13 @@ Common options (same for `expects` and `exposes`):
60
60
 
61
61
  | Option | Meaning |
62
62
  | --- | --- |
63
- | `type:` | `is_a?` check. `type: :boolean` (no Ruby Boolean class; also defines a `field?` predicate), `type: :uuid`, `type: :params` (a Hash or any `ActionController::Parameters`). Union: `type: [String, Symbol]`. |
63
+ | `type:` | `is_a?` check. `type: :boolean` (no Ruby Boolean class; also defines a `field?` predicate), `type: :uuid`, `type: :params` (a Hash or any `ActionController::Parameters`). Union: `type: [String, Symbol]`. A token outside that grammar (`type: false`, `type: [String, nil]`) raises `ArgumentError` at declaration. |
64
64
  | `optional: true` | Don't fail when the field is missing or nil (≡ `allow_blank: true`); removes the auto presence check. **Preferred** spelling. Caveat: a *typed* field still type-checks a non-nil blank — `type: Hash, optional: true` still rejects `""` (a `type: String` field accepts it, since `"".is_a?(String)`). |
65
65
  | `allow_empty: true` | Accept an empty collection or string but **not** `nil` — the field stays required. Needs a `type:` whose values can be empty — `Array`/`Hash`/`Set`/`String`/`:params`, or any class or module defining `empty?`; raises otherwise, as does any value other than `true`/`false`/`nil`. Pair with a tolerance flag inverted (`optional: true, allow_empty: false`) for "may be omitted, but not empty". Don't also declare `presence:` — the two answer the same question, and a disagreement raises. |
66
66
  | `allow_nil:` / `allow_blank:` | Finer-grained than `optional:`. |
67
67
  | `default:` | Used when the field is missing or explicitly `nil` (**not** for blank values). |
68
68
  | `sensitive: true` | Filter the value in logs / error reports / `inspect`. Accepts a proc/symbol for runtime decisions. |
69
- | `of:` | For `type: Array` **only** — validates each element's class (`of: String`, `of: [String, Numeric]`). Errors report the failing index. |
69
+ | `of:` | Names what is INSIDE a container. `type: Array`: each element's class (`of: String`, `of: [String, Numeric]`), errors reporting the failing index. `type: Hash`: a map (`of: { keys: Symbol, values: Integer }`) — either axis may be omitted to leave it unconstrained, and a failing entry is reported by its ordinal, never its key. Refused on any other type, a union `type: [Array, Hash]` included. It **nests**: an element or an axis may take an inner-contract bag carrying `klass:`/`of:`/`shape:`/`message:` — `of: { klass: Array, of: Integer }` (an array of arrays), `of: { values: { klass: Hash, shape: … } }` (a map of shaped records; members at an axis must be objects answering `field`/`validations`, e.g. a `Struct.new(:field, :validations)`, since no block form reaches an axis). On a Hash, `of:` may sit beside a `shape:` or a block, which names specific keys: those keys are **exempt** from the map contract, exactly as JSON Schema's `additionalProperties` applies only to keys `properties` does not match. |
70
70
  | `validate:` | Custom: `validate: ->(v) { "must be > 10" unless v > 10 }` — return a string (or raise) to fail. |
71
71
  | any ActiveModel validation | e.g. `length:`, `format:`, `numericality:` — passed through as if to `validates`. |
72
72
 
@@ -92,14 +92,29 @@ See <https://teamshares.github.io/axn/reference/tool-invoker>.
92
92
  | `done!("msg", **kw)` | Abort now as **success** (early return); skips remaining `call` + `after` hooks. |
93
93
  | `log("msg", level: :info)` | Log via `Axn.config.logger`, prefixed with the class name. |
94
94
  | field readers | Read any `expects` field by name; `result.<field>` reads exposures (rare inside `call`). |
95
+ | `Axn::Extensions::Tracing.annotate_span(**attrs)` | Write vendor-namespaced OTel attributes onto axn's own `axn.call` span — for a gem, not an app (apps use `tag`/`dimension`); never `OpenTelemetry::Trace.current_span`. |
95
96
 
96
97
  If you declare `exposes :x` you must `expose x: …` on every success path — **unless** `x` is also an
97
98
  `expects` field, in which case Axn auto-copies it (see Gotchas). Outbound validation still runs on
98
99
  `done!`, so a required exposure that's unset makes the action fail with `OutboundValidationError`.
99
100
 
100
101
  Hooks: `before`, `after`, `around` (block or symbol method). A `fail!`/raise in a hook fails the
101
- action. `done!` skips `after` hooks but lets `around` finish. Callbacks (`on_success`, `on_error`,
102
- `on_failure`, `on_exception`) run *after* `call` and do **not** flip `ok?`.
102
+ action. `done!` skips `after` hooks and because it unwinds via an exception, statements *after*
103
+ `chain.call` in an `around` hook are skipped too (`fail!` and an unhandled raise unwind the same
104
+ way). Put such teardown in an `ensure` inside the `around`, or use `use :transaction`, which rescues
105
+ the signal so the transaction still commits. Note the `around` hook (and its `ensure`) covers only
106
+ halts raised **after the hook chain is entered** — an inbound `expects` failure, or an *inbound*
107
+ `preprocess:`/`default:` callable that raises, settles before the hooks run, so neither fires. The
108
+ `exposes` side is bounded too: outbound resolution (an `exposes` `default:`, outbound validation)
109
+ runs *after* the hook body returns, so the hooks complete **normally** and never observe a raise from
110
+ it — an `around` that rescues to record failures misses them. Use the callbacks for per-call
111
+ observability that must not miss either end. Callbacks
112
+ (`on_success`, `on_error`, `on_failure`, `on_exception`) fire once the action **settles** — which is
113
+ not the same as "after `call`": they fire even when `call` never ran, as on an inbound validation
114
+ failure. That is what makes them the seam that sees every call. `on_error` is a superset, co-firing
115
+ with whichever of `on_failure`/`on_exception` applies. A raise in a callback does **not** flip `ok?` —
116
+ it is swallowed, logged, and reported to `Axn.config.on_ignored_exception` (which defaults to your
117
+ `on_exception` handler) carrying `context[:axn_ignored]`.
103
118
  <https://teamshares.github.io/axn/usage/writing>.
104
119
 
105
120
  ## Using a result
@@ -149,7 +164,9 @@ expects :user, model: true
149
164
 
150
165
  `user_id` always means *the record's primary key*, on every path. Passing both a record and a
151
166
  disagreeing `user_id` (default `:find` finder) raises `InboundValidationError` — contradictory
152
- input is a developer error. Source: `lib/axn/core/field_resolvers/model.rb`.
167
+ input is a developer error. `klass:` must be a single Class/Module (no union, no `type:`-style
168
+ pseudo-type); anything else raises `ArgumentError` at declaration. Source:
169
+ `lib/axn/core/field_resolvers/model.rb`.
153
170
 
154
171
  **`on:` — subfields (the `:extract` resolver).** Declare expectations about nested data and get a
155
172
  flat reader:
@@ -162,8 +179,12 @@ expects :zip, on: "address.billing" # dotted path; reader: zip
162
179
  ```
163
180
 
164
181
  Subfields support all the normal options and `default:`; `readers: false` skips reader creation;
165
- `as:`/`prefix:` rename. `default:`/`preprocess:`/`sensitive:` are **not** allowed on a *nested
166
- parent*. Subfield hashes accept string **or** symbol keys (indifferent). Source:
182
+ `as:`/`prefix:` rename. `default:`/`preprocess:`/`sensitive:` work on a *nested parent* too (whether
183
+ reached by dotted path or by pointing `on:` at another subfield). `default:`/`preprocess:` resolve on
184
+ the **read path**, when the subfield is read; `sensitive:` is not part of that read — it resolves
185
+ only when something requests redaction (see Gotchas). Either way the parent is never mutated and
186
+ intermediates are never materialized; on an ambient parent (`on: :ambient_context`) only
187
+ `user_facing:` is unsupported. Subfield hashes accept string **or** symbol keys (indifferent). Source:
167
188
  `lib/axn/core/field_resolvers/extract.rb`. Reference:
168
189
  <https://teamshares.github.io/axn/reference/class>.
169
190
 
@@ -190,6 +211,9 @@ reported to `Axn.config.on_exception`. Key consequences:
190
211
  the idiom for the "save an ActiveRecord model" case — a plain action plus
191
212
  `fails_on ActiveRecord::RecordInvalid, &:message` surfaces the record's validation errors as the
192
213
  failure message (and, e.g., `fails_on Stripe::CardError, &:message` for a card-declined message).
214
+ `if:`/`unless:` (evaluated against the action at settlement, same mechanism as `error`/callbacks)
215
+ gate the reclassification itself, not just the message — a condition living *inside* the message
216
+ block never did that, which is the footgun `if:`/`unless:` exist to close.
193
217
  - **`expects` violations are dev-facing by default** → exception bucket, pages, generic
194
218
  `"Something went wrong"`. A missing required input is your bug. Mark a genuinely caller-supplied
195
219
  field `user_facing: true` (or a String/Symbol/Proc message) to move *its* violations to the
@@ -215,12 +239,28 @@ error "email already taken", if: ArgumentError # reason → "Couldn't sync use
215
239
  fail! "missing field" # reason → "Couldn't sync user: missing field"
216
240
  ```
217
241
 
218
- Composing actions: a base `error` on the parent auto-prefixes a child failure surfaced via `call!`
219
- **only when the child failed via `fail!`** (re-raised as `Axn::Failure`). A child `fails_on`-matched
220
- exception or any raised exception bubbles as the *original* exception, so the parent settles as a
221
- failure but `result.error` shows just the parent headline; the child's message is **not** woven in.
222
- To carry the child's message (e.g. a `RecordInvalid`/model-strategy child), or to add per-call
223
- context, use non-bang `call` + `fail!("context: #{child.error}")`.
242
+ Composing actions: a base `error` on the parent auto-prefixes the child's **resolved `result.error`**
243
+ surfaced via `call!`, and this is **bucket-independent** it applies whether the child failed via
244
+ `fail!`, a `fails_on`-classified exception, or an unexpected exception. What differs by bucket is the
245
+ *exception object*, not the message: `fail!` re-raises as `Axn::Failure`, while a `fails_on`-matched
246
+ or unhandled exception bubbles as the **original** exception. Either way the child's message is woven
247
+ in (`"Onboarding failed: Charge failed: card declined"`) — **unless the parent itself declares a
248
+ matching conditional reason**, which *replaces* the child's presentation rather than prefixing it
249
+ (`error "Record not found", if: NotFoundErr` on the parent yields `"Onboarding failed: Record not
250
+ found"`, and `standalone: true` drops the parent's base too, leaving `"Record not found"`). So a
251
+ parent that authors its own reason for an exception class opts out of carrying the child's context.
252
+ An *unexpected* exception still picks up a
253
+ leaf if a conditional `error "…", if: SomeError` matches it — reason matching is independent of
254
+ `fails_on` — giving `"Onboarding failed: Charge failed: retry later"` while the outcome stays
255
+ `exception`. Only with **no matching reason** is there no leaf, and then just the declared base
256
+ headers chain (`"Onboarding failed: Charge failed"`). The raw exception message never enters
257
+ `result.error` **by default** — it stays the technical `#message` on `result.exception` — but you can
258
+ opt a class in explicitly with `error(if: SomeError, &:message)` or `fails_on SomeError, &:message`,
259
+ and once opted in it aggregates like any other reason (`"Onboarding failed: Charge failed: <raw
260
+ message>"`). Only do that where the message is genuinely user-facing. A level declaring no base
261
+ contributes nothing. Reach for non-bang `call` +
262
+ `fail!("context: #{child.error}")` when you want to author a *different* message than this automatic
263
+ aggregation, or to add per-call context — not to carry the child's message through.
224
264
 
225
265
  ⚠️ **Message bodies are NOT redacted** and propagate outward to every ancestor's `result.error`,
226
266
  logs, and error trackers. Never interpolate secrets/PII into `error`/`success`/`fail!` text — put
@@ -246,8 +286,17 @@ sensitive values in `sensitive:` fields. Detail:
246
286
  unchanged (not wrapped). `fails_on` reclassification is sticky across `call!` boundaries.
247
287
  - **Hooks vs callbacks.** A raise/`fail!` in a `before`/`after`/`around` hook flips `ok?` to false; a
248
288
  raise in a callback (`on_success` etc.) is reported but does **not** change `ok?`.
249
- - **`sensitive:` proc timing.** For `expects`, the `sensitive:` callable runs *before* defaults are
250
- applied guard against `nil` if it depends on another field.
289
+ - **Callable-option timing.** A `sensitive:` Proc is `instance_exec`'d with **no arguments** (write
290
+ `sensitive: -> { !include_pii }`, reading other fields by name — a lambda declaring a parameter
291
+ raises), and it resolves lazily, only when something actually redacts. By then `default:`s are
292
+ applied, so it reads another field's *defaulted* value. `preprocess:` is the opposite: it runs
293
+ *before* defaults, seeing `nil` for an omitted top-level field. Only a **non-nil** result bypasses
294
+ the `default:` — return `nil` and the default still applies, so an intentional nil does not survive
295
+ (`false` does, matching `default:`'s missing-or-nil rule). On a **subfield** the preprocessor runs
296
+ only when the leaf's immediate parent is present: `parent: {}` still invokes it with `nil`, but an
297
+ absent or `nil` parent skips it entirely. The subfield's own `default:` still resolves in that case
298
+ — the reader returns the default, and is `nil` only when there is none. So don't rely on a nested
299
+ `preprocess:` to manufacture a value when the parent is missing; declare a `default:` for that.
251
300
 
252
301
  ## Strategies (DRYed configuration via `use`)
253
302
 
@@ -264,13 +313,25 @@ chains existing action classes. Data flows via the shared context; a step failur
264
313
  with the step name prefixed (`"validate: Email is invalid"`).
265
314
  <https://teamshares.github.io/axn/usage/steps>.
266
315
 
316
+ ## Declaring your entry point
317
+
318
+ If you're building a gem that dispatches Axns on behalf of an external trigger (an inbound webhook, a
319
+ scheduled job runner, a queue consumer) rather than calling them straight from app code, wrap your
320
+ dispatch in `Axn::Extensions::InvokedVia.with(:your_gem) { handler.call!(**args) }`. The value becomes
321
+ the `invoked_via` dimension on the whole call tree — every nested sub-Axn, span, log line, metric, and
322
+ exception report — so an app can tell "traffic that came in through your gem" apart from ordinary
323
+ direct calls without touching the classes it dispatches. A tool-adapter gem gets this automatically
324
+ through `Axn::Tools::Invoker`'s `adapter:` kwarg; everyone else calls `InvokedVia.with` once, at the
325
+ outermost point they control. `dimension :invoked_via` / `tag :invoked_via` are reserved — declaring
326
+ either raises. See `docs/recipes/declaring-entry-points.md` for the full recipe.
327
+
267
328
  ## Pointers
268
329
 
269
330
  Human docs — <https://teamshares.github.io/axn/>:
270
331
  build (`/usage/writing`), use (`/usage/using`), class DSL (`/reference/class`), instance helpers
271
332
  (`/reference/instance`), result (`/reference/axn-result`), strategies (`/strategies/`), steps
272
333
  (`/usage/steps`), async (`/reference/async`), config (`/reference/configuration`), tool invoker
273
- (`/reference/tool-invoker`).
334
+ (`/reference/tool-invoker`), entry points (`/recipes/declaring-entry-points`).
274
335
 
275
336
  Source entry points (resolve with `bundle show axn`):
276
337
  - `lib/axn.rb` — `include Axn` wiring.
@@ -279,4 +340,4 @@ Source entry points (resolve with `bundle show axn`):
279
340
  - `lib/axn/core/validation/validators/` — `type`, `of`, `model`, `validate`, `shape` validators.
280
341
  - `lib/axn/core/flow/` — `messages.rb`, `fails_on.rb`, `handlers/` (failure/message/callback resolution).
281
342
  - `lib/axn/result.rb`, `lib/axn/core/context/facade.rb` — the `Result` surface.
282
- - `lib/axn/strategies/` — `model.rb`, `form.rb`, `transaction.rb`.
343
+ - `lib/axn/strategies/` — `form.rb`, `transaction.rb`; `lib/axn/extras/strategies/client.rb` (`use :client`).
@@ -48,6 +48,11 @@ GemName.wrap(axn_class, **opts) # one Axn -> the transport's native tool object
48
48
  - **Directory membership is optional.** `extend Axn::Tools::AdapterRoots` → a validated `tool_roots`
49
49
  setting the registry reads. Its `validate!` reuses core's broad-path guard (rejects `app`/`actions`/`.`/`..`).
50
50
  The reference gems don't adopt it (they use explicit `tool`/`configure`); add it only if it fits.
51
+ - **Shipping a non-empty default root? Call `tool_roots_default %w[agent_tools]`** — don't re-declare
52
+ `setting :tool_roots` by hand, which means hand-copying `AdapterRoots.validate!`'s lambda too.
53
+ `tool_roots_default` validates EAGERLY at the call site (fails at gem load, not at the registry's first
54
+ read) and re-declares through the normal `setting` path, so an app's own assignment still wins and
55
+ `config.reset!(:tool_roots)` returns to the adapter's default, not core's `[]`.
51
56
 
52
57
  Source: `lib/axn/tools.rb` (`.register_adapter`, `.for`, `.versions`), `lib/axn/tools/registry.rb` (membership,
53
58
  eager-load), `lib/axn/tools/adapter_roots.rb`, `lib/axn/core/tool_declaration.rb` (`tool` DSL, `tool_name`).
@@ -103,8 +108,17 @@ Source: `lib/axn/core/schema_reflection.rb`, `lib/axn/internal/reflection/schema
103
108
  pre-pass — it drifts from the renderer.
104
109
  - Keep the two guarantees apart: encodability is unconditional, declared-shape is what the flag buys.
105
110
  `reject_opaque: false` never means "might not be JSON" — that is why it isn't named `strict:`.
106
-
107
- Source: `lib/axn/extensions/serialization.rb` (the renderer itself is `lib/axn/internal/reflection/values.rb`, core-internal).
111
+ - This is call-time by construction, not a boot check: the verdict is about the exposed VALUE's own class
112
+ (does it define its own `to_h`/`as_json`), and a field's declared `type:` is only a lower bound on what
113
+ could show up there — a subclass or a singleton method can make an otherwise-opaque declared type render
114
+ cleanly. A check keyed on the declared class alone would refuse programs that run correctly.
115
+ - **If `reject_opaque:` is per-tool (usually is), call `YourAdapter.serialize_exposed(result)` instead of
116
+ `render` directly.** `Axn::Tools::AdapterSerialization` bundles the declaration and the
117
+ resolve-then-render chain — see Per-adapter configuration below. `render(result, reject_opaque:
118
+ config.reject_opaque)` reading the flag off the gem-wide `config` is the mistake this exists to
119
+ prevent: it ignores any per-tool `configure(:key)` / `tool key: { … }` override.
120
+
121
+ Source: `lib/axn/extensions/serialization.rb` (the renderer itself is `lib/axn/internal/reflection/values.rb`, core-internal), `lib/axn/tools/adapter_serialization.rb`.
108
122
 
109
123
  ## Per-adapter configuration
110
124
 
@@ -117,8 +131,15 @@ Source: `lib/axn/extensions/serialization.rb` (the renderer itself is `lib/axn/i
117
131
  - A **render toggle** (structured serialized `exposes` vs. the Axn's message) is a common per-adapter
118
132
  setting. `axn-mcp` and `axn-ruby_llm` both name it `present_as` (`:structured` / `:message`) — reuse the
119
133
  name/values if you have the concept. It's adapter-specific, not core (an `axn-http_api` has no such toggle).
134
+ - **`reject_opaque_exposed_values` is shared — declare it with `extend Axn::Tools::AdapterSerialization` +
135
+ `declare_reject_opaque_exposed_values! default: <bool>`**, not a hand-written `setting`. `default:` is a
136
+ REQUIRED kwarg with no core-picked value: an HTTP adapter with a published `output_schema` wants `true`
137
+ (axn-openapi); an LLM-facing adapter usually wants `false` — ugly-but-honest beats a failed call
138
+ (axn-mcp, axn-ruby_llm). Never fix the default in a shared declaration; parameterize it.
139
+ - **The transport-mapping step needs its own never-raises guard — use `guard_tool_response`,** from the
140
+ same mixin. See Invocation & result → response below.
120
141
 
121
- Source: `lib/axn/configurable.rb` (`config_namespace`, `resolve_override_for`, `overrides`).
142
+ Source: `lib/axn/configurable.rb` (`config_namespace`, `resolve_override_for`, `overrides`), `lib/axn/tools/adapter_serialization.rb`.
122
143
 
123
144
  ## Extension registry
124
145
 
@@ -135,6 +156,9 @@ Source: `lib/axn/extensions/config.rb`, `lib/axn/core/semantic_hints.rb`.
135
156
  (always-on wire coercion, opt-in user-facing input-error surfacing, undeclared-key rejection, the
136
157
  ambient guard) that a trusted in-process `.call` deliberately omits. See
137
158
  <https://teamshares.github.io/axn/reference/tool-invoker>.
159
+ - Pass `adapter: :your_key` to `Invoker.new` and every call it makes is stamped with the `invoked_via`
160
+ dimension across its whole call tree — the seam that lets an app's dashboard separate tool-driven
161
+ traffic from ordinary direct calls. See <https://teamshares.github.io/axn/recipes/declaring-entry-points>.
138
162
  - Map from: `result.ok?`; `result.error` (**user-facing** — show to the LLM/client); `result.success` /
139
163
  `result.message` (success string); `result.exception` (**dev-facing** detail, e.g. the
140
164
  `Axn::InboundValidationError` — do **NOT** surface it).
@@ -147,8 +171,17 @@ Source: `lib/axn/extensions/config.rb`, `lib/axn/core/semantic_hints.rb`.
147
171
  `fail!("…", standalone: true)`.
148
172
  - For per-field inbound detail: `Axn::Tools::Invoker.input_invalid?(result)` and
149
173
  `result.exception.field_errors`.
150
-
151
- Source: `lib/axn/extensions.rb` (`owned_failure?`), `lib/axn/tools/invoker.rb`, `lib/axn/result.rb`.
174
+ - **Never let the mapping step escape.** `axn_class.call` (via the Invoker) never raises, but the
175
+ result→response mapping AFTER it — `serialize_exposed`/render, your own response-building code — runs
176
+ outside core's executor and CAN raise. Wrap just that step:
177
+ `YourAdapter.guard_tool_response(axn_class, on_error: ->(e) { your_error_response }) { map_result_to_response }`.
178
+ It reports through `Axn.config.on_exception`, re-raises when `Axn::Extensions.raises_in_dev?`, and
179
+ otherwise calls `on_error` with the exception so you build the transport-native error response (this
180
+ method never constructs one). **Scope it to the mapping only — never to `axn_class.call`** (already
181
+ reports its own exceptions; wrapping both double-reports).
182
+
183
+ Source: `lib/axn/extensions.rb` (`owned_failure?`), `lib/axn/tools/invoker.rb`, `lib/axn/result.rb`,
184
+ `lib/axn/tools/adapter_serialization.rb` (`guard_tool_response`).
152
185
 
153
186
  ## Error boundary
154
187
 
@@ -174,6 +207,20 @@ Source: `lib/axn/extensions.rb` (`owned_failure?`), `lib/axn/tools/invoker.rb`,
174
207
 
175
208
  Source: `lib/axn/error.rb`.
176
209
 
210
+ ## Span annotation
211
+
212
+ If your gem needs to write vendor-namespaced OTel attributes it can't know at declaration time
213
+ (`gen_ai.*`, `db.*`, …), use `Axn::Extensions::Tracing.annotate_span(**attrs)` — never
214
+ `OpenTelemetry::Trace.current_span`. That resolves through `OpenTelemetry::Context.current`, ambient
215
+ process-wide state anything else running mid-chain can move (verified against the Datadog OTel bridge in
216
+ production, PRO-3278); `annotate_span` is the span axn's own tracer opened for the action currently
217
+ running, direct, no guessing. It no-ops (no raise) when there is no span, skips a `nil` value, and
218
+ converts a Symbol key to a String. `axn-ruby_llm`'s `record_otel_attributes!` is the reference use.
219
+ `Axn::Extensions::Tracing.current_span` is the raw accessor for anything beyond attributes — valid only
220
+ for the duration of the action's own body; don't hold it past the call.
221
+
222
+ Source: `lib/axn/extensions/tracing.rb`, `docs/reference/configuration.md#annotating-the-span-from-your-own-code`.
223
+
177
224
  ## ambient_context
178
225
 
179
226
  Server/session data (`current_user`, `company`) an author declares via `expects :user_id, on: :ambient_context`.
@@ -234,14 +281,17 @@ Source: `lib/axn/core/ambient_context.rb`, `lib/axn/tools/invoker.rb`.
234
281
 
235
282
  Docs — <https://teamshares.github.io/axn/>: authoring a tool-adapter gem
236
283
  (`/recipes/authoring-tool-adapters`), tool invoker (`/reference/tool-invoker`), gem configuration
237
- (`/recipes/gem-configuration`), factory (`/reference/factory`), class DSL (`/reference/class`), result
238
- (`/reference/axn-result`). Action-authoring: `AGENTS-consuming.md` (this gem).
284
+ (`/recipes/gem-configuration`), declaring an entry point (`/recipes/declaring-entry-points`), factory
285
+ (`/reference/factory`), class DSL (`/reference/class`), result (`/reference/axn-result`).
286
+ Action-authoring: `AGENTS-consuming.md` (this gem).
239
287
 
240
288
  Core source entry points (resolve with `bundle show axn`):
241
289
  - `lib/axn/tools.rb` — `Axn::Tools.register_adapter`, `.adapters`, `.for`, `.versions`, `.validate_contracts!`.
242
290
  - `lib/axn/extensions.rb` — `Axn::Extensions.best_effort`, `.config`, `.owned_failure?` (extension-author surface).
243
291
  - `lib/axn/tools/registry.rb`, `lib/axn/tools/adapter_roots.rb`, `lib/axn/core/tool_declaration.rb` —
244
292
  membership, `tool_name`.
293
+ - `lib/axn/tools/adapter_serialization.rb` — `declare_reject_opaque_exposed_values!`,
294
+ `serialize_exposed`, `guard_tool_response`.
245
295
  - `lib/axn/core/schema_reflection.rb`, `lib/axn/internal/reflection/schema.rb`, `lib/axn/internal/reflection/values.rb` — reflection.
246
296
  - `lib/axn/configurable.rb` — `config_namespace`, `resolve_override_for`, `overrides`.
247
297
  - `lib/axn/tools/invoker.rb` — the tool call path.