Skip to content

Add monitor mode: report violations without rejecting requests - #5

Open
VSN2015 wants to merge 1 commit into
feat/openapi-exportfrom
feat/monitor-mode
Open

Add monitor mode: report violations without rejecting requests#5
VSN2015 wants to merge 1 commit into
feat/openapi-exportfrom
feat/monitor-mode

Conversation

@VSN2015

@VSN2015 VSN2015 commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Why

Almost no Rails API is greenfield. The apps that need typed params contracts most are years-old monoliths with unknown clients in the wild — old mobile app versions, third-party integrations, forgotten cron jobs. Adopting Permittable (or even tightening one field on an existing contract) meant flipping live traffic from "accepted" to "422" in a single deploy, with no way to know what breaks until it breaks. So the safe choice was to never adopt.

This PR adds the same escape hatch browsers invented for CSP (Content-Security-Policy-Report-Only): monitor mode. 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. You can't enforce what you haven't measured; now you can measure first.

How to use it

Per contract

class OrdersController < ApplicationController
  permit_params :create, root: :order, mode: :monitor do
    required :sku,      :string
    optional :quantity, :integer, in: 1..99
  end

  # The action doesn't have to change while monitoring — it can keep reading
  # params the old way; the contract validates in the before_action.
end

App-wide, from an initializer

# config/initializers/permittable.rb
Permittable.mode = ENV.fetch("PERMITTABLE_MODE", "enforce").to_sym

A rule's own mode: always beats the global, in both directions — so you can monitor app-wide and pin finished controllers to mode: :enforce one at a time, or enforce app-wide and monitor just the contract you're tightening.

Dashboard the would-be rejections

The existing invalid_parameters.permittable event now carries mode: in its payload (:monitor / :enforce), so one subscriber covers both:

ActiveSupport::Notifications.subscribe("invalid_parameters.permittable") do |*, payload|
  StatsD.increment("params.violations",
                   tags: ["controller:#{payload[:controller]}",
                          "action:#{payload[:action]}",
                          "mode:#{payload[:mode]}"])
end

The logger also warns on each monitored violation with the offending paths:

Permittable: [monitor] #create would have been rejected: order.quantity (inclusion)

Read the verdict in the action or in tests

permittable_violations   # => [{ param: "order.quantity", code: "inclusion" }] — or [] when clean

Under enforce mode it swallows its own trigger's raise, which makes "would this request fail?" a one-liner in request specs.

The rollout recipe

  1. Write contracts for a legacy controller. The action code stays as-is.
  2. Deploy with PERMITTABLE_MODE=monitor. Behaviour is unchanged; telemetry starts.
  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.
  4. Flip to enforce, controller by controller. Every 422 you now return is one you already counted.

Behaviour on a violating request in monitor mode

  • Nothing raises, nothing renders — the action runs, and the before_action can never halt.
  • permitted_params returns the raw pass-through: exactly what the client sent — no casts, no defaults, no transforms — so behaviour is byte-for-byte the pre-contract app. A missing root: passes an empty hash (the envelope you asked for isn't there); a rootless contract drops only Rails' routing keys, mirroring their exemption from the unknown-keys check.
  • Monitor 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. Results stay memoized per action, so nothing validates or instruments twice.
  • Exported OpenAPI marks the operation with x-permittable-mode: "monitor" — the docs must not 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.

Design notes

  • Follows the gem's one idea — a contract is data. mode is just a fourth reader decision at the raise-vs-report branch point; the precedents (unknown: :ignore/:log/:error, enforce:, the notification event) were already in place.
  • Fail-at-boot discipline kept: an invalid mode: raises at class load; Permittable.mode = rejects invalid values at assignment.
  • Fully additive: default mode is :enforce, and the enforce path behaves exactly as before. The only observable change for existing apps is the new mode: :enforce key on the notification payload.
  • No new dependencies; plain-Ruby hosts keep working (without before_action, monitor validation stays lazy — documented).

Testing

  • 13 new examples (125 total, 0 failures): macro/setter validation, clean-request parity with enforce, raw pass-through (no cast/default/transform), missing-root and routing-keys fallbacks, global-vs-per-rule precedence both ways, eager before_action validation, single-notification memoization, finalize violate! under monitor, permittable_violations in both modes, the OpenAPI vendor extension, and an end-to-end spec through the real ActionController stack (violating request → 200 with raw payload).
  • RuboCop clean.

Stacked on #3

Based on feat/openapi-export because the x-permittable-mode export marker touches Permittable::OpenAPI. Merge #3 first, then re-target this PR to master (GitHub does it automatically on merge).

🤖 Generated with Claude Code

Adopting contracts on a live API used to mean flipping unknown clients
from "accepted" to "422" in a single deploy. mode: :monitor runs the
full pipeline but reports violations instead of rejecting: the same
invalid_parameters.permittable event fires (payload mode: :monitor),
the logger warns, and permitted_params returns the raw params passed
through untouched. Monitor rules validate eagerly in the before_action
regardless of enforce:, so telemetry never depends on the action
calling permitted_params. Permittable.mode sets the app-wide default;
a rule's own mode: wins in both directions. permittable_violations
reads the recorded details, and exported OpenAPI operations carry
x-permittable-mode: "monitor" so the docs don't promise a 422 the
server doesn't yet send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant