fix(v2): pin the parse password precedence rule and reject a non-object options - #160
Merged
Merged
Conversation
`password` is shorthand for the contract field `options["password"]`, and `_build_parse_body` already lets the explicit field win the tie. That rule lived only in a comment and a docstring, and only the sync route's dict branch had a test for it -- ade-typescript had shipped the opposite rule, so the same call decrypted with a different password depending on the SDK. - pin the rule on the `options` JSON-string branch and on `parse_jobs.create`, the two paths that had no conflict coverage - state the cross-SDK contract next to the code that implements it, replacing a vaguer note that read as being about folding rather than precedence - document `password` and the rule in the README, which had no encrypted-PDF section at all No behavior change; ade-typescript moves to this rule to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_build_parse_body` coerced `options` with `dict(json.loads(...))`, and `dict()`
accepts any pair-sequence -- so a JSON *array* silently became the options dict:
options='[["password", "sneaky"]]', password="kw" -> {"password": "sneaky"}
The smuggled key then won the precedence tie against the caller's own `password`
argument, which is the exact silent substitution that rule exists to prevent.
Non-dict scalars fared no better: `TypeError: 'int' object is not iterable`, or a
`ValueError` about a "dictionary update sequence" -- neither naming the field.
Route `options` through `_coerce_options`, mirroring `coerce_schema_to_dict` in
`lib/schema_utils.py`: decode a string, require an object, and name the field
when it is not one. Malformed JSON still surfaces as the `ValueError` that
`json.loads` raises, exactly as it does for `schema`.
Found while aligning precedence with ade-typescript, which rejects the same
inputs (landing-ai/ade-typescript#121).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The implementation consistently applies the documented rule across shared sync, async, and job request construction.
Pull request overview
Clarifies and enforces V2 parse password precedence while rejecting invalid non-object options.
Changes:
- Adds strict options coercion.
- Tests sync and job precedence behavior.
- Documents encrypted PDF handling.
File summaries
| File | Description |
|---|---|
src/landingai_ade/resources/v2/parse.py |
Validates options and documents precedence. |
tests/api_resources/v2/test_parse.py |
Adds regression and validation tests. |
README.md |
Documents encrypted PDF passwords. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review follow-ups on the precedence work. `_coerce_options` narrowed the non-string branch from `dict()` to `Mapping`, not just the JSON-string branch -- and that half was undocumented and untested. It is deliberate: `dict()` accepts any pair-sequence in list form too, so `[["password", "x"]]` smuggled a password exactly like its string twin did. The docstring now says so, including the one place this stops mirroring `coerce_schema_to_dict` (that helper takes a pydantic model; `options` never advertised one), and the rejection test covers both forms plus the previously uncovered `Unsupported options type` branch. Also: drop the `str` arm of the final serialization, which `_coerce_options` made unreachable while still reading as "a pre-serialized string is forwarded verbatim" -- the opposite of what the code does; add async precedence coverage, since all four call sites share `_build_parse_body` but only the sync two were pinned; make the rejection cases table-driven so one bad input no longer hides the rest; and fix a README example that imported `os` but used an undefined `Path`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Public annotations exclude documented string options, and malformed-JSON exceptions are documented incorrectly.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/landingai_ade/resources/v2/parse.py:270
- Malformed JSON does not raise
TypeError:_coerce_optionsletsjson.loadsraisejson.JSONDecodeError, and the new test explicitly asserts that behavior. This exception promise is therefore inaccurate; distinguish malformed JSON from decoded non-objects.
field. Must be a mapping, or a JSON string that decodes to an object --
anything else raises `TypeError` before the request is sent.
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Balanced
…tion errors Copilot review on #160. The docs this branch added advertise a JSON string for `options`, and `_coerce_options` accepts one, but every public annotation still said `Optional[Mapping[str, object]]` -- so a typed caller could not use the advertised form without the `type: ignore` the tests carried. Widen all six sync/async parse and job signatures, matching how extract already types its coercible sibling (`schema: Union[str, Mapping[str, object], Type[BaseModel]]`), and drop the ignores that are no longer needed. griffe reports no breakage: the change is additive. The same docs also promised `TypeError` for anything unacceptable, which is wrong for the case most likely to hit it -- malformed JSON propagates `json.JSONDecodeError` from `json.loads`, as this branch's own test asserts. Name both paths instead, in the two docstrings and the README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tian-lan-landing
added a commit
that referenced
this pull request
Sep 9, 2026
…e aliases check-v2-paths.sh scopes client.v2 to the spec's /v2 routes. The wiring prompt scoped routes too, and said nothing about which FIELDS back a /v2 request — which is how the parse password went wrong: the spec has declared it at options.password and nowhere else since 2026-07-16, the same spec still declares a top-level `password` on /v1/ade/parse*, and both SDKs ended up with a hand-written top-level shorthand whose tie-break was written down nowhere. They picked opposite ones, so one call sent a different password per language (#160, landing-ai/ade-typescript#121). - the prompt's SCOPE block now scopes fields as well as routes, names both traps (a nested spec field is not a top-level one; a /v1 top-level field is not a /v2 field), and tells the AI pass to leave a new convenience alias to a maintainer rather than invent one. - CONTRIBUTING records the two aliases that exist (`password` -> options.password, `strict` -> options.strict), the precedence rule for each, the language-specific traps in both SDKs, and — plainly — that nothing in CI checks any of it, so a PR touching a non-spec top-level param has to be diffed against the other repo by hand. A mechanical gate for this (check-v2-aliases.sh + a shared manifest) was built and dropped as more machinery than the problem warrants: it could not verify a tie-break from code anyway, only that a rule had been written down somewhere. The risk stays with review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tian-lan-landing
added a commit
to landing-ai/ade-typescript
that referenced
this pull request
Sep 9, 2026
…e aliases check-v2-paths.sh scopes client.v2 to the spec's /v2 routes. The wiring prompt scoped routes too, and said nothing about which FIELDS back a /v2 request — which is how the parse password went wrong: the spec has declared it at options.password and nowhere else since 2026-07-16, the same spec still declares a top-level `password` on /v1/ade/parse*, and both SDKs ended up with a hand-written top-level shorthand whose tie-break was written down nowhere. They picked opposite ones, so one call sent a different password per language (#121, landing-ai/ade-python#160). - the prompt's SCOPE block now scopes fields as well as routes, names both traps (a nested spec field is not a top-level one; a /v1 top-level field is not a /v2 field), and tells the AI pass to leave a new convenience alias to a maintainer rather than invent one. - CONTRIBUTING records the two aliases that exist (`password` -> options.password, `strict` -> options.strict), the precedence rule for each, the language-specific traps in both SDKs, and — plainly — that nothing in CI checks any of it, so a PR touching a non-spec top-level param has to be diffed against the other repo by hand. A mechanical gate for this (check-v2-aliases.sh + a shared manifest) was built and dropped as more machinery than the problem warrants: it could not verify a tie-break from code anyway, only that a rule had been written down somewhere. The risk stays with review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tian-lan-landing
added a commit
that referenced
this pull request
Sep 9, 2026
…e aliases check-v2-paths.sh scopes client.v2 to the spec's /v2 routes. The wiring prompt scoped routes too, and said nothing about which FIELDS back a /v2 request — which is how the parse password went wrong: the spec has declared it at options.password and nowhere else since 2026-07-16, the same spec still declares a top-level `password` on /v1/ade/parse*, and both SDKs ended up with a hand-written top-level shorthand whose tie-break was written down nowhere. They picked opposite ones, so one call sent a different password per language (#160, landing-ai/ade-typescript#121). - the prompt's SCOPE block now scopes fields as well as routes, names both traps (a nested spec field is not a top-level one; a /v1 top-level field is not a /v2 field), and tells the AI pass to leave a new convenience alias to a maintainer rather than invent one. - CONTRIBUTING records the two aliases that exist (`password` -> options.password, `strict` -> options.strict), the precedence rule for each, the language-specific traps in both SDKs, and — plainly — that nothing in CI checks any of it, so a PR touching a non-spec top-level param has to be diffed against the other repo by hand. A mechanical gate for this (check-v2-aliases.sh + a shared manifest) was built and dropped as more machinery than the problem warrants: it could not verify a tie-break from code anyway, only that a rule had been written down somewhere. The risk stays with review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tian-lan-landing
added a commit
that referenced
this pull request
Sep 9, 2026
…e aliases (#161) check-v2-paths.sh scopes client.v2 to the spec's /v2 routes. The wiring prompt scoped routes too, and said nothing about which FIELDS back a /v2 request — which is how the parse password went wrong: the spec has declared it at options.password and nowhere else since 2026-07-16, the same spec still declares a top-level `password` on /v1/ade/parse*, and both SDKs ended up with a hand-written top-level shorthand whose tie-break was written down nowhere. They picked opposite ones, so one call sent a different password per language (#160, landing-ai/ade-typescript#121). - the prompt's SCOPE block now scopes fields as well as routes, names both traps (a nested spec field is not a top-level one; a /v1 top-level field is not a /v2 field), and tells the AI pass to leave a new convenience alias to a maintainer rather than invent one. - CONTRIBUTING records the two aliases that exist (`password` -> options.password, `strict` -> options.strict), the precedence rule for each, the language-specific traps in both SDKs, and — plainly — that nothing in CI checks any of it, so a PR touching a non-spec top-level param has to be diffed against the other repo by hand. A mechanical gate for this (check-v2-aliases.sh + a shared manifest) was built and dropped as more machinery than the problem warrants: it could not verify a tie-break from code anyway, only that a rule had been written down somewhere. The risk stays with review. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
tian-lan-landing
added a commit
to landing-ai/ade-typescript
that referenced
this pull request
Sep 9, 2026
…e aliases (#122) check-v2-paths.sh scopes client.v2 to the spec's /v2 routes. The wiring prompt scoped routes too, and said nothing about which FIELDS back a /v2 request — which is how the parse password went wrong: the spec has declared it at options.password and nowhere else since 2026-07-16, the same spec still declares a top-level `password` on /v1/ade/parse*, and both SDKs ended up with a hand-written top-level shorthand whose tie-break was written down nowhere. They picked opposite ones, so one call sent a different password per language (#121, landing-ai/ade-python#160). - the prompt's SCOPE block now scopes fields as well as routes, names both traps (a nested spec field is not a top-level one; a /v1 top-level field is not a /v2 field), and tells the AI pass to leave a new convenience alias to a maintainer rather than invent one. - CONTRIBUTING records the two aliases that exist (`password` -> options.password, `strict` -> options.strict), the precedence rule for each, the language-specific traps in both SDKs, and — plainly — that nothing in CI checks any of it, so a PR touching a non-spec top-level param has to be diffed against the other repo by hand. A mechanical gate for this (check-v2-aliases.sh + a shared manifest) was built and dropped as more machinery than the problem warrants: it could not verify a tie-break from code anyway, only that a rule had been written down somewhere. The risk stays with review. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
passwordis shorthand for the contract fieldoptions["password"]. This SDK already let the explicit field win; ade-typescript let the shorthand win — same call, different password, depending on the language. Aligned on this SDK's rule; ade-typescript moves to match in landing-ai/ade-typescript#121.9c3883e— docs + tests, no behavior change. Pins the rule on the two paths that had no conflict coverage (optionsas a JSON string, andparse_jobs.create), and documentspasswordin the README, which had no encrypted-PDF section at all.ba6e807— one real bug found while doing that.dict(json.loads(...))accepts any pair-sequence, so a JSON array silently became the options dict:The smuggled key then beat the caller's own
password— the exact silent substitution the precedence rule exists to prevent.optionsnow goes through_coerce_options, mirroringcoerce_schema_to_dict.Fixes:
🤖 Generated with Claude Code