Skip to content

feat(toolkit)!: bind $top/$skiptoken, reject other $ options - #4422

Merged
MikeFalcon77 merged 1 commit into
constructorfabric:mainfrom
capybutler:fix/odata-top-alias
Aug 11, 2026
Merged

feat(toolkit)!: bind $top/$skiptoken, reject other $ options#4422
MikeFalcon77 merged 1 commit into
constructorfabric:mainfrom
capybutler:fix/odata-top-alias

Conversation

@capybutler

@capybutler capybutler commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

$top is canonical OData (OASIS OData 4.01 Part 2: URL Conventions, §5.1.6 "System Query Options
$top and $skip"), and this repo already documented it, tested it, and named it in error payloads.
Nothing bound it. The wire binding for the whole OData family lives in exactly one place,
toolkit::api::odata::ODataParams, which declared an unprefixed limit with no $top field and no
alias — and toolkit-odata does no HTTP query parsing at all. So ?$top=<n> was accepted and
discarded on every endpoint using the OData extractor, and limit was the only page-size
parameter that actually worked.

This binds $top as #[serde(alias = "$top")] on ODataParams.limit: one slot, two accepted
spellings. limit keeps working for every current caller, the platform matches canonical OData, and
the $top field violation already emitted by toolkit-odata::problem_mapping becomes truthful
without a rename.

Binding $top alone would have made the $ namespace worse, not better — raised in review, and
the second half of this PR. ODataParams claims every $-prefixed query key by convention but
bound four of them, and axum drops query keys no extractor claimed. A client paginating the
canonical way sends $top with $skip: before, both were dropped and the wrong page size was an
immediate tell; with $top bound and $skip still dropped, the page size is honored, the offset
silently is not, and the client re-reads page one while believing it advanced.

OData does not leave this to taste. Part 1: Protocol, §6.1 "Query Option Extensibility":

OData services SHOULD NOT require any query options to be specified in a request. Services SHOULD
fail any request that contains query options that they do not understand and MUST fail any request
that contains unsupported OData query options defined in the version of this specification
supported by the service.

$skip and $count are OData-defined and unsupported here, so refusing them is a MUST; a typo
like $filtre is the SHOULD case. The same section reserves the $ and @ prefixes for OData,
which is why this guard polices $ keys only: an unprefixed key belongs to the handler's own params
struct (chat-engine's q, file-storage's offset), and refusing those stays each gear's business
(account-management has reject_non_odata_params).

Changes

  • libs/toolkit/src/api/odata.rs
    • #[serde(alias = "$top")] on ODataParams.limit, plus rustdoc on the struct stating that this
      is the single wire-binding point for OData query parameters.
    • #[serde(alias = "$skiptoken")] on ODataParams.cursor — the same one-slot-two-spellings shape
      as $top. $skiptoken is OData's opaque continuation token for server-driven paging, which is
      what PageInfo.next_cursor already is, and resource-group's own docs advertise the spelling.
    • ACCEPTED_SYSTEM_QUERY_OPTIONS ($filter, $orderby, $select, $top, $skiptoken) and a
      guard that answers 400 for every other $-prefixed key, before any value parsing. One
      UNSUPPORTED_QUERY_PARAM field violation per offending key, deduplicated, in wire order, so one
      round trip names every mistake.
    • The violation description separates the three cases a caller can hit: $skip / $count /
      $format are OData-defined but unsupported and each says what to send instead; $Top is a
      supported option in a spelling that does not bind, and names the one that does; $filtre is not
      an OData option at all and reads as unknown query option, not unsupported.
  • libs/toolkit-odata/src/problem_mapping.rs — the InvalidLimit violation keeps $top as the
    field name and now describes both spellings: "Invalid page size parameter ($top, alias limit)".
  • libs/toolkit-odata/src/limits.rs — docs only. ODataLimits::max_top was documented as an
    enforced "maximum $top value (default 1000)"; nothing in the repo constructs ODataLimits, so
    nothing enforced it. The module docs now say it is an opt-in validator and point at what actually
    caps each request. Deleting the type was the alternative and was rejected: it is a pub
    re-export, so removal breaks downstream compilation for no gain.
  • docs/toolkit_unified_system/07_odata_pagination_select_filter.md — the page-size section (both
    spellings, the ambiguity rule, $top=0, and the note that upper-bound capping is the handler's
    job), a continuation-token section, and an Unsupported system query options section: the
    accepted set, the refusal table, the §6.1 quote behind it, and the two boundaries it implies —
    unprefixed keys are out of scope, and case-sensitivity is a stated deviation from Part 2 §5.1
    (which wants option names accepted case-insensitively and with or without $) rather than an
    oversight. Also corrects that file's error table, which listed HTTP 422 for every OData error
    variant: all of them except Db map through OdataError::invalid_argument(), which renders as
    400, and the canonical error taxonomy has no 422 status at all — so no OData error could ever
    have produced one. That is a pre-existing documentation error independent of $top; five of the
    six corrected rows concern $filter, $orderby, and cursors. Fixed here (raised in review)
    because the same table names InvalidLimit.
  • gears/system/account-management/.../handlers/common.rsreject_non_odata_params claimed
    $-prefixed keys were "out of scope: the OData extractor parses them and rejects unknown ones
    ($filtre etc.)". That was false when written — it is exactly the hole this PR closes — and the
    gear built its non-$ guard on top of the assumption. The comment now points at
    ACCEPTED_SYSTEM_QUERY_OPTIONS, and its test no longer pins $skip / $count as keys AM accepts.

Why $skip is refused rather than implemented. Offset paging is not this platform's model:
ODataQuery carries no offset field and paginate_odata is keyset-only, so honoring $skip would
mean adding a pagination mode, not binding a parameter. $count has no truthful answer either —
PageInfo is {next_cursor, prev_cursor, limit}, and serving a total would mean the COUNT(*) per
page that keyset pagination exists to avoid.

Out of scope. Toolkit crates plus the one account-management comment this PR falsifies. The
gear-side items from #4413 — the vacuous $top clamp test in account-management, and the
chat-engine SearchQuery doc comment that credits toolkit-odata for its own serde renames — are
left to a separate change. Accepting $Top case-insensitively and the unprefixed canonical spellings
(top, filter) that Part 2 §5.1 asks for is a separate conformance change; this PR answers 400
naming the spelling that binds instead of dropping the request.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Breaking change details

Two wire-behavior changes, both on every endpoint using the OData extractor (ledger, chat-engine,
mini-chat, account-management, resource-group, usage-collector).

1. ?$top=<n> now sets the page size instead of being silently ignored.

  • A caller that sent $top and received the endpoint default now receives the page size it asked
    for.
  • Gears that reject an out-of-range page size rather than clamping now answer 400 where they
    answered 200. Concretely: usage-collector rejects $top > 1000
    (gears/system/usage-collector/.../usage_records.rs) — that branch was unreachable from the wire
    before and is reachable now.
  • Sending $top and limit in one request is ambiguous and answers 400 INVALID_QUERY_PARAMS
    (serde reports the second spelling as a duplicate field).

2. Unsupported $-prefixed keys now answer 400 where they previously answered 200 with the
key discarded. Anything outside $filter, $orderby, $select, $top, $skiptoken: $skip,
$count, $expand, $search, $format, $compute, $index, $schemaversion, miscased
spellings such as $Top, and typos such as $filtre. The response is
400 UNSUPPORTED_QUERY_PARAM, one violation per offending key, each naming the key and what to
send instead.

Migration:

  • Callers using limit and cursor — none.
  • Callers that sent $top expecting it to be ignored must stop sending it, or send a value within
    the endpoint's cap. Do not send both spellings in one request.
  • Callers that sent $skip must drop it and page with the previous response's
    page_info.next_cursor, sent as $skiptoken or cursor.
  • Callers that sent any other $ option must drop it.

Not affected: unprefixed query parameters of any name, and chat-engine's /search, which binds its
own $top / $skip in SearchQuery outside this extractor and is unchanged. That leaves $skip
working on /search and refused elsewhere — a platform inconsistency this PR documents but does not
resolve.

Version bump justification

Required by CONTRIBUTING §Enforcement. No Cargo.toml versions are touched in this PR —
release-plz derives them from the conventional commit (feat(toolkit)!: + BREAKING CHANGE:
footer).

Crate Current Expected Why
cf-gears-toolkit 0.6.18 0.7.0 Breaking wire-behavior change (two of them), pre-1.0 → bump 0.(x+1).0 per the CONTRIBUTING table. No Rust-API break: the ODataParams field set is unchanged (serde aliases only) and ACCEPTED_SYSTEM_QUERY_OPTIONS is additive, so semver_check has nothing to flag and downstream compilation is unaffected.
cf-gears-toolkit-odata 0.8.4 0.8.5 (PATCH) Error-description string and rustdoc only. No public API or type change; ODataLimits intentionally kept.
cf-gears-account-management 0.5.0 0.5.1 (PATCH) One rustdoc comment corrected and one unit test's key set updated. No API, behavior, or wire change of its own. Flagged because release-plz keys off the commit, whose ! marker describes the toolkit wire contract — if it proposes a breaking bump for AM, that is the marker leaking, not a real break.

Testing

  • Unit tests pass
    • cargo test -p cf-gears-toolkit --lib odata → 37 passed, 0 failed
    • cargo test -p cf-gears-toolkit --lib → 261 passed, 0 failed
    • cargo test -p cf-gears-toolkit-odata --lib → 111 passed, 0 failed
    • Blast radius, gears using the extractor: cf-gears-account-management → 800 passed,
      cf-gears-usage-collector → 368 passed
  • Integration tests pass — not run locally (AM's tests/api_*.rs need Postgres). All six
    extractor gears were grepped for requests carrying a now-rejected option; there are none, so
    no integration expectation flips. Left to CI.
  • Manual testing completed — behavior is covered by extractor-level tests that build real
    axum requests, so no manual pass was made.
  • New tests added for new functionality

Twelve tests in libs/toolkit/src/api/odata_tests.rs, each watched fail before the code existed:

Test Asserts
test_extract_odata_query_top_binds_limit ?$top=10 reaches ODataQuery.limit
test_extract_odata_query_top_zero_error ?$top=0400, field_violations[0].field == "$top", code INVALID_LIMIT
test_extract_odata_query_top_and_limit_conflict ?$top=20&limit=50400 INVALID_QUERY_PARAMS
test_odata_extractor_top_alias End-to-end through the OData extractor with $filter + $top
test_extract_odata_query_skiptoken_binds_cursor ?$skiptoken=<encoded CursorV1> reaches ODataQuery.cursor
test_extract_odata_query_skip_rejected ?$skip=20400, field $skip, reason UNSUPPORTED_QUERY_PARAM, detail names $skiptoken
test_extract_odata_query_count_rejected ?$count=true400, field $count, detail says a page carries no total
test_extract_odata_query_misspelled_option_rejected ?$filtre=…400, described as unknown, not unsupported
test_extract_odata_query_miscased_option_rejected ?$Top=10400, detail names the spelling that binds
test_extract_odata_query_reports_every_unsupported_option ?$skip&$count&$expand → three violations, in wire order
test_extract_odata_query_repeated_unsupported_option_reported_once ?$skip=20&$skip=40 → one violation, not two
test_extract_odata_query_non_odata_params_pass_through ?q=needle&context=2&limit=5200; the guard must not police the non-$ namespace

The last one is the regression guard that matters most: it pins that handlers sharing the query
string with the extractor (chat-engine's q / context, file-storage's offset) keep working.

Documentation

  • Code is documented with rustdoc comments — ODataParams (wire-binding role),
    ODataParams.limit and .cursor (both spellings each, ambiguity rule),
    ACCEPTED_SYSTEM_QUERY_OPTIONS and ODATA_SYSTEM_QUERY_OPTIONS (why a $ key is refused as
    unsupported vs unknown), the guard itself (§6.1, and what a silent drop looks like to a
    caller), ODataLimits / max_top / validate_top (opt-in, not automatic), and the
    InvalidLimit arm in problem_mapping.rs
  • README updated (if applicable) — not applicable
  • API documentation updated (if applicable) —
    docs/toolkit_unified_system/07_odata_pagination_select_filter.md: page size, continuation
    token, unsupported system query options, plus the error-table status correction (422400)
    described under Changes. The correction changes no behavior and no crate version — the file
    lives in docs/, outside both crates.
    Note for gear owners: OpenAPI cannot express one parameter under two names — publish the
    spelling your gear treats as canonical and mention the other in its description. A gear that
    wants unprefixed accidents like ?status=approved refused rather than ignored needs its own
    guard; the extractor only polices the $ namespace.

Checklist

  • Code follows project style guidelines
  • Self-review completed
  • No linting errors (cargo clippy -p cf-gears-toolkit -p cf-gears-toolkit-odata -p cf-gears-account-management --all-targets → clean)
  • Code is properly formatted (cargo fmt -p cf-gears-toolkit -p cf-gears-toolkit-odata -p cf-gears-account-management -- --check → clean)
  • Tests pass (see Testing)

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • OData pagination now accepts $top alongside limit.
    • Continuation requests can use $skiptoken alongside cursor.
    • Duplicate aliases are rejected, and invalid page sizes return clear validation errors.
    • Unknown, unsupported, repeated, or incorrectly cased OData system options are rejected with HTTP 400 responses.
  • Documentation

    • Expanded guidance on pagination aliases, supported query options, validation behavior, and error responses.
    • Clarified that non-OData query parameters continue to pass through.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

OData extraction now accepts $top/limit and $skiptoken/cursor aliases. It rejects unsupported or unknown $-prefixed options with ordered, deduplicated HTTP 400 violations. Documentation and endpoint tests reflect the updated behavior.

Changes

OData query validation

Layer / File(s) Summary
Query binding and option rejection
libs/toolkit/src/api/odata.rs
ODataParams accepts pagination aliases. The extractor validates raw query pairs before deserialization and rejects unsupported or unknown $-prefixed options.
Query behavior coverage
libs/toolkit/src/api/odata_tests.rs
Tests cover aliases, duplicate and zero-value inputs, unsupported and incorrectly cased options, wire-order reporting, deduplication, and unprefixed parameters.
Error and limit documentation
libs/toolkit-odata/src/limits.rs, libs/toolkit-odata/src/problem_mapping.rs, libs/toolkit-odata/src/lib.rs
Documentation describes $top/limit page-size validation and the related HTTP field violation.
Endpoint and guide alignment
docs/toolkit_unified_system/07_odata_pagination_select_filter.md, gears/system/account-management/account-management/src/api/rest/handlers/common.rs, gears/system/account-management/account-management/src/api/rest/handlers/common_tests.rs
Documentation and endpoint tests describe accepted options and extractor rejection behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Request as Raw query request
  participant Extractor as OData extractor
  participant Params as ODataParams
  Request->>Extractor: Provide raw query pairs
  Extractor->>Extractor: Reject unsupported dollar-prefixed options
  Extractor->>Params: Deserialize accepted aliases
  Params-->>Extractor: Return bound OData parameters
Loading

Possibly related PRs

Suggested reviewers: mikefalcon77, diffora

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: binding $top and $skiptoken and rejecting other $-prefixed options.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@code-ranker-app

code-ranker-app Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

code-ranker report for this PR (built on fork): https://reports.code-ranker.com/HFjerQ5owagxuTPLMc9q2w/

@capybutler
capybutler force-pushed the fix/odata-top-alias branch from ddabffe to 6a74bb6 Compare August 5, 2026 14:29
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.36364% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
libs/toolkit/src/api/odata.rs 96.29% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@capybutler
capybutler marked this pull request as ready for review August 6, 2026 04:33

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
libs/toolkit/src/api/odata_tests.rs (1)

267-295: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the complete error contract in both tests.

The $top=0 test checks the status and field, but not the INVALID_LIMIT reason. The conflict test checks the reason, but its comment promises the query field without asserting it.

Suggested assertions
         assert_eq!(
             violations[0].get("field").and_then(|f| f.as_str()),
             Some("$top"),
             "violation was {:?}",
             violations[0]
         );
+        assert_eq!(
+            violations[0].get("reason").and_then(|r| r.as_str()),
+            Some("INVALID_LIMIT"),
+            "violation was {:?}",
+            violations[0]
+        );

         assert_eq!(
             violations[0].get("reason").and_then(|r| r.as_str()),
             Some("INVALID_QUERY_PARAMS"),
             "violation was {:?}",
             violations[0]
         );
+        assert_eq!(
+            violations[0].get("field").and_then(|f| f.as_str()),
+            Some("query"),
+            "violation was {:?}",
+            violations[0]
+        );

Also applies to: 297-327

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/toolkit/src/api/odata_tests.rs` around lines 267 - 295, Complete the
error-contract assertions in test_extract_odata_query_top_zero_error and the
related conflict test: verify the $top=0 violation includes the INVALID_LIMIT
reason, and assert the conflict violation contains the promised query field.
Preserve the existing status, field, and conflict assertions while validating
these additional fields in the parsed field_violations entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/toolkit_unified_system/07_odata_pagination_select_filter.md`:
- Around line 377-379: Update the InvalidLimit entry in the error table to
document the current $top=0 behavior: HTTP 400 with field_violations[0].field
set to $top, replacing the stale HTTP 422 status and description. Keep the table
consistent with the pagination guidance near the $top validation section.

In `@libs/toolkit/src/api/odata.rs`:
- Around line 25-34: Add endpoint-specific maximum page-size validation in
ODataParams before calling ODataQuery::with_limit(limit), applying the same cap
to both limit and $top aliases through ODataQuery.limit. Add an integration test
covering values above the endpoint limit for both parameter spellings and verify
the expected rejection or capped behavior.

---

Nitpick comments:
In `@libs/toolkit/src/api/odata_tests.rs`:
- Around line 267-295: Complete the error-contract assertions in
test_extract_odata_query_top_zero_error and the related conflict test: verify
the $top=0 violation includes the INVALID_LIMIT reason, and assert the conflict
violation contains the promised query field. Preserve the existing status,
field, and conflict assertions while validating these additional fields in the
parsed field_violations entries.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ef85d23a-cbc1-4227-97e6-1874211012c9

📥 Commits

Reviewing files that changed from the base of the PR and between fef39f1 and 6a74bb6.

📒 Files selected for processing (6)
  • docs/toolkit_unified_system/07_odata_pagination_select_filter.md
  • libs/toolkit-odata/src/lib.rs
  • libs/toolkit-odata/src/limits.rs
  • libs/toolkit-odata/src/problem_mapping.rs
  • libs/toolkit/src/api/odata.rs
  • libs/toolkit/src/api/odata_tests.rs

Comment thread docs/toolkit_unified_system/07_odata_pagination_select_filter.md
Comment thread libs/toolkit/src/api/odata.rs
@capybutler
capybutler force-pushed the fix/odata-top-alias branch 2 times, most recently from d77d021 to c19ea5d Compare August 6, 2026 10:26

- OpenAPI cannot express one parameter under two names. Publish the spelling
your gear treats as canonical and mention the other in its `description`.
- `$top=0` is rejected (`InvalidLimit` → `400`, `field_violations[0].field` is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This section needs a third bullet about $skip, and probably a code change too.

OData clients send $top and $skip as a pair. Before this PR both were dropped, so a client that tried to paginate got the same page every time and noticed on request two. After this PR $top works and $skip is still dropped, so the page size changes, the offset never does, and the client gets page one forever while believing it is paging through the set. That is harder to debug than the old behavior, not easier.

Two options:

Reject $skip with 400 InvalidArgument, field $skip, detail pointing at cursor. Costs one more field on ODataParams and one check in the extractor. A client that sends it finds out immediately.

Document it here: state that $skip is not supported, that offset paging is not the model, and that cursor is the replacement.
I would do both. Option 2 alone still leaves the silent-drop behavior in place for anyone who doesn't read the docs, and the whole point of binding $top is that clients now expect canonical OData paging to work.

Same question applies to $count, though it's less urgent — it has no working equivalent under another name, so nobody is half-served by it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both, and the reject generalized past $skip / $count.

ODataParams claimed the whole $ namespace but bound four options, so every other $ key was accepted and discarded — not only $skip and $count, but $expand, $search, $format, miscased $Top, and typos like $filtre (a dropped $filtre returns the unfiltered collection with a 200). A dedicated $skip field would have closed two holes out of a class. extract_odata_query now refuses any $ key outside ACCEPTED_SYSTEM_QUERY_OPTIONS$filter, $orderby, $select, $top, $skiptoken — before any value parsing: 400, one UNSUPPORTED_QUERY_PARAM violation per offending key, deduplicated, in wire order, so one round trip names every mistake.

The spec makes this obligatory rather than a preference, which is worth having in the PR — OASIS OData 4.01 Part 1: Protocol, §6.1 "Query Option Extensibility":

OData services SHOULD NOT require any query options to be specified in a request. Services SHOULD fail any request that contains query options that they do not understand and MUST fail any request that contains unsupported OData query options defined in the version of this specification supported by the service.

$skip and $count are OData-defined and unsupported here, so refusing them is the MUST; $filtre is the SHOULD case. §6.1 also reserves the $ and @ prefixes for OData, and that is what keeps this guard off the unprefixed namespace: q, context, offset belong to the handler's own params struct, with a test pinning that they still pass. Refusing unprefixed accidents like ?status=approved stays each gear's business — AM's reject_non_odata_params.

One correction to the framing, which does not change the conclusion. The observable symptom is the same before and after: identical rows on request two. What binding $top alone changed is the signal — pre-PR a wrong page size was an immediate tell that the server ignored OData, and post-PR the page size looks right, so the repeated page is the only tell left. Worse signal, not a new failure mode.

$skip is refused rather than implemented because offset paging is not the model at any layer: ODataQuery carries no offset field and paginate_odata is keyset-only, so honoring it means adding a pagination mode, not binding a parameter. Its violation detail points at $skiptoken / cursor.

On $count: same treatment, and not planned. PageInfo is {next_cursor, prev_cursor, limit}, so there is no total to report, and adding one means the COUNT(*) per page that keyset pagination exists to avoid. The violation detail says exactly that.

Two things that came out of this and are worth flagging:

  • $skiptoken is now a cursor alias, same one-slot-two-spellings shape as $top. It is OData's opaque continuation token for server-driven paging, which is what PageInfo.next_cursor already is, and resource-group's own docs advertise the spelling — without the alias the new catch-all would have started 400ing it.
  • AM's comment was already wrong about this. reject_non_odata_params documented that "$-prefixed keys are intentionally out of scope: the OData extractor parses them and rejects unknown ones ($filtre etc.)". That was false when written, and the gear built its non-$ guard on top of the assumption; its test pinned $skip / $count as keys AM accepts. Both now match the extractor.

Two decisions I would rather you weigh in on than settle myself:

  1. Binding stays case-sensitive. Part 2 §5.1 says a 4.01 service MUST accept system query option names case-insensitively and with or without the $ prefix — so strictly, $Top and an unprefixed top should both bind. Neither does; both now answer 400 naming the spelling that binds, which is at least not a silent drop. Full conformance (normalize casing, accept top / filter / orderby / select) is a larger change and is documented as a separate one. Reasonable line to draw?
  2. Cursor violations are still keyed to cursor, not $skiptoken, while page-size violations are keyed to the canonical $top with both spellings in the description. Renaming the cursor key would change error payloads for every existing cursor caller with no functional gain, so I left the asymmetry. Say if you would rather it match $top's treatment.

Also worth knowing: chat-engine's /search binds its own $top / $skip in SearchQuery outside this extractor, so $skip keeps working there and answers 400 everywhere else. The PR documents that inconsistency without resolving it.

Twelve extractor tests cover this (each watched fail first), including one that pins the non-$ namespace as untouched. The third bullet you asked for plus a continuation-token section and an "Unsupported system query options" section — accepted set, refusal table, the §6.1 quote, and the two boundaries above — are in docs/toolkit_unified_system/07_odata_pagination_select_filter.md.

`$top` is canonical OData (OASIS OData 4.01 Part 2: URL Conventions,
§5.1.6 "System Query Options $top and $skip"), and this repo already
documented it, tested it, and named it in error payloads. Nothing bound
it. The wire binding for the OData family lives in exactly one place,
`toolkit::api::odata::ODataParams`, which declared an unprefixed `limit`
with no `$top` field and no alias; `toolkit-odata` does no HTTP query
parsing at all. So `?$top=<n>` was accepted and discarded on every
endpoint using the `OData` extractor, and `limit` was the only page-size
parameter that worked.

Bind it as `#[serde(alias = "$top")]` on `ODataParams.limit` — one slot,
two accepted spellings. This is the alias option from constructorfabric#4413: `limit`
keeps working for every current caller, the platform matches canonical
OData, and the `$top` field violation in `problem_mapping.rs` becomes
truthful without a rename.

Binding `$top` alone would have made the `$` namespace worse rather than
better, which is the second half of this change. `ODataParams` claims
every `$`-prefixed key by convention but bound four of them, and axum
drops query keys no extractor claimed. A client paginating the canonical
way sends `$top` with `$skip`: before, both were dropped and the wrong
page size was an immediate tell; with `$top` bound and `$skip` still
dropped, the page size is honored, the offset silently is not, and the
client re-reads page one while believing it advanced.

OData does not leave this to taste. Part 1: Protocol, §6.1 "Query Option
Extensibility": services "SHOULD fail any request that contains query
options that they do not understand and MUST fail any request that
contains unsupported OData query options defined in the version of this
specification supported by the service". The same section reserves the
`$` and `@` prefixes for OData, which is why this guard polices `$` keys
only — an unprefixed key belongs to the handler's own params struct
(chat-engine's `q`, file-storage's `offset`), and refusing those stays
each gear's business (account-management has `reject_non_odata_params`).

The extractor now binds five options and refuses every other `$` key
with 400, before parsing any value:

* `$filter`, `$orderby`, `$select`, `$top`, `$skiptoken`, listed in
  `ACCEPTED_SYSTEM_QUERY_OPTIONS`.
* `$skiptoken` is a second alias in the same shape as `$top`, folding
  onto `ODataParams.cursor`. It is OData's opaque continuation token for
  server-driven paging, which is what `PageInfo.next_cursor` already is,
  and resource-group's own docs advertise the spelling.
* One `UNSUPPORTED_QUERY_PARAM` field violation per offending key,
  deduplicated, in wire order, so one round trip names every mistake.
* The description separates the three cases a caller can hit:
  `$skip` / `$count` / `$format` are OData-defined but unsupported and
  each says what to send instead; `$Top` is a supported option in a
  spelling that does not bind, and names the one that does; `$filtre` is
  not an OData option at all and reads as unknown, not unsupported.

`$skip` is refused rather than implemented because offset paging is not
this platform's model: `ODataQuery` carries no offset field and
`paginate_odata` is keyset-only, so honoring it would mean a new
pagination mode rather than a bound parameter. `$count` has no truthful
answer either — `PageInfo` is `{next_cursor, prev_cursor, limit}`, and
serving a total would mean the `COUNT(*)` per page that keyset
pagination exists to avoid.

Binding stays case-sensitive, which Part 2 §5.1 asks a 4.01 service not
to do: it wants option names accepted case-insensitively and with or
without the `$` prefix. Neither `$Top` nor an unprefixed `top` binds
here, and both now answer 400 naming the spelling that does instead of
being dropped. Closing that gap is a separate conformance change and is
documented as one.

Docs in the same crates stopped describing things as they were not:

* `toolkit-odata::limits` documented `ODataLimits::max_top` as an
  enforced "maximum $top value (default 1000)". Nothing in the repo
  constructs `ODataLimits`, so nothing enforced it. The module docs now
  state that it is an opt-in validator and point at what does cap each
  request. Deleting the type was the alternative and was rejected: it is
  a `pub` re-export, so removal would break downstream compilation for
  no gain.
* The toolkit OData guide documents both page-size spellings, the
  ambiguity rule, the continuation token, and the refused `$` options
  with the §6.1 requirement behind them.
* `account-management`'s `reject_non_odata_params` claimed `$`-prefixed
  keys were "out of scope: the `OData` extractor parses them and rejects
  unknown ones (`$filtre` etc.)". That was false when written — it is
  the hole this change closes — and its test pinned `$skip` / `$count`
  as keys AM accepts. Both now match the extractor.

Also corrects that guide's error table, which listed HTTP 422 for every
`OData` error variant. All of them except `Db` map through
`OdataError::invalid_argument()`, which renders as 400, and the canonical
error taxonomy has no 422 status at all — so no `OData` error could ever
have produced one. This is a pre-existing documentation error independent
of `$top`; five of the six corrected rows concern `$filter`, `$orderby`,
and cursors. Fixed here because the same table names `InvalidLimit`.

Scope is the toolkit crates plus the one account-management comment this
change falsifies. The gear-side items in constructorfabric#4413 — the vacuous `$top`
clamp test in account-management and the chat-engine `SearchQuery` doc
comment that credits `toolkit-odata` for its own serde renames — are
left to a separate change.

BREAKING CHANGE: two wire-behavior changes on every endpoint using the
`OData` extractor.

`?$top=<n>` now sets the page size instead of being silently ignored. A
caller that sent `$top` and got the endpoint default now gets the page
size it asked for; gears that reject an out-of-range page size rather
than clamping (usage-collector, `$top > 1000`) now answer 400 where they
answered 200. Sending `$top` and `limit` in one request is ambiguous and
answers 400 `INVALID_QUERY_PARAMS`.

Any `$`-prefixed query key outside `$filter`, `$orderby`, `$select`,
`$top` and `$skiptoken` now answers 400 `UNSUPPORTED_QUERY_PARAM` where
it previously answered 200 with the key discarded: `$skip`, `$count`,
`$expand`, `$search`, `$format`, miscased spellings such as `$Top`, and
typos such as `$filtre`. Callers must drop the parameter, or replace
`$skip` with the previous page's `next_cursor` sent as `$skiptoken` or
`cursor`. Unprefixed parameters are unaffected, and chat-engine's
`/search` binds its own `$top` / `$skip` outside this extractor and is
unchanged.

Refs constructorfabric#4413

Signed-off-by: capybutler <capybutler@gmail.com>
@capybutler
capybutler force-pushed the fix/odata-top-alias branch from c19ea5d to 25f6a71 Compare August 10, 2026 02:16
@capybutler capybutler changed the title feat(toolkit)!: bind $top as an alias of limit feat(toolkit)!: bind $top/$skiptoken, reject other $ options Aug 10, 2026
@MikeFalcon77
MikeFalcon77 merged commit 69f65a7 into constructorfabric:main Aug 11, 2026
30 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 11, 2026
capybutler added a commit to capybutler/gears-rust that referenced this pull request Aug 11, 2026
…tered routes

The gear published `docs/usage-collector-v1.yaml` by hand, so the document and
the code could drift apart without anything failing.

A new in-tree test builds the route registry from the `OperationBuilder`
registrars. It needs no server, no database, and no `Service`. The test fails
when the document disagrees with the code. It compares the set of operations,
methods, operation ids, tags, parameters, request and response bodies, status
codes, the canonical error surface, and the authentication posture. It also
checks that every published component is documented and that every `$ref`
resolves. Descriptions, summaries, and examples stay under manual control, and
the module header states which fields the test enforces. The test reads the
document with `serde-saphyr`, which is already in the lockfile, so no new crate
enters the tree.

The test proved that the document was wrong. Schema names now match the code
(`CreateUsageRecords*`, `QueryAggregatedUsageRecordsRequest`, `Page_UsageType`).
The document now lists the `metadata.<key>`, `$filter`, `$orderby`, and
`$select` parameters on the routes that accept them. The `MetadataFilter` schema
is gone, because no route takes that body.

Page size is published under both spellings the endpoint accepts. constructorfabric#4422 bound
`$top` — canonical `OData` (OASIS `OData` 4.01 Part 2, section 5.1.6) — as
`#[serde(alias = "$top")]` over `ODataParams.limit`, so the extractor folds
`$top` and `limit` onto one `ODataQuery.limit` slot and rejects a request
carrying both as a duplicate field. The document and the `OperationBuilder`
registry therefore declare both: publishing either alone would under-report the
accepted surface, which is the drift class this test exists to catch. The list
allowlist keeps admitting both spellings, the aggregate allowlist keeps
rejecting both (aggregation is not paginated), and the `MAX_PAGE_SIZE` violation
names `$top` with the alias in its detail — the parsed `ODataQuery` does not
carry the spelling the caller sent, and this is the convention `toolkit_odata`
already uses for `InvalidLimit`.

The TimescaleDB plugin's `effective_page_size` documented its floor-to-1 as a
guard against a `$top=0` "the core gateway passes through unclamped". The
gateway does not: the toolkit `OData` extractor rejects a zero page size with
`InvalidLimit` before any handler runs. The floor still earns its place — an
in-process SDK caller builds its own `ODataQuery` and reaches neither that check
nor `prepare_list_query` — so the docs and tests now state the reason that
holds.

`DECOMPOSITION.md`, `DESIGN.md`, `domain-model.md`, `ADR-0012`, and the
usage-emission and usage-query features get the same corrections.

Signed-off-by: capybutler <capybutler@gmail.com>
capybutler added a commit to capybutler/gears-rust that referenced this pull request Aug 11, 2026
…tered routes

The gear published `docs/usage-collector-v1.yaml` by hand, so the document and
the code could drift apart without anything failing.

A new in-tree test builds the route registry from the `OperationBuilder`
registrars. It needs no server, no database, and no `Service`. The test fails
when the document disagrees with the code. It compares the set of operations,
methods, operation ids, tags, parameters, request and response bodies, status
codes, the canonical error surface, and the authentication posture. It also
checks that every published component is documented and that every `$ref`
resolves. Descriptions, summaries, and examples stay under manual control, and
the module header states which fields the test enforces. The test reads the
document with `serde-saphyr`, which is already in the lockfile, so no new crate
enters the tree.

The test proved that the document was wrong. Schema names now match the code
(`CreateUsageRecords*`, `QueryAggregatedUsageRecordsRequest`, `Page_UsageType`).
The document now lists the `metadata.<key>`, `$filter`, `$orderby`, and
`$select` parameters on the routes that accept them. The `MetadataFilter` schema
is gone, because no route takes that body.

Page size is published under both spellings the endpoint accepts. constructorfabric#4422 bound
`$top` — canonical `OData` (OASIS `OData` 4.01 Part 2, section 5.1.6) — as
`#[serde(alias = "$top")]` over `ODataParams.limit`, so the extractor folds
`$top` and `limit` onto one `ODataQuery.limit` slot and rejects a request
carrying both as a duplicate field. The document and the `OperationBuilder`
registry therefore declare both: publishing either alone would under-report the
accepted surface, which is the drift class this test exists to catch. The list
allowlist keeps admitting both spellings, the aggregate allowlist keeps
rejecting both (aggregation is not paginated), and the `MAX_PAGE_SIZE` violation
names `$top` with the alias in its detail — the parsed `ODataQuery` does not
carry the spelling the caller sent, and this is the convention `toolkit_odata`
already uses for `InvalidLimit`.

The TimescaleDB plugin's `effective_page_size` documented its floor-to-1 as a
guard against a `$top=0` "the core gateway passes through unclamped". The
gateway does not: the toolkit `OData` extractor rejects a zero page size with
`InvalidLimit` before any handler runs. The floor still earns its place — an
in-process SDK caller builds its own `ODataQuery` and reaches neither that check
nor `prepare_list_query` — so the docs and tests now state the reason that
holds.

`DECOMPOSITION.md`, `DESIGN.md`, `domain-model.md`, `ADR-0012`, and the
usage-emission and usage-query features get the same corrections.

Signed-off-by: capybutler <capybutler@gmail.com>
capybutler added a commit to capybutler/gears-rust that referenced this pull request Aug 11, 2026
…tered routes

The gear published `docs/usage-collector-v1.yaml` by hand, so the document and
the code could drift apart without anything failing.

A new in-tree test builds the route registry from the `OperationBuilder`
registrars. It needs no server, no database, and no `Service`. The test fails
when the document disagrees with the code. It compares the set of operations,
methods, operation ids, tags, parameters, request and response bodies, status
codes, the canonical error surface, and the authentication posture. It also
checks that every published component is documented and that every `$ref`
resolves. Descriptions, summaries, and examples stay under manual control, and
the module header states which fields the test enforces. The test reads the
document with `serde-saphyr`, which is already in the lockfile, so no new crate
enters the tree.

The test proved that the document was wrong. Schema names now match the code
(`CreateUsageRecords*`, `QueryAggregatedUsageRecordsRequest`, `Page_UsageType`).
The document now lists the `metadata.<key>`, `$filter`, `$orderby`, and
`$select` parameters on the routes that accept them. The `MetadataFilter` schema
is gone, because no route takes that body.

Page size is published under both spellings the endpoint accepts. constructorfabric#4422 bound
`$top` — canonical `OData` (OASIS `OData` 4.01 Part 2, section 5.1.6) — as
`#[serde(alias = "$top")]` over `ODataParams.limit`, so the extractor folds
`$top` and `limit` onto one `ODataQuery.limit` slot and rejects a request
carrying both as a duplicate field. The document and the `OperationBuilder`
registry therefore declare both: publishing either alone would under-report the
accepted surface, which is the drift class this test exists to catch. The list
allowlist keeps admitting both spellings, the aggregate allowlist keeps
rejecting both (aggregation is not paginated), and the `MAX_PAGE_SIZE` violation
names `$top` with the alias in its detail — the parsed `ODataQuery` does not
carry the spelling the caller sent, and this is the convention `toolkit_odata`
already uses for `InvalidLimit`.

The TimescaleDB plugin's `effective_page_size` documented its floor-to-1 as a
guard against a `$top=0` "the core gateway passes through unclamped". The
gateway does not: the toolkit `OData` extractor rejects a zero page size with
`InvalidLimit` before any handler runs. The floor still earns its place — an
in-process SDK caller builds its own `ODataQuery` and reaches neither that check
nor `prepare_list_query` — so the docs and tests now state the reason that
holds.

`DECOMPOSITION.md`, `DESIGN.md`, `domain-model.md`, `ADR-0012`, and the
usage-emission and usage-query features get the same corrections.

Signed-off-by: capybutler <capybutler@gmail.com>
capybutler added a commit to capybutler/gears-rust that referenced this pull request Aug 11, 2026
…tered routes

The gear published `docs/usage-collector-v1.yaml` by hand, so the document and
the code could drift apart without anything failing.

A new in-tree test builds the route registry from the `OperationBuilder`
registrars. It needs no server, no database, and no `Service`. The test fails
when the document disagrees with the code. It compares the set of operations,
methods, operation ids, tags, parameters, request and response bodies, status
codes, the canonical error surface, and the authentication posture. It also
checks that every published component is documented and that every `$ref`
resolves. Descriptions, summaries, and examples stay under manual control, and
the module header states which fields the test enforces. The test reads the
document with `serde-saphyr`, which is already in the lockfile, so no new crate
enters the tree.

The test proved that the document was wrong. Schema names now match the code
(`CreateUsageRecords*`, `QueryAggregatedUsageRecordsRequest`, `Page_UsageType`).
The document now lists the `metadata.<key>`, `$filter`, `$orderby`, and
`$select` parameters on the routes that accept them. The `MetadataFilter` schema
is gone, because no route takes that body.

Page size is published under both spellings the endpoint accepts. constructorfabric#4422 bound
`$top` — canonical `OData` (OASIS `OData` 4.01 Part 2, section 5.1.6) — as
`#[serde(alias = "$top")]` over `ODataParams.limit`, so the extractor folds
`$top` and `limit` onto one `ODataQuery.limit` slot and rejects a request
carrying both as a duplicate field. The document and the `OperationBuilder`
registry therefore declare both: publishing either alone would under-report the
accepted surface, which is the drift class this test exists to catch. The list
allowlist keeps admitting both spellings, the aggregate allowlist keeps
rejecting both (aggregation is not paginated), and the `MAX_PAGE_SIZE` violation
names `$top` with the alias in its detail — the parsed `ODataQuery` does not
carry the spelling the caller sent, and this is the convention `toolkit_odata`
already uses for `InvalidLimit`.

The TimescaleDB plugin's `effective_page_size` documented its floor-to-1 as a
guard against a `$top=0` "the core gateway passes through unclamped". The
gateway does not: the toolkit `OData` extractor rejects a zero page size with
`InvalidLimit` before any handler runs. The floor still earns its place — an
in-process SDK caller builds its own `ODataQuery` and reaches neither that check
nor `prepare_list_query` — so the docs and tests now state the reason that
holds.

`DECOMPOSITION.md`, `DESIGN.md`, `domain-model.md`, `ADR-0012`, and the
usage-emission and usage-query features get the same corrections.

Signed-off-by: capybutler <capybutler@gmail.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.

2 participants