Validate once per action, so a rejection instruments once - #22
Conversation
Code review — #22 Validate once per actionVerdict: ready to merge. The fix does exactly what the description claims, is confined to per-request instance state keyed by action, leaves monitor mode and standalone Verified
Strengths
Minor
|
| if @permittable_validated.key?(action) | ||
| outcome = @permittable_validated[action] | ||
| raise outcome if outcome.is_a?(InvalidParameters) | ||
|
|
There was a problem hiding this comment.
Storing outcome in @permittable_validated[action] and immediately re-raising cached InvalidParameters guarantees deterministic single execution of validation and notifications per action.
| @permittable_validated[action] = validate_params_contract!(rule, action) | ||
| rescue InvalidParameters => e | ||
| # ArgumentError is deliberately NOT memoized: a contract that does not | ||
| # cover the action is a bug to fix, not a verdict on this request. |
There was a problem hiding this comment.
Rescuing only InvalidParameters specifically avoids memoizing ArgumentError, ensuring programmer errors or configuration bugs aren't masked across invocations.
permitted_params is documented as memoized per action, but it only memoized SUCCESSES. On a violation it raised without storing anything, so the next read revalidated from scratch and fired invalid_parameters.permittable again — one bad request, several events, double-counted in every dashboard built on that hook. It was not a corner case. permittable_violations followed by permitted_params is the pattern the monitor-mode documentation suggests for "would this request fail?", and it hit this every time: events for one bad request read 4 times: 4 # before events for one bad request read 4 times: 1 # after The memo now remembers the outcome rather than only a value: a rejection is stored and re-raised, so the contract runs — and instruments — exactly once per action per request, and the same exception object comes back rather than an equal-looking new one. ArgumentError is deliberately still raised fresh every time and never memoized. A contract that does not cover the action is a bug to fix, not a verdict on this request, and memoizing it would make the second call look like a rejection. Also promotes the spec suite's recording_notifications helper out of the monitor-mode block, since observability now needs it too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
019c89f to
5e92ed4
Compare
The bug
permitted_paramsis documented as memoized per action — but it only memoized successes. On a violation it raised without storing anything, so the next read revalidated from scratch and firedinvalid_parameters.permittableagain.One bad request, several events, double-counted in every dashboard built on that hook — which is the hook the README tells you to build on.
It wasn't a corner case
permittable_violationsfollowed bypermitted_paramsis the pattern the monitor-mode documentation itself suggests for "would this request fail?", and it hit this every single time —permittable_violationstriggers a validation pass, swallows the raise, and leaves nothing memoized for the action's own read.The fix
The memo now remembers the outcome rather than only a value: a rejection is stored and re-raised, so the contract runs — and instruments — exactly once per action per request. The same exception object comes back, not an equal-looking new one.
One deliberate exclusion
ArgumentErroris still raised fresh every time and never memoized. A contract that doesn't cover the action is a bug to fix, not a verdict on this request — and memoizing it would make the second call look like a rejection. There's a spec pinning that it raises on every call.Also
Promotes the spec suite's
recording_notificationshelper out of the monitor-mode block, since the observability block now needs it too.Verification
ArgumentErrornever memoized, and a clean read still memoized and silent