permittable 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa679480fdac311694469096c7537926586e987ce2e7a724099611ec91dd3d2b
4
- data.tar.gz: fa1785c674f0cbf388ce9f74b87f22344adef95cd5a5bfa6c81490fb8958a124
3
+ metadata.gz: e6009aed769f45203bb8053f4310d4ac986ec941815704e613f63bddf819bf92
4
+ data.tar.gz: 721cead2003e010c773e4bab9de20830d85f40182a049b8e4db6fa82d1f377d7
5
5
  SHA512:
6
- metadata.gz: 66e7a85dad6e8029dbc8827b1a55ac14a297e3dc006315ffc66efc0e2f7dd6179ee6247e33f5de93a06d7fe08c3ff1205e30bf6dde6b16d93bfd381bdd3da813
7
- data.tar.gz: fc0e4a920d62a687c960b808301841327bde86c85a2cf90c36c65e0ac61f133844e0733cd6ad3075298a8680d5a1360e9319495c4e60a9382449047bf6d76c0e
6
+ metadata.gz: 5ff703088a1c1389cc49150521380c56f937d4ba0f99de0137e7390a91fa66a16bdad09092f0c65964b94e332bee170b17bdc6d7e9d03c11a7b2020f95953c78
7
+ data.tar.gz: 64b5097368d5df03d8dfacb4c9eb79cf6ecb08e2c0b933a80281201139e496f143f0336d886d4a4d33a15dfc0f2c7642f10ee5641e625874a80526717386d869
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 0.4.0 (2026-08-24)
4
+ <!-- title: monitor mode -->
5
+
6
+ The rollout switch. Adopting contracts on a live API — or tightening an existing one — used to mean flipping unknown clients from "accepted" to "422" in a single deploy. A contract can now run in **monitor mode**: the full pipeline executes (unwrap, cast, validate, defaults), but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed. Deploy monitoring, dashboard the would-be rejections, then enforce controller by controller — every 422 you finally return is one you already counted.
7
+
8
+ ### Added
9
+ - **`mode: :monitor` on `permit_params`, and an app-wide `Permittable.mode` default** (`:enforce` unless set; a rule's own `mode:` always wins, in both directions). On a violating request in monitor mode nothing raises and nothing renders: the `invalid_parameters.permittable` event fires with `mode: :monitor`, the logger warns with the offending paths, and `permitted_params` returns the **raw pass-through** — exactly what the client sent, no casts, no defaults, no transforms (a missing `root:` passes an empty hash; a rootless contract drops only the router's bookkeeping keys). Monitor rules validate **eagerly in the `before_action` regardless of `enforce:`**, so telemetry never depends on the action calling `permitted_params` — legacy actions still reading `params` directly are exactly the ones being monitored.
10
+ - **`permittable_violations(action = nil)`** — the recorded violation details for the (memoized) validation of `action`, `[]` when the request was clean. The monitor-mode observable; under enforce it swallows its own trigger's raise, making "would this request fail?" a one-liner in tests.
11
+ - **`mode:` key on the `invalid_parameters.permittable` payload** (`:enforce` / `:monitor`), so one subscriber can dashboard enforced rejections and monitored would-be rejections side by side. Additive — existing subscribers are unaffected.
12
+ - **`x-permittable-mode: "monitor"`** on exported OpenAPI operations whose rule declares monitor mode — the docs must not promise a 422 the server doesn't yet send. Only the per-rule declaration is exported; the app-wide `Permittable.mode` is runtime configuration, not contract data.
13
+
14
+ Contracts that don't opt in are byte-for-byte unaffected: the default mode is `:enforce` and the enforce path behaves exactly as before.
15
+
3
16
  ## 0.3.0 (2026-08-24)
4
17
  <!-- title: OpenAPI export -->
5
18
 
data/README.md CHANGED
@@ -54,7 +54,7 @@ A violating request never reaches your action:
54
54
  - [Violations and error responses](#violations-and-error-responses) · [Custom error messages](#custom-error-messages-message) · [Unknown parameters](#unknown-parameters)
55
55
  - [Output reshaping](#output-reshaping-transform-and-finalize) · [The schema-drift guard](#the-schema-drift-guard)
56
56
  - [Sensitive parameters](#sensitive-parameters-and-log-redaction) · [Instrumentation](#instrumentation)
57
- - [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
57
+ - [Monitor mode](#monitor-mode-roll-out-without-rejecting) · [Exporting OpenAPI](#exporting-openapi-docs-that-cannot-drift)
58
58
  - [API reference](#api-reference) · [Errors caught at class load](#errors-caught-at-class-load) · [Compatibility](#compatibility)
59
59
 
60
60
  ---
@@ -72,6 +72,7 @@ A violating request never reaches your action:
72
72
  | Reshapes output | ❌ | ❌ | ✅ |
73
73
  | Checked against your schema at boot | ❌ | ❌ | ✅ |
74
74
  | Exports OpenAPI / JSON Schema | ❌ | ❌ | ✅ |
75
+ | Report-only rollout mode | ❌ | ❌ | ✅ |
75
76
 
76
77
  The design rests on one idea: **a contract is data, not code.** It is declared once at the class level, frozen, inheritable, and introspectable. Everything else here follows from that — the drift guard can read it at boot, `finalize` can run on a bare object with no controller state, and the whole contract can be printed or tested without a request.
77
78
 
@@ -108,10 +109,12 @@ params
108
109
 
109
110
  Validation is **lazy by default**: it runs on the first `permitted_params` call, so an action that never reads params never pays for it. Pass `enforce: true` to run it in a `before_action` instead, rejecting bad requests before the action body executes. Results are **memoized per action**.
110
111
 
112
+ In [monitor mode](#monitor-mode-roll-out-without-rejecting) the same flow runs, but a violation is reported instead of raised and the request proceeds with the raw params passed through.
113
+
111
114
  ## Declaring a contract
112
115
 
113
116
  ```ruby
114
- permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &contract)
117
+ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &contract)
115
118
  ```
116
119
 
117
120
  | Option | Default | Meaning |
@@ -121,6 +124,7 @@ permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: fals
121
124
  | `model:` | `nil` | Model class, or `true` to infer from `controller_name`, enabling the [drift guard](#the-schema-drift-guard) |
122
125
  | `unknown:` | `:ignore` | `:ignore` / `:log` / `:error` — how to treat undeclared keys |
123
126
  | `enforce:` | `false` | `false` validates lazily on first use; `true` validates in a `before_action` |
127
+ | `mode:` | `nil` | `nil` follows `Permittable.mode`; `:monitor` reports violations instead of rejecting — see [monitor mode](#monitor-mode-roll-out-without-rejecting) |
124
128
  | `desc:` | `nil` | Documentation only — becomes the operation description in [exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) |
125
129
 
126
130
  `permit_params` is **repeatable**, and **the last matching rule wins**. Contracts behave like configuration: a base controller declares a catch-all, and a subclass overrides it for specific actions.
@@ -383,6 +387,47 @@ ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*,
383
387
  end
384
388
  ```
385
389
 
390
+ ## Monitor mode (roll out without rejecting)
391
+
392
+ Adopting contracts on a live API — or tightening one field on an existing contract — has a chicken-and-egg problem: you cannot know what the 422s would break until you enforce them, and you dare not enforce them until you know. Old mobile app versions, third-party integrations, and forgotten cron jobs all send what they send. `mode: :monitor` resolves it: the full pipeline runs (unwrap, cast, validate, defaults), but a violation is **reported instead of rejected** and the request proceeds exactly as it did before the contract existed.
393
+
394
+ ```ruby
395
+ class OrdersController < ApplicationController
396
+ permit_params :create, root: :order, mode: :monitor do
397
+ required :sku, :string
398
+ optional :quantity, :integer, in: 1..99
399
+ end
400
+
401
+ # The action doesn't have to change while monitoring — it can keep reading
402
+ # params the old way; the contract validates in the before_action.
403
+ end
404
+ ```
405
+
406
+ Or flip the whole app at once and pin controllers to their final mode one at a time — a rule's own `mode:` always beats the global, in both directions:
407
+
408
+ ```ruby
409
+ # config/initializers/permittable.rb
410
+ Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym
411
+ ```
412
+
413
+ On a violating request in monitor mode:
414
+
415
+ - **Nothing raises and nothing renders** — the action runs.
416
+ - The [`invalid_parameters.permittable` event](#instrumentation) fires with `mode: :monitor` in the payload (enforced violations carry `mode: :enforce`), and the logger warns with the offending paths. Point your existing subscriber at a dashboard and you have a per-controller rollout report.
417
+ - `permitted_params` returns the **raw pass-through**: exactly what the client sent, untouched — no casts, no defaults, no transforms. A missing `root:` passes an empty hash (the envelope you asked for isn't there); a rootless contract drops only Rails' routing keys.
418
+ - `permittable_violations` returns the recorded details (`[]` when the request was clean), if the action wants to branch on or tag the traffic.
419
+
420
+ Monitor-mode rules validate **eagerly in the `before_action`, regardless of `enforce:`** — telemetry must not depend on the action calling `permitted_params`, since legacy actions still reading `params` directly are exactly the ones worth monitoring. (On a plain-Ruby host without `before_action`, validation stays lazy.)
421
+
422
+ The rollout recipe:
423
+
424
+ 1. Write contracts for a legacy controller. The action code stays as-is.
425
+ 2. Deploy with `PERMITTABLE_MODE=monitor`. Behaviour is unchanged; telemetry starts.
426
+ 3. Watch the dashboard. Every entry is a real client that would have been rejected — fix the contract, or wait for that traffic to drain.
427
+ 4. Flip to enforce, controller by controller. Every 422 you now return is one you already counted.
428
+
429
+ [Exported OpenAPI](#exporting-openapi-docs-that-cannot-drift) marks operations whose rule declares `mode: :monitor` with `x-permittable-mode: "monitor"` — the docs shouldn't promise a 422 the server doesn't yet send. Only the per-rule declaration is exported: the global `Permittable.mode` is runtime configuration, not contract data.
430
+
386
431
  ## Exporting OpenAPI (docs that cannot drift)
387
432
 
388
433
  Because a contract is data, it has a third reader beyond the validator and the drift guard: an exporter that emits **OpenAPI 3.1** (whose request bodies are plain JSON Schema). The schema is generated from the same frozen data the server enforces, so — like the drift guard, pointed outward — the docs cannot lie:
@@ -422,7 +467,7 @@ How contracts map:
422
467
 
423
468
  Every operation references shared components for the [error envelope](#violations-and-error-responses): a `422` response always, plus a `400` when the contract declares a `root:`. So consumers get typed *errors*, not just typed inputs.
424
469
 
425
- **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
470
+ **What is honestly unrepresentable stays visible instead of guessed.** A `format:` regexp using a Ruby-only construct (or flags) is exported as `x-permittable-pattern` rather than a mistranslated `pattern`; `validate:`/`transform:` are flagged `x-permittable-custom-validation`/`x-permittable-transformed`; actions covered only by a catch-all rule on a plain-Ruby host appear under `"*"` with `x-permittable-catch-all`; operations whose rule runs in [monitor mode](#monitor-mode-roll-out-without-rejecting) carry `x-permittable-mode: "monitor"`; operations with no matching route land in `x-permittable-controllers` instead of being dropped. The schema documents the canonical JSON encoding — the runtime additionally accepts string-encoded scalars (`"42"`, `"true"`) for form/query payloads.
426
471
 
427
472
  Output is deterministic (fixed key order, declaration-order properties), so the generated file can be committed and reviewed as a diff — a contract change shows up in the same PR as its documentation change.
428
473
 
@@ -432,8 +477,9 @@ Output is deterministic (fixed key order, declaration-order properties), so the
432
477
 
433
478
  | Method | Purpose |
434
479
  |---|---|
435
- | `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation, or `ArgumentError` when no contract covers the action |
436
- | `enforce_params_contract` | The `before_action` entry point. Only validates rules declared `enforce: true`. Public, so hosts can `skip_before_action` it |
480
+ | `permitted_params(action = action_name)` | The cast, validated, defaulted `HashWithIndifferentAccess`. Memoized per action. Raises `InvalidParameters` on violation (in [monitor mode](#monitor-mode-roll-out-without-rejecting), returns the raw pass-through instead), or `ArgumentError` when no contract covers the action |
481
+ | `permittable_violations(action = action_name)` | The violation details recorded by validating `action` `[]` when clean. Triggers the same memoized validation; under enforce it swallows the raise, making "would this request fail?" a one-liner |
482
+ | `enforce_params_contract` | The `before_action` entry point. Validates rules declared `enforce: true` and all [monitor-mode](#monitor-mode-roll-out-without-rejecting) rules. Public, so hosts can `skip_before_action` it |
437
483
  | `render_invalid_parameters(error)` | The `rescue_from` target. Renders via the host's `render_error` when defined, the inline envelope otherwise |
438
484
 
439
485
  ### Class methods
@@ -450,6 +496,7 @@ Output is deterministic (fixed key order, declaration-order properties), so the
450
496
  |---|---|
451
497
  | `Permittable.filter_parameter_registry` | The live registry of `sensitive:` field names |
452
498
  | `Permittable.filter_parameter_registry=` | Swap in your own duck-typed registry |
499
+ | `Permittable.mode` / `Permittable.mode=` | App-wide default (`:enforce`) for rules that don't declare their own `mode:` |
453
500
  | `Permittable::InvalidParameters` | Raised on violation; carries `#details` and `#status` |
454
501
  | `Permittable::JsonSchema` | Contract data → JSON Schema fragments (`.rule`, `.object`, `.field`) |
455
502
  | `Permittable::OpenAPI` | OpenAPI 3.1 assembly (`.document`, `.operations_for`, `.request_body_for`, `.components`) |
@@ -471,6 +518,7 @@ A bad contract is a programmer error, so it fails when the class loads — never
471
518
  - An empty contract, or a nested block declaring no sub-fields
472
519
  - `finalize` declared twice, without a block, or inside a nested block
473
520
  - `permit_params` without a block, or an invalid `unknown:` mode
521
+ - An invalid `mode:` (and `Permittable.mode =` rejects invalid values at assignment)
474
522
  - A `model:` that isn't an ActiveRecord class, or `model: true` that can't be inferred
475
523
 
476
524
  ## Compatibility
@@ -488,7 +536,7 @@ Using [concerns_on_rails](https://github.com/VSN2015/concerns_on_rails)? `Concer
488
536
 
489
537
  ```sh
490
538
  bundle install
491
- bundle exec rspec # 112 examples
539
+ bundle exec rspec # 125 examples
492
540
  bundle exec rubocop
493
541
  ```
494
542
 
@@ -135,6 +135,10 @@ module Permittable
135
135
  operation["description"] = rule[:desc] if rule[:desc]
136
136
  operation["requestBody"] = rule_request_body(rule)
137
137
  operation["responses"] = responses_for(rule)
138
+ # The docs must not promise a 422 the server doesn't yet send. Only
139
+ # the rule's own declaration is contract data — the app-wide
140
+ # Permittable.mode is runtime configuration the export can't see.
141
+ operation["x-permittable-mode"] = "monitor" if rule[:mode] == :monitor
138
142
  operation["x-permittable-catch-all"] = true if action == "*"
139
143
  operation
140
144
  end
@@ -1,3 +1,3 @@
1
1
  module Permittable
2
- VERSION = "0.3.0".freeze
2
+ VERSION = "0.4.0".freeze
3
3
  end
data/lib/permittable.rb CHANGED
@@ -63,6 +63,21 @@ require "permittable/filter_parameter_registry"
63
63
  # action that never reads params never pays. `enforce: true` installs the
64
64
  # check as a before_action instead (reject before the action body runs).
65
65
  #
66
+ # MONITOR MODE — the rollout switch. `mode: :monitor` on a rule (or
67
+ # `Permittable.mode = :monitor` app-wide; a rule's own mode: wins) runs the
68
+ # full pipeline but REPORTS violations instead of rejecting: the same
69
+ # "invalid_parameters.permittable" event fires (payload mode: :monitor),
70
+ # the logger warns, and permitted_params returns the raw params passed
71
+ # through untouched — no casts, no defaults, no transforms — so behaviour
72
+ # is identical to the pre-contract app (a missing root: passes an empty
73
+ # hash; a rootless contract drops only the router's bookkeeping keys).
74
+ # Monitor rules validate eagerly in the before_action regardless of
75
+ # enforce:, because telemetry must not depend on the action calling
76
+ # permitted_params — legacy actions still reading `params` directly are
77
+ # exactly the ones being monitored — and monitoring can never halt the
78
+ # request. `permittable_violations` reads the recorded details ([] when
79
+ # the request was clean).
80
+ #
66
81
  # Coercion is deliberately STRICT — ActiveModel::Type is not used, because its
67
82
  # casts are lenient by design ("abc".to_i == 0, Boolean.cast("abc") == true)
68
83
  # and silently corrupting untrusted input is exactly what a contract must not
@@ -119,6 +134,7 @@ module Permittable
119
134
  LABEL = "Permittable".freeze
120
135
  SCALAR_TYPES = %i[string integer float decimal boolean date datetime].freeze
121
136
  UNKNOWN_MODES = %i[ignore log error].freeze
137
+ MODES = %i[enforce monitor].freeze
122
138
  # Rails merges routing bookkeeping into params; a top-level (root: false)
123
139
  # unknown-keys check must not flag them.
124
140
  ROUTING_KEYS = %w[controller action format].freeze
@@ -144,6 +160,26 @@ module Permittable
144
160
  end
145
161
 
146
162
  attr_writer :filter_parameter_registry
163
+
164
+ # App-wide default for rules that don't declare their own mode:.
165
+ # :enforce (the default) rejects violating requests; :monitor reports
166
+ # them — same instrumentation event with payload mode: :monitor, plus a
167
+ # logger.warn — and lets the request proceed with the raw params passed
168
+ # through. This is the rollout switch for brownfield adoption: set it
169
+ # from an initializer (Permittable.mode =
170
+ # ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym) and flip controllers
171
+ # to their final mode one at a time, since a rule's own mode: always
172
+ # wins over this default.
173
+ def mode
174
+ @mode || :enforce
175
+ end
176
+
177
+ def mode=(value)
178
+ value = value.to_sym
179
+ raise ArgumentError, "#{LABEL}: mode must be one of #{MODES.join(', ')}" unless MODES.include?(value)
180
+
181
+ @mode = value
182
+ end
147
183
  end
148
184
 
149
185
  # Raised when the request violates the matching contract. `details` is an
@@ -564,14 +600,23 @@ module Permittable
564
600
  # undeclared keys, at every nesting level.
565
601
  # enforce: false (default) validates lazily on the first
566
602
  # permitted_params call; true validates in a before_action.
603
+ # mode: nil (default) follows Permittable.mode; :enforce rejects
604
+ # violating requests; :monitor reports them and passes the
605
+ # raw params through (see MONITOR MODE in the module
606
+ # comment).
567
607
  # desc: documentation only — carried on the rule for exporters
568
608
  # (Permittable::OpenAPI); the runtime never reads it.
569
- def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, desc: nil, &block)
609
+ def permit_params(*actions, root: false, model: nil, unknown: :ignore, enforce: false, mode: nil, desc: nil, &block)
570
610
  raise ArgumentError, "#{LABEL}: permit_params requires a block declaring the contract fields" unless block
571
611
 
572
612
  unknown = unknown.to_sym
573
613
  raise ArgumentError, "#{LABEL}: :unknown must be one of #{UNKNOWN_MODES.join(', ')}" unless UNKNOWN_MODES.include?(unknown)
574
614
 
615
+ mode = mode&.to_sym
616
+ if mode && !MODES.include?(mode)
617
+ raise ArgumentError, "#{LABEL}: :mode must be one of #{MODES.join(', ')}, or nil to follow Permittable.mode"
618
+ end
619
+
575
620
  builder = ContractBuilder.new
576
621
  fields = builder.build(&block)
577
622
  raise ArgumentError, "#{LABEL}: a contract must declare at least one field" if fields.empty?
@@ -581,7 +626,7 @@ module Permittable
581
626
  register_sensitive_params(fields)
582
627
 
583
628
  rule = { actions: actions.flatten.map(&:to_s).freeze, root: root && root.to_sym,
584
- model: model_class, unknown: unknown, enforce: !!enforce, fields: fields,
629
+ model: model_class, unknown: unknown, enforce: !!enforce, mode: mode, fields: fields,
585
630
  finalize: builder.finalizer, desc: desc }.freeze
586
631
  self.permittable_contracts = permittable_contracts + [rule]
587
632
  end
@@ -657,21 +702,44 @@ module Permittable
657
702
  rule = self.class.permit_rule_for(action)
658
703
  raise ArgumentError, "#{LABEL}: no params contract declared covering ##{action}" unless rule
659
704
 
660
- @permittable_validated[action] = validate_params_contract!(rule)
705
+ @permittable_validated[action] = validate_params_contract!(rule, action)
661
706
  end
662
707
 
663
708
  # before_action entry point (public so hosts can `skip_before_action
664
- # :enforce_params_contract`). Only rules that opted in with
665
- # `enforce: true` validate here.
709
+ # :enforce_params_contract`). Two kinds of rule validate here: those that
710
+ # opted in with `enforce: true`, and monitor-mode rules — monitoring must
711
+ # not depend on the action calling permitted_params (legacy actions still
712
+ # reading `params` directly are exactly the ones being monitored), and it
713
+ # can never halt the request because monitor mode never raises.
666
714
  def enforce_params_contract
667
715
  action = permittable_action_name
668
716
  return nil unless action
669
717
 
670
718
  rule = self.class.permit_rule_for(action)
671
- permitted_params(action) if rule && rule[:enforce]
719
+ permitted_params(action) if rule && (rule[:enforce] || permittable_mode(rule) == :monitor)
672
720
  nil
673
721
  end
674
722
 
723
+ # The violation details recorded by validating `action` (default: the
724
+ # current action) — [] when the request satisfied the contract. Triggers
725
+ # the same memoized validation as permitted_params, so under monitor mode
726
+ # this is the request-level observable ("what would have been
727
+ # rejected?"); under enforce mode it swallows the raise and hands back
728
+ # the details, which makes "would this request fail?" a one-liner in
729
+ # tests.
730
+ def permittable_violations(action = nil)
731
+ action = (action || permittable_action_name).to_s
732
+ @permittable_violations ||= {}
733
+ unless @permittable_violations.key?(action)
734
+ begin
735
+ permitted_params(action)
736
+ rescue InvalidParameters
737
+ # validation recorded the details before raising
738
+ end
739
+ end
740
+ @permittable_violations.fetch(action)
741
+ end
742
+
675
743
  # rescue_from target — renders through the shared envelope (the host's
676
744
  # render_error when present, the identical inline shape otherwise).
677
745
  def render_invalid_parameters(error)
@@ -683,7 +751,7 @@ module Permittable
683
751
 
684
752
  private
685
753
 
686
- def validate_params_contract!(rule)
754
+ def validate_params_contract!(rule, action)
687
755
  violations = []
688
756
  source = permittable_root_hash(rule, violations)
689
757
  result = ActiveSupport::HashWithIndifferentAccess.new
@@ -693,19 +761,53 @@ module Permittable
693
761
  end
694
762
  # finalize only sees a hash every field vouched for — never garbage.
695
763
  result = permittable_run_finalize(rule[:finalize], result, violations) if violations.empty? && rule[:finalize]
764
+ violations.each(&:freeze)
765
+ (@permittable_violations ||= {})[action] = violations.freeze
696
766
  return result if violations.empty?
767
+ return permittable_monitor_pass_through(rule, source, violations) if permittable_mode(rule) == :monitor
697
768
 
698
769
  raise_invalid_parameters!(violations, status: source ? :unprocessable_entity : :bad_request)
699
770
  end
700
771
 
772
+ # A rule's own mode: wins; otherwise the app-wide Permittable.mode.
773
+ def permittable_mode(rule)
774
+ rule[:mode] || Permittable.mode
775
+ end
776
+
777
+ # Monitor mode's violation path: emit the same instrumentation event the
778
+ # enforce path does (payload mode: :monitor) plus a warn line, then hand
779
+ # back exactly what the client sent — no casts, no defaults, no
780
+ # transforms — so behaviour is identical to the pre-contract app. A
781
+ # missing root: passes an empty hash through (the envelope you asked for
782
+ # isn't there); a rootless contract drops only the router's bookkeeping
783
+ # keys, mirroring their exemption from the unknown-keys check.
784
+ def permittable_monitor_pass_through(rule, source, violations)
785
+ permittable_instrument_violations(violations, mode: :monitor)
786
+ if respond_to?(:logger) && logger
787
+ logger.warn("#{LABEL}: [monitor] ##{permittable_action_name} would have been rejected: " \
788
+ "#{permittable_violation_summary(violations)}")
789
+ end
790
+ return ActiveSupport::HashWithIndifferentAccess.new unless source
791
+
792
+ passed = ActiveSupport::HashWithIndifferentAccess.new(source)
793
+ rule[:root] ? passed : passed.except(*ROUTING_KEYS)
794
+ end
795
+
701
796
  def raise_invalid_parameters!(violations, status:)
702
- violations.each(&:freeze)
797
+ permittable_instrument_violations(violations, mode: :enforce)
798
+ raise InvalidParameters.new("Invalid parameters: #{permittable_violation_summary(violations)}",
799
+ details: violations, status: status)
800
+ end
801
+
802
+ def permittable_instrument_violations(violations, mode:)
703
803
  ActiveSupport::Notifications.instrument(
704
804
  "invalid_parameters.permittable",
705
- controller: permittable_controller_name, action: permittable_action_name, details: violations
805
+ controller: permittable_controller_name, action: permittable_action_name, details: violations, mode: mode
706
806
  )
707
- summary = violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
708
- raise InvalidParameters.new("Invalid parameters: #{summary}", details: violations, status: status)
807
+ end
808
+
809
+ def permittable_violation_summary(violations)
810
+ violations.map { |v| v[:message] ? "#{v[:param]} #{v[:message]}" : "#{v[:param]} (#{v[:code]})" }.join(", ")
709
811
  end
710
812
 
711
813
  # One violation detail entry. A field's `message:` (String, or Hash keyed
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: permittable
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen