Skip to content

Surface structured parse diagnostics from PolicySet.parsePolicies - #367

Open
jamesmulcahy wants to merge 4 commits into
cedar-policy:mainfrom
jamesmulcahy:surface-parse-diagnostics
Open

jamesmulcahy wants to merge 4 commits into
cedar-policy:mainfrom
jamesmulcahy:surface-parse-diagnostics

Conversation

@jamesmulcahy

Copy link
Copy Markdown

Problem

PolicySet.parsePolicies throws InternalException whose message is format!("Internal JNI Error: {e}") (CedarJavaFFI/src/interface.rs, in jni_failed). For a parse failure that discards everything miette recorded — the source span, the tokens the parser expected, the help text — and because ParseErrors' Display prints only its first error, every subsequent error is lost too.

A policy author writing this:

forbid(principal, Foo::Action::"Read", resource == Foo::Table::"t");

sees only:

Internal error: Internal JNI Error: unexpected token `::`

No location, and no hint that the mistake is a missing action ==. The Rust CLI, on the same input:

× failed to parse policy set
╰─▶ unexpected token `::`
 ╭────
 1 │ forbid(principal, Foo::Action::"Read", resource == Foo::Table::"t");
 ·                      ─┬
 ·                       ╰── expected `!=`, `)`, `,`, `:`, `<`, `<=`, `==`, `>`, `>=`, `in`, or `is`
 ╰────

Measured against cedar-java 4.10.0:

PolicySet.parsePolicies("forbid(principal, Foo::Action::\"Read\", resource);");
// getMessage(): Internal error: Internal JNI Error: unexpected token `::`
// getErrors():  [Internal JNI Error: unexpected token `::`]   // size 1, always

Change

Everything needed already exists in the crate:

  • PolicySet::from_str returns ParseErrors, which is IntoIterator<Item = ParseError>
  • each ParseError implements miette::Diagnostic
  • cedar_policy::ffi::DetailedError already has impl<E: miette::Diagnostic + ?Sized> From<&E> — the impl the validation path already uses

So parsePoliciesJni downcasts ParseErrors and throws a new PolicyParseException extends InternalException carrying List<DetailedError>, the same representation AuthorizationEngine.validate already returns:

PolicyParseException e = ...;
e.getDetailedErrors();
// [message=unexpected token `::`,
//  sourceLocations=[SourceLabel{label="expected `!=`, `)`, `,`, `:`, `<`, `<=`, `==`,
//                   `>`, `>=`, `in`, or `is`", start=21, end=23}]]

Existing catch (InternalException e) blocks are unaffected. If building the richer exception fails for any reason, the generic path is used, so a parse error can never turn into a different kind of failure.

Two message changes, both up for discussion

before after
getMessage() Internal error: Internal JNI Error: unexpected token ... Internal error: unexpected token ...
getErrors() [Internal JNI Error: unexpected token ...] [unexpected token ...]
  1. The "Internal JNI Error: " prefix is dropped — it describes the binding rather than the policy, and reading "Internal error" for an ordinary typo suggests a fault in the library rather than something the caller can fix.
  2. getErrors() carries one entry per parse error rather than a single entry for the whole document, which is what its plural contract always implied.

Both are independent of the diagnostics and easy to drop if you'd rather keep the strings frozen — happy to revise.

Breaking?

Not to any type or signature: PolicyParseException is a subclass and no existing method changes shape. Callers string-matching on the message text of a parse failure would see the two differences above.

Testing

  • 6 new tests in PolicyParseDiagnosticsTests — span covers the offending token, all errors reported not just the first, help text survives where Cedar supplies it, still catchable as InternalException, message/getErrors() strings pinned, valid policy sets unaffected.
  • Full CedarJava suite: 68,723 tests, 0 failures.
  • cargo test in CedarJavaFFI: 69 passed.

Local FFI builds used cargo build --release --features partial-eval for the host target rather than the cargo zigbuild cross-compile path, since zig was not available in my environment; CI exercises the normal path.

Scope

Deliberately limited to parsePolicies. Policy.parseStaticPolicy, Policy.parsePolicyTemplate, Schema.parse and PolicyFormatter lose detail the same way and would be natural follow-ups — kept out here to keep the change focused, per CONTRIBUTING.

Related: #68 asks for the id of a malformed policy, which is adjacent but stops short of diagnostics.

Parse failures are reduced to format!("Internal JNI Error: {e}") in
jni_failed, discarding the miette diagnostic cedar produced: the source
span, the tokens the parser expected, and the help text. ParseErrors'
Display also prints only its first error, so subsequent errors are lost.

Add PolicyParseException, a subclass of InternalException carrying
List<DetailedError> - the same representation the validation path already
returns. parsePoliciesJni downcasts ParseErrors and converts each
ParseError via the existing From<&E: miette::Diagnostic> impl. If building
the richer exception fails for any reason the generic path is used, so a
parse error can never become a different kind of failure.

Existing catch (InternalException) blocks are unaffected. Two message
details change deliberately: the "Internal JNI Error: " prefix is dropped,
since it describes the binding rather than the policy and reads as a
library fault rather than a typo the caller can fix; and getErrors()
carries one entry per parse error rather than a single entry for the whole
document, which is what its plural contract always implied.

Signed-off-by: James Mulcahy <jmulcahy@netflix.com>
@jamesmulcahy
jamesmulcahy force-pushed the surface-parse-diagnostics branch from 8d0587c to 5276059 Compare August 24, 2026 16:35
@jamesmulcahy

Copy link
Copy Markdown
Author

Full disclosure -- I've not written any rust before myself, and Claude helped with this change. Background/motivation is well summarized by Claude above. The TL;DR is that the cedar CLI gives much better error output than the Java API -- and I'm trying to improve the experience through Java so our users can be more meaningful & actionable feedback when they provide an invalid policy.

@jamesmulcahy

Copy link
Copy Markdown
Author

Hi @lianah @mark-creamer-amazon @muditchaudhary -- James from Netflix here, we met a few weeks ago!

Our Cedar usage is going well, but we've run into some UX friction with policy validation that should be improved by this PR. Would appreciate your time in reviewing! Thank you!

@mark-creamer-amazon

Copy link
Copy Markdown
Contributor

Hey James, I'll take a look today. Thanks for the PR!


@Test
public void validPolicySetStillParses() {
org.junit.jupiter.api.Assertions.assertDoesNotThrow(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: we should import assertDoesNotThrow above

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack; fixed this in my local branch

@mark-creamer-amazon

mark-creamer-amazon commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

I agree with the direction of this PR, but I do worry about breaking existing consumers here.

  1. Removing the Internal JNI Error: prefix could of course technically break a consumer inspecting and branching on this prefix's presence (unless they strip it).
    • I think this is the smaller risk I'd personally be willing to move forward regardless
  2. Similarly I've seen at least one downstream consumer that is doing a strict regex match on getMessage(), which would break with this change, e.g. java.util.regex.Pattern.compile("^Internal error: Internal JNI Error: unexpected token ([^]*)$");`. More commonly there's likely other consumers that branch based on what's expected to be a single message, e.g.
    if (e.getMessage().equals("Internal error: Internal JNI Error: failure a")) {
        ...
    } else if (e.getMessage().equals("Internal error: Internal JNI Error: failure b")) {
        ...
    }
    
    I think this class of issue class is enough where
  3. While getErrors does return a List and implies plurality, all InternalException's from the FFI thus far have been using the basic constructor, which only populates one element. But as getErrors() returns a List, I'll concede that perhaps it was never safe for a consumer to expect .size() == 1 or refer solely to errs.get(0) as the only element. I personally think that us fully populating the getErrors() result list is the right direction.

I think I'm hesitant towards the getMessage() behavior change, but I'm fine with getErrors() changing to return the other errors.

@jamesmulcahy

Copy link
Copy Markdown
Author

I agree with the direction of this PR, but I do worry about breaking existing consumers here.

[...]

I think I'm hesitant towards the getMessage() behavior change, but I'm fine with getErrors() changing to return the other errors.

Understood, I think that's a reasonable take. I'll update the PR to conform with your guidance. Thanks for the review!

Review feedback on cedar-policy#367: dropping the "Internal JNI Error: " prefix from
getMessage() is a breaking change. Consumers branch on that string and at
least one matches it with an anchored regex, so it is effectively part of
the API even though the prefix describes the binding rather than the policy.

PolicyParseException now takes the message the generic path would have
produced and passes it through, so getMessage() is byte-for-byte unchanged:
"Internal error: Internal JNI Error: " followed by ParseErrors' Display,
which prints the first error alone. internal_error_message is the single
definition of that string, shared with the throw_internal fallback, so the
two paths cannot drift.

The added detail is reached through the accessors instead. getErrors() still
carries one entry per parse error - the plurality its List contract always
implied - each the bare Cedar message, and getDetailedErrors() carries the
miette diagnostics. InternalException gains a protected constructor setting
message and error list independently; the existing ones derive the message
from the list, which would have widened it as the list was populated.

messagesDropTheInternalJniErrorPrefix becomes messageIsUnchangedForBackCompat
and now guards against the regression it previously asserted, and the
multiple-error test pins the message to the first error alone. Also imports
assertDoesNotThrow, and adds the CHANGELOG entry the PR was missing.

Signed-off-by: James Mulcahy <jmulcahy@netflix.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jamesmulcahy

Copy link
Copy Markdown
Author

@mark-creamer-amazon — Claude Code here, posting as James's agent; he's asked me to follow up on your review and has reviewed this reply before it went out.

Two commits pushed, branch now at ffdf01b.

getMessage() is unchanged

Point 2 taken in full — the message is now byte-for-byte what the generic error path produced, prefix and all. Your example pattern

Pattern.compile("^Internal error: Internal JNI Error: unexpected token `([^`]*)`$");

still matches, and it can't be widened by a document with several errors: ParseErrors' Display prints only the first error, and the message is still exactly that, so populating the list doesn't leak into the string. There's a test pinning the literal value now (messageIsUnchangedForBackCompat) — it asserts the inverse of what the earlier revision asserted, so the regression you flagged is now guarded rather than introduced.

Mechanically: PolicyParseException takes the message as a constructor argument, and the Rust side has a single internal_error_message() helper used by both the enriched path and the plain InternalException fallback, so the two can't drift apart. InternalException gained a protected constructor that sets message and error list independently — the existing constructors derive the message by joining the list, which is what made a per-error list necessarily widen the message. Existing constructors are untouched.

The one point I'd like your explicit call on: the prefix on getErrors() entries

getErrors() now returns one entry per parse error, per your point 3. What's still a judgement call is what each entry contains. As pushed:

getMessage() -> "Internal error: Internal JNI Error: unexpected token `::`"   (unchanged, prefix retained)
getErrors()  -> ["unexpected token `::`", "unexpected token `}`"]             (bare, no prefix)

To be explicit: the original error message keeps the prefix exactly where it has always been. The prefix is absent only from the individual list elements. The reasoning is that "Internal JNI Error: " describes the binding rather than any one parse error, so once the list is per-error, repeating it on every element reads as though each error were its own separate JNI failure.

Your point 1 said you'd accept removing the prefix as the smaller risk, but that was said about getMessage(), and I don't want to assume it carries over to the list. The conservative alternative is to prefix element 0 only — preserving the exact former value of getErrors().get(0) for anyone reading just that element, leaving 1..n bare. Strictly non-breaking, though incoherent as a contract.

Does the bare-element version match your expectations, or would you prefer element 0 keep the prefix? Either is fine by us; it's a small change and we'd rather have your explicit sign-off than guess.

Also in this push

  • getDetailedErrors() returns Cedar's miette diagnostics per error — message, source span, expected tokens, help text. This is where the new information lives now that the message is fixed.
  • Javadoc on DetailedError.SourceLabel documenting that the spans are UTF-8 byte offsets, not String indices. Worth calling out because the obvious source.substring(start, end) throws StringIndexOutOfBoundsException on any policy text containing a non-ASCII character, which we hit while testing. It also records that offsets are absolute within the whole parsed text rather than per-policy, that a span can be empty (an unterminated string literal produces one), and that the span covers the unexpected token — which for a missing operand is the token that followed it, sometimes a line later.
  • assertDoesNotThrow import nit from your inline comment.
  • A checkstyleMain whitespace violation this PR had introduced in PolicyParseException — that was failing the build and I should have caught it earlier.

Verified locally: the 6 PolicyParseDiagnosticsTests pass, javadoc and checkstyleMain are clean, and the full suite's failure count is identical to the pre-change baseline on this tree (58, all in SharedIntegrationTests and unrelated to parsing — happy to share the before/after if useful).

Thanks again for the careful review — the getMessage() objection was the right call and the change is better for it.

🤖 Generated with Claude Code

The spans Cedar reports are UTF-8 byte offsets, but nothing said so
beyond "in bytes" on the two fields, and the obvious way to use them --
source.substring(start, end) -- is wrong the moment the policy text
contains a non-ASCII character. It does not fail quietly: on a document
with an accented identifier or an emoji in a comment it throws
StringIndexOutOfBoundsException, because the byte offset runs past the
end of the shorter UTF-16 string.

SourceLabel now carries a class-level explanation with the byte-slicing
snippet callers should use instead, and the field comments name the
offsets as UTF-8 and give their inclusivity. It also records three other
properties that are not apparent from the types, all confirmed against
the parser:

  - offsets are absolute within the whole parsed text rather than
    relative to the enclosing policy, so they stay usable when several
    policies are parsed together, and they carry no policy identity of
    their own;
  - a span may be empty, which is what an unterminated string literal
    produces, so a renderer must not assume a character to underline;
  - a span covers the unexpected token, which for a missing operand is
    the token that followed it, possibly on a later line.

PolicyParseException's own Javadoc described how the class differed from
the generic error path that preceded it, and said getErrors() "does
change" -- relative to a revision no reader of the released class will
have seen. Rewritten to say what the type is: a list contrasting the
three accessors by fidelity, which is what a caller needs in order to
choose between them. The rationale for freezing getMessage() stays in
the private Rust that builds it, where it warns whoever might
reasonably re-break it, rather than in public API documentation.

Also adds the whitespace checkstyleMain wants inside the empty
TypeReference body in PolicyParseException, which was failing the build.

Signed-off-by: James Mulcahy <jmulcahy@netflix.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jamesmulcahy
jamesmulcahy force-pushed the surface-parse-diagnostics branch from ffdf01b to c1eb82d Compare September 16, 2026 18:54
SpotBugs flagged the blanket `catch (Exception)` in readDetailedErrors
(REC_CATCH_EXCEPTION). Catch JsonProcessingException and RuntimeException
instead: same defensive behaviour, no catch of exceptions that cannot
arise.

Narrowing the catch exposed CT_CONSTRUCTOR_THROW, since the constructor
can now throw and leave a partially initialised object. Make the class
final, SpotBugs' remedy for that rule; it was never meant to be
subclassed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: James Mulcahy <jmulcahy@netflix.com>
@jamesmulcahy
jamesmulcahy force-pushed the surface-parse-diagnostics branch from 1e6a918 to 8632241 Compare September 17, 2026 01:29
@jamesmulcahy

Copy link
Copy Markdown
Author

@mark-creamer-amazon I pushed a fix for the CI failure; if you can approve again I'm optimistic we'll get a clean run, but I'll keep an eye on it!

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.

2 participants