axn 0.1.0.pre.alpha.4.3 → 0.1.0.pre.alpha.5

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 (153) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +109 -0
  3. data/AGENTS-consuming.md +282 -0
  4. data/AGENTS-tool-adapters.md +250 -0
  5. data/CHANGELOG.md +173 -0
  6. data/README.md +20 -2
  7. data/lib/axn/async/adapters/active_job.rb +6 -0
  8. data/lib/axn/async/adapters/sidekiq/auto_configure.rb +21 -2
  9. data/lib/axn/async/adapters/sidekiq/death_handler.rb +13 -3
  10. data/lib/axn/async/adapters/sidekiq/middleware.rb +3 -2
  11. data/lib/axn/async/adapters/sidekiq/worker.rb +47 -0
  12. data/lib/axn/async/adapters/sidekiq.rb +125 -35
  13. data/lib/axn/async/adapters.rb +10 -2
  14. data/lib/axn/async/batch_enqueue.rb +32 -0
  15. data/lib/axn/async/enqueue_all_orchestrator.rb +112 -34
  16. data/lib/axn/async/exception_reporting.rb +97 -18
  17. data/lib/axn/async/ownership.rb +101 -0
  18. data/lib/axn/async.rb +37 -15
  19. data/lib/axn/configurable.rb +667 -0
  20. data/lib/axn/configuration.rb +249 -32
  21. data/lib/axn/core/ambient_context.rb +298 -0
  22. data/lib/axn/core/automatic_logging.rb +65 -7
  23. data/lib/axn/core/context/facade.rb +50 -42
  24. data/lib/axn/core/context/facade_inspector.rb +230 -69
  25. data/lib/axn/core/context/internal.rb +37 -17
  26. data/lib/axn/core/context.rb +72 -0
  27. data/lib/axn/core/contract/redaction.rb +550 -0
  28. data/lib/axn/core/contract/shape_declaration.rb +535 -0
  29. data/lib/axn/core/contract/subfield_contradictions.rb +228 -0
  30. data/lib/axn/core/contract.rb +1799 -125
  31. data/lib/axn/core/contract_for_subfields.rb +636 -58
  32. data/lib/axn/core/default_call.rb +2 -2
  33. data/lib/axn/core/executor.rb +1654 -0
  34. data/lib/axn/core/extension_metadata.rb +50 -0
  35. data/lib/axn/core/field_resolvers/extract.rb +117 -9
  36. data/lib/axn/core/field_resolvers/model.rb +35 -19
  37. data/lib/axn/core/field_resolvers.rb +32 -2
  38. data/lib/axn/core/flow/callbacks.rb +0 -1
  39. data/lib/axn/core/flow/fails_on.rb +86 -0
  40. data/lib/axn/core/flow/handlers/descriptors/message_descriptor.rb +27 -36
  41. data/lib/axn/core/flow/handlers/invoker.rb +20 -4
  42. data/lib/axn/core/flow/handlers/matcher.rb +33 -22
  43. data/lib/axn/core/flow/handlers/resolvers/message_resolver.rb +177 -27
  44. data/lib/axn/core/flow/messages.rb +21 -16
  45. data/lib/axn/core/flow.rb +2 -0
  46. data/lib/axn/core/logging.rb +6 -1
  47. data/lib/axn/core/method_shadowing.rb +38 -0
  48. data/lib/axn/core/naming.rb +56 -0
  49. data/lib/axn/core/nesting_tracking.rb +46 -12
  50. data/lib/axn/core/schema_reflection.rb +79 -0
  51. data/lib/axn/core/semantic_hints.rb +32 -0
  52. data/lib/axn/core/tagging.rb +166 -0
  53. data/lib/axn/core/tool_declaration.rb +239 -0
  54. data/lib/axn/core/validation/base.rb +383 -0
  55. data/lib/axn/core/validation/fields.rb +171 -40
  56. data/lib/axn/core/validation/validators/non_emptiness_validator.rb +70 -0
  57. data/lib/axn/core/validation/validators/shape_validator.rb +165 -6
  58. data/lib/axn/core/validation/validators/type_validator.rb +29 -3
  59. data/lib/axn/core/validation/validators/validate_validator.rb +35 -5
  60. data/lib/axn/core/versioning.rb +80 -0
  61. data/lib/axn/core.rb +47 -13
  62. data/lib/axn/error.rb +38 -0
  63. data/lib/axn/exceptions.rb +388 -15
  64. data/lib/axn/extensions/config.rb +23 -0
  65. data/lib/axn/extensions/serialization.rb +49 -0
  66. data/lib/axn/extensions.rb +237 -0
  67. data/lib/axn/extras/strategies/client.rb +28 -3
  68. data/lib/axn/extras/strategies/vernier.rb +1 -1
  69. data/lib/axn/factory.rb +159 -20
  70. data/lib/axn/field_declarations.rb +27 -0
  71. data/lib/axn/form_object.rb +4 -0
  72. data/lib/axn/internal/async_serialization.rb +198 -0
  73. data/lib/axn/internal/call_logger.rb +85 -29
  74. data/lib/axn/internal/carried_presentation.rb +27 -0
  75. data/lib/axn/internal/coercion.rb +174 -0
  76. data/lib/axn/internal/contract_error_handling.rb +20 -1
  77. data/lib/axn/internal/current_call_options.rb +53 -0
  78. data/lib/axn/internal/cycle_guard.rb +109 -0
  79. data/lib/axn/internal/exception_classification.rb +41 -0
  80. data/lib/axn/internal/exception_context.rb +55 -82
  81. data/lib/axn/internal/field_config.rb +100 -18
  82. data/lib/axn/internal/identity.rb +120 -0
  83. data/lib/axn/internal/native_methods.rb +387 -0
  84. data/lib/axn/internal/reflection/property_names.rb +998 -0
  85. data/lib/axn/internal/reflection/schema.rb +1795 -0
  86. data/lib/axn/internal/reflection/values.rb +634 -0
  87. data/lib/axn/internal/reflection.rb +43 -0
  88. data/lib/axn/internal/registry.rb +5 -4
  89. data/lib/axn/internal/rendering.rb +154 -0
  90. data/lib/axn/internal/resolved_subfields.rb +53 -0
  91. data/lib/axn/internal/shape_graph.rb +485 -0
  92. data/lib/axn/internal/subfield_tree.rb +108 -0
  93. data/lib/axn/internal/text.rb +75 -0
  94. data/lib/axn/internal/tracing.rb +82 -17
  95. data/lib/axn/mountable/mounting_strategies/_base.rb +4 -0
  96. data/lib/axn/mountable/mounting_strategies/step.rb +111 -13
  97. data/lib/axn/mountable/mounting_strategies.rb +10 -2
  98. data/lib/axn/rails/engine.rb +15 -0
  99. data/lib/axn/result.rb +115 -14
  100. data/lib/axn/rubocop.rb +1 -0
  101. data/lib/axn/strategies.rb +10 -2
  102. data/lib/axn/testing/spec_helpers.rb +16 -0
  103. data/lib/axn/testing.rb +52 -0
  104. data/lib/axn/tools/adapter_roots.rb +36 -0
  105. data/lib/axn/tools/invoker.rb +52 -0
  106. data/lib/axn/tools/registry.rb +434 -0
  107. data/lib/axn/tools/version_group.rb +38 -0
  108. data/lib/axn/tools.rb +167 -0
  109. data/lib/axn/version.rb +1 -1
  110. data/lib/axn.rb +28 -8
  111. data/lib/rubocop/cop/axn/README.md +52 -0
  112. data/lib/rubocop/cop/axn/ambient_context_bypass.rb +90 -0
  113. metadata +55 -42
  114. data/.cursor/rules/axn-framework-patterns.mdc +0 -43
  115. data/.cursor/rules/general-coding-standards.mdc +0 -27
  116. data/.cursor/rules/spec/testing-patterns.mdc +0 -40
  117. data/CONTRIBUTING.md +0 -37
  118. data/Rakefile +0 -221
  119. data/docs/.vitepress/config.mjs +0 -79
  120. data/docs/advanced/conventions.md +0 -38
  121. data/docs/advanced/mountable.md +0 -506
  122. data/docs/advanced/profiling.md +0 -351
  123. data/docs/advanced/rough.md +0 -45
  124. data/docs/index.md +0 -36
  125. data/docs/intro/about.md +0 -46
  126. data/docs/intro/overview.md +0 -154
  127. data/docs/recipes/memoization.md +0 -131
  128. data/docs/recipes/rubocop-integration.md +0 -106
  129. data/docs/recipes/suppressing-duplicate-async-reports.md +0 -65
  130. data/docs/recipes/testing.md +0 -94
  131. data/docs/recipes/validating-user-input.md +0 -11
  132. data/docs/reference/async/active-job.md +0 -283
  133. data/docs/reference/async/sidekiq.md +0 -235
  134. data/docs/reference/async.md +0 -443
  135. data/docs/reference/axn-result.md +0 -107
  136. data/docs/reference/class.md +0 -679
  137. data/docs/reference/configuration.md +0 -622
  138. data/docs/reference/form-object.md +0 -252
  139. data/docs/reference/instance.md +0 -39
  140. data/docs/strategies/client.md +0 -212
  141. data/docs/strategies/form.md +0 -244
  142. data/docs/strategies/index.md +0 -272
  143. data/docs/strategies/transaction.md +0 -30
  144. data/docs/usage/setup.md +0 -42
  145. data/docs/usage/steps.md +0 -335
  146. data/docs/usage/using.md +0 -68
  147. data/docs/usage/writing.md +0 -427
  148. data/lib/axn/context.rb +0 -52
  149. data/lib/axn/core/validation/subfields.rb +0 -65
  150. data/lib/axn/executor.rb +0 -460
  151. data/lib/axn/extension_config.rb +0 -13
  152. data/lib/axn/internal/piping_error.rb +0 -33
  153. data/lib/axn/internal/subfield_path.rb +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a911da94d085860df8fc6aed9bbe869edae27c2ff0e5821b2c07a043644c4fb5
4
- data.tar.gz: 0d56247c256cc52efc8258a1716b0c4274114411948b084fe872fc63285907c3
3
+ metadata.gz: c7c950f2b3d95c1f76aa0d963531b635e08c57058a2c3e2c8d07ccd40355faa5
4
+ data.tar.gz: 459ab003e2d8802c4c547ff6b89f6de3cc673e7e50072a66928d6a1b78444db7
5
5
  SHA512:
6
- metadata.gz: d39402f76d8df64e831007e046f0d5c8ac7fcd009246bbee9e7296db789b3afa270f5e63987eb23c88821f3f0ee2fecf3e7ec576b5dc9ce0f9e0972ba1811f93
7
- data.tar.gz: 2347b225abbab7c3e705ca071b661e104c18c4d6333d5eb8563eadf26559035485f79682f4e4a23f3c6e3dabf30209475570fd07ab511a9b75f9d5537d320f41
6
+ metadata.gz: b8917cfa50c90daf33a41ebc40c90e5bec9bafe926da10a70ac4b73c34d5883185569af921d8f7693dd4f0b38074d07d675ecc8d3f7b4808be4b69ef8e52c6b9
7
+ data.tar.gz: 96a0838272f6f800206d2c85beae9c82f1e1ef954db7d940d856c431719870639bc4e63f1ad1883621665ac67eaa676bbc270c1072c0bf517d458e268d6790ef
data/.rubocop.yml ADDED
@@ -0,0 +1,109 @@
1
+ # RuboCop cops are not loaded by default in this repo
2
+ # Downstream consumers can enable them by adding:
3
+ # require:
4
+ # - axn/rubocop
5
+
6
+ # Merge with (rather than replace) RuboCop's built-in AllCops/Exclude — 'node_modules/**/*',
7
+ # 'tmp/**/*', 'vendor/**/*', '.git/**/*'. Declaring an Exclude array replaces those built-ins, and
8
+ # every glob resolves relative to the config file that declares it, so without this a gem doing
9
+ # `inherit_gem: axn` ends up with nothing excluding its OWN tree: it inherits globs pointing into the
10
+ # axn gem dir, having lost the built-ins that pointed at itself. CI then lints the bundle that
11
+ # ruby/setup-ruby's bundler-cache installs into an in-repo vendor/bundle, reading each vendored gem's
12
+ # own .rubocop.yml and dying on the plugins those `require:` but the consuming gem doesn't bundle.
13
+ # `inherit_mode` declared HERE governs the merge for every consumer, so they inherit the built-in
14
+ # excludes whether or not their own config remembers to re-declare them.
15
+ inherit_mode:
16
+ merge:
17
+ - Exclude
18
+
19
+ AllCops:
20
+ TargetRubyVersion: 3.2
21
+ SuggestExtensions: false
22
+ NewCops: enable
23
+ # Deltas only — vendor/node_modules/tmp/.git arrive via the merge above. The built-in globs are
24
+ # top-level-anchored, so a nested bundle (a locally-vendored dummy app) still needs naming.
25
+ Exclude:
26
+ - 'benchmark/**/*'
27
+ - 'bin/benchmark'
28
+ - 'spec_rails/dummy_app/vendor/**/*'
29
+ - 'spec_rails/dummy_app/tmp/**/*'
30
+
31
+ Style/MultilineBlockChain:
32
+ Enabled: false
33
+
34
+ Style/SingleLineMethods:
35
+ Enabled: false
36
+
37
+ Style/StringLiterals:
38
+ Enabled: true
39
+ EnforcedStyle: double_quotes
40
+
41
+ Style/Documentation:
42
+ Enabled: false
43
+
44
+ Style/TrailingCommaInArguments:
45
+ EnforcedStyleForMultiline: comma
46
+
47
+ Style/TrailingCommaInArrayLiteral:
48
+ EnforcedStyleForMultiline: comma
49
+
50
+ Style/TrailingCommaInHashLiteral:
51
+ EnforcedStyleForMultiline: comma
52
+
53
+ Style/ClassAndModuleChildren:
54
+ Enabled: false
55
+
56
+ Style/HashSyntax:
57
+ EnforcedShorthandSyntax: always
58
+
59
+ Naming/MemoizedInstanceVariableName:
60
+ Enabled: false
61
+
62
+ Style/DoubleNegation:
63
+ Enabled: false
64
+
65
+ Metrics/BlockLength:
66
+ Enabled: false
67
+
68
+ Metrics/ModuleLength:
69
+ Enabled: false
70
+
71
+ Metrics/ClassLength:
72
+ Max: 250
73
+ Exclude:
74
+ - 'spec_rails/dummy_app/lib/integration/**/*'
75
+
76
+ Metrics/MethodLength:
77
+ Max: 70
78
+
79
+ Metrics/PerceivedComplexity:
80
+ Max: 20
81
+
82
+ Metrics/AbcSize:
83
+ Max: 60
84
+
85
+ Metrics/CyclomaticComplexity:
86
+ Max: 20
87
+
88
+ Lint/EmptyBlock:
89
+ Enabled: false
90
+
91
+ Naming/MethodParameterName:
92
+ AllowedNames: e, on, id, if, as
93
+
94
+ Metrics/ParameterLists:
95
+ Max: 11
96
+
97
+ Layout/LineLength:
98
+ Max: 160
99
+
100
+ Naming/VariableNumber:
101
+ Exclude:
102
+ - 'spec/**/*'
103
+
104
+ # Sidekiq::RetrySet doesn't have empty? method
105
+ Style/ZeroLengthPredicate:
106
+ Exclude:
107
+ - 'spec_rails/dummy_app/lib/integration/**/*'
108
+
109
+
@@ -0,0 +1,282 @@
1
+ # Axn — agent guide
2
+
3
+ For an LLM writing code that **calls** or **declares** Axn actions (not modifying Axn internals).
4
+ Covers the contract, result/failure behavior, idioms, and gotchas; points out to the docs and source
5
+ for depth. On an edge case, read the source — paths below, via `bundle show axn`.
6
+ Docs: <https://teamshares.github.io/axn/>.
7
+
8
+ ## Mental model
9
+
10
+ An Axn (Action) is a callable service object with a declared contract: inbound fields (`expects`),
11
+ outbound fields (`exposes`), and a `call` body. `Foo.call(...)` **always** returns an `Axn::Result`
12
+ — it never raises for ordinary failures (exceptions are swallowed and reported). You branch on
13
+ `result.ok?`. Business logic aborts with `fail!`; unexpected errors raise and are caught for you.
14
+
15
+ ```ruby
16
+ class CreateWidget
17
+ include Axn
18
+
19
+ expects :name, type: String
20
+ expects :category, type: String, optional: true
21
+ exposes :widget
22
+
23
+ # Idiom: set meaningful messages — the generic fallbacks ("Action completed successfully" /
24
+ # "Something went wrong") are almost never what a caller should see. Do this by default.
25
+ success "Widget created"
26
+ error "Couldn't create widget"
27
+
28
+ def call
29
+ fail!("Name is taken") if Widget.exists?(name:)
30
+ expose widget: Widget.create!(name:, category:)
31
+ end
32
+ end
33
+
34
+ result = CreateWidget.call(name: "Sprocket")
35
+ result.ok? # => true
36
+ result.widget # => #<Widget ...> (reader for the exposed field)
37
+ result.success # => "Widget created"
38
+
39
+ result = CreateWidget.call(name: "Existing") # name taken → fail!
40
+ result.ok? # => false
41
+ result.error # => "Couldn't create widget: Name is taken" (base headline prefixes the reason)
42
+ ```
43
+
44
+ Each declared field gets a reader **inside** the action (`name`, `category`) and each exposed field
45
+ gets a reader **on the result** (`result.widget`). Inside `call` you never touch a raw params hash —
46
+ you read the named fields.
47
+
48
+ ## Declaring the contract
49
+
50
+ `expects` declares inbound fields; `exposes` declares outbound. Both take one or more field names
51
+ plus shared options. Validation runs automatically (inbound before `call`, outbound after).
52
+
53
+ ```ruby
54
+ expects :email, type: String
55
+ expects :role, inclusion: { in: %w[admin member] }, optional: true
56
+ exposes :user
57
+ ```
58
+
59
+ Common options (same for `expects` and `exposes`):
60
+
61
+ | Option | Meaning |
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]`. |
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
+ | `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
+ | `allow_nil:` / `allow_blank:` | Finer-grained than `optional:`. |
67
+ | `default:` | Used when the field is missing or explicitly `nil` (**not** for blank values). |
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. |
70
+ | `validate:` | Custom: `validate: ->(v) { "must be > 10" unless v > 10 }` — return a string (or raise) to fail. |
71
+ | any ActiveModel validation | e.g. `length:`, `format:`, `numericality:` — passed through as if to `validates`. |
72
+
73
+ `expects`-only extras: `model:` (auto-hydrate a record, below), `on:` (subfields, below),
74
+ `as:`/`prefix:` (rename the reader), `preprocess:` (coerce before validation/defaults),
75
+ `user_facing:` (blame the caller, see Failure semantics).
76
+
77
+ These validations are the **developer contract** (how the action is called) — not pretty
78
+ user-facing copy. For user-facing input validation reach for `use :form`. Full option detail:
79
+ <https://teamshares.github.io/axn/reference/class>.
80
+
81
+ If this action runs as a tool (via `Axn::Tools::Invoker`, an adapter's runtime for model-supplied
82
+ args), declare a `type:` on every field rather than a defensive per-field `coerce: true` — tool
83
+ calls always coerce, and coercion plus schema reflection (`input_schema`) both key off `type:`.
84
+ See <https://teamshares.github.io/axn/reference/tool-invoker>.
85
+
86
+ ## Inside `call`
87
+
88
+ | Helper | Effect |
89
+ | --- | --- |
90
+ | `expose key: val` / `expose :key, val` | Set an exposed field on the result. Only declared `exposes` keys are allowed. |
91
+ | `fail!("msg", **kw)` | Abort now as a **failure**; `result.error` = msg; optional kwargs exposed first. |
92
+ | `done!("msg", **kw)` | Abort now as **success** (early return); skips remaining `call` + `after` hooks. |
93
+ | `log("msg", level: :info)` | Log via `Axn.config.logger`, prefixed with the class name. |
94
+ | field readers | Read any `expects` field by name; `result.<field>` reads exposures (rare inside `call`). |
95
+
96
+ If you declare `exposes :x` you must `expose x: …` on every success path — **unless** `x` is also an
97
+ `expects` field, in which case Axn auto-copies it (see Gotchas). Outbound validation still runs on
98
+ `done!`, so a required exposure that's unset makes the action fail with `OutboundValidationError`.
99
+
100
+ 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?`.
103
+ <https://teamshares.github.io/axn/usage/writing>.
104
+
105
+ ## Using a result
106
+
107
+ `Axn::Result` is uniform across every action:
108
+
109
+ | Member | Meaning |
110
+ | --- | --- |
111
+ | `ok?` | Succeeded? |
112
+ | `error` | User-facing error string when `!ok?` (else nil). |
113
+ | `success` / `message` | Success string when `ok?`; `message` is always set (success or error). |
114
+ | `outcome` | String inquirer: `outcome.success?` / `failure?` / `exception?`. |
115
+ | `exception` | The swallowed exception, if any (mostly for tests/diagnostics). |
116
+ | `<exposed field>` | Reader for each declared exposure. |
117
+
118
+ ```ruby
119
+ result = Actions::Slack::Post.call(channel: "#eng", message: text)
120
+ if result.ok?
121
+ @thread_id = result.thread_id
122
+ else
123
+ flash[:alert] = result.error
124
+ end
125
+ ```
126
+
127
+ Invocation variants:
128
+
129
+ - `Foo.call(**kw)` → always returns a `Result`; exceptions swallowed. **Default.**
130
+ - `Foo.call!(**kw)` → returns a `Result` on success; a `fail!` raises `Axn::Failure`, any other
131
+ error re-raises as-is. Use in scripts / when you want failures to bubble.
132
+ - `Foo.call_async(**kw)` → enqueue as a background job (configure with `async :sidekiq` /
133
+ `async :active_job`). See <https://teamshares.github.io/axn/reference/async>.
134
+
135
+ `Result` supports pattern matching (`in ok: true, user: User => u`).
136
+
137
+ ## Field resolvers (`model:` and `on:`)
138
+
139
+ **`model:` — hydrate a record from an id.** `expects :user, model: true` adds expectations that
140
+ `user_id` is supplied (derived from the field name) and `User.find(user_id)` returns a record, and
141
+ defines both a `user` reader (the record) and a `user_id` reader. Variants: `model: User`,
142
+ `model: { klass: User, finder: :find_by_slug }`.
143
+
144
+ ```ruby
145
+ expects :user, model: true
146
+ # called with user_id: 5 → user_id == 5, user resolves the record
147
+ # called with user: <rec> → user_id == rec.id, user is that record
148
+ ```
149
+
150
+ `user_id` always means *the record's primary key*, on every path. Passing both a record and a
151
+ disagreeing `user_id` (default `:find` finder) raises `InboundValidationError` — contradictory
152
+ input is a developer error. Source: `lib/axn/core/field_resolvers/model.rb`.
153
+
154
+ **`on:` — subfields (the `:extract` resolver).** Declare expectations about nested data and get a
155
+ flat reader:
156
+
157
+ ```ruby
158
+ expects :event, type: Hash
159
+ expects :data, type: Hash, on: :event
160
+ expects :id, :type, on: :data # readers: id, type (extract event[:data][:id], ...)
161
+ expects :zip, on: "address.billing" # dotted path; reader: zip
162
+ ```
163
+
164
+ 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:
167
+ `lib/axn/core/field_resolvers/extract.rb`. Reference:
168
+ <https://teamshares.github.io/axn/reference/class>.
169
+
170
+ ## Failure semantics (read this — most subtle bugs live here)
171
+
172
+ Every non-success outcome lands in exactly one bucket:
173
+
174
+ | How it ends | `outcome` | `on_failure` | `on_exception` + **global report** | `result.exception` |
175
+ | --- | --- | --- | --- | --- |
176
+ | `fail!("…")` | `failure` | fires | **no** | `Axn::Failure` |
177
+ | `fails_on`-matched raise | `failure` | fires | **no** | the original exception |
178
+ | any other raised error | `exception` | — | **yes** (e.g. Honeybadger) | the original exception |
179
+
180
+ So `fail!` is for **expected, user-facing** outcomes; an unhandled raise is treated as a **bug** and
181
+ reported to `Axn.config.on_exception`. Key consequences:
182
+
183
+ - **`fails_on ExceptionClass`** reclassifies a raised exception from *bug* to *expected failure*
184
+ (fires `on_failure`, skips the global report, keeps the original on `result.exception`). Put it on
185
+ the action that **raises** the exception — it doesn't suppress a report from a deeper action. Only
186
+ reclassify deterministic/non-transient errors (e.g. `ActiveRecord::RecordInvalid`), never a
187
+ transient one you'd want retried. In async, a `fails_on` failure is terminal (no retry).
188
+ `result.error` never defaults to the exception's own (technical) `#message`; opt a specific class
189
+ in with `fails_on ExceptionClass, &:message` when that message is genuinely user-facing. This is
190
+ the idiom for the "save an ActiveRecord model" case — a plain action plus
191
+ `fails_on ActiveRecord::RecordInvalid, &:message` surfaces the record's validation errors as the
192
+ failure message (and, e.g., `fails_on Stripe::CardError, &:message` for a card-declined message).
193
+ - **`expects` violations are dev-facing by default** → exception bucket, pages, generic
194
+ `"Something went wrong"`. A missing required input is your bug. Mark a genuinely caller-supplied
195
+ field `user_facing: true` (or a String/Symbol/Proc message) to move *its* violations to the
196
+ failure bucket with a meaningful `result.error`. The field stays required. In a mixed failure
197
+ (a `user_facing:` field *and* a plain one), dev-facing wins and it still pages.
198
+ - **A nested bug is reported once**, from the innermost action that treats it as a bug, however deep
199
+ the `call!` chain.
200
+
201
+ **Messages — declare `success` and `error` by default.** The fallbacks are the generic
202
+ `"Action completed successfully"` / `"Something went wrong"`; declare a meaningful `success "…"` and
203
+ `error "…"` on every action whose result a caller surfaces. Both accept a string, a symbol (action
204
+ method), or a block (evaluated in instance context: `error { "Failed for #{name}" }`).
205
+
206
+ **Base/reason model.** An *unconditional* `error "Headline"` is the **base**: it's the fallback and it
207
+ auto-prefixes every failure reason as `"Headline: reason"` (a conditional `error … if:`, a
208
+ `prefixed: true` entry, and `fail!` strings). A *conditional* `error "…", if: SomeError` is a reason.
209
+ Most-recently-declared matching reason wins. `success`/`done!` work the same. A literal and a block
210
+ behave identically — conditionality (not string-vs-block) sets the role.
211
+
212
+ ```ruby
213
+ error "Couldn't sync user" # base / fallback + prefix
214
+ error "email already taken", if: ArgumentError # reason → "Couldn't sync user: email already taken"
215
+ fail! "missing field" # reason → "Couldn't sync user: missing field"
216
+ ```
217
+
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}")`.
224
+
225
+ ⚠️ **Message bodies are NOT redacted** and propagate outward to every ancestor's `result.error`,
226
+ logs, and error trackers. Never interpolate secrets/PII into `error`/`success`/`fail!` text — put
227
+ sensitive values in `sensitive:` fields. Detail:
228
+ <https://teamshares.github.io/axn/usage/writing#prefixing-failure-reasons>. Source:
229
+ `lib/axn/core/flow/messages.rb`, `lib/axn/core/flow/fails_on.rb`.
230
+
231
+ ## Gotchas
232
+
233
+ - **Indifferent access is top-level only.** Declared keys, call-arg keys, and `expose` keys are all
234
+ symbolized, so `expects :note` matches `.call("note" => x)` (the `.call(**params)` case). But field
235
+ **values** — including nested hashes — are untouched: reach into a nested value with the key type
236
+ it actually has (or declare an `on:` subfield, which *is* indifferent).
237
+ - **Auto-copy of `expects` + `exposes` fields.** A field declared with *both* is copied from input to
238
+ result automatically on **all** paths — success, `done!`, `fail!`, and exception. Lets a caller
239
+ read `result.user.errors` after a failed save without a manual `expose`. Don't redundantly
240
+ `expose` it.
241
+ - **`done!` rolls back a manual `ActiveRecord::Base.transaction`** (it's implemented via an
242
+ exception). Use `use :transaction` for transaction-safe early completion.
243
+ - **Default `call`.** Omit `call` entirely and Axn synthesizes one that exposes each declared
244
+ `exposes` by calling a same-named method. A method returning `nil` (no default) counts as missing.
245
+ - **`call` vs `call!`.** With `call!`, a `fail!` raises `Axn::Failure`; any *other* error re-raises
246
+ unchanged (not wrapped). `fails_on` reclassification is sticky across `call!` boundaries.
247
+ - **Hooks vs callbacks.** A raise/`fail!` in a `before`/`after`/`around` hook flips `ok?` to false; a
248
+ 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.
251
+
252
+ ## Strategies (DRYed configuration via `use`)
253
+
254
+ - **`use :form do … end`** — validate user input via an `Axn::FormObject` (full ActiveModel
255
+ validations) before `call`; exposes `form`. For genuinely user-facing input.
256
+ <https://teamshares.github.io/axn/strategies/form>.
257
+ - **`use :transaction`** — wrap the action in a DB transaction that `done!` won't roll back.
258
+ - **`use :client`** (Faraday) for HTTP APIs.
259
+
260
+ ## Composition (steps)
261
+
262
+ `step :name, expects: […], exposes: […] do … end` defines inline sequential steps; `steps(A, B, C)`
263
+ chains existing action classes. Data flows via the shared context; a step failure fails the parent
264
+ with the step name prefixed (`"validate: Email is invalid"`).
265
+ <https://teamshares.github.io/axn/usage/steps>.
266
+
267
+ ## Pointers
268
+
269
+ Human docs — <https://teamshares.github.io/axn/>:
270
+ build (`/usage/writing`), use (`/usage/using`), class DSL (`/reference/class`), instance helpers
271
+ (`/reference/instance`), result (`/reference/axn-result`), strategies (`/strategies/`), steps
272
+ (`/usage/steps`), async (`/reference/async`), config (`/reference/configuration`), tool invoker
273
+ (`/reference/tool-invoker`).
274
+
275
+ Source entry points (resolve with `bundle show axn`):
276
+ - `lib/axn.rb` — `include Axn` wiring.
277
+ - `lib/axn/core/contract.rb` — `expects`/`exposes` declaration.
278
+ - `lib/axn/core/field_resolvers/` — `model.rb` (`model:`), `extract.rb` (`on:` subfields).
279
+ - `lib/axn/core/validation/validators/` — `type`, `of`, `model`, `validate`, `shape` validators.
280
+ - `lib/axn/core/flow/` — `messages.rb`, `fails_on.rb`, `handlers/` (failure/message/callback resolution).
281
+ - `lib/axn/result.rb`, `lib/axn/core/context/facade.rb` — the `Result` surface.
282
+ - `lib/axn/strategies/` — `model.rb`, `form.rb`, `transaction.rb`.
@@ -0,0 +1,250 @@
1
+ # Axn — tool-adapter author guide
2
+
3
+ For an LLM writing an **axn tool-adapter gem**: a gem that exposes plain Axn actions over a tool/agent
4
+ transport (`axn-mcp` → MCP, `axn-ruby_llm` → RubyLLM function-calling, a hypothetical `axn-http_api` →
5
+ OpenAPI). Not for action authors — for *those* (declaring/calling Axns) read `AGENTS-consuming.md`, the
6
+ sibling in this gem. On an edge case, read the core source — paths below, via `bundle show axn`.
7
+ Docs: <https://teamshares.github.io/axn/recipes/authoring-tool-adapters>.
8
+
9
+ ## Mental model
10
+
11
+ **Author-once.** A tool is a plain Axn (`include Axn` + `expects`/`exposes`/`call`) with nothing
12
+ transport-specific. The SAME class is wrapped by every adapter, called directly, or enqueued async. Your
13
+ adapter *projects* that class into the transport's native tool object, reading everything from Axn's public
14
+ reflection surface. Two hard rules:
15
+
16
+ - **Never require the author to write against your adapter.** No marker mixin, no adapter base class they
17
+ must subclass. They write a normal Axn; you wrap it.
18
+ - **Never mutate the shared class in a way that breaks a different adapter.** The class is wrapped by
19
+ others. Overriding `input_schema` to a non-Hash is the canonical bug that retired `Axn::MCP::Tool` — do
20
+ transport shaping inside `wrap`, not by redefining reflection on the class.
21
+
22
+ Core owns the shared machinery (membership, naming, reflection, config store, extension registry,
23
+ invocation contract); you consume its public API. Expose exactly two public methods so every adapter has
24
+ the same shape:
25
+
26
+ ```ruby
27
+ GemName.tools # zero-arg: Axn::Tools.for(:key).map { |a| wrap(a) }
28
+ GemName.wrap(axn_class, **opts) # one Axn -> the transport's native tool object
29
+ ```
30
+
31
+ `.tools` MUST be zero-arg — so every `wrap` option must default (name/description from the class).
32
+
33
+ ## Registration & discovery
34
+
35
+ - **Register at gem load, from the entry file:** `Axn::Tools.register_adapter(:key)`. Pass a config source
36
+ second arg (`Axn::Tools.register_adapter(:key, self)`) ONLY if you offer directory discovery; else omit.
37
+ Re-registering with no source is idempotent.
38
+ - **Enumerate with `Axn::Tools.for(:key)`** — returns the latest version per `tool_name`, sorted by
39
+ `tool_name` (a duplicate `(tool_name, tool_version)` raises), tool-root dirs eager-loaded first. Pass
40
+ `all_versions: true` for every version, or use `Axn::Tools.versions(:key, tool_name)` for one tool's group.
41
+ - **Only currently-loaded classes are enumerated.** A `tool :key` class outside a tool-root dir must be
42
+ `require`d first. Enumerate from `config.after_initialize` / `to_prepare` — **never** a
43
+ `config/initializers` file (runs before autoload paths are wired; `Axn::Tools.for` warns).
44
+ - **Membership** = `(directory grant ∪ declaration grant) − except`, computed by the registry. You don't
45
+ parse it — you call `Axn::Tools.for`. The author declares it: `tool` (all adapters), `tool :mcp` (add),
46
+ `tool mcp: { … }` / `configure(:mcp)` (implies `:mcp`), residency under a tool root, `tool false` (opt
47
+ out), `tool except: :x` (narrow).
48
+ - **Directory membership is optional.** `extend Axn::Tools::AdapterRoots` → a validated `tool_roots`
49
+ setting the registry reads. Its `validate!` reuses core's broad-path guard (rejects `app`/`actions`/`.`/`..`).
50
+ The reference gems don't adopt it (they use explicit `tool`/`configure`); add it only if it fits.
51
+
52
+ Source: `lib/axn/tools.rb` (`.register_adapter`, `.for`, `.versions`), `lib/axn/tools/registry.rb` (membership,
53
+ eager-load), `lib/axn/tools/adapter_roots.rb`, `lib/axn/core/tool_declaration.rb` (`tool` DSL, `tool_name`).
54
+
55
+ ## Naming & description
56
+
57
+ - **Name = `axn_class.tool_name(:your_key)` — pass your adapter key.** Don't roll your own — the same Axn
58
+ must yield the same name across adapters. It's provider-safe, never blank, honors `tool name:` and prefix
59
+ stripping. `Axn::Tools.for` sorts on `tool_name(:your_key)` and collapses to the latest per name (uniqueness is
60
+ on `(tool_name, tool_version)`); a per-adapter `tool your_key: { name: }` override is only returned when
61
+ you pass the key — the zero-arg form ignores per-adapter overrides, so reading it would publish a name
62
+ the registry didn't collapse on.
63
+ - **Description = `axn_class.description`.** `wrap`'s `description:` defaults to it (keeps `.tools` zero-arg).
64
+
65
+ ## Schema reflection
66
+
67
+ - Use public `axn_class.input_schema` / `axn_class.output_schema` — plain JSON Schema **Hashes**. Wrap them
68
+ into your transport's schema object inside `wrap`.
69
+ - **Don't** reach into `Axn::Internal::Reflection::Schema` internals. **Don't** override `input_schema` to a non-Hash
70
+ (breaks other adapters on the shared class).
71
+ - `on: :ambient_context` fields are **auto-excluded** from `input_schema` — you get a clean model-facing
72
+ schema; don't re-add them.
73
+ - Reflection is best-effort, biased **stricter** than runtime (a schema-following call won't be rejected),
74
+ with one documented **looser** case (an invalid literal `default:`). Surface the caveat; don't fight it.
75
+ A deep subfield under a `model:`/non-object parent is omitted with a `logger.warn` — pass it through.
76
+
77
+ Source: `lib/axn/core/schema_reflection.rb`, `lib/axn/internal/reflection/schema.rb`.
78
+
79
+ ## Value serialization
80
+
81
+ - Render a success result's exposures with `Axn::Extensions::Serialization.render(result)` → JSON-safe Hash.
82
+ Don't hand-roll (it handles Symbol/BigDecimal/Time/`as_json`-vs-`to_h` so output matches `output_schema`).
83
+ - **You pass no config list** — `render` derives the declared `exposes` from the result itself. Rendering a
84
+ subset is deliberately unsupported: it would emit a body contradicting `output_schema`.
85
+ - **Don't reach into `Axn::Internal::Reflection::Values`.** `render` is the surface; the renderer's helpers are private, and
86
+ what stays public is there for core's own cross-module callers, not for you.
87
+ - No **value** in the result is one `JSON.generate` refuses (no non-finite number, no non-UTF-8 bytes, no
88
+ cycle, no collapsed property). That is a promise about values, NOT about your encoder's config: a structure
89
+ deeper than `max_nesting` (100 default) still raises `JSON::NestingError`. Drop your pre-*pass* over the
90
+ value graph; **keep** your encode `rescue`.
91
+ - Raises `Axn::Extensions::Serialization::UnserializableValue` (an `ArgumentError`), naming the path, on five
92
+ unconditional defects: a cycle; two exposed field NAMES that render as the same JSON property (compared the same
93
+ canonicalized way as a Hash key, since a declared field is itself a property name); two Hash keys that
94
+ render as one JSON property (compared as the PROPERTY each produces, not as the Ruby String its `to_s`
95
+ returned — keys are transcoded to UTF-8 first, so one property name in two encodings collides); a
96
+ non-finite Float (incl. a `BigDecimal`/`Rational` coercing to one); and a
97
+ String — or a key's String form — whose bytes have no UTF-8 rendering (stricter than `valid_encoding?`:
98
+ `"\xFF"` in `BINARY` is valid BINARY and unencodable; a valid ISO-8859-1/Shift_JIS value transcodes and
99
+ passes untouched, a key comes back as UTF-8). Add `reject_opaque: true` to also reject a
100
+ value or key that declares no rendering of its own — `to_s` inherited from `Object`, or (in Rails)
101
+ ActiveSupport's generic `Object#as_json` ivar dump as its only projection. A meaningful
102
+ `to_s`/`as_json`/`to_h` defined anywhere else passes, address-looking or not. Never write your own
103
+ pre-pass — it drifts from the renderer.
104
+ - Keep the two guarantees apart: encodability is unconditional, declared-shape is what the flag buys.
105
+ `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).
108
+
109
+ ## Per-adapter configuration
110
+
111
+ - `extend Axn::Configurable` + `config_namespace :key` (declare it before any overridable setting); declare
112
+ `setting :x, …, overridable: true`. See <https://teamshares.github.io/axn/recipes/gem-configuration>.
113
+ - **Resolve a per-class value with `Axn::<Mod>.resolve_override_for(axn_class, :x)` — NOT
114
+ `axn_class.public_send(:x)`.** A wrapped plain Axn never included your `overrides` module, so it has no
115
+ such accessor; but the app may have set the value via `configure(:key)` / `tool key: { … }`.
116
+ `resolve_override_for` is the shadow-proof reader over the override store.
117
+ - A **render toggle** (structured serialized `exposes` vs. the Axn's message) is a common per-adapter
118
+ setting. `axn-mcp` and `axn-ruby_llm` both name it `present_as` (`:structured` / `:message`) — reuse the
119
+ name/values if you have the concept. It's adapter-specific, not core (an `axn-http_api` has no such toggle).
120
+
121
+ Source: `lib/axn/configurable.rb` (`config_namespace`, `resolve_override_for`, `overrides`).
122
+
123
+ ## Extension registry
124
+
125
+ - Add transport-only vocabulary without a core change: `Axn::Extensions.config.register_semantic_hint(:open_world,
126
+ :closed_world)` at load. Read `axn_class._semantic_hints` in `wrap` to map declared hints to your
127
+ annotations; let an explicit adapter override win. Hints are advisory (nothing enforces them).
128
+
129
+ Source: `lib/axn/extensions/config.rb`, `lib/axn/core/semantic_hints.rb`.
130
+
131
+ ## Invocation & result → response
132
+
133
+ - **`axn_class.call(**kwargs)` returns an `Axn::Result` and never raises for a business failure** (`call!`
134
+ raises; `call` doesn't). Prefer calling through **`Axn::Tools::Invoker`** — it applies the tool contract
135
+ (always-on wire coercion, opt-in user-facing input-error surfacing, undeclared-key rejection, the
136
+ ambient guard) that a trusted in-process `.call` deliberately omits. See
137
+ <https://teamshares.github.io/axn/reference/tool-invoker>.
138
+ - Map from: `result.ok?`; `result.error` (**user-facing** — show to the LLM/client); `result.success` /
139
+ `result.message` (success string); `result.exception` (**dev-facing** detail, e.g. the
140
+ `Axn::InboundValidationError` — do **NOT** surface it).
141
+ - **`Axn::Extensions.owned_failure?(exception)`** — true for an axn-owned failure (`Axn::Failure` or a
142
+ user-facing validation error, whose `#message` is client-safe), false for a foreign exception
143
+ reclassified via `fails_on` (technical cause — don't leak). Check it before reading `#message` off an
144
+ exception.
145
+ - **Impose no gem-wide error headline.** Surface `result.error`; let each tool declare its own base
146
+ `error "…"`. A base `error` prefixes `fail!("reason")` as `"Headline: reason"` unless
147
+ `fail!("…", standalone: true)`.
148
+ - For per-field inbound detail: `Axn::Tools::Invoker.input_invalid?(result)` and
149
+ `result.exception.field_errors`.
150
+
151
+ Source: `lib/axn/extensions.rb` (`owned_failure?`), `lib/axn/tools/invoker.rb`, `lib/axn/result.rb`.
152
+
153
+ ## Error boundary
154
+
155
+ - **Root your gem's errors at `Axn::Error`.** Give the gem a base class that `include`s the module, and
156
+ subclass every specific error from it: `class Axn::Webhooks::Error < StandardError; include Axn::Error;
157
+ end`. Two sibling gems already have such a base and need only the `include`.
158
+ - **It's a module, not a base class, on purpose.** `rescue` matches a module via `is_a?`, so including it
159
+ costs a class no ancestry — the adapter keeps whatever superclass its ecosystem needs (`< Faraday::Error`,
160
+ `< Timeout::Error`) and is still `rescue Axn::Error`-catchable. A base class would force a choice.
161
+ - **Including it is a promise, not decoration**: the class becomes public, documented, rescuable, and
162
+ breaking to remove.
163
+ - **The tag is inherited** — a tagged class cannot have an untagged subclass, so a public error family
164
+ can't grow a secretly-internal member.
165
+ - **Two things stay outside the boundary on purpose.** `Axn::Failure` is a control-flow signal from
166
+ `call!`, not a fault, so it is deliberately untagged — `rescue Axn::Error` around a `call!` should mean
167
+ "axn objected," not "axn objected or the action intentionally failed"; a caller wanting both writes
168
+ `rescue StandardError`. Generic `ArgumentError`s raised for DSL misuse (a malformed `on:`, an
169
+ unregistered tool adapter) also stay plain — that's this repo's convention for a declaration mistake, not
170
+ a runtime fault, and it isn't specific to what your adapter raises.
171
+ - **Why bother:** a consuming app's `rescue Axn::Error` catches core's errors AND every participating
172
+ adapter's, which is the payoff for following the convention instead of inventing a per-gem base with no
173
+ shared catch.
174
+
175
+ Source: `lib/axn/error.rb`.
176
+
177
+ ## ambient_context
178
+
179
+ Server/session data (`current_user`, `company`) an author declares via `expects :user_id, on: :ambient_context`.
180
+
181
+ - **Spread it AS `ambient_context:`** — pass the injected context as the `ambient_context:` keyword, NOT
182
+ nested under an adapter key. Nesting couples the Axn to one adapter; spreading keeps it portable (the
183
+ same class resolves from an MCP server context, from `Current` on a direct call, or from ruby_llm). On a
184
+ direct `.call`, strip any model-supplied `ambient_context` from the args before splatting them (`**`) or
185
+ the model could override your trusted value — or just use the Invoker, which strips it for you.
186
+ - It's **filtered to declared keys**; the injected value must be a `Hash` / hash-like (responds to
187
+ `key?`/`[]`, e.g. `HashWithIndifferentAccess`) — axn keys into it and drops any source it can't key into,
188
+ so a bare opaque object resolves every ambient field as absent. Wrap a transport context object in a Hash
189
+ of the injected fields (keys string or symbol, indifferent).
190
+ - **Always pass an explicit `ambient_context:` (even `{}`)** — it *replaces* the `Current`-derived default
191
+ (no merge), preventing server-side state leaking into the call. The Invoker also strips any
192
+ `ambient_context` smuggled through model args before merging yours.
193
+
194
+ Source: `lib/axn/core/ambient_context.rb`, `lib/axn/tools/invoker.rb`.
195
+
196
+ ## Live transport capabilities
197
+
198
+ - Progress/cancellation are **objects/operations, not ambient data** — they don't survive ambient_context
199
+ filtering. Expose them via an adapter handle scoped with `ActiveSupport::IsolatedExecutionState`
200
+ (thread-/fiber-scoped per the configured isolation level), matching how axn scopes its own per-execution
201
+ state. A raw `Thread.current[...]` local is wrong under a Fiber scheduler. See `Axn::MCP.server_context` /
202
+ `with_server_context` in axn-mcp.
203
+
204
+ ## Inline / one-off tools
205
+
206
+ - **Don't ship a per-gem `define`.** Wrap a core `Axn::Factory.build`:
207
+ `GemName.wrap(Axn::Factory.build(expects:, exposes:, axn_name: "…", description: "…") { … })`. The block is
208
+ the `#call` body (keyword-only args; not available for `exposes`/`shape:` coercion). A factory-built
209
+ class is **not** auto-discovered by `Axn::Tools.for` (synthetic name) — the constructor holds the reference
210
+ and wraps it directly. See <https://teamshares.github.io/axn/reference/factory>.
211
+
212
+ ## Deprecations
213
+
214
+ - Own a dedicated `ActiveSupport::Deprecation.new("1.0", "gem-name")` as `GemName.deprecator`, so a
215
+ consuming Rails app can register it (`Rails.application.deprecators[:gem] = GemName.deprecator`) and
216
+ govern its behavior. `axn-mcp` does this; `axn-ruby_llm` currently uses raw `warn` — follow axn-mcp.
217
+
218
+ ## Testing
219
+
220
+ - Reuse `Axn::Testing::SpecHelpers` (`build_axn { … }`, `with_ambient_context`) to construct the wrapped
221
+ Axns. Verify adapter output against **real** transport objects (a real `MCP::Tool::Response`/`InputSchema`,
222
+ a real `RubyLLM::Tool`), not hand-built hashes. **Pin the exact user-facing failure/success strings.** An
223
+ end-to-end spec driving a real `MCP::Server.new(tools:, server_context:)` catches wiring a unit test can't.
224
+
225
+ ## Reference gems
226
+
227
+ - **axn-mcp** — adapter key `:mcp`; `wrap` → `::MCP::Tool` subclass → `MCP::Tool::Response`. Full worked
228
+ example of every convention above (registration, `server_context`, `semantic_hints` → annotations,
229
+ dedicated deprecator, real-object specs).
230
+ - **axn-ruby_llm** — adapter key `:ruby_llm`; `wrap` → `::RubyLLM::Tool`. No output schema, no
231
+ transport-capability handle, no semantic_hints (RubyLLM has no annotations) — a simpler adapter surface.
232
+
233
+ ## Pointers
234
+
235
+ Docs — <https://teamshares.github.io/axn/>: authoring a tool-adapter gem
236
+ (`/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).
239
+
240
+ Core source entry points (resolve with `bundle show axn`):
241
+ - `lib/axn/tools.rb` — `Axn::Tools.register_adapter`, `.adapters`, `.for`, `.versions`, `.validate_contracts!`.
242
+ - `lib/axn/extensions.rb` — `Axn::Extensions.best_effort`, `.config`, `.owned_failure?` (extension-author surface).
243
+ - `lib/axn/tools/registry.rb`, `lib/axn/tools/adapter_roots.rb`, `lib/axn/core/tool_declaration.rb` —
244
+ membership, `tool_name`.
245
+ - `lib/axn/core/schema_reflection.rb`, `lib/axn/internal/reflection/schema.rb`, `lib/axn/internal/reflection/values.rb` — reflection.
246
+ - `lib/axn/configurable.rb` — `config_namespace`, `resolve_override_for`, `overrides`.
247
+ - `lib/axn/tools/invoker.rb` — the tool call path.
248
+ - `lib/axn/core/ambient_context.rb` — ambient filtering/resolution.
249
+ - `lib/axn/factory.rb` — `Axn::Factory.build`.
250
+ - `lib/axn/error.rb` — `Axn::Error`, the public-error boundary tag.