Skip to content

feat: read discover pagination from the request body [#51] - #57

Merged
manjudr merged 10 commits into
developmentfrom
feat/51-add-pagenation-discoveryservice
Oct 27, 2026
Merged

manjudr merged 10 commits into
developmentfrom
feat/51-add-pagenation-discoveryservice

Conversation

@tdprajwal

@tdprajwal tdprajwal commented Oct 23, 2026 •

Copy link
Copy Markdown

Discover took limit and offset from the query string, which the request signature does not cover. This moves them into the request body and returns the applied page, with a total capped at the retrieval depth, as message.pagination.

Breaking: ?limit= and ?offset= are now refused with a 400.

Closes #51

A discover answered one page and said nothing about it. The page could only
be asked for in the query string, which the request signature does not cover
and which no Beckn caller knows to send, and the answer had no member to say
which page it was or whether more existed - so a short page and the last page
read the same.

The validator now loads the OAN profile of beckn.yaml, pinned at commit
dfc1097 of OpenAgriNet/protocol-specifications-v2 (no tag exists yet; swap
the SHA for one when it is cut, never for the branch). It adds limit/offset to
DiscoverAction and an optional pagination to OnDiscoverAction. Recorded as
A28 in the plan.

- The page is read from the body, each field falling back to the deprecated
  query parameter of the same name, so existing callers (verify.sh among
  them) keep working. The body wins because it is the signed half.
- pagination reports the page APPLIED, after default and clamp: a caller that
  asked for 5000 must read back 100, or its next offset skips results.
- total is len(fused) and is sent only when exact. It is omitted whenever any
  retrieval list came back as long as MaxCandidatesPerMode, because that list
  may have been cut by its LIMIT. Rejected: reviving the count query, which
  A19 deleted for costing 100x the search. Reverting the cap check makes
  TestATotalIsReportedOnlyWhenNoModeHitItsCap report 8 where 10 matched.
- The past-depth refusal is unchanged and now points at $['message']['offset'].
- config refuses maxPageSize above 100, the schema's maximum, which would
  otherwise answer query-string callers with a pagination.limit that fails
  its own schema.

Not in scope: the same profile lets context.networkId be an array.
beckn.Context still reads a string, so a list is refused by the envelope
parser exactly as before; its meaning for scope and visibleTo is its own
issue.
Pagination now comes only from message.limit and message.offset; the
query string is no longer read. The response returns the applied page
as message.pagination.
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🛡️ Trivy security scan (CRITICAL,HIGH,MEDIUM,LOW)

View full run

Go dependencies

No findings at CRITICAL,HIGH,MEDIUM,LOW.

Container image

No findings at CRITICAL,HIGH,MEDIUM,LOW.

@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

📊 Test Coverage: ✅ Passed — 97% of changed lines covered, min 80%

@tdprajwal
tdprajwal requested a review from manjudr October 23, 2026 06:18
@tdprajwal
tdprajwal force-pushed the feat/51-add-pagenation-discoveryservice branch from 5658fef to 3b765b3 Compare October 26, 2026 05:59
0.1.0 read ?limit=/?offset=; ignoring them now would silently return the
default page. Refuse with SCH_INVALID_FORMAT naming the new location.

BREAKING CHANGE: discover pagination moved from ?limit=/?offset= to
message.limit/message.offset; the query parameters are now refused with 400.
@tdprajwal
tdprajwal force-pushed the feat/51-add-pagenation-discoveryservice branch from 3b765b3 to 47a8714 Compare October 26, 2026 06:41
manjudr and others added 2 commits October 27, 2026 13:29
Total is the ranked list's length before paging, capped at the retrieval
depth so it never points past the last page a caller can fetch.
Comment thread src/platform/config/config.go Outdated
// SpecCachePath. LoadSpecIndex already warns loudly and falls back, so an
// offline boot is slower and noisier, not broken.
SpecURL string `env:"VALIDATION_SPEC_URL" envDefault:"https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/tags/core-v2.0.0-lts/api/v2.0.0/beckn.yaml"`
SpecURL string `env:"VALIDATION_SPEC_URL" envDefault:"https://raw.githubusercontent.com/OpenAgriNet/protocol-specifications-v2/dfc109759b3781fde74539adc79df63fccbadb83/api/v2.0.0/beckn.yaml"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We also need to update this url in the adapter configuration

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.

Pinned to a8451c4, the head of protocol-specifications-v2#1, before it merges: DiscoverPagination only exists there, so binding the schema test to it and removing flattenAllOf both need it.

Will re-pin to the merged commit when it lands. The adapter config needs the same URL.

@manjudr manjudr left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Second review pass, after protocol-specifications-v2#1 was updated. Three spec changes affect this PR: DiscoverPagination is now a separate schema from the CS Pagination, all three of its fields are required, and maximum: 100 is gone from the request limit.


1. The spec pin stops the clamp from ever running

Problem

src/platform/config/config.go:234 and tests/testdata/beckn-v2.0.0.yaml both pin spec commit dfc1097. That commit still puts maximum: 100 on the request limit, and that has since been reverted on the spec branch.

So a request of {"intent": {"textSearch": "wheat"}, "limit": 5000} is rejected by the pinned spec with 5000 is greater than the maximum of 100, and accepted by the current spec.

SchemaValidator runs before the Discover controller (src/app/router.go:125) and EnableL1Schema is true by default. On the pinned spec the request is refused before it reaches the clamp at src/discover/intent_mapper.go:221, so the clamp never runs in production. The clamp tests at src/discover/service_test.go:375 and src/discover/controller_test.go:324 pass only because mount() does not wire the validator.

Recommendation

Re-pin SpecURL and re-vendor tests/testdata/beckn-v2.0.0.yaml once protocol-specifications-v2#1 merges. The clamp logic itself is correct and matches CON-013-31 — only the pin needs to move.


2. The conformance test checks the wrong schema

Problem

src/beckn/schema_conformance_test.go:72 maps the Go Pagination struct to schemaPath("Pagination"). In the updated spec, #/components/schemas/Pagination is the CS catalog schema, and on_discover now uses DiscoverPagination.

The test passes either way, so nothing tells you it is testing a contract this service does not serve. The two schemas really do differ: {"limit": 10, "offset": 0} is accepted by Pagination and rejected by DiscoverPagination with 'total' is a required property.

Recommendation

Bind the struct to DiscoverPagination, and rename the Go type to DiscoverPagination too. Then the next spec rename fails the test instead of quietly pointing it somewhere else.


3. Total *int with omitempty can produce an invalid response

Problem

src/beckn/actions.go:162. total is a required field now, so if Total is ever nil the response omits it and fails validation. There is one construction site today (src/discover/service.go:80) and it always sets the value, so this is a trap for later rather than a live bug. But a pointer with omitempty says the field is optional, and the contract says it is required.

Recommendation

Change it to Total int with json:"total".


4. flattenAllOf is dead code

Problem

src/beckn/schema_conformance_test.go:167. Its comment describes Pagination as allOf[PaginationParams, {total}]. That composition was reverted on the spec branch, and no schema the test binds uses allOf any more.

Recommendation

Delete the helper and the comment.


Resolved since the last pass

  • Capping total at MaxCandidatesPerMode is fine now. The spec defines total as how far a caller can page, which a DS may cap at its retrieval depth.
  • CON-013-30 asks for a stable order within one result set, with no resource repeated or skipped across its pages. RRF already breaks ties by id at src/storage/postgres/fusion.go:44, and the comment above it gives the same page-slice reasoning. No change needed.

@manjudr manjudr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Three more findings, on process and readability rather than behaviour.


5. No self review

Problem

The PR is authored by @tdprajwal, and the only review activity on it is mine. The author has not gone through their own diff and left comments on it.

Recommendation

Self review the diff before this goes further. Walk the change, comment on anything a reviewer would ask about, and resolve what you can yourself. A PR without a self review should not be taken up for line-by-line review.


6. The PR description says too much

Problem

The description carries What, Why and a Testing section that lists each test that runs. Most of it restates the diff, which a reviewer can read.

Recommendation

Cut it to two things: what the problem is, and what this PR does about it. Something like:

Discover took limit and offset from the query string, which the request signature does not cover. This moves them into the request body and returns the applied page as message.pagination.

Drop the Testing list. CI reports whether tests pass.


7. Decisions are written as code comments instead of ADRs

Problem

43 of the 168 added lines in src/ are comments, and the valuable ones record decisions rather than explain code:

  • src/discover/intent_mapper.go:213 — why an oversize limit is clamped and not refused.
  • src/discover/service.go:78 — why the applied page is echoed and not the requested one.
  • src/storage/postgres/fusion.go:39 — why RRF ties break by id, and what happens to a page if they do not.
  • src/platform/config/config.go:640 — why maxPageSize cannot sit below defaultPageSize.

These are design decisions. In a comment they are invisible to anyone who is not already reading that file, and they go stale when the decision changes.

The repo already keeps ADRs in docs/adr/, and none of them covers pagination.

Recommendation

Write one ADR, docs/adr/00NN-discover-pagination.md, holding these decisions: pagination is read from the signed body, counted in resources, clamped rather than refused, total capped at retrieval depth, and order made stable by the id tiebreak. Then cut the comments down to a line each, pointing at the ADR.

Keep the short comments that explain something local and non-obvious. Remove the ones that narrate what the code already says.

tdprajwal and others added 2 commits October 27, 2026 19:39
The pinned dfc1097 capped the request limit at 100, so the validator
refused an oversize limit before the clamp could run. Pin to a8451c4,
bind the response to DiscoverPagination with a required total, and move
the pagination decisions from code comments into ADR-0017.
Both depth guards refused a page whose tail crossed the retrieval depth,
but the page slice already truncates that tail. At depth 500, limit 30
refused offset 480 with 30 results still unread, and the caller could
never satisfy the spec's end-of-results test (offset + count == total).
It only looked right at limit 20, which divides 500 evenly.

The guard now reads the offset alone: a page that starts past the depth
is refused, one that starts inside it is answered, short if need be.
@manjudr

manjudr commented Oct 27, 2026

Copy link
Copy Markdown
Member

Pushed 72afbaa to this branch — one fix, plus the two tests that pinned the old behaviour.

Problem

Both depth guards refused a page whose end crossed the retrieval depth:

if offset+limit > search.MaxCandidatesPerMode {

But pageOf already cuts the tail short, so there was nothing to protect against. The effect is that the last results are unreachable whenever the limit does not divide the depth evenly. At depth 500 with limit: 30, offset 480 is a 400 while 20 results are still sitting there, and total keeps saying 500. The caller is told there is more, and every page it asks for is refused. It only looked correct at limit: 20, because 500 divides by 20.

That also means the end-of-results test in the spec — offset + count == total — can never become true at those page sizes.

Recommendation

Read the offset alone. A page that starts past the depth is refused; a page that starts inside it is answered, short if need be.

if offset >= search.MaxCandidatesPerMode {

Same change in withinRetrievalDepth. ADR-0017 needed no edit — it already says "a page past the retrieval depth is refused", which is what this now does.

Checked against the docker stack with SEARCH_MAX_CANDIDATES_PER_MODE=10 and a 12-resource catalog:

offset 0 offset 4 offset 8 offset 10
before 200, 4 200, 4 400 400
after 200, 4 200, 4 200, 2 400

After the change 8 + 2 == 10 == total, so the caller can tell it has reached the end. limit: 3 and limit: 10 walk to the end cleanly too, and offset 10 is still refused.

go test ./... and make lint are clean.

The depth counts ids PER MODE, so fusing two modes yields more ids than
either retrieved — 14 from a depth of 8 on the test corpus. Only `total`
was trimmed to the depth; the page was still cut from the untrimmed
list, so the last page carried more resources than `total` left room
for and offset + count == total never came true.

Trim the fused list once, before the page is cut, so the page and the
total come from the same list.

@manjudr manjudr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Design-principles pass over the full branch diff (20 files, +386/−113).

1. SearchResult.Total does not say what it holds, and the depth cap is applied twice

Problem

src/domain/query.go:128 says "Total counts the ranked ids before paging". That is no longer true for the Postgres backend — src/storage/postgres/search_repository.go:130 now trims the fused list to MaxCandidatesPerMode before counting it, so Total already comes back capped. src/discover/service.go:77 then caps it a second time with min(result.Total, s.cfg.Search.MaxCandidatesPerMode).

Two layers owning the same rule means a backend can disagree with the doc and nothing catches it. The memory backend already does: src/storage/memory/search.go:68 sets Total: len(page) and never mentions MaxCandidatesPerMode. It is test-only today, so no caller sees it, but it is the same interface returning a different number under the same field name.

Recommendation

Make the contract explicit on the type: change the comment at src/domain/query.go:128 to "Total is the number of ranked ids a caller can page to — the ranked list capped at the retrieval depth. Every backend caps it." Then drop the min() at src/discover/service.go:77 and pass result.Total straight through, and fix src/storage/memory/search.go:68 to return the capped list length so the two backends honour one contract.


2. The retrieval-depth guard lives in two layers

Problem

The same rule — refuse an offset at or past MaxCandidatesPerMode — is written twice, once at src/discover/intent_mapper.go:225 and once at src/storage/postgres/search_repository.go:145. They are not a validation plus a cheap assertion; they are the same comparison with two different messages.

The evidence is this PR: fixing the off-by-one needed both to change in lockstep. Miss one and the service either refuses a page the storage layer would have served, or serves a page the mapper thought it had rejected.

Recommendation

Put the comparison in one place — a small func OffsetBeyondDepth(offset, depth int) bool next to the search config — and have both call sites use it. Keep the two different error types (one is a client fault, one is an internal guard); share only the condition.


3. pageFrom takes a parameter it does not use for its stated job

Problem

src/discover/controller.go:99 is pageFrom(query url.Values, action beckn.DiscoverAction) (Page, error). The page is built entirely from action. query exists only so the function can reject a caller that passed ?limit= or ?offset=. The name says it reads a page; it also enforces a transport rule.

Recommendation

Split them. Keep pageFrom(action beckn.DiscoverAction) Page with no error return, and move the query-string check into its own rejectQueryStringPagination(query url.Values) error called before it in the handler. Each then does one thing and the signature stops lying.


4. The reason for the pre-page trim is a code comment, not the ADR

Problem

src/storage/postgres/search_repository.go:124-127 carries a four-line comment explaining why the fused list is trimmed before the page is cut — that the cap counts ids per mode, so fusing several modes yields more than any one retrieved. That is a design decision, and it sits in the only place a reader will not look for one.

ADR-0017 decision 5 states the outcome ("total is the ranked list's length, capped at maxCandidatesPerMode") but not that the cap must be applied before the slice, which is the part that was actually wrong.

Recommendation

Add to docs/adr/0017-discover-pagination.md under Decision: "The cap is applied to the fused list before the page is cut. maxCandidatesPerMode counts ids per retrieval mode, so a fused list is longer than any one mode returned; slicing first produces a page that runs past the total the caller pages against." Then cut the comment at search_repository.go:124-127 down to one line pointing at the ADR.


Resolved

  • The four findings from the earlier pass are fixed in ff7b9fd.
  • The PR description is down to two lines and no longer restates the diff.
  • ADR-0017 is added and covers the signed-body page, the clamp, the applied-page echo and the capped total.
  • A self review is present, though a single comment across a 20-file diff is thin.

Correction to my earlier comment

I wrote that go test ./... and make lint are both clean. The test claim stands; the lint claim does not. make lint reports 10 staticcheck SA5011 issues on this branch and 12 on origin/development. All of them are in test files this PR does not touch, and all are the usual false positive where staticcheck does not know t.Fatalf ends the test. The PR lowers the count rather than raising it, but the gate is red on both branches and I should not have said otherwise.

The depth rule was written in two places and the page was read by a
function doing two jobs.

- Search.OffsetBeyondDepth and Search.CapToDepth hold the comparison
  once; the request layer and the storage layer both call them.
- pageFrom reads the signed body; rejectQueryStringPagination refuses a
  page the signature does not cover.
- SearchResult.Total says what it holds and where it is capped.
- ADR-0017 records why the cap is applied before the page is cut, and
  why the refusal tests the offset rather than offset + limit.
`ranked` meant the ranked modes in one function and the ranked id lists
in another, in the same file, and `fold` said nothing about what it
folded into.

- negotiate returns rankedModes, partitionOutcomes returns rankedIDs.
- withinRetrievalDepth reads as a predicate but returns an error, so it
  is ensureWithinRetrievalDepth.
- pageOf's manual clamp is min().
- RRF presizes for the fused worst case: 6 allocations and 79KB against
  32 and 169KB, at the same wall time.

@manjudr manjudr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pagination verified end to end against the running stack at two retrieval depths: no overlap or gaps across limits 1-20, every page matches the canonical slice for its offset, the short last page satisfies offset + count == total, and an offset at or past the depth is refused. 26/26 test packages pass and CI is green.

Two notes for the record, neither blocking: make lint fails with 5 pre-existing staticcheck SA5011 in untouched test files (development has 12), and the in-memory backend leans on the service to cap Total rather than capping it itself, which is documented on domain.SearchResult.Total.

@manjudr
manjudr merged commit a25b213 into development Oct 27, 2026
3 checks passed
@manjudr
manjudr deleted the feat/51-add-pagenation-discoveryservice branch October 27, 2026 17:14
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