karst 0.2.0 → 0.4.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +6 -1
  3. data/CHANGELOG.md +82 -2
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +118 -2
  6. data/docs/advanced-configuration.md +106 -0
  7. data/docs/rails8-authentication.md +24 -4
  8. data/docs/request-reproduction.md +242 -0
  9. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  10. data/lib/karst/access/approved_populations.rb +15 -6
  11. data/lib/karst/access/errors.rb +16 -0
  12. data/lib/karst/access/identity_probe.rb +151 -0
  13. data/lib/karst/access/local_path.rb +42 -0
  14. data/lib/karst/access/observed_endpoint.rb +46 -0
  15. data/lib/karst/access/outcome_groups.rb +93 -0
  16. data/lib/karst/access/population_discovery.rb +25 -1
  17. data/lib/karst/access/principal_source.rb +24 -3
  18. data/lib/karst/access/probe.rb +397 -0
  19. data/lib/karst/access/search.rb +14 -6
  20. data/lib/karst/access/sweep.rb +42 -112
  21. data/lib/karst/cli/identity_serialization.rb +82 -0
  22. data/lib/karst/cli/outcome_summary.rb +131 -0
  23. data/lib/karst/cli/principal_reference.rb +51 -0
  24. data/lib/karst/cli/reproduction.rb +267 -0
  25. data/lib/karst/cli/verification.rb +219 -55
  26. data/lib/karst/configuration.rb +52 -6
  27. data/lib/karst/identity/evidence.rb +110 -0
  28. data/lib/karst/identity/observation.rb +167 -0
  29. data/lib/karst/identity/warden_adapter.rb +26 -1
  30. data/lib/karst/identity.rb +157 -22
  31. data/lib/karst/mcp/compatibility.rb +24 -0
  32. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  33. data/lib/karst/mcp/reproduction.rb +58 -0
  34. data/lib/karst/mcp/server.rb +27 -8
  35. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  36. data/lib/karst/reproduction/curl.rb +73 -0
  37. data/lib/karst/reproduction/exercise.rb +246 -0
  38. data/lib/karst/reproduction/observation.rb +87 -0
  39. data/lib/karst/reproduction/sanitizer.rb +210 -0
  40. data/lib/karst/version.rb +1 -1
  41. data/lib/karst/web/middleware.rb +105 -16
  42. data/lib/karst/web/panel.rb +302 -47
  43. data/lib/karst.rb +2 -0
  44. data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
  45. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  46. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  47. metadata +33 -6
@@ -14,6 +14,9 @@ module Karst
14
14
 
15
15
  Candidate = Value.define(:model_name, :method_name, :principal_source)
16
16
 
17
+ UNRESOLVED = Object.new.freeze
18
+ private_constant :UNRESOLVED
19
+
17
20
  ModelGroup = Value.define(:model_name, :candidate_names, :principal_source) do
18
21
  def candidates
19
22
  candidate_names.map do |method_name|
@@ -28,6 +31,20 @@ module Karst
28
31
  end
29
32
  end
30
33
 
34
+ # `principal_sources:`, when given, is either the caller's already-
35
+ # resolved effective principal source Hash (see
36
+ # Karst::Identity::Snapshot), or a zero-argument callable that resolves
37
+ # to one -- used, at most once, lazily, only if a check here actually
38
+ # needs it (#confirms? never does). The callable form lets a caller
39
+ # hand over a source of truth that is not resolved yet at construction
40
+ # time but will be by the time this discovery's #call actually runs
41
+ # (see Karst::Web::Middleware, which builds one discovery instance
42
+ # before resolving the operation's Identity::Snapshot and reuses it
43
+ # afterward). Omitted, configuration is resolved here instead.
44
+ def initialize(principal_sources: UNRESOLVED)
45
+ @principal_sources = principal_sources
46
+ end
47
+
31
48
  def call
32
49
  Result.new(model_groups: model_groups, load_warning: @load_warning)
33
50
  end
@@ -119,7 +136,7 @@ module Karst
119
136
  # and re-parse model source once per model group too.
120
137
  def principal_source_klasses
121
138
  @principal_source_klasses ||= begin
122
- sources = Karst.config.principal_sources || {}
139
+ sources = resolved_principal_sources || {}
123
140
  sources.each_with_object({}) do |(name, source), memo|
124
141
  klass = source.record_klass
125
142
  memo[name] = klass if klass
@@ -127,6 +144,13 @@ module Karst
127
144
  end
128
145
  end
129
146
 
147
+ def resolved_principal_sources
148
+ return @principal_sources.call if @principal_sources.respond_to?(:call)
149
+ return Karst.config.principal_sources if @principal_sources.equal?(UNRESOLVED)
150
+
151
+ @principal_sources
152
+ end
153
+
130
154
  # A deliberately small Ripper AST reader. It recognizes only literal
131
155
  # names and the two callable forms accepted here: -> {} and lambda {}.
132
156
  # rubocop:disable Metrics/ClassLength
@@ -19,12 +19,15 @@ module Karst
19
19
  class PrincipalSource
20
20
  attr_reader :name, :records, :populations
21
21
 
22
- def initialize(name:, records:, populations: {})
22
+ # `evaluation` is internal: the shared memo cell #evaluated_once copies
23
+ # carry. nil (the default) evaluates `records` on every call.
24
+ def initialize(name:, records:, populations: {}, evaluation: nil)
23
25
  raise ArgumentError, "principal source #{name.inspect} must be callable" unless records.respond_to?(:call)
24
26
 
25
27
  @name = name.to_sym
26
28
  @records = records
27
29
  @populations = self.class.normalize_populations(@name, populations)
30
+ @evaluation = evaluation
28
31
  end
29
32
 
30
33
  # Evaluates the configured records callable. Never enumerates or
@@ -32,7 +35,10 @@ module Karst
32
35
  # Relation, exactly like Karst::Identity.principals already did for
33
36
  # the single-source case.
34
37
  def evaluate
35
- records.call
38
+ return records.call unless @evaluation
39
+ return @evaluation[:records] if @evaluation.key?(:records)
40
+
41
+ @evaluation[:records] = records.call
36
42
  end
37
43
 
38
44
  # The Active Record class this source's records ultimately belong to,
@@ -53,6 +59,20 @@ module Karst
53
59
  nil
54
60
  end
55
61
 
62
+ # A copy of this source whose records callable runs at most once, for
63
+ # as long as the copy (and any #with_populations copy of it) lives: the
64
+ # first #evaluate result is reused by every later #evaluate and
65
+ # #record_klass. Configuration#principal_sources hands out these
66
+ # copies, one set per resolution, so a single operation holding one
67
+ # resolution (see Karst::Identity::Snapshot) never re-runs an
68
+ # application callable to sample, match approvals, and check Devise
69
+ # scopes, while the next resolution still evaluates afresh. `records`
70
+ # itself is unchanged, and a callable that raises is not memoized: each
71
+ # caller sees the failure, as before.
72
+ def evaluated_once
73
+ self.class.new(name: @name, records: @records, populations: @populations, evaluation: {})
74
+ end
75
+
56
76
  # A copy of this source with `extra` populations appended after its own
57
77
  # configured ones. Used by Karst::Access::ApprovedPopulations to fold
58
78
  # locally approved discovered scopes into the effective configuration,
@@ -66,7 +86,8 @@ module Karst
66
86
  merged = self.class.normalize_populations(@name, extra).reject { |name, _| @populations.key?(name) }
67
87
  return self if merged.empty?
68
88
 
69
- self.class.new(name: @name, records: @records, populations: @populations.merge(merged))
89
+ self.class.new(name: @name, records: @records, populations: @populations.merge(merged),
90
+ evaluation: @evaluation)
70
91
  end
71
92
 
72
93
  # Accepts a raw Hash of name => (callable, or {records:,
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "uri"
5
+ require_relative "errors"
6
+ require_relative "probe_application"
7
+ require_relative "database_isolation"
8
+ require_relative "identity_probe"
9
+ require_relative "../identity"
10
+ require_relative "../value"
11
+
12
+ module Karst
13
+ module Access
14
+ # The one place Karst executes a request against the application and
15
+ # observes what it did.
16
+ #
17
+ # Access::Sweep ("which identities get through this route?") and
18
+ # Reproduction::Exercise ("what does this exact request do?") issue every
19
+ # request through #call and differ only in what they ask and how they
20
+ # present the answer. So one request, as one identity, against one target
21
+ # produces one observation, whichever feature -- and whichever CLI
22
+ # command, MCP tool, or panel operation above it -- asked.
23
+ #
24
+ # Every probe runs in three phases, and only the middle one is the request
25
+ # under test:
26
+ #
27
+ # setup Access::IdentityProbe#establish (sign-in requests and hooks),
28
+ # plus Karst's own Warden sign-in when it can only be applied
29
+ # inside the target request itself (see Identity.establishing)
30
+ # target exactly one request, observed
31
+ # teardown Access::IdentityProbe#release (sign-out requests and hooks)
32
+ #
33
+ # Target evidence -- mutating SQL, the halted callback, the controller
34
+ # lifecycle, renders -- is recorded only by subscriptions that exist for
35
+ # the target request's own duration, only on this thread, and never while
36
+ # Karst is doing its own identity work inside that request. A sign-in
37
+ # that inserts a session row, a sign-out that deletes it, or a Devise
38
+ # :trackable UPDATE is setup/teardown, never the route's behavior; a write
39
+ # the route itself makes always is. Writes are counted when attempted, so
40
+ # the rollback below never erases them from the evidence.
41
+ #
42
+ # All three phases run inside one rollback-only transaction on the same
43
+ # connection -- containment for database writes on that connection and
44
+ # nothing else (jobs, mail, outbound HTTP, files, and other connections are
45
+ # not contained).
46
+ #
47
+ # Three kinds of failure are kept apart rather than folded together:
48
+ #
49
+ # - The target raising (or rendering a captured exception) is observed
50
+ # application behavior: exception_class/exception_phase.
51
+ # - Identity setup failing is identity evidence (identity.establishment
52
+ # :failed and its error). The target still runs and is still
53
+ # observed -- except when Karst's own in-request sign-in raised, in
54
+ # which case the route never ran and nothing is reported for it.
55
+ # - Karst failing to observe (one of its own subscribers raising, a
56
+ # misconfigured observe_identity, a request that never reached the
57
+ # application) fails the call with ObservationError or the
58
+ # configuration error itself. Evidence Karst could not gather is never
59
+ # reported as the application having done nothing.
60
+ class Probe
61
+ # The authoritative observation of one target request.
62
+ #
63
+ # controller/action are the controller the target request dispatched to,
64
+ # read from its own Rack env (nil when it reached none).
65
+ # controller_completed is whether process_action finished without an
66
+ # exception (nil when process_action never ran). status, redirect
67
+ # (query-stripped), response_content_type, and route_params (the
68
+ # router's own path_parameters, minus controller/action) are read only
69
+ # when the target completed, so a raising target never inherits an
70
+ # earlier request's response. halted_callback is the filter exactly as
71
+ # Rails reported it. write_count counts mutating SQL statements the
72
+ # target attempted. elapsed_ms covers the whole probe, setup and
73
+ # teardown included.
74
+ Observation = Value.define(
75
+ :identity, :controller, :action, :controller_completed, :status, :redirect, :response_content_type,
76
+ :halted_callback, :exception_class, :exception_phase, :rendered, :write_count, :route_params, :elapsed_ms
77
+ )
78
+
79
+ def initialize(application)
80
+ @endpoint = ProbeApplication.for(application)
81
+ rescue ProbeApplication::ConstructionError => e
82
+ raise Unavailable, e.message, cause: e
83
+ end
84
+
85
+ # `target` is a local path, including any query string, exactly as it
86
+ # is sent. `body` travels as the request body, never as query
87
+ # parameters.
88
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
89
+ def call(principal:, target:, http_method: "GET", body: nil, headers: {})
90
+ identity = IdentityProbe.new(principal)
91
+ session = open_session(identity)
92
+ method = http_method.to_s.downcase.to_sym
93
+ window = TargetWindow.new(identity)
94
+ started = monotonic
95
+ with_rollback do
96
+ # Never raises: a probe whose identity could not be established
97
+ # still runs, and reports what the application actually saw.
98
+ identity.establish(session)
99
+ begin
100
+ window.run { session.process(method, target, params: body, headers: headers) }
101
+ # Strictly before release: clearing the identity is exactly what
102
+ # would make a completed request look anonymous, and a sign-out
103
+ # request would replace the target's env and response.
104
+ identity.observe(:request_completion)
105
+ window.capture(session)
106
+ ensure
107
+ identity.release(session)
108
+ end
109
+ end
110
+ window.verify!
111
+ window.observation(elapsed(started))
112
+ end
113
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
114
+
115
+ private
116
+
117
+ # One fresh integration session per probe, reused for setup, target,
118
+ # and teardown so cookies behave like one real client.
119
+ def open_session(identity)
120
+ require "action_dispatch/testing/integration" unless defined?(ActionDispatch::Integration::Session)
121
+
122
+ session = ActionDispatch::Integration::Session.new(identity.endpoint(@endpoint))
123
+ session.host!(@endpoint.host) if @endpoint.respond_to?(:host) && @endpoint.host
124
+ session
125
+ end
126
+
127
+ def with_rollback
128
+ raise Unavailable, "Active Record rollback isolation is unavailable" unless defined?(ActiveRecord::Base)
129
+
130
+ ActiveRecord::Base.transaction(requires_new: true) do
131
+ yield
132
+ raise ActiveRecord::Rollback
133
+ end
134
+ end
135
+
136
+ def monotonic
137
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
138
+ end
139
+
140
+ def elapsed(started)
141
+ ((monotonic - started) * 1000.0).round(1)
142
+ end
143
+
144
+ # Everything observed about one target request: open only while that
145
+ # request runs, on the thread that runs it.
146
+ # rubocop:disable Metrics/ClassLength
147
+ class TargetWindow
148
+ def initialize(identity)
149
+ @identity = identity
150
+ @thread = Thread.current
151
+ @writes = 0
152
+ @render_events = []
153
+ @paused = false
154
+ end
155
+
156
+ # rubocop:disable Naming/BlockForwarding -- anonymous block forwarding
157
+ # needs Ruby 3.1; this file runs on Ruby 2.7.
158
+ def run(&request)
159
+ @identity.begin_target
160
+ subscribed(&request)
161
+ @completed = true
162
+ rescue Identity::EstablishmentError => e
163
+ # Karst's own sign-in raised inside the request, before the
164
+ # application dispatched it: identity setup failed, and the route
165
+ # never ran.
166
+ @identity.establishment_failed(e.message)
167
+ rescue StandardError => e
168
+ target_raised(e)
169
+ end
170
+ # rubocop:enable Naming/BlockForwarding
171
+
172
+ def capture(session)
173
+ env = @identity.request_env
174
+ @controller, @action = dispatched(env)
175
+ @controller_completed = !@dispatch.key?(:exception) if @dispatch
176
+ return unless @completed && env
177
+
178
+ # Rails may render an application exception through ShowExceptions
179
+ # rather than re-raise it, depending on host and Rails-version
180
+ # configuration; the original is then recorded on the env.
181
+ @exception = env["action_dispatch.exception"]
182
+ return if @exception
183
+
184
+ # Only now is session.request/session.response provably the target's:
185
+ # ActionDispatch::Integration::Session reassigns them only after the
186
+ # request returns without raising.
187
+ @status = session.response.status
188
+ @redirect = clean_redirect(session.response.location) if @status >= 300 && @status < 400
189
+ @response_content_type = response_content_type(session)
190
+ @route_params = route_params(session)
191
+ end
192
+
193
+ # A misconfigured observation seam is a configuration error and says
194
+ # so; any other failure of Karst's own observation means the evidence
195
+ # is incomplete, and none of it is reported.
196
+ def verify!
197
+ return unless @failure
198
+ raise @failure if @failure.is_a?(Identity::ConfigurationError)
199
+
200
+ raise ObservationError,
201
+ "Karst could not observe this request (#{@failure.class}: #{@failure.message}); " \
202
+ "no evidence was reported for it",
203
+ cause: @failure
204
+ end
205
+
206
+ def observation(elapsed_ms)
207
+ Observation.new(
208
+ identity: @identity.evidence, controller: @controller, action: @action,
209
+ controller_completed: @controller_completed, status: @status, redirect: @redirect,
210
+ response_content_type: @response_content_type, halted_callback: @halted_callback,
211
+ exception_class: @exception&.class&.name, exception_phase: (exception_phase if @exception),
212
+ rendered: rendered, write_count: @writes, route_params: @route_params, elapsed_ms: elapsed_ms
213
+ )
214
+ end
215
+
216
+ private
217
+
218
+ # Only an exception from a request that actually reached the
219
+ # application is the application's own; anything earlier is Karst
220
+ # failing to issue the request at all.
221
+ def target_raised(error)
222
+ if @identity.request_env
223
+ @exception = error
224
+ else
225
+ @failure ||= error # rubocop:disable Naming/MemoizedInstanceVariableName
226
+ end
227
+ end
228
+
229
+ # Notification subscriptions are process-wide; #recording? keeps a
230
+ # concurrent request on another thread of a real development server
231
+ # from ever being counted as this target's evidence.
232
+ def subscribed(&block)
233
+ notifications = ActiveSupport::Notifications
234
+ notifications.subscribed(guarded(:record_write), "sql.active_record") do
235
+ notifications.subscribed(guarded(:record_halt), "halted_callback.action_controller") do
236
+ notifications.subscribed(guarded(:record_render), "!render_template.action_view") do
237
+ notifications.subscribed(guarded(:record_dispatch), "process_action.action_controller", &block)
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ # A subscriber that raises would raise into the application's own
244
+ # instrumented code -- a SQL statement, a callback chain, a
245
+ # transaction -- and surface as the target's exception. Karst's
246
+ # failure is kept as Karst's instead (see #verify!).
247
+ def guarded(handler)
248
+ lambda do |_name, _start, _finish, _id, payload|
249
+ send(handler, payload) if recording?
250
+ rescue StandardError => e
251
+ @failure ||= e
252
+ end
253
+ end
254
+
255
+ def recording?
256
+ Thread.current.equal?(@thread) && !@paused && !Identity.establishing?
257
+ end
258
+
259
+ def record_write(payload)
260
+ @writes += 1 if DatabaseIsolation.mutation?(payload[:sql])
261
+ end
262
+
263
+ def record_halt(payload)
264
+ @halted_callback ||= payload[:filter]
265
+ # The identity the application had established when this access
266
+ # decision was made -- not after the request unwound. Karst's own
267
+ # observation, so nothing it causes is the target's evidence.
268
+ pause { @identity.observe(:halted_callback) }
269
+ end
270
+
271
+ # "!render_template.action_view" -- not the higher-level, no-bang
272
+ # "render_template.action_view" -- because it is the one event
273
+ # ActionView::Template itself fires for every template, partial, and
274
+ # layout it renders, and the only one carrying that template's own
275
+ # relative virtual_path rather than an absolute source file. It fires
276
+ # once per Template#render call, whether that call completed or raised
277
+ # (payload[:exception]/[:exception_object] are set first when it
278
+ # raised), so it is a faithful, ordered record of what the target
279
+ # request rendered.
280
+ def record_render(payload)
281
+ return unless payload[:virtual_path]
282
+
283
+ @render_events << { virtual_path: payload[:virtual_path].to_s, completed: !payload.key?(:exception),
284
+ exception_object: payload[:exception_object] }
285
+ end
286
+
287
+ # The last process_action to finish is the outermost one.
288
+ def record_dispatch(payload)
289
+ @dispatch = payload
290
+ end
291
+
292
+ def pause
293
+ @paused = true
294
+ yield
295
+ ensure
296
+ @paused = false
297
+ end
298
+
299
+ # Rails puts the dispatching controller on the request env before any
300
+ # callback runs, so this is present even for a halted request.
301
+ def dispatched(env)
302
+ instance = env && env["action_controller.instance"]
303
+ return [nil, nil] unless instance
304
+
305
+ [presence(instance.class.name), presence(instance.respond_to?(:action_name) ? instance.action_name : nil)]
306
+ rescue StandardError
307
+ [nil, nil]
308
+ end
309
+
310
+ def presence(value)
311
+ value.to_s.empty? ? nil : value.to_s
312
+ end
313
+
314
+ def rendered
315
+ @render_events.map { |event| { virtual_path: event[:virtual_path], completed: event[:completed] }.freeze }
316
+ .freeze
317
+ end
318
+
319
+ # The most specific phase Karst can prove an observed exception
320
+ # occurred in, never guessed from its class or message.
321
+ #
322
+ # A render-level exception reaches the controller wrapped in a new
323
+ # ActionView::Template::Error (see ActionView::Template#handle_render_
324
+ # error), so the object the request ultimately raised is not the one
325
+ # "!render_template.action_view" recorded -- it is that object's
326
+ # #cause, or its #cause's #cause for a partial nested inside a
327
+ # template inside a layout. Walking #cause is therefore required to
328
+ # recognize a render exception at all once it has unwound past the
329
+ # template that raised it.
330
+ def exception_phase
331
+ return "render" if @render_events.any? { |event| same_exception?(@exception, event[:exception_object]) }
332
+ return "controller" if @dispatch&.key?(:exception)
333
+
334
+ "unknown"
335
+ end
336
+
337
+ def same_exception?(exception, candidate)
338
+ return false unless candidate
339
+
340
+ chain = exception
341
+ depth = 0
342
+ while chain && depth < 20
343
+ return true if chain.equal?(candidate)
344
+
345
+ chain = chain.respond_to?(:cause) ? chain.cause : nil
346
+ depth += 1
347
+ end
348
+ false
349
+ end
350
+
351
+ # The router's own path_parameters -- observed routing, not a second
352
+ # recognize_path guess that could disagree with what dispatched.
353
+ # nil when Karst observed no route.
354
+ def route_params(session)
355
+ raw = session.request.path_parameters
356
+ return nil unless raw.is_a?(Hash) && !raw.empty?
357
+
358
+ raw.each_with_object({}) do |(key, value), result|
359
+ result[key.to_s] = value unless %w[controller action].include?(key.to_s)
360
+ end
361
+ rescue StandardError
362
+ nil
363
+ end
364
+
365
+ def response_content_type(session)
366
+ value = session.response.content_type if session.response.respond_to?(:content_type)
367
+ value.to_s.empty? ? nil : value.to_s
368
+ rescue StandardError
369
+ nil
370
+ end
371
+
372
+ # Query-and-fragment-stripped, never just query-stripped: a redirect
373
+ # target is exactly as capable of carrying a credential in its
374
+ # fragment (an OAuth-style "/callback#access_token=...") as in its
375
+ # query string, and a fragment is never sent back to the server on
376
+ # the follow-up request, so there is no reason reproduction needs it
377
+ # either. The regex fallback below mirrors that -- it drops
378
+ # everything from the first "?" *or* "#", whichever comes first --
379
+ # so a Location Karst cannot even parse as a URI still cannot leak
380
+ # through the one delimiter the query-only split used to miss.
381
+ def clean_redirect(location)
382
+ text = location.to_s
383
+ return nil if text.empty?
384
+
385
+ uri = URI.parse(text)
386
+ uri.query = nil
387
+ uri.fragment = nil
388
+ uri.to_s
389
+ rescue StandardError
390
+ text.split(/[?#]/, 2).first
391
+ end
392
+ end
393
+ private_constant :TargetWindow
394
+ # rubocop:enable Metrics/ClassLength
395
+ end
396
+ end
397
+ end
@@ -14,11 +14,11 @@ module Karst
14
14
  #
15
15
  # This is deliberately an orchestrator built on top of the existing
16
16
  # primitives rather than new behavior inside them. Access::Sweep still
17
- # owns every actual request (and therefore every rollback, write
18
- # observation, exception, and halted-callback observation), and
19
- # CandidatePopulation still owns resolving one configured callable into
20
- # bounded records. Search only decides what to run next and records what
21
- # it chose not to run.
17
+ # issues every request (each one an Access::Probe, which owns every
18
+ # rollback, write observation, exception, and halted-callback
19
+ # observation), and CandidatePopulation still owns resolving one
20
+ # configured callable into bounded records. Search only decides what to
21
+ # run next and records what it chose not to run.
22
22
  #
23
23
  # Approval boundary: the populations considered here are exactly the
24
24
  # ones on the Karst::Access::PrincipalSource objects handed to this
@@ -164,10 +164,18 @@ module Karst
164
164
  def attempt_population(source_name, name, callable, source)
165
165
  return skip(source_name, name, :budget_exhausted) unless budget_remaining.positive?
166
166
 
167
- resolved = candidate_records(source_name, name, callable, source)
167
+ resolved = resolve_population(source_name, name, callable, source)
168
168
  return resolved if resolved.is_a?(PopulationAttempt)
169
169
 
170
170
  sweep_population(source_name, name, resolved[:records], resolved[:population])
171
+ end
172
+
173
+ # A population that fails to *resolve* is reported as :unresolved. A
174
+ # failure while *probing* its records is not a fact about the
175
+ # population, so it is never folded into that state: it propagates
176
+ # exactly as it would from the ordinary sample.
177
+ def resolve_population(source_name, name, callable, source)
178
+ candidate_records(source_name, name, callable, source)
171
179
  rescue StandardError => e
172
180
  skip(source_name, name, :unresolved, error: "#{e.class}: #{e.message}")
173
181
  end