igniter 0.4.3 → 0.5.0

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 (162) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/incremental.rb +142 -0
  22. data/examples/llm_tools.rb +237 -0
  23. data/examples/mesh.rb +239 -0
  24. data/examples/mesh_discovery.rb +267 -0
  25. data/examples/mesh_gossip.rb +162 -0
  26. data/examples/ringcentral_routing.rb +1 -1
  27. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  28. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  29. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  30. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  31. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  32. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  33. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  34. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  35. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  36. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  37. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  38. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  39. data/lib/igniter/agents/proactive_agent.rb +208 -0
  40. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  41. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  42. data/lib/igniter/agents.rb +56 -0
  43. data/lib/igniter/application/app_config.rb +32 -0
  44. data/lib/igniter/application/autoloader.rb +18 -0
  45. data/lib/igniter/application/generator.rb +157 -0
  46. data/lib/igniter/application/scheduler.rb +109 -0
  47. data/lib/igniter/application/yml_loader.rb +39 -0
  48. data/lib/igniter/application.rb +174 -0
  49. data/lib/igniter/capabilities.rb +68 -0
  50. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  51. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  52. data/lib/igniter/consensus/cluster.rb +183 -0
  53. data/lib/igniter/consensus/errors.rb +14 -0
  54. data/lib/igniter/consensus/executors.rb +43 -0
  55. data/lib/igniter/consensus/node.rb +320 -0
  56. data/lib/igniter/consensus/read_query.rb +30 -0
  57. data/lib/igniter/consensus/state_machine.rb +58 -0
  58. data/lib/igniter/consensus.rb +58 -0
  59. data/lib/igniter/content_addressing.rb +133 -0
  60. data/lib/igniter/contract.rb +12 -0
  61. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  62. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  63. data/lib/igniter/dataflow/diff.rb +37 -0
  64. data/lib/igniter/dataflow/diff_state.rb +81 -0
  65. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  66. data/lib/igniter/dataflow/window_filter.rb +48 -0
  67. data/lib/igniter/dataflow.rb +65 -0
  68. data/lib/igniter/dsl/contract_builder.rb +71 -7
  69. data/lib/igniter/executor.rb +60 -0
  70. data/lib/igniter/extensions/capabilities.rb +39 -0
  71. data/lib/igniter/extensions/content_addressing.rb +5 -0
  72. data/lib/igniter/extensions/dataflow.rb +117 -0
  73. data/lib/igniter/extensions/incremental.rb +50 -0
  74. data/lib/igniter/extensions/mesh.rb +31 -0
  75. data/lib/igniter/fingerprint.rb +43 -0
  76. data/lib/igniter/incremental/formatter.rb +81 -0
  77. data/lib/igniter/incremental/result.rb +69 -0
  78. data/lib/igniter/incremental/tracker.rb +108 -0
  79. data/lib/igniter/incremental.rb +50 -0
  80. data/lib/igniter/integrations/llm/config.rb +48 -4
  81. data/lib/igniter/integrations/llm/executor.rb +221 -28
  82. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  83. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  84. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  85. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  86. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  87. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  88. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  89. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  90. data/lib/igniter/integrations/llm.rb +37 -1
  91. data/lib/igniter/memory/agent_memory.rb +104 -0
  92. data/lib/igniter/memory/episode.rb +29 -0
  93. data/lib/igniter/memory/fact.rb +27 -0
  94. data/lib/igniter/memory/memorable.rb +90 -0
  95. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  96. data/lib/igniter/memory/reflection_record.rb +28 -0
  97. data/lib/igniter/memory/store.rb +115 -0
  98. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  99. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  100. data/lib/igniter/memory.rb +80 -0
  101. data/lib/igniter/mesh/announcer.rb +55 -0
  102. data/lib/igniter/mesh/config.rb +45 -0
  103. data/lib/igniter/mesh/discovery.rb +39 -0
  104. data/lib/igniter/mesh/errors.rb +31 -0
  105. data/lib/igniter/mesh/gossip.rb +47 -0
  106. data/lib/igniter/mesh/peer.rb +21 -0
  107. data/lib/igniter/mesh/peer_registry.rb +51 -0
  108. data/lib/igniter/mesh/poller.rb +77 -0
  109. data/lib/igniter/mesh/router.rb +109 -0
  110. data/lib/igniter/mesh.rb +85 -0
  111. data/lib/igniter/metrics/collector.rb +131 -0
  112. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  113. data/lib/igniter/metrics/snapshot.rb +8 -0
  114. data/lib/igniter/metrics.rb +37 -0
  115. data/lib/igniter/model/aggregate_node.rb +34 -0
  116. data/lib/igniter/model/collection_node.rb +3 -2
  117. data/lib/igniter/model/compute_node.rb +13 -0
  118. data/lib/igniter/model/remote_node.rb +18 -2
  119. data/lib/igniter/node_cache.rb +231 -0
  120. data/lib/igniter/replication/bootstrapper.rb +61 -0
  121. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  122. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  123. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  124. data/lib/igniter/replication/expansion_plan.rb +38 -0
  125. data/lib/igniter/replication/expansion_planner.rb +142 -0
  126. data/lib/igniter/replication/manifest.rb +45 -0
  127. data/lib/igniter/replication/network_topology.rb +123 -0
  128. data/lib/igniter/replication/node_role.rb +42 -0
  129. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  130. data/lib/igniter/replication/replication_agent.rb +87 -0
  131. data/lib/igniter/replication/role_registry.rb +73 -0
  132. data/lib/igniter/replication/ssh_session.rb +77 -0
  133. data/lib/igniter/replication.rb +54 -0
  134. data/lib/igniter/runtime/cache.rb +35 -6
  135. data/lib/igniter/runtime/execution.rb +26 -2
  136. data/lib/igniter/runtime/input_validator.rb +6 -2
  137. data/lib/igniter/runtime/node_state.rb +7 -2
  138. data/lib/igniter/runtime/resolver.rb +323 -31
  139. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  140. data/lib/igniter/server/client.rb +44 -1
  141. data/lib/igniter/server/config.rb +13 -6
  142. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  143. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  144. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  145. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  146. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  147. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  148. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  149. data/lib/igniter/server/http_server.rb +54 -17
  150. data/lib/igniter/server/router.rb +54 -21
  151. data/lib/igniter/server/server_logger.rb +52 -0
  152. data/lib/igniter/server.rb +6 -0
  153. data/lib/igniter/skill/feedback.rb +116 -0
  154. data/lib/igniter/skill/output_schema.rb +110 -0
  155. data/lib/igniter/skill.rb +218 -0
  156. data/lib/igniter/temporal.rb +84 -0
  157. data/lib/igniter/tool/discoverable.rb +151 -0
  158. data/lib/igniter/tool.rb +52 -0
  159. data/lib/igniter/tool_registry.rb +144 -0
  160. data/lib/igniter/version.rb +1 -1
  161. data/lib/igniter.rb +17 -0
  162. metadata +128 -1
@@ -0,0 +1,207 @@
1
+ # Capability-Based Security — v1
2
+
3
+ Capabilities let executors declare what external resources they need, and let operators
4
+ enforce policies that deny specific capabilities at runtime. This makes the security surface
5
+ of every graph visible and auditable at a glance.
6
+
7
+ ## Quick Start
8
+
9
+ ```ruby
10
+ require "igniter/capabilities"
11
+
12
+ # 1. Declare capabilities on executors
13
+ class DatabaseLookup < Igniter::Executor
14
+ capabilities :database
15
+
16
+ def call(id:)
17
+ DB.find(id)
18
+ end
19
+ end
20
+
21
+ class PureCalculator < Igniter::Executor
22
+ pure # shorthand for capabilities(:pure)
23
+
24
+ def call(x:, y:)
25
+ x + y
26
+ end
27
+ end
28
+
29
+ # 2. Enforce a policy
30
+ policy = Igniter::Capabilities::Policy.new(denied: [:database])
31
+ Igniter::Capabilities.policy = policy
32
+
33
+ # 3. Execution raises CapabilityViolationError for denied nodes
34
+ class MyContract < Igniter::Contract
35
+ define do
36
+ input :id
37
+ compute :record, depends_on: :id, call: DatabaseLookup
38
+ output :record
39
+ end
40
+ end
41
+
42
+ MyContract.new(id: 42).resolve_all
43
+ # => Igniter::Capabilities::CapabilityViolationError:
44
+ # Node 'record' executor DatabaseLookup uses denied capabilities: database
45
+ ```
46
+
47
+ ## Executor DSL
48
+
49
+ ### `capabilities(*caps)`
50
+
51
+ Declare one or more capabilities required by the executor.
52
+
53
+ ```ruby
54
+ class EmailSender < Igniter::Executor
55
+ capabilities :network, :external_api
56
+
57
+ def call(to:, body:)
58
+ Mailer.send(to: to, body: body)
59
+ end
60
+ end
61
+ ```
62
+
63
+ ### `pure`
64
+
65
+ Shorthand for `capabilities(:pure)`. Marks the executor as having no side effects — its
66
+ output is fully determined by its inputs. `pure` executors participate in
67
+ [content-addressed caching](CONTENT_ADDRESSING_V1.md).
68
+
69
+ ```ruby
70
+ class TaxCalculator < Igniter::Executor
71
+ pure
72
+
73
+ def call(amount:, rate:)
74
+ amount * rate
75
+ end
76
+ end
77
+ ```
78
+
79
+ ### `pure?`
80
+
81
+ Returns `true` if the executor declares the `:pure` capability.
82
+
83
+ ```ruby
84
+ TaxCalculator.pure? # => true
85
+ ```
86
+
87
+ ### Accessing declared capabilities
88
+
89
+ ```ruby
90
+ EmailSender.declared_capabilities # => [:network, :external_api]
91
+ TaxCalculator.declared_capabilities # => [:pure]
92
+ ```
93
+
94
+ ## Known Capabilities
95
+
96
+ | Symbol | Meaning |
97
+ |--------|---------|
98
+ | `:pure` | No side effects; output determined by inputs only |
99
+ | `:network` | Makes outbound TCP/HTTP connections |
100
+ | `:database` | Reads or writes a database |
101
+ | `:filesystem` | Reads or writes the local filesystem |
102
+ | `:external_api` | Calls a third-party API |
103
+ | `:messaging` | Publishes to a message queue or event stream |
104
+ | `:queue` | Reads from a job or task queue |
105
+ | `:cache` | Reads or writes a distributed cache |
106
+
107
+ You can also use any custom symbol — the system does not restrict you to this list.
108
+
109
+ ## Policy Enforcement
110
+
111
+ ### `Igniter::Capabilities::Policy`
112
+
113
+ ```ruby
114
+ policy = Igniter::Capabilities::Policy.new(denied: [:network, :filesystem])
115
+ Igniter::Capabilities.policy = policy
116
+ ```
117
+
118
+ Setting the policy to `nil` disables all capability checks:
119
+
120
+ ```ruby
121
+ Igniter::Capabilities.policy = nil # no-op, all executors run freely
122
+ ```
123
+
124
+ The policy is global (process-wide). It is typically set at boot time.
125
+
126
+ ### `CapabilityViolationError`
127
+
128
+ Raised by the resolver at the start of `resolve_compute` before the executor is invoked.
129
+ The error includes the node name and the violated capabilities in its message:
130
+
131
+ ```ruby
132
+ begin
133
+ contract.resolve_all
134
+ rescue Igniter::Capabilities::CapabilityViolationError => e
135
+ puts e.message
136
+ # => "Node 'fetch_data' executor DataFetcher uses denied capabilities: network"
137
+ end
138
+ ```
139
+
140
+ ## Graph Introspection
141
+
142
+ After `require "igniter/extensions/capabilities"` (or `require "igniter/capabilities"`) the
143
+ compiled graph gains two introspection methods:
144
+
145
+ ### `required_capabilities`
146
+
147
+ Returns a Hash of `{ node_name => [capabilities] }` for every node whose executor
148
+ declares at least one capability.
149
+
150
+ ```ruby
151
+ require "igniter/capabilities"
152
+
153
+ class MyContract < Igniter::Contract
154
+ define do
155
+ input :id
156
+ compute :record, depends_on: :id, call: DatabaseLookup
157
+ compute :total, depends_on: :record, call: PureCalculator
158
+ output :total
159
+ end
160
+ end
161
+
162
+ MyContract.compiled_graph.required_capabilities
163
+ # => { record: [:database], total: [:pure] }
164
+ ```
165
+
166
+ ### `capabilities_for(node_name)`
167
+
168
+ Returns the declared capabilities of a single node as an array.
169
+
170
+ ```ruby
171
+ MyContract.compiled_graph.capabilities_for(:record)
172
+ # => [:database]
173
+
174
+ MyContract.compiled_graph.capabilities_for(:total)
175
+ # => [:pure]
176
+
177
+ MyContract.compiled_graph.capabilities_for(:id)
178
+ # => [] (input nodes have no executor)
179
+ ```
180
+
181
+ ## Environment-Based Policy Pattern
182
+
183
+ A common pattern is to load the policy from environment configuration so that the
184
+ same codebase enforces different rules in development vs. production:
185
+
186
+ ```ruby
187
+ # config/igniter.rb
188
+ DENIED_CAPS = case ENV["RAILS_ENV"]
189
+ when "test" then %i[network database filesystem]
190
+ when "production" then []
191
+ else []
192
+ end
193
+
194
+ Igniter::Capabilities.policy = Igniter::Capabilities::Policy.new(denied: DENIED_CAPS)
195
+ ```
196
+
197
+ This lets your test suite run without real I/O while production executions are unrestricted.
198
+
199
+ ## Files
200
+
201
+ | File | Purpose |
202
+ |------|---------|
203
+ | `lib/igniter/capabilities.rb` | `Capabilities` module, `Policy` class, `CapabilityViolationError` |
204
+ | `lib/igniter/extensions/capabilities.rb` | Patches `CompiledGraph` with `required_capabilities` / `capabilities_for` |
205
+ | `lib/igniter/executor.rb` | `capabilities`, `pure`, `pure?`, `declared_capabilities`, `fingerprint` DSL |
206
+ | `lib/igniter/runtime/resolver.rb` | `check_capability_policy!` guard in `resolve_compute` |
207
+ | `spec/igniter/capabilities_spec.rb` | 16 examples |
@@ -0,0 +1,477 @@
1
+ # Distributed Consensus with Igniter — v1
2
+
3
+ `require "igniter/consensus"` provides a Raft-inspired consensus cluster built on
4
+ Igniter's Actor primitives. The Raft protocol is fully encapsulated — users interact
5
+ with the high-level `Cluster` API and an optional `StateMachine` subclass.
6
+
7
+ ---
8
+
9
+ ## Quick start
10
+
11
+ ```ruby
12
+ require "igniter/consensus"
13
+
14
+ # Start a 5-node cluster with the built-in key-value state machine
15
+ cluster = Igniter::Consensus::Cluster.start(nodes: %i[n1 n2 n3 n4 n5])
16
+ cluster.wait_for_leader
17
+
18
+ cluster.write(key: :price, value: 99) # replicated to all nodes
19
+ cluster.read(:price) # => 99
20
+
21
+ cluster.stop!
22
+ ```
23
+
24
+ ---
25
+
26
+ ## Architecture
27
+
28
+ Two complementary Igniter primitives map naturally to consensus protocols:
29
+
30
+ | Primitive | Role |
31
+ |-----------|------|
32
+ | `Igniter::Consensus::Node` | Raft agent — leader election, log replication (internal) |
33
+ | `Igniter::Consensus::Cluster` | Lifecycle management + high-level read/write API |
34
+ | `Igniter::Consensus::StateMachine` | User-extensible state machine DSL |
35
+ | `Igniter::Consensus::ReadQuery` | Built-in single-shot read Contract |
36
+
37
+ ```
38
+ Cluster of 5 Node agents, each registered in Igniter::Registry
39
+
40
+ ┌─────────────────────────────────────────────────────────────┐
41
+ │ n1: follower │ n2: follower │ n3: LEADER │ n4: follower │ n5: follower
42
+ └───────────────┴───────────────┴──────────────┴───────────────┴───────────────┘
43
+
44
+ ┌──────────────────┼──────────────────┐
45
+ heartbeat (50ms) AppendEntries commit on quorum(3/5)
46
+ ```
47
+
48
+ ### Leader election flow
49
+
50
+ ```
51
+ Follower ──(timeout 1–1.5 s)──► Candidate ──(quorum votes)──► Leader
52
+ ▲ │
53
+ └────────────────── AppendEntries heartbeat (50 ms) ◄──────────┘
54
+ ```
55
+
56
+ 1. A follower that receives no heartbeat within its randomised election timeout
57
+ becomes a **Candidate** and broadcasts `RequestVote` to all peers.
58
+ 2. A node that hasn't voted in this term grants its vote if the candidate's log
59
+ is at least as up-to-date as its own.
60
+ 3. The first candidate to collect **majority votes** (quorum = ⌊N/2⌋ + 1) becomes
61
+ **Leader** and immediately starts heartbeating.
62
+ 4. Randomised timeouts (1.0–1.5 s) prevent simultaneous elections (split votes).
63
+
64
+ ---
65
+
66
+ ## `Cluster` API
67
+
68
+ ### Starting a cluster
69
+
70
+ ```ruby
71
+ cluster = Igniter::Consensus::Cluster.start(
72
+ nodes: %i[n1 n2 n3 n4 n5], # Registry names for each node
73
+ state_machine: MyStateMachine, # optional — default is KV store
74
+ verbose: false, # print Raft events to stdout
75
+ )
76
+ ```
77
+
78
+ `start` creates nodes and returns immediately — it does **not** wait for leader
79
+ election. Call `wait_for_leader` if you need a leader before proceeding.
80
+
81
+ ### Waiting for a leader
82
+
83
+ ```ruby
84
+ leader_ref = cluster.wait_for_leader # blocks up to ~2 s
85
+ leader_ref = cluster.wait_for_leader(timeout: 5) # custom timeout
86
+ ```
87
+
88
+ Raises `Igniter::Consensus::NoLeaderError` if no leader is elected within the timeout.
89
+
90
+ ### Writing
91
+
92
+ ```ruby
93
+ # Default KV protocol
94
+ cluster.write(key: :price, value: 99)
95
+
96
+ # Custom state machine command
97
+ cluster.write(type: :add_order, id: "o1", data: { price: 42, qty: 10 })
98
+ ```
99
+
100
+ Raises `NoLeaderError` if no leader is available. Returns `self` (chainable).
101
+
102
+ ### Reading
103
+
104
+ ```ruby
105
+ cluster.read(:price) # => 99 (reads from leader's committed state)
106
+ cluster.state_machine_snapshot # => { price: 99, ... } (full snapshot)
107
+ ```
108
+
109
+ ### Quorum and status
110
+
111
+ ```ruby
112
+ cluster.quorum_size # => 3 (minimum votes for 5-node cluster)
113
+ cluster.has_quorum? # => true
114
+ cluster.alive_count # => 5
115
+ cluster.status # => [{ node_id: :n1, role: :follower, term: 2, ... }, ...]
116
+ ```
117
+
118
+ ### Stopping
119
+
120
+ ```ruby
121
+ cluster.stop! # graceful stop of all nodes (timeout: 2s default)
122
+ cluster.stop!(timeout: 5)
123
+ ```
124
+
125
+ ### Contract integration
126
+
127
+ ```ruby
128
+ q = cluster.read_contract(key: :price) # returns ReadQuery instance
129
+ q.resolve_all
130
+ q.result.value # => 99
131
+ ```
132
+
133
+ ---
134
+
135
+ ## `StateMachine` — custom command reducers
136
+
137
+ Subclass `Igniter::Consensus::StateMachine` and declare handlers with `apply`:
138
+
139
+ ```ruby
140
+ class OrderBook < Igniter::Consensus::StateMachine
141
+ # Each handler receives (state, command) and must return the NEW state (immutably).
142
+ apply :add_order do |state, cmd|
143
+ state.merge(cmd[:id] => cmd[:order])
144
+ end
145
+
146
+ apply :cancel_order do |state, cmd|
147
+ state.reject { |k, _| k == cmd[:id] }
148
+ end
149
+
150
+ apply :update_price do |state, cmd|
151
+ return state unless state.key?(cmd[:id])
152
+ state.merge(cmd[:id] => state[cmd[:id]].merge(price: cmd[:price]))
153
+ end
154
+ end
155
+
156
+ cluster = Igniter::Consensus::Cluster.start(
157
+ nodes: %i[n1 n2 n3 n4 n5],
158
+ state_machine: OrderBook,
159
+ )
160
+
161
+ cluster.write(type: :add_order, id: "ord-1", order: { vendor: "ACME", price: 42.0 })
162
+ cluster.read("ord-1") # => { vendor: "ACME", price: 42.0 }
163
+ ```
164
+
165
+ ### Default KV protocol (no subclass needed)
166
+
167
+ When no `state_machine:` is provided, commands use a simple key-value protocol:
168
+
169
+ | Command | Effect |
170
+ |---------|--------|
171
+ | `{ key: :x, value: 42 }` | Set `state_machine[:x] = 42` |
172
+ | `{ key: :x, op: :delete }` | Remove `:x` from state machine |
173
+
174
+ ---
175
+
176
+ ## `ReadQuery` — declarative Contract read
177
+
178
+ `ReadQuery` is a built-in `Igniter::Contract` with the dependency graph
179
+ `find_leader → read_value`:
180
+
181
+ ```ruby
182
+ q = Igniter::Consensus::ReadQuery.new(cluster: cluster, key: :price)
183
+ q.resolve_all
184
+ q.result.value # => 99
185
+ ```
186
+
187
+ Or use `Cluster#read_contract` for convenience:
188
+
189
+ ```ruby
190
+ q = cluster.read_contract(key: :price)
191
+ q.resolve_all
192
+ q.result.value # => 99
193
+ ```
194
+
195
+ ### Custom read Contract
196
+
197
+ You can build your own Contracts using the bundled executors:
198
+
199
+ ```ruby
200
+ class PriceCheck < Igniter::Contract
201
+ define do
202
+ input :cluster
203
+ input :threshold
204
+
205
+ compute :leader, with: :cluster, call: Igniter::Consensus::FindLeader
206
+ compute :current_price, with: [:leader], call: ReadCurrentPrice
207
+ compute :verdict, with: [:current_price, :threshold], call: EvaluatePrice
208
+
209
+ output :verdict
210
+ end
211
+ end
212
+ ```
213
+
214
+ ---
215
+
216
+ ## Practical Example — `BidAuction`
217
+
218
+ Models the auction problem: N vendors submit bids durably to the consensus log
219
+ before the winner is selected. Combines `Igniter::Contract` parallel execution
220
+ with consensus-backed durability.
221
+
222
+ ```ruby
223
+ class SubmitBid < Igniter::Executor
224
+ # The dep name varies per compute node (vendor1_bid / vendor2_bid / …).
225
+ # Using ** captures whichever named bid dep is passed.
226
+ def call(cluster:, **bid_kwarg)
227
+ bid = bid_kwarg.values.first # { vendor_id:, price: }
228
+ ref = cluster.leader
229
+ raise Igniter::ResolutionError, "No leader — cannot submit bid" unless ref
230
+ ref.send(:client_write, command: { key: :"bid_#{bid[:vendor_id]}", value: bid[:price] })
231
+ bid
232
+ end
233
+ end
234
+
235
+ class SelectWinner < Igniter::Executor
236
+ def call(bid1:, bid2:, bid3:)
237
+ [bid1, bid2, bid3].min_by { |b| b[:price] }
238
+ end
239
+ end
240
+
241
+ class BidAuction < Igniter::Contract
242
+ runner :thread_pool, pool_size: 3 # bid1, bid2, bid3 run concurrently
243
+
244
+ define do
245
+ input :cluster
246
+ input :vendor1_bid # { vendor_id: String, price: Float }
247
+ input :vendor2_bid
248
+ input :vendor3_bid
249
+
250
+ # No deps between bid1/bid2/bid3 → submitted to the consensus log in parallel
251
+ compute :bid1, with: [:cluster, :vendor1_bid], call: SubmitBid
252
+ compute :bid2, with: [:cluster, :vendor2_bid], call: SubmitBid
253
+ compute :bid3, with: [:cluster, :vendor3_bid], call: SubmitBid
254
+
255
+ # Depends on all three → runs only after every bid is committed
256
+ compute :winner, with: [:bid1, :bid2, :bid3], call: SelectWinner
257
+
258
+ output :winner
259
+ end
260
+ end
261
+ ```
262
+
263
+ Usage:
264
+
265
+ ```ruby
266
+ auction = BidAuction.new(
267
+ cluster: cluster,
268
+ vendor1_bid: { vendor_id: "alpha", price: 45.00 },
269
+ vendor2_bid: { vendor_id: "betacor", price: 38.50 },
270
+ vendor3_bid: { vendor_id: "gamma", price: 52.00 },
271
+ )
272
+ auction.resolve_all
273
+ puts auction.result.winner # => { vendor_id: "betacor", price: 38.5 }
274
+ ```
275
+
276
+ The Igniter dependency graph enforces the correct ordering automatically:
277
+ - `bid1`, `bid2`, `bid3` have no mutual deps → `thread_pool` submits them concurrently.
278
+ - `winner` depends on all three → it never runs before every bid is logged.
279
+
280
+ ---
281
+
282
+ ## Quorum Failure and Safety Guarantees
283
+
284
+ Raft is a **CP system** (Consistent + Partition-tolerant). With fewer than `⌊N/2⌋ + 1`
285
+ nodes alive, no leader can be elected and the cluster is **unavailable** — but it
286
+ never returns stale or conflicting data.
287
+
288
+ ```ruby
289
+ # Kill enough nodes to break quorum (3/5 needed, only 2 survive)
290
+ (3).times { cluster_nodes.pop.kill }
291
+
292
+ begin
293
+ cluster.read_contract(key: :price).resolve_all
294
+ rescue Igniter::Error => e
295
+ puts e.message
296
+ # => "No leader in cluster — retry later [graph=ReadQuery, node=leader …]"
297
+ end
298
+
299
+ # Or catch at the Cluster level before even attempting a Contract:
300
+ cluster.has_quorum? # => false
301
+ cluster.write(key: :x, value: 1)
302
+ # => Igniter::Consensus::NoLeaderError: No leader available
303
+ ```
304
+
305
+ `FindLeader` scans all known nodes, finds no leader, and raises `Igniter::ResolutionError`.
306
+ The resolver enriches it with full context (graph, node, execution_id).
307
+
308
+ ---
309
+
310
+ ## Critical Implementation Gotchas
311
+
312
+ ### 1. `next` not `return` inside `on` blocks
313
+
314
+ `on :type do |state:, payload:| … end` registers a **Proc** (not a lambda).
315
+ `return` inside a Proc raises `LocalJumpError` at runtime.
316
+ Use `next value` for early exits:
317
+
318
+ ```ruby
319
+ # ✗ LocalJumpError at runtime
320
+ on :vote_response do |state:, payload:|
321
+ return state unless msg[:vote_granted]
322
+ end
323
+
324
+ # ✓ correct
325
+ on :vote_response do |state:, payload:|
326
+ next state unless msg[:vote_granted]
327
+ end
328
+ ```
329
+
330
+ ### 2. Sync-reply handlers must return a non-Hash
331
+
332
+ The Agent runner applies this logic to the handler's return value:
333
+
334
+ ```
335
+ Hash → @state_holder.set(result) # treated as NEW STATE; caller gets nil
336
+ other → send as reply to call()
337
+ ```
338
+
339
+ For synchronous queries, wrap the result in a Struct:
340
+
341
+ ```ruby
342
+ StatusInfo = Struct.new(:role, :term, :node_id, keyword_init: true)
343
+
344
+ on :status do |state:, payload:|
345
+ StatusInfo.new(role: state[:role], term: state[:term], node_id: state[:node_id])
346
+ end
347
+
348
+ ref.call(:status).role # => :leader
349
+ ```
350
+
351
+ `Igniter::Consensus::Node` uses `NodeStatus` and `NodeReadResult` structs internally.
352
+
353
+ ### 3. `ref.state` vs `ref.call()`
354
+
355
+ | Method | Mechanism | Use when |
356
+ |--------|-----------|----------|
357
+ | `ref.state` | Reads `StateHolder` directly (Mutex, no mailbox) | Polling from the main thread; leader discovery |
358
+ | `ref.call(:type)` | Goes through mailbox, blocks until reply | Need agent-thread consistency; sync queries |
359
+
360
+ `Cluster#leader` and `Cluster#read` use `ref.state` for performance-sensitive
361
+ leader polling.
362
+
363
+ ### 4. Class-method helpers must NOT be `private_class_method`
364
+
365
+ Helpers called from `schedule`/`on` blocks (which run in the Agent's Runner thread)
366
+ must be accessible via explicit class reference (`Node.find_peer`, etc.). Making them
367
+ `private_class_method` blocks these calls since the blocks use explicit receiver form.
368
+
369
+ ### 5. Heartbeat : election timeout ratio
370
+
371
+ Raft recommends heartbeat interval be **10× smaller** than the minimum election
372
+ timeout. With Ruby's green-thread scheduling jitter, a 1:20 ratio is more reliable:
373
+
374
+ ```ruby
375
+ HEARTBEAT_INTERVAL = 0.05 # 50 ms
376
+ ELECTION_TIMEOUT_BASE = 1.0 # 1000 ms minimum (1:20 ratio)
377
+ ELECTION_TIMEOUT_JITTER = 0.5 # + random 0–500 ms
378
+ ```
379
+
380
+ With a tighter ratio (e.g., 1:3) followers can time out before they receive the
381
+ first heartbeat from a freshly elected leader, causing cascading elections.
382
+
383
+ ---
384
+
385
+ ## Extending the Pattern
386
+
387
+ ### Custom state machine commands
388
+
389
+ ```ruby
390
+ class InventoryMachine < Igniter::Consensus::StateMachine
391
+ apply :set do |state, cmd| state.merge(cmd[:key] => cmd[:value]) end
392
+ apply :incr do |state, cmd| state.merge(cmd[:key] => (state[cmd[:key]] || 0) + cmd[:by]) end
393
+ apply :delete do |state, cmd| state.reject { |k, _| k == cmd[:key] } end
394
+ end
395
+ ```
396
+
397
+ ### Distributed lock
398
+
399
+ ```ruby
400
+ class AcquireLock < Igniter::Executor
401
+ def call(cluster:, lock_key:, owner:)
402
+ ref = cluster.leader
403
+ raise Igniter::ResolutionError, "No leader" unless ref
404
+ current = ref.state[:state_machine][lock_key]
405
+ raise Igniter::ResolutionError, "Lock held by #{current}" if current
406
+ ref.send(:client_write, command: { key: lock_key, value: owner })
407
+ :acquired
408
+ end
409
+ end
410
+ ```
411
+
412
+ ### Multi-Raft / partitioned keyspace
413
+
414
+ Start separate clusters per shard and route writes by key hash:
415
+
416
+ ```ruby
417
+ SHARD_CLUSTERS = {
418
+ 0 => Igniter::Consensus::Cluster.start(nodes: %i[n1a n2a n3a]),
419
+ 1 => Igniter::Consensus::Cluster.start(nodes: %i[n1b n2b n3b]),
420
+ }
421
+
422
+ def shard_for(key) = key.hash % SHARD_CLUSTERS.size
423
+ def cluster_for(key) = SHARD_CLUSTERS[shard_for(key)]
424
+ ```
425
+
426
+ ### Redis-backed log persistence
427
+
428
+ Access the underlying Node agent via `Igniter::Registry` to intercept writes:
429
+
430
+ ```ruby
431
+ # Subscribe to writes via a custom state machine that persists to Redis:
432
+ class RedisBackedMachine < Igniter::Consensus::StateMachine
433
+ apply :set do |state, cmd|
434
+ $redis.rpush("raft:log", { key: cmd[:key], value: cmd[:value] }.to_json)
435
+ state.merge(cmd[:key] => cmd[:value])
436
+ end
437
+ end
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Running the Demo
443
+
444
+ ```bash
445
+ bundle exec ruby examples/consensus.rb
446
+ ```
447
+
448
+ Output covers 10 steps:
449
+
450
+ | Step | Description |
451
+ |------|-------------|
452
+ | 1 | Start 5-node cluster |
453
+ | 2 | Leader elected via `wait_for_leader` |
454
+ | 3 | Two writes committed to the log |
455
+ | 4 | Full cluster status snapshot |
456
+ | 5 | `ReadQuery` contract reads `:price` |
457
+ | 6 | Leader crash simulation |
458
+ | 7 | New leader elected; write + read after failover |
459
+ | 8 | Custom `CounterMachine` in a 3-node cluster |
460
+ | 9 | `BidAuction` — three vendors bid in parallel; bids replicated before winner selected |
461
+ | 10 | Quorum failure — `ReadQuery` raises `ResolutionError` (CP guarantee) |
462
+
463
+ ---
464
+
465
+ ## Files
466
+
467
+ | File | Purpose |
468
+ |------|---------|
469
+ | `lib/igniter/consensus.rb` | Entry point (`require "igniter/consensus"`) |
470
+ | `lib/igniter/consensus/cluster.rb` | **Public API** — lifecycle, read, write, status |
471
+ | `lib/igniter/consensus/state_machine.rb` | User DSL — `apply :type do \|state, cmd\| end` |
472
+ | `lib/igniter/consensus/node.rb` | Internal Raft agent (full protocol) |
473
+ | `lib/igniter/consensus/executors.rb` | `FindLeader`, `ReadValue`, `SubmitCommand` |
474
+ | `lib/igniter/consensus/read_query.rb` | `ReadQuery` built-in Contract |
475
+ | `lib/igniter/consensus/errors.rb` | `NoLeaderError`, `QuorumLostError` |
476
+ | `examples/consensus.rb` | Full working demo (10 steps) |
477
+ | `spec/igniter/consensus_spec.rb` | Test suite (35 examples) |