The allow/deny shape is right, and the argument for it that people skip is the one you led with: a policy engine concentrates your highest-risk code in one place on purpose. I want to put one failure next to it, because it hides inside this exact pattern.
count(deny) == 0 is only as safe as your deny rules are reachable. OPA's default is that a built-in function which hits a runtime error evaluates to undefined and does not halt policy evaluation. So a deny rule whose body errors does not fail loudly. It produces no message, the count stays where it was, and the policy says yes.
Your own example shows how ordinary that is. to_number(input.film.rating) is fine for "18". It is undefined for "U", "PG" or "12A", and the pound sign in the fees message suggests UK certificates, where those are real values. The moment that conversion goes undefined, the entire age rule drops out of the deny set and a member of any age gets the 12A. default allow := false does not save you, because allow was never the thing that failed. A rule that was supposed to say no simply stopped speaking.
So alongside the pattern I would run evaluation with strict-builtin-errors, which turns that silent abstention back into an error you can see. And I would write the tests to assert on which deny reasons came back rather than only on the final boolean, because allow == false passes just as happily when one rule fired correctly as when that rule fired and two others quietly went missing.
One more, on where those messages go. They are genuinely useful, which is what makes them easy to leak. An outstanding late fee of a specific amount belongs in an audit log. Returned to whoever made the request, it answers a question about a member's account to anyone who can shape an input. I keep the set internally and give the caller a decision and a correlation id.