karst 0.1.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -12
  3. data/CHANGELOG.md +114 -47
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +137 -9
  6. data/SECURITY.md +1 -1
  7. data/docs/advanced-configuration.md +112 -7
  8. data/docs/rails8-authentication.md +191 -0
  9. data/docs/request-reproduction.md +242 -0
  10. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  11. data/lib/karst/access/approved_populations.rb +15 -6
  12. data/lib/karst/access/errors.rb +16 -0
  13. data/lib/karst/access/identity_probe.rb +151 -0
  14. data/lib/karst/access/local_path.rb +42 -0
  15. data/lib/karst/access/observed_endpoint.rb +46 -0
  16. data/lib/karst/access/outcome_groups.rb +93 -0
  17. data/lib/karst/access/population_approval.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +25 -1
  19. data/lib/karst/access/population_revocation.rb +34 -0
  20. data/lib/karst/access/principal_source.rb +24 -3
  21. data/lib/karst/access/probe.rb +397 -0
  22. data/lib/karst/access/search.rb +14 -6
  23. data/lib/karst/access/sweep.rb +42 -112
  24. data/lib/karst/cli/identity_serialization.rb +82 -0
  25. data/lib/karst/cli/outcome_summary.rb +131 -0
  26. data/lib/karst/cli/principal_reference.rb +51 -0
  27. data/lib/karst/cli/reproduction.rb +267 -0
  28. data/lib/karst/cli/verification.rb +219 -55
  29. data/lib/karst/configuration.rb +57 -11
  30. data/lib/karst/execution_context.rb +6 -7
  31. data/lib/karst/identity/evidence.rb +110 -0
  32. data/lib/karst/identity/observation.rb +167 -0
  33. data/lib/karst/identity/warden_adapter.rb +26 -1
  34. data/lib/karst/identity.rb +157 -22
  35. data/lib/karst/mcp/compatibility.rb +24 -0
  36. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  37. data/lib/karst/mcp/reproduction.rb +58 -0
  38. data/lib/karst/mcp/server.rb +27 -8
  39. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  40. data/lib/karst/railtie.rb +4 -7
  41. data/lib/karst/reproduction/curl.rb +73 -0
  42. data/lib/karst/reproduction/exercise.rb +246 -0
  43. data/lib/karst/reproduction/observation.rb +87 -0
  44. data/lib/karst/reproduction/sanitizer.rb +210 -0
  45. data/lib/karst/version.rb +1 -1
  46. data/lib/karst/web/badge.rb +5 -31
  47. data/lib/karst/web/browser_identity.rb +10 -13
  48. data/lib/karst/web/csrf.rb +43 -0
  49. data/lib/karst/web/middleware.rb +145 -84
  50. data/lib/karst/web/panel.rb +343 -125
  51. data/lib/karst/web/populations_panel.rb +46 -336
  52. data/lib/karst.rb +3 -3
  53. data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
  54. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  55. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  56. metadata +38 -18
  57. data/lib/karst/access/population_config_snippet.rb +0 -67
  58. data/lib/karst/access/population_preview.rb +0 -83
  59. data/lib/karst/access/resource_evidence.rb +0 -233
  60. data/lib/karst/spec/catalog.rb +0 -199
  61. data/lib/karst/spec/example_observation.rb +0 -31
  62. data/lib/karst/spec/observer.rb +0 -300
  63. data/lib/karst/spec/principal.rb +0 -12
  64. data/lib/karst/spec/reporter.rb +0 -83
  65. data/lib/karst/spec/request_observation.rb +0 -38
  66. data/lib/karst/spec/scenario.rb +0 -65
  67. data/lib/tasks/karst.rake +0 -34
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08cd05c0ed4b829d53920c0cde0a5844fcb518f380b63616a812f7baef7a3e0d'
4
- data.tar.gz: 43a7fde748e330ec79a586b2621f3110c0f77061594a456b8968d5beb0eba897
3
+ metadata.gz: 513a3fddea6f4ffa3b738ec37e53fd3b21ad39efb5f0f7997efb926cf9acfe37
4
+ data.tar.gz: 22608af3514ea1172109a1ab746ef8a7c76059dae2ef212159d4567ba90025bb
5
5
  SHA512:
6
- metadata.gz: 5b34b65961902ec98e9f2d26e8d26add2f9cb20ad440d0ea5d098e95817e75abb1db4aeca809ebb662a2481ed08ef466deba5ec0c07ed192034331ddc6b3a1cc
7
- data.tar.gz: 9396ea4e987e42f208e8f01d2c6e9127817ed902577dac113de07dee7187c6eb272b2126249153caa95412bd0dbe8514cec622652150244423f6d2d4beaf1db7
6
+ metadata.gz: 12ff6ed9b797cd6c8cf4856c3eb95ace456211d9c73dd5f769696d308e8b23da9885823250727f6bf8dace8b3b8c6d5cbb8e90baab59390fc9ac18abd7cf75a2
7
+ data.tar.gz: f856fbd9d25cea5b4da16aa0ee0764346f29c9faaa104b9073489c4e3aef986a35680ffb5e435270c8945d3878727259fad3956783f224ca484c2b3aa10a47cc
data/ARCHITECTURE.md CHANGED
@@ -5,14 +5,16 @@ This document describes how Karst is put together and the policy that governs wh
5
5
  ## Component map
6
6
 
7
7
  - `Karst::Configuration` — the whole public configuration surface, deliberately small: one on/off switch, the escape hatches for authentication Karst cannot infer, and four bounds with working defaults. It also owns `#principal_sources`, the single effective-configuration method every adapter reads. Karst installs no `ActiveSupport::Notifications` subscriber at boot; the only SQL Karst observes is what a probe itself emits, through a scoped per-probe subscription in `Access::DatabaseIsolation`.
8
- - `Karst::Spec::Observer` / `Spec::Reporter` — an opt-in RSpec integration that turns real spec execution (via `ActiveSupport::Notifications` and Warden's public hooks) into a deterministic JSON scenario artifact, with no source parsing and no database access.
9
- - `Karst::Spec::Catalog` — a read-only index over that artifact; requires none of RSpec, Rails, or a database to read an already-written catalog.
10
8
  - `Karst::Web::Middleware` / `Web::Panel` / `Web::Badge` / `Web::Locality` — Karst's development-only HTTP surface: `GET /karst` served directly at the Rack boundary (no engine, route, or controller) plus an optional page-local badge injected into eligible host HTML responses.
11
9
  - `Karst::Access::PrincipalSampler` — a bounded candidate-selection step ahead of `Access::Sweep`, with nothing configurable in it. It materializes one recent-N pool with a single `LIMIT` query, then stratifies that pool in memory over states derived from the schema itself: boolean columns, `enum` columns, nullable-foreign-key presence/absence, and low-cardinality scalars, minus anything PII- or tenancy-shaped. Its total query volume is exactly one query, independent of table size -- no discovery query, `COUNT`, unbounded scan, or live subquery. It only selects candidates and never executes a route, so `Access::Sweep` remains the sole behavioral-evidence contract. Candidate populations are deliberately not here: they are `Access::Search`'s second stage, so "the ordinary sample failed, then `system_admins` reached it" stays reportable rather than being folded into an ordinary-looking sample.
12
10
  - `Karst::Access::CandidatePopulation` — resolves one configured name => callable pair into a bounded, already-queried population plus a provenance label (`population=system_admins`), or `nil` when calling the callable does not yield an `ActiveRecord::Relation` scoped to the same model being sampled; never raises for a misconfigured population, and never issues more than one `LIMIT`-bounded query regardless of the underlying relation's row count. Preserves the configured relation's own ordering when it has one, adding a deterministic primary-key fallback only when it does not. Deliberately does not claim that a configured callable is a "real" Rails named scope -- Active Record exposes no reliable, public way to distinguish a method defined via the `scope` macro from an ordinary handwritten class method, so this class validates only what it can actually observe: the shape of what calling the callable returns. A population is a hint about where meaningful candidates might live -- "these records are worth trying," never "these records satisfy the behavior" -- so it carries no authorization or behavioral claim; only `Access::Sweep`'s runtime execution produces that evidence. Deliberately generic over what a candidate represents: `PrincipalSampler` is its only caller today, but nothing in this class assumes authentication, so a future artifact-population caller (`Subscription.renewable`, `Import.with_sheets`) could reuse it unchanged.
13
11
  - `Karst::Access::PrincipalSource` / `PrincipalSelection` — the multi-source layer above `PrincipalSampler`. A `PrincipalSource` is "which records may Karst consider at all": a name, a lazily-evaluated records callable, and its own optional `populations`. Those two keys are the whole spec; any other key raises rather than being ignored. `Configuration#principal_sources` normalizes either an explicit `config.principal_sources` Hash or a bare `config.principals` (plus `config.principal_populations`) into one implicit `:default` source, so every downstream consumer only ever handles "one or more sources." `PrincipalSelection` runs `PrincipalSampler` independently per source -- never materializing sources together -- and allocates combined candidates fairly within one overall `limit`, tagging `source=<name>` on candidates only once more than one source is actually configured.
12
+ - `Karst::Identity::Observer` / `Identity::EvidenceBuilder` / `Access::IdentityProbe` / `Access::ObservedEndpoint` — the runtime-identity seam, which exists to keep three things that are constantly confused apart: the identity Karst was *asked* to run as (intent), the one its own seam *established* (setup), and the one the Rails application itself *observed* while running the request (evidence). `Observer` reads the latter from the probe request's own env — through `config.observe_identity` when configured, otherwise the application's own Warden proxy via Warden's public `Proxy#user`, which deserializes an existing session and never runs strategies — and fails closed to "unobservable" when neither is available, rather than echoing the requested identity back. `ObservedEndpoint` wraps the probe endpoint for exactly one request so Karst holds that request's live Rack env, which is what makes *halt-time* observation possible: Rails puts the dispatching controller on the env before any callback runs, so the identity the application had established when an access callback halted the request is observable at that moment, not merely after the request unwound. `IdentityProbe` owns one probe's whole identity lifecycle and deliberately never raises out of establishment or release — a probe whose identity could not be established is exactly the probe whose runtime observation matters most. `EvidenceBuilder` turns the observations into one `Identity::Evidence`, whose `confirmation` is the only field a machine consumer may read to decide whether a principal claim is true. `Reproduction::Exercise` (below) reuses this same seam rather than maintaining a second identity implementation.
13
+ - `Karst::Reproduction::Exercise` / `Observation` / `Sanitizer` / `Curl` — request reproduction: "something calls this endpoint, what request exercises the same behavior?". `Exercise` issues **exactly one** caller-specified request through the same `Access::ProbeApplication` endpoint and the same same-connection rollback `Access::Sweep` uses, and drives the request's identity through the identical `Access::IdentityProbe` lifecycle `Access::Sweep` uses — requested, established, runtime-observed (captured at `halted_callback` and again at request completion, with halt-time evidence preferred for the reported `identity`, exactly as `Access::Sweep` prefers it for its access decision), confirmed, released — so a reproduced request's `identity` document is a `Karst::Identity::Evidence`, never a bare echo of the identity Karst was asked to send. `Exercise` reports only what it observed -- the controller/action that actually dispatched, the router's own `path_parameters`, the halted callback, the response, the write count. Deliberately not `Access::Sweep` with the GET restriction lifted: a sweep runs one route as up to 25 users, so a mutating method there would mean 25 real creates, 25 enqueued jobs, and 25 delivered mails; `Sweep` stays GET-only and reproduction's blast radius stays one always-explicitly-asked-for request. Anything `Exercise` could not observe is `nil` and named in `Observation#unobserved` rather than inferred from routes, controller source, or strong parameters. `Sanitizer` is the single redaction boundary: it builds an `ActiveSupport::ParameterFilter` from *the reproduced application's own* `config.filter_parameters` (never `Rails.application`, so one application's rules can never filter another's parameters) and applies one conservative credential-name net on top of that result, never instead of it; headers are decided by name alone, so a credential header's value is never read, compared, or echoed. `Referer` and `Origin` are the one exception -- their *value* is a URL, not a credential, so it is parsed and stripped to scheme/host/path (query, fragment, userinfo discarded) rather than judged by name; a value that does not parse as a URL is masked outright. `Access::Probe`'s own redirect sanitization strips a redirect's query string and fragment the same way, for the same reason. `Curl` renders one `Observation` as a runnable command containing exactly what Karst sent -- never a header or credential invented to make the command look complete. `Karst::CLI::Reproduction` is the one adapter over all of it, shared verbatim by `bin/rails karst:reproduce`, the MCP `reproduce_request` tool, and the `/karst` panel, so no surface can show a recipe another would not; its identity serialization is the same one `Karst::CLI::Verification` uses, so the two schemas describe identity evidence identically.
14
+ - `Karst::Access::OutcomeGroups` — the single definition of "these probes observed the same thing", shared by `Access::Result#groups`, the CLI/MCP evidence document, the human CLI summary (`Karst::CLI::OutcomeSummary`), and the `/karst` panel. The key is every observed fact about the request (`SEMANTIC_FIELDS`: status, redirect, exception, halted callback, dispatched controller/action, body marker, writes, rollback) plus the configured `usable_access_outcome` verdict; everything about the probe that made it (`PROBE_METADATA`: requested principal, identity evidence, sampling reasons, elapsed time) is excluded, so equivalent outcomes are never split by timing or identity and distinct ones are never merged. Groups are ordered largest first with a deterministic tie-break over the key itself. A spec requires every `Access::Outcome` field to be classified as one or the other.
15
+ - `Karst::Identity::Snapshot` — one high-level operation's view of the effective identity configuration (principal sources with approvals and selections folded in, setup readiness, browser Test As support), taken once from a single `Configuration#principal_sources` resolution and passed down explicitly, never cached beyond that resolution. Each resolution's `PrincipalSource` copies evaluate their records callable at most once (`PrincipalSource#evaluated_once`). `CLI::Verification` takes one per run. `Web::Middleware` takes one per `/karst` request, after any local-state write that request makes -- reusing the same already-loaded `PopulationApprovals::Record` and `PopulationDiscovery` instance for whatever else that request resolves, including discovering candidate populations to suggest when the sample found nothing usable (`Web::Middleware#inline_population_candidates`). Approving a population is the one request that necessarily takes two resolutions -- pre-write validation, then the post-write render -- each still exactly one file read and one parse (`Web::Middleware#inline_population_approval_result`; see "When configuration is read" in [docs/advanced-configuration.md](docs/advanced-configuration.md#when-configuration-is-read)).
16
+ - `Karst::Access::LocalPath` — the single definition of "a local application path Karst may execute", used by both `Access::Sweep` and `Reproduction::Exercise`. A security check duplicated per caller is a security check that eventually diverges.
14
17
  - `Karst::Access::PopulationDiscovery` / `PopulationApprovals` / `ApprovedPopulations` — the local approval workflow that removes hand-written `config.principal_populations` from the normal path. `PopulationDiscovery` parses application model source with Ripper and lists statically named, zero-parameter Rails `scope` declarations; it executes no scope, issues no query, and mutates nothing. `PopulationApprovals` is the machine-local record of which of those a developer explicitly approved (`tmp/karst/approved_populations.json`) -- plain model and scope names, never executable Ruby, never user data, never evaluated, and fails closed on any malformed, unreadable, or version-mismatched document. `ApprovedPopulations` folds an approval back into ordinary `PrincipalSource` populations inside `Configuration#principal_sources`, but only when Karst is in development/test, the entry's model name matches the Active Record class of an already-configured principal source (the class always comes from that source, never from the file), and current discovery still confirms that exact scope. Explicit configuration wins outright on name conflict and keeps its position first. Because the merge happens in the one effective-configuration method, the panel, `bin/rails karst:verify`, and the MCP `verify_access` tool inherit approvals identically and none of them reads approval state itself.
15
- - `Karst::Access::ResourceEvidence` — a read-only, downstream step for one already-selected `Access::Sweep` outcome. Reports simple, directly observed foreign-key relationships (column name plus id equality, nothing else) between the exact resource a route addresses and one exact principal; never a join, a `has_many` traversal, or any other attribute. Resource resolution from a route path trusts only Rails' own route recognition plus controller-to-model naming convention, while principal resolution always goes through `Karst::Identity.resolve` and therefore cannot escape `config.principals`; when either step is ambiguous it reports a limitation string rather than guessing. Deliberately a separate class from both `Sweep` and `PrincipalSampler`: it runs no route and selects no candidates, only compares two already-identified records.
16
18
 
17
19
  Each area is deliberately narrow and composable; none of them depends on the others' internals beyond the public objects listed above.
18
20
 
@@ -24,11 +26,11 @@ The guiding rule: **core Karst functionality works on Rails 6.1 even where an op
24
26
 
25
27
  ### Capability degradation
26
28
 
27
- | Rails / Rack | `require "karst"` | Access search (`/karst`, CLI, MCP) | Spec Observer | Scenario Catalog | `/karst` panel | Page-local badge |
28
- |---------------------|:---:|:---:|:---:|:---:|:---:|:---:|
29
- | 6.1 / Rack 2 | yes | yes | yes | yes | yes | **unavailable** |
30
- | 7.0 / Rack 2 | yes | yes | yes | yes | yes | **unavailable** |
31
- | 7.1, 7.2, 8.x / Rack 3 | yes | yes | yes | yes | yes | yes |
29
+ | Rails / Rack | `require "karst"` | Access search (`/karst`, CLI, MCP) | `/karst` panel | Page-local badge |
30
+ |---------------------|:---:|:---:|:---:|:---:|
31
+ | 6.1 / Rack 2 | yes | yes | yes | **unavailable** |
32
+ | 7.0 / Rack 2 | yes | yes | yes | **unavailable** |
33
+ | 7.1, 7.2, 8.x / Rack 3 | yes | yes | yes | yes |
32
34
 
33
35
  The badge is the one capability that degrades. `Karst::Web::Badge` only ever rewrites a Rack response body that reports itself bufferable via Rack's own `to_ary` idiom (the same check `Rack::ETag` relies on for the same reason). Under Rack 2, `ActionDispatch`'s response body wrapper never exposes `to_ary`, so every response reports non-bufferable and Badge leaves it untouched — this is a real, per-response runtime check, not a hardcoded Rails-version branch, so it needs no maintenance as new Rack/Rails combinations appear. `/karst` itself does not depend on badge injection at all: it is a small, independent Rack middleware branch keyed on `PATH_INFO`, so it is unaffected. Karst never monkey-patches `ActionView`, never consumes a streaming body to work around this, and never weakens `Content-Length`, CSP, or host middleware semantics to force badge parity onto Rack 2.
34
36
 
@@ -38,12 +40,12 @@ Ruby 2.7 has no `Data.define` (added in Ruby 3.2). Every former `Data.define` si
38
40
 
39
41
  ### Request-local state: `Karst::ExecutionContext`
40
42
 
41
- The page badge and the spec observer both need request-local (not global, not thread-shared-and-racy) correlation storage: evidence captured inside a notification callback, read back out after the call returns. Modern Rails provides exactly this via `ActiveSupport::IsolatedExecutionState`, added in Rails 7.0. `Karst::ExecutionContext` is the seam:
43
+ The page badge needs request-local (not global, not thread-shared-and-racy) correlation storage: evidence captured inside a notification callback, read back out after the call returns. Modern Rails provides exactly this via `ActiveSupport::IsolatedExecutionState`, added in Rails 7.0. `Karst::ExecutionContext` is the seam:
42
44
 
43
45
  - When `ActiveSupport::IsolatedExecutionState` is defined, `Karst::ExecutionContext` delegates directly to it — modern Rails keeps using its own preferred primitive, with no extra indirection cost.
44
46
  - Otherwise (Rails 6.1), it falls back to `ThreadLocalStore`, a plain per-thread `Hash` reached through `Thread#thread_variable_get`/`thread_variable_set` — deliberately not `Thread#[]`/`[]=`, which are fiber-local and would silently miss context under a Fiber scheduler.
45
47
 
46
- The fallback mirrors `IsolatedExecutionState`'s own default `:thread` isolation level: storage is shared by every Fiber running on one OS thread, not isolated per Fiber. Karst's own usage (one badge or spec correlation captured and read back within a single synchronous request or example) never spans multiple concurrently-scheduled Fibers, so this has no observable effect on Karst's supported behavior — it is documented so a future caller does not assume Fiber isolation the fallback cannot provide.
48
+ The fallback mirrors `IsolatedExecutionState`'s own default `:thread` isolation level: storage is shared by every Fiber running on one OS thread, not isolated per Fiber. Karst's own usage (one badge correlation captured and read back within a single synchronous request) never spans multiple concurrently-scheduled Fibers, so this has no observable effect on Karst's supported behavior — it is documented so a future caller does not assume Fiber isolation the fallback cannot provide.
47
49
 
48
50
  Both backends share the same three-method contract (`[]`, `[]=`, `delete`), cleanup happens in the caller's own `ensure` block exactly as before, and neither backend introduces global mutable state: each thread only ever sees its own slot, so concurrent Puma requests cannot cross-contaminate each other's context.
49
51
 
@@ -54,6 +56,6 @@ Compatibility decisions live behind exactly two narrow seams — `Karst::Value`
54
56
  ### CI
55
57
 
56
58
  - `unit-test` — Ruby 3.2, the root `Gemfile` (RSpec + RuboCop against everything except `spec/integration`).
57
- - `rails-integration` matrix — `spec/integration` against a version-pinned `Gemfile` per row, each a real Rails application booted through Rack: Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, Rails 7.2 and 8.0 on Ruby 3.3. Every row is a required, blocking job.
59
+ - `rails-integration` matrix — `spec/integration` against a version-pinned `Gemfile` per row, each a real Rails application booted through Rack: Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, and Rails 7.2, 8.0, and 8.1 on Ruby 3.3. Every row is a required, blocking job. Each row also runs the Devise, multi-Devise, custom-authentication, and Rails-generated-authentication golden paths in isolated processes.
58
60
 
59
- The Rails 6.1 row is what backs the compatibility claim in this document: it boots a real `Rails::Application`, exercises `GET` against ordinary routes and `/karst`, and runs the scenario observer/catalog round trip, all against genuine Ruby 2.7 syntax and Rails 6.1 APIs — not an assumption that "this probably still works."
61
+ The Rails 6.1 row is what backs the compatibility claim in this document: it boots a real `Rails::Application`, exercises `GET` against ordinary routes and `/karst`, all against genuine Ruby 2.7 syntax and Rails 6.1 APIs — not an assumption that "this probably still works."
data/CHANGELOG.md CHANGED
@@ -1,67 +1,134 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to Karst will be documented in this file.
3
+ All notable changes to Karst are documented in this file.
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project intends to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once releases begin.
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
7
 
7
- ## [Unreleased]
8
+ ## [0.4.0]
9
+
10
+ 0.4.0 is the first release since 0.2.0. A 0.3.0 version was prepared on
11
+ `main` (version constant and changelog section) but was never tagged or
12
+ published to RubyGems; there is no 0.3.0 release. Everything prepared for it
13
+ ships here, together with the changes made on `main` since.
14
+
15
+ ### Added
16
+
17
+ - `--as MODEL:ID` on `bin/rails karst:verify` and `bin/rails karst:reproduce` — a human-only way to run a probe as one specific existing record instead of a sampled one (e.g. `karst:verify GET /admin/imports/123 --as User:72`). Resolved exclusively through the same `Identity.resolve` lookup the panel's own **Test as** button already uses, so a record outside a configured principal source never resolves; there is no `constantize`/`find` bypass. Cannot be combined with `--anonymous`, and is not exposed by either MCP tool — an agent can pick a request, never a principal.
18
+ - Runtime-confirmed identity: every probe outcome now carries an `identity` evidence object separating the *requested* identity (intent), the *established* one (setup), and the *observed* one (what the Rails application itself resolved while running the request). `identity.confirmation` fails closed — `confirmed`, `confirmed_anonymous`, `absent`, `mismatch`, `contaminated`, or `unobservable` — and only the first two are evidence about identity.
19
+ - Anonymous probes as a first-class probe kind: `bin/rails karst:verify --anonymous`, and `verify_access(identity: "anonymous")` over MCP. Karst establishes no identity and then verifies the application really did resolve none; stale state that produces a principal anyway is reported as `contaminated`, never as anonymous. An anonymous probe requires no principal source at all.
20
+ - `config.observe_identity`, the observation seam for non-Devise authentication. Devise/Warden applications need no configuration: Karst reads the application's own Warden proxy from the probe request.
21
+ - Halt-time identity observation: when an access callback halts a request, Karst records the identity the application had established *at that decision*, plus `identity.observed_at` and `identity.changed_during_request`.
22
+ - Each outcome now reports the `controller` and `action` the probed request actually dispatched to, and the evidence document carries `provenance` (Karst/Rails/Ruby versions, environment, observation timestamp).
23
+ - **Request reproduction.** Answers "something calls this endpoint -- what
24
+ request do I send to exercise the same behavior?". Karst issues exactly one
25
+ caller-specified request through the real application, inside the same
26
+ rolled-back transaction the access sweep uses, and drives that request's
27
+ identity through the same requested/established/observed/confirmed
28
+ lifecycle `verify_access` uses, reporting what it observed: the
29
+ controller/action that dispatched, the router's own route parameters, the
30
+ halted callback, the response, database writes -- plus a cURL command for
31
+ exactly what it sent. Available at `/karst` under **Reproduce request**, as
32
+ `bin/rails karst:reproduce METHOD PATH`, and as the `reproduce_request` MCP
33
+ tool. Secrets never come back out: parameters pass through the application's
34
+ own `config.filter_parameters` (Rails' own `ActiveSupport::ParameterFilter`)
35
+ plus a conservative credential-name net, and credential-bearing headers
36
+ become placeholders such as `<API_KEY>` without their values ever being read.
37
+ See [docs/request-reproduction.md](docs/request-reproduction.md).
38
+ - MCP now exposes two tools. `verify_access` is unchanged and stays GET-only.
39
+ - Reproduction evidence now reports `execution.controller_completed` (did the
40
+ Rails controller lifecycle finish without raising -- distinct from whether
41
+ it dispatched at all), `execution.exception_phase` (the most specific phase
42
+ -- `controller`, `render`, or `unknown` -- that instrumentation actually
43
+ proves a raised exception occurred in, derived from
44
+ `ActiveSupport::Notifications` events rather than the exception's class or
45
+ message), and `execution.rendered` (the templates, partials, and layouts
46
+ Karst observed being rendered or raising, by structural virtual path only).
8
47
 
9
48
  ### Fixed
10
49
 
11
- - **The golden path was broken for every real Devise/Warden application.** `Karst::Access::ProbeApplication`'s deliberately minimal probe Rack stack never wrapped `Warden::Manager`, so `env["warden"]` never existed inside a probe and every principal failed with `Karst::Identity::Unavailable` instead of real observed evidence. Fixed by wrapping a `Warden::Manager` (reusing Devise's own already-configured scope defaults, session serializers, and failure app when Devise is present) directly around the router, in the same position a real host application's own compiled middleware stack uses. Because `Karst::Identity.with` establishes probe identity *before* the caller's own request runs, `Karst::Identity::WardenAdapter#assume` now queues the principal (via a thread-local, request-scoped slot -- never Warden's own global `Test::Helpers` queue, which would leak across concurrent unrelated requests) and applies it the moment that exact probe's own request reaches the newly-wrapped `Warden::Manager` -- the same queue-for-next-request idiom `Warden::Test::Helpers#login_as` and `Devise::Test::IntegrationHelpers#sign_in` already use for the identical problem in integration tests.
12
- - `ProbeApplication` no longer passes Devise's live `warden_config` Hash directly into `Warden::Manager.new`'s `options` argument: that constructor destructively deletes `:default_strategies` from whatever Hash it's given, silently corrupting the one shared config object every *real* host request also authenticates through -- a single Karst probe could previously break the host application's own Devise login for the rest of the process. A duplicate is used instead, and only through `Warden::Manager`'s config *block* (`Warden::Manager.new(endpoint) { |config| config.merge!(...) }`), never as constructor `options` -- passing it as `options` also mis-splats Devise's own already-per-scope `:default_strategies` Hash into `:_all`, misfiling every scope name as if it were itself a strategy and raising Warden's own `"Invalid strategy <scope>"` the moment more than one Devise model is involved.
13
- - `ProbeApplication::Environment#call` built a fresh, merged copy of the incoming Rack env on every call instead of mutating it in place, so any state a wrapped middleware set during the request (in particular `env["warden"]`) was invisible to the exact env object `ActionDispatch::Integration::Session` retains afterward as `#request.env` -- `WardenAdapter#clear` could never find the very proxy `#assume` had just used, raising `Karst::Identity::Unavailable` from Access::Sweep's own cleanup path even on an otherwise fully successful probe.
14
- - **"Stop testing as" could fail and leave the assumed identity active.** `Karst::Web::BrowserIdentity#return_path` raised when the panel's hidden `path` field was blank -- which it always is on a plain `/karst` visit with no `?path=` query string, exactly what a developer sees immediately after Test As redirects them to the page they were testing. Karst now falls back to `/karst` itself for a blank return path instead of raising.
15
- - **A multi-Devise application's ambiguity notice never appeared until *after* a route was already selected.** `Web::Panel#access_section` rendered nothing at all -- not even the "Karst found N user types, which should Karst test?" selection form, and not the custom-authentication pointer either -- until a URL was chosen, so a fresh multi-Devise (or non-Devise) installation's very first `/karst` visit looked entirely blank with no indication anything needed attention. The same setup notice now renders regardless of whether a route is selected yet.
50
+ - **`bin/rails karst:mcp` reported a missing `mcp` gem when the installed one was merely outside Karst's supported range.** An application whose bundle resolved `mcp` to a version Karst did not admit -- for example 1.6.1, selected through another gem's `mcp ~> 1.5` -- was told to add the dependency it already had. The command now names the version it found and the range it requires, and reports the dependency as missing only when it is.
51
+ - **Request reproduction could echo a secret through Referer, Origin, or a redirect.** `Reproduction::Sanitizer` treated `Referer` and `Origin` as safe, request-shape headers and echoed them verbatim, but both are URLs whose query string (and, for an OAuth-style redirect, fragment) can carry a real credential — `Referer: https://example.test/reset?token=...`, `Referer: .../callback#access_token=...`. They are now parsed as URLs and stripped to scheme/host/path (query, fragment, and userinfo discarded) rather than treated as safe or matched against the credential-name net; a value that does not parse as a URL at all is masked outright. `Access::Probe`'s own redirect sanitization had the same gap in the other direction: it stripped a redirect's query string but not its fragment (`/callback#access_token=...` came back whole), and its own parse-failure fallback split only on `?`, not `#`. Both are now stripped, in both the happy path and the fallback.
52
+ - **A reproduced application's own `filter_parameters` raising silently fell back to the global `Rails.application`'s, not to "no application filters."** `Reproduction::Exercise` already documented that it must never let `Sanitizer` reach for `Rails.application` in place of the application Karst actually issued the request against, but its own error handling returned `nil` on failure, which `Sanitizer` treats as "no filters were given, read the global" — exactly the fallback the documented contract rules out. It now returns an empty Array, so a broken or unavailable `filter_parameters` means this application declared none, never a stranger's rules applied to its parameters.
53
+ - **Outcome groups were split by timing.** The `karst:verify --json` / MCP `verify_access` evidence grouped outcomes by a key that included each probe's own `elapsed_ms`, so equivalent outcomes (25 users all halted at `require_admin` with `403`) came back as up to 25 separate groups. Outcomes are now grouped by what the request observed only — status, redirect, halted callback, exception, dispatched controller/action, writes, and the configured usable verdict — through one shared definition (`Karst::Access::OutcomeGroups`) that the CLI, MCP, and `/karst` panel all use.
54
+ - The human `karst:verify` output described a whole sample using only its *first* probe's status and identity; it now summarizes every outcome.
55
+ - Repeated configuration work during one operation. Setup checks, the search, and every **Test as** button on `/karst` each re-resolved the effective principal sources — re-reading the approval file, re-parsing model source, and re-running the `principals` callable — up to once per listed user on the inferred-Devise path. Each operation now resolves them once (`Karst::Identity::Snapshot`); a later operation still sees changed configuration.
56
+ - A `/karst` request that found no usable outcome and went on to discover candidate populations to suggest still re-read the approval file and re-parsed model source independently of the resolution `Identity::Snapshot` had already just taken (`Web::Middleware#inline_population_candidates`) — now it reuses that same read and parse. Approving a population still resolves configuration twice (once to validate the submission before the write, once more for the fresh render after it), each exactly once, not several times over.
57
+ - **Stale cross-request response evidence in reproduction.** If a request run
58
+ on the same session immediately before the target (identity establishment,
59
+ most often) completed, and the target then raised before producing a
60
+ response of its own, Karst could report the *earlier* request's status,
61
+ content type, and redirect as if it had observed them for the target --
62
+ because `ActionDispatch::Integration::Session` only updates its
63
+ request/response objects after the target's own `app.call` returns without
64
+ raising. Response evidence (`status`, `response_content_type`, `redirect`)
65
+ is now read only when Karst can prove it belongs to the target request;
66
+ otherwise it is `nil` and named in `unobserved`, exactly like any other
67
+ fact Karst did not observe. See
68
+ [docs/request-reproduction.md](docs/request-reproduction.md).
16
69
 
17
- ### Removed
70
+ ### Changed
18
71
 
19
- - **`config.principal_dimensions`.** Declaring which coarse states Karst should try to cover during the ordinary sample is no longer part of the product. Schema-derived stratification (boolean columns, `enum` columns, nullable-foreign-key presence/absence, low-cardinality scalars, minus anything PII- or tenancy-shaped) was already the zero-configuration path and is now the only one; a configured dimension replaced it wholesale rather than improving it, so the option could only ever make sampling narrower than the default. The case it actually existed for -- a user too rare for a recent sample to reach -- is what candidate populations are for, and those report themselves as named evidence instead of quietly reshaping an ordinary-looking sample. `Karst::Access::PrincipalDimension` is removed. `Karst::Access::PrincipalSource` no longer accepts `dimensions:`, and a `dimensions:` key inside a `config.principal_sources` spec now raises `ArgumentError` rather than being silently ignored -- as does any other unrecognized key. Scalar sampling reasons render plainly (`role=local_admin`, not `role="local_admin"`), preserving exactly what the panel displayed before.
20
- - **`config.artifact_source` / `config.access_scenario`.** Explicit artifact scenarios swept application records ("can any recent import be opened cross-account?") rather than answering "which user can reach this route," had no entry point in the primary workflow, and were already absent from the README. `Karst::Access::ArtifactSource`, `Access::Scenario`, and `Access::ScenarioSweep` are removed along with the panel's per-scenario buttons and the middleware's `artifact_sweep` operation.
21
- - **`config.buffer_size`, and the runtime SQL evidence subsystem with it.** Karst kept a process-wide, bounded `sql.active_record` buffer from an earlier product direction; no Karst surface has reported it since `/karst` was restructured around access search, so a host application was paying a notification callback on every query for evidence nobody could see. `Karst.buffer`, `Karst.window`, `Karst.subscribe!`/`unsubscribe!`/`subscribed?`, `Karst::Buffer`, `Karst::Subscription`, and `Karst::Sql::Event`/`Canonicalizer`/`Shape`/`Window` are removed, as is the railtie's `after_initialize` subscription. **Karst now installs no `ActiveSupport::Notifications` subscriber at boot at all.** Database-write evidence during a probe is unaffected: it has always come from `Access::DatabaseIsolation`'s own scoped, per-probe subscription, not from this buffer.
72
+ - Optional MCP support now targets the tested MCP 1.5 and 1.6 series
73
+ (`gem "mcp", ">= 1.5.0", "< 1.7"`), replacing 0.2.0's stale MCP 0.9.x
74
+ constraint that conflicted with current integrations and failed schema
75
+ validation with JSON 3.x. CI exercises both the oldest admitted release
76
+ (1.5.0) and the newest 1.6.x.
77
+ - **Verification evidence schema is now version 3** (0.2.0 emitted version 1; version 2 existed only on unreleased main).
78
+ - The ambiguous `principal` / `verified_principal` keys are gone rather than renamed in place: a consumer reading "principal" and believing it described the request that actually ran is exactly the false attribution this schema exists to make impossible. Outcomes carry `identities` (each with `requested`, `observed`, `confirmation`), and the top level carries `verified_identity`.
79
+ - Grouped `outcomes` (under `sample` and each population) no longer carry `elapsed_ms` — a group of several probes has no single elapsed time; `verified_outcome.elapsed_ms` and `summary.elapsed_ms` are unchanged — and gain `verified_usable`. Groups are ordered largest first.
80
+ - **Reproduction evidence schema is version 3.** Request reproduction is new in this release, so version 3 is the first one published; versions 1 and 2 existed only on unreleased main. Relative to those:
81
+ - Its `identity` document reports `requested`/`observed`/`confirmation` (a `Karst::Identity::Evidence`) rather than echoing back the identity Karst assumed as if it were what ran; a mismatch, absence, or unobservable outcome now stays visible instead of being reported as "sent as User X."
82
+ - `execution` gained `controller_completed`, `exception_phase`, and `rendered` (additive), but `response.status`/`response_content_type`/`redirect` also stopped trusting `session.request`/`session.response` once a target request raised before producing one -- see Fixed, above. A consumer that read those fields after `execution.exception_class` was already set was reading a value from an earlier request; it now reads `nil`, named in `unobserved`.
83
+ - A probe whose identity setup fails now still runs and is still observed, and reports the failure as `identity.establishment` rather than as an application exception — "asked for User #123, application saw nobody, halted at `authorize_admin`" is the evidence that matters most there.
84
+ - Per-probe write and halted-callback observation now ignores notifications raised on other threads, so a concurrent request in a development server cannot be counted as a probe's own evidence.
85
+ - `karst:verify` prints a bounded outcome summary: one line per observed outcome with its user count, largest first, a few example users for each outcome other than a single largest one, identity confirmation counts, and write warnings. `--json` still carries every user.
22
86
 
23
- Setting any removed option raises `Karst::RemovedConfiguration` (a `NoMethodError` subclass) with a message naming the removal and what replaced it. Karst never silently ignores a removed option or reinterprets it as something else. See [docs/advanced-configuration.md](docs/advanced-configuration.md#removed-configuration).
87
+ ## [0.2.0]
88
+
89
+ ### Added
90
+
91
+ - Documented and acceptance-tested support for Rails 8 generated authentication.
92
+ - Added local selection of the user models Karst should test when a Devise application has multiple mappings.
93
+ - Added inline, local approval of discovered candidate populations after an unsuccessful ordinary sample.
94
+ - Added `config.population_retry_limit` for bounding approved-population retries.
24
95
 
25
96
  ### Changed
26
97
 
27
- - **`config.enabled` now actually gates Karst.** It previously controlled only the SQL subscription removed above, which meant setting `config.enabled = false` disabled nothing a developer could observe -- `/karst`, the badge, `bin/rails karst:verify`, and the MCP `verify_access` tool all kept working, contradicting the README. It is now the single switch for Karst's whole development surface, re-read live on every request so turning Karst off never depends on initializer ordering. The default is unchanged (development/test only), and production remains off regardless.
28
- - Configuration is restructured around "usually, you don't": the README's configuration section is now the off switch and a pointer, and every remaining option is documented in [docs/advanced-configuration.md](docs/advanced-configuration.md) as either an escape hatch for authentication Karst cannot infer or a bound with a working default. `access_sweep_limit`, `principal_candidate_pool_size`, `population_retry_limit`, and `usable_access_outcome` remain configurable but no longer appear on the normal installation path.
98
+ - Candidate-population approval now happens inline in the failed `/karst` result, followed by an immediate retry.
99
+ - `/karst/populations` is limited to inspecting and revoking stored approvals, including stale approvals.
100
+ - MCP support is opt-in instead of a runtime dependency of the gem.
101
+ - The product surface is simplified around route-access verification: an ordinary bounded sample followed, when needed, by a separate bounded search of approved candidate populations.
102
+ - `config.enabled` now gates all of Karst's development surfaces.
103
+
104
+ ### Removed
105
+
106
+ - The `Karst::Spec::*` observer, catalog, and scenario subsystem.
107
+ - `Karst::Access::ResourceEvidence` and inferred resource-relationship presentation.
108
+ - Candidate-population preview and Ruby-snippet export.
109
+ - The `karst:populations` rake task.
110
+ - Redundant population-management and discovery UI superseded by inline approval.
111
+ - The unused runtime SQL buffer and its public `Karst.buffer` and `Karst.window` analysis surface.
112
+ - Unused artifact scenarios and configurable principal dimensions.
29
113
 
30
- - Candidate populations leave `Karst::Access::PrincipalSampler` entirely and become a deliberate second search stage owned by `Karst::Access::Search`, run only when the ordinary sample finds nothing usable — so "the sample found nothing; then `system_admins` reached it" is reportable as observed evidence rather than silently folded into an ordinary-looking sample. `PrincipalSampler` and `PrincipalSelection` no longer accept or report populations, and the sampler's query budget drops to exactly one bounded recent-pool query. Configured dimensions still shape the ordinary sample, with generic schema discovery as their fallback.
31
- - The manual `population_sweep` operation and its "Try another population" panel section are removed, along with `Karst::Access::PopulationSuggestion` (its name-ranking heuristic only existed to order that manual choice). Retries are automatic.
32
- - Panel copy now says "user" rather than "principal" in the access workflow.
33
- - `/karst/populations` is reorganized around approval rather than snippet generation: candidate groups are approved with a checkbox and one **Approve selected groups** button, approvals and stale approvals are surfaced first, and Ruby snippet generation moves to an advanced export path for applications that prefer to commit their populations (or for CI, where machine-local approval state is deliberately not consulted). Preview remains available, bounded and rollback-safe, and is never required in order to approve. The page's POST operations now require a same-origin request, since approving writes local state that outlives the request.
114
+ ## [0.1.0]
34
115
 
35
116
  ### Added
36
117
 
37
- - **Local selection of an ambiguous Devise setup, so a multi-model Devise app no longer needs an initializer to resolve it.** Karst still refuses to guess when `Devise.mappings` reports more than one model and nothing is explicitly configured — that safety boundary is unchanged — but the `/karst` panel now shows every detected model as a checkbox right where the old "configure `config.principals` explicitly" message used to sit, and one **Save** persists the choice. Selecting one or more models produces the same `Karst::Access::PrincipalSource` machinery an explicit `config.principal_sources` would: one independently queryable source per selected model, keyed by that model's own Devise/Warden scope (`User` → `:user`, `Admin` → `:admin`), never collapsed into a combined source. The selection is recorded by the new `Karst::Access::PrincipalSourceSelection` in `tmp/karst/principal_source_selection.json` — the same machine-local, git-ignored, development/test-only mechanism candidate-population approval already uses — holding only bare model names, never executable Ruby, never constantized; `Karst::Access::SelectedPrincipalSources` revalidates every entry against Devise's own current metadata on every read, silently dropping a model Devise no longer maps, and reverting to the ambiguous prompt if that empties the selection entirely. An explicit `config.principals`/`config.principal_sources` always wins outright over a saved selection. Probe identity already resolved Devise/Warden scope per principal regardless of source count; browser identity eligibility and **Stop testing as** now do the same — the browser session retains the exact scope an assumed identity was established under, so clearing never has to guess which of several selected sources produced it. `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool all pick up a saved selection automatically, and both CLI and MCP return the same actionable, structured error (pointing at `/karst` instead of at Ruby configuration) while nothing is selected yet.
38
- - **Local approval of discovered candidate populations, so `config.principal_populations` is no longer part of the normal workflow.** A developer who installs Karst and finds no usable user at `/karst` now sees one small contextual action (`Karst found 3 application-defined user groups that could be tried — Review candidate groups`), approves the groups Karst may try at `/karst/populations`, and from then on those groups are searched automatically — with no initializer editing, no generated Ruby to paste, and identical behavior through `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool. Discovery is unchanged and remains conservative (`Karst::Access::PopulationDiscovery`: statically named, zero-parameter Rails `scope` declarations parsed from application model source with Ripper; no execution, no query, no state change) — and **discovery is still not approval**. Approval is recorded by `Karst::Access::PopulationApprovals` in `tmp/karst/approved_populations.json`, deliberately machine-local, git-ignored development state rather than committed project configuration; it holds only model and scope names (never user data, never executable Ruby, never a lambda) and Karst never evaluates it. `Karst::Access::ApprovedPopulations` folds approvals into `Configuration#principal_sources` — the single source of truth every adapter already reads — only when Karst is in development/test, only for a model that is already a configured or inferred principal source (the class always comes from that source, never from the file), and only while current discovery still confirms that exact scope, so a removed or renamed scope stops being executed with no file edit (and is shown as unused on the review page), and a hand-written entry naming an ordinary class method is never confirmed at all. A malformed, unreadable, or version-mismatched approval file approves nothing and says so. Explicit `config.principal_populations`/`config.principal_sources[...] :populations` keeps working unchanged, wins outright on a name conflict, and keeps its configured order ahead of approved populations. `Access::Search`'s two-stage semantics, retry bounds, deduplication, rollback safety, evidence states, and early stop are untouched.
39
- - `Karst::Access::Search`: after an ordinary bounded sample observes no usable user, Karst now automatically retries each *approved* candidate population in configuration order, stopping at the first verified success. Bounded by `config.population_retry_limit` records per population (default 3, max 10) and by `config.access_sweep_limit` total extra requests. Every approved population is reported, including the ones deliberately not run.
40
- - `config.population_retry_limit`.
41
- - Initial project documentation, contributor tooling, and continuous integration.
42
- - Configuration and an idempotent, deliberately inert `sql.active_record` subscription lifecycle.
43
- - Automatic subscription after Rails initialization when Karst is enabled.
44
- - Immutable, minimal SQL events constructed internally from valid `sql.active_record` notifications.
45
- - Bounded, thread-safe, process-local retention of recent events through `Karst.buffer`.
46
- - Experimental, conservative SQL canonicalization independent of event capture, preserving structural casts and list cardinality while normalizing supported literals, whitespace, and ordinary comments.
47
- - Internal deterministic query-shape identity: a SHA-256-based fingerprint over canonicalized SQL, with declared `IN (?+)` placeholder-list arity equivalence, feeding an immutable `Karst::Sql::Shape` that aggregates count, cache hits, duration statistics, and up to three sample events (first, slowest, latest) per shape.
48
- - `Karst.window`, Karst's first public analysis API: one immutable `Karst::Sql::Window` snapshot per call, derived from exactly one `Karst.buffer.to_a` read and grouped into `shapes` (deterministically ordered by count, then duration, then fingerprint) and `declined` events, with `event_count`, `capacity`, and `saturated` reporting whether older events may already have been evicted from the retained window.
49
- - `Buffer#capacity`, exposing the fixed capacity of the retained buffer so `Karst.window` can report it.
50
- - `GET /karst`, a development-only HTTP evidence surface served by a small Rack middleware (no engine, route, or controller) that presents `Karst.enabled?`, `Karst.subscribed?`, and basic `Karst.window` counts. Loopback-only, gated by `Rails.env.development?` at both insertion and request time, and transparent to every other request.
51
- - `Karst::Spec::Observer`, an opt-in RSpec integration (`require "karst/spec/observer"`) that turns real spec execution into a deterministic JSON scenario catalog: for every example that reaches a browser-facing (HTML) Rails request, it records the request's method, recovered route pattern, controller/action, format, status, and redirect target (with any query string stripped, since redirect targets can carry the same class of secret as request paths), alongside the Warden principal immediately before and after that request and whether it changed -- raw evidence rather than a "setup versus subject" classification, since a single request offers no reliable signal for telling a signup or checkout-completion route that happens to authenticate apart from a login route. Also records the example's stable id, file/line, nested description, and outcome. Built entirely from `ActiveSupport::Notifications` and Warden's public hooks; never parses spec source, route-helper arguments, or FactoryBot calls, and never persists into the host application's database.
52
- - `Karst::Spec::Catalog`, a read-only index over the JSON artifact `Karst::Spec::Observer` writes, answering `catalog.scenarios_for(controller:, action:, http_method: nil)` from an immutable `Karst::Spec::Scenario` per browser-facing request -- one example that issues several such requests (a denied attempt, then an allowed retry) legitimately produces one Scenario each. Indexed by controller/action, Rails' own stable routing identity, so dynamic id segments never fragment lookup; `http_method` narrows further only when one controller/action answers more than one verb. Loads from `tmp/karst/scenarios.json` by default (or `Rails.root`-relative when Rails is loaded), reports an explicit `:missing`/`:invalid`/`:ready` status so "not yet generated" is never confused with "zero scenarios observed," and skips malformed individual entries rather than failing the whole artifact. `observed_status`/`observed_redirect` name what a spec run observed, not what it asserted; `example_outcome` (`passed`/`failed`/`pending`) keeps a failing example's evidence visible without presenting it as verified. Reuses `Karst::Spec::Principal`'s type/id/scope evidence as-is, reintroduces no setup-versus-subject classification, and requires no RSpec, Rails, or database access to read an already-written artifact. Each Scenario keeps both `principal_before` and `principal_after` rather than collapsing to one side, since a signup or checkout-completion scenario is exactly the case where the identity a request produces is the evidence that matters.
53
- - Explicit, data-only RSpec scenario names through `karst: "Name"` or `karst: { name: "Name" }` metadata, serialized alongside the existing stable example provenance. Malformed opt-in metadata fails the example with a clear configuration error; unannotated discovery remains unchanged.
54
- - Ruby 2.7 and Rails 6.1 support, backed by a blocking CI job: `require "karst"`, runtime SQL evidence, the spec observer, the scenario catalog, and `/karst` all work unchanged on that floor. The page-local badge is the one capability that degrades there (Rack 2 cannot safely expose a bufferable response body to rewrite); `/karst` remains directly reachable. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy).
55
- - `Karst::Value`, a small internal `Struct`-based immutable value-object helper standing in for Ruby 3.2's `Data.define` across every Karst value object (`Sql::Event`, `Sql::Shape`, `Sql::Window`, `Spec::Principal`, `Spec::RequestObservation`, `Spec::ExampleObservation`, `Spec::Scenario`), used uniformly on every supported Ruby.
56
- - `Karst::ExecutionContext`, a small internal request-local storage seam used by the page badge and the spec observer: delegates to `ActiveSupport::IsolatedExecutionState` where available, and falls back to a per-thread store on Rails 6.1.
57
- - `bin/rails generate karst:install`, optional Rails scaffolding for the host-specific seams Karst cannot safely infer: a documented, entirely commented-out `config/initializers/karst.rb` placeholder for every identity hook, a small explicitly named `KarstIdentityController` whose `create` action resolves the submitted principal strictly through `Karst::Identity.resolve` (never a bare `Model.find`, so it can never reach outside the configured `config.principals` scope) before raising `NotImplementedError` with a `TODO` until a developer wires up this application's real authentication against that already-resolved principal, and development-only routes for that controller (idempotent across repeated runs, via Thor's own file-collision handling and a duplicate-safe route insertion). Implements no Devise/Warden/generic authentication mechanism and never runs automatically; existing manual `Karst.configure` setups have no need to run it.
58
- - `Karst::Access::PrincipalSampler`, an optional candidate-selection step ahead of the experimental access sweep: over an Active Record relation or model class it replaces "first 25 rows" with up to `access_sweep_limit` deterministic principals chosen for database-state diversity rather than whatever happens to sort first -- boolean columns, `enum` columns, nullable-foreign-key presence/absence, and other low-cardinality scalar columns (this is schema-state diversity the sampler observed in the database, not behavioral diversity; it never executes a route). Column candidacy requires an observed cardinality of 10 or fewer (via one bounded `DISTINCT ... LIMIT` query per candidate column, never a full-table scan or `COUNT(*)`), a conservative PII-aware column-name filter that unconditionally excludes anything resembling email, name, phone, address, token, password, or other sensitive fields regardless of cardinality, and a separate name-based exclusion for foreign keys shaped like a tenant/account/organization boundary (`tenant_id`, `account_id`, and similar) -- checked independently of nullability and cardinality, since a *nullable* such column would otherwise reach presence/absence sampling without ever going through the cardinality check. Query volume is bounded by dimension and limit counts, not row count (verified flat between 300 and 8,000 rows in the test suite), and is enforced as a hard invariant at every query-issuing call site via `PrincipalSampler.query_budget(limit)` -- `#call` may return fewer than `limit` principals if the budget is exhausted, but never issues more queries than that budget declares. Never escapes the configured principal scope (an already tenant-scoped relation stays tenant-scoped), and returns each selected principal alongside the minimal evidence (e.g. `"premium=true"`) that earned it a slot. Raises `Karst::Access::PrincipalSampler::UnsupportedPrimaryKey` (a `Karst::Access::Error`) for an Active Record source with a composite or missing primary key, rather than failing obscurely mid-query. Falls back to the existing bounded-first strategy, unchanged, for any non-Active-Record Enumerable source. `/karst` prefers this over "first 25" whenever the configured principal source supports it, purely as a label distinction -- selection, execution, identity assumption, and rollback semantics in `Karst::Access::Sweep` itself are unchanged.
59
- - `Karst::Access::ResourceEvidence`, a read-only, opt-in follow-up step for one specific `Access::Sweep` outcome: given the exact resource a route addresses and one specific principal, it reports simple, directly observed foreign-key relationships between those two records (e.g. `Document#user_id` pointing at that exact `User#id`) -- evidence, not an authorization claim, and never phrased causally. Only foreign-key-shaped columns (ending in `_id`) are ever inspected, so no other attribute (name, email, token, ...) is read or shown; only a direct column-value comparison between the two given records is made, never a join, a `has_many` traversal, or any multi-hop graph walk. `.for_outcome` additionally resolves the resource from a route path using only Rails' own route recognition plus its controller-to-model naming convention, and only trusts that resolution when every step succeeds unambiguously (a recognized route with an `:id` segment, a controller name that classifies to a real loaded Active Record class, and a record that actually exists for that id); anything softer -- an unrecognized route, a controller with no conventional model, a missing record -- produces a reported limitation rather than a guess. `Result#to_text` renders the plain evidence format (principal, observed status, then related state).
60
- - Candidate population discovery and curation, an opt-in workflow on top of `config.principal_populations`/`config.principal_sources`. `Karst::Access::PopulationDiscovery` uses Ruby's standard-library Ripper AST parser to list only statically named, zero-argument Rails `scope` declarations made directly in application model source; it never enumerates or invokes arbitrary class methods, executes scope bodies, or issues SQL. Concern-contributed scopes are explicitly outside this initial discovery boundary. Run it via `bin/rails karst:populations` or the `/karst/populations` page (linked from the main panel). The page groups scopes by model, collapsed by default behind native `<details>` (small vanilla JS only powers client-side search/filter -- no frontend framework), stays usable at 150 models/500 scopes, and surfaces the current selection first. `Karst::Access::PopulationPreview` is a separate, explicit, `LIMIT 3`-bounded validation step (never a `COUNT`) for one discovered scope at a time. `Karst::Access::PopulationConfigSnippet` renders a curated selection into copy-pasteable `config.principal_populations`/`config.principal_sources` Ruby -- nesting per source when more than one is involved, so the same population name on two different models stays distinguishable -- and never writes to the host application's files. The main `/karst` panel gains a **Try another population** guided retry, shown only once an analysis found no usable outcome: one button per already-*approved* (configured, not merely discovered) population, running a fresh bounded sweep against just that population, respecting the existing `access_sweep_limit`. When the analysis observed a halted controller callback, approved populations are optionally ranked by `Karst::Access::PopulationSuggestion`, a transparent, no-AI substring-overlap name heuristic that always shows every approved population and never claims the suggested one will change the outcome -- only running it does.
118
+ - Initial release of route-access verification through `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool.
119
+ - Bounded sampling of existing users with observed status, redirects, halted callbacks, exceptions, and database writes.
120
+ - Candidate-population discovery, preview, configuration export, and approval through the original population-management workflow and `karst:populations` rake task.
121
+ - Browser **Test as** and **Stop testing as** workflows, a page-local badge on Rack 3, and custom-authentication generator scaffolding.
122
+ - Automatic single-model Devise support, explicit multi-model/custom-authentication configuration, and access-search rollback isolation.
123
+ - The opt-in RSpec observer/catalog/scenario subsystem and inferred resource-relationship evidence.
124
+ - Runtime SQL capture through a bounded buffer and `Karst.window` analysis.
125
+ - Ruby 2.7+ and Rails 6.1+ compatibility.
61
126
 
62
127
  ### Changed
63
128
 
64
- - Restructured `/karst` around its primary workflow -- "which existing principal can I use to test what I'm looking at" -- rather than giving equal visual weight to route context, spec evidence, access analysis, and raw SQL evidence. A compact route header (method, path, controller/action) replaces the old verbose route form; **Analyze N principals** is the obvious primary action; usable principals and their **Test as** actions remain prominent (per the actionable hierarchy above); a currently-assumed browser identity is now shown as a clear banner exposing **Stop testing as**, worded so it never implies Karst can restore a previous session. Spec evidence and Runtime SQL evidence are unchanged in substance but are now demoted under a collapsed **Diagnostics** section (native `<details>`/`<summary>`, no JavaScript), each summarized by a useful count (e.g. "Spec evidence — 3 matching scenarios", "Runtime SQL — 544 observations · 66 shapes") and expanded only on request; a disabled SQL capture state is called out instead of silently collapsing. Coherent, non-dominating states were added for an unconfigured principal source, unconfigured browser Test-as, and no route selected yet.
65
- - Reorganized `/karst` access results around a host-configurable usable-outcome presentation policy (2xx by default): usable sampled principals and their Test-as actions are prominent, exact-resource relationship evidence is shown when available, and every other raw outcome remains preserved in a subordinate expandable section. Resource-evidence principal resolution now obeys the configured principal source.
66
- - Marked configuration, buffer, subscription, and the experimental SQL canonicalizer as private implementation constants.
67
- - Lowered `required_ruby_version` to `>= 2.7` and the `activesupport` dependency to `>= 6.1, < 9`.
129
+ - Reframed the primary interface around finding an existing user who can reach a selected route.
130
+ - Candidate populations became an automatic second search stage after the ordinary sample found no usable user.
131
+
132
+ [0.4.0]: https://github.com/SilenceDogood1984/karst/releases/tag/v0.4.0
133
+ [0.2.0]: https://rubygems.org/gems/karst/versions/0.2.0
134
+ [0.1.0]: https://rubygems.org/gems/karst/versions/0.1.0
data/CONTRIBUTING.md CHANGED
@@ -32,7 +32,7 @@ Every behavior change requires tests at the narrowest useful level. Bug fixes sh
32
32
 
33
33
  ### Rails compatibility
34
34
 
35
- Karst's compatibility harness currently covers Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, and Rails 7.2 and 8.0 on Ruby 3.3. The Rails 6.1 / Ruby 2.7 job is blocking, the same as every other row: Karst claims that floor because CI proves it, not the other way around. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy) for how optional features (currently: the page-local badge) degrade on that floor instead of raising.
35
+ Karst's compatibility harness currently covers Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, and Rails 7.2, 8.0, and 8.1 on Ruby 3.3. The Rails 6.1 / Ruby 2.7 job is blocking, the same as every other row: Karst claims that floor because CI proves it, not the other way around. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy) for how optional features (currently: the page-local badge) degrade on that floor instead of raising.
36
36
 
37
37
  The repository uses version-specific Gemfiles under `gemfiles/`. This keeps each dependency set explicit and lets Bundler and CI use their standard `BUNDLE_GEMFILE` behavior without an additional dependency-management tool. Matrix lockfiles are intentionally not committed: compatibility CI resolves the current dependency set allowed by each Rails line, so it detects dependency-resolution regressions instead of only testing a previously locked snapshot. To run one target locally:
38
38
 
@@ -43,3 +43,11 @@ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile EXPECTED_RAILS_VERSION=7.2 \
43
43
  ```
44
44
 
45
45
  Run every compatibility target with `bin/test-rails`. To add a Rails version, add a version-specific Gemfile, add it to `bin/test-rails`, and add the matching Rails/Ruby entry to the `rails-integration` matrix in `.github/workflows/ci.yml`. Choose a Ruby version on which that Rails release installs and runs, and keep linting out of the compatibility matrix.
46
+
47
+ The optional MCP dependency has its own, smaller matrix. `Karst::Mcp::Compatibility::REQUIREMENTS` is the one range both the gemspec and `bin/rails karst:mcp` enforce, and the `mcp-compatibility` CI job runs the MCP specs and the shared-process integration suite against its oldest admitted release (`gemfiles/mcp_1_5_0.gemfile`) and the newest release of its latest admitted series (`gemfiles/mcp_1_6.gemfile`). To admit a new MCP series, test it the same way first, then widen `REQUIREMENTS` and move the newest-series Gemfile and matrix entry to it. To run one MCP target locally:
48
+
49
+ ```sh
50
+ BUNDLE_GEMFILE=gemfiles/mcp_1_6.gemfile bundle install
51
+ BUNDLE_GEMFILE=gemfiles/mcp_1_6.gemfile EXPECTED_RAILS_VERSION=8.0 EXPECTED_MCP_REQUIREMENT="~> 1.6.0" \
52
+ bundle exec rspec spec/mcp spec/integration --exclude-pattern "spec/integration/*golden_path*"
53
+ ```