Skip to content

feat: add the Mandi provider plugin - #13

Merged
manjudr merged 10 commits into
feat/41-oan-adapter-pluginsfrom
feat/8-mandi-plugin
Sep 8, 2026
Merged

feat: add the Mandi provider plugin#13
manjudr merged 10 commits into
feat/41-oan-adapter-pluginsfrom
feat/8-mandi-plugin

Conversation

@ameersohel45

Copy link
Copy Markdown
Collaborator

Targets feat/41-oan-adapter-plugins rather than development, because it builds on the machinery that PR #2 introduces. Merge that one first.

What

A second provider capability, openagrinet:MandiPrice, served against Agmarknet's Vistaar select. Additive: six new files, one line added to the plugin build list, and nothing existing changed.

The domain package — pkg/plugin/implementation/mandi/

38 lines, plus a 20-line prerequisites file that is deliberately empty. It supplies its name and nothing else; recognising the capability, resolving the call plan, authenticating, calling with the registry's budget and translating in both directions are all internal/upstream's.

internal/upstream needed no change for it. That was the test of whether the machinery and domain split in PR #2 actually held, and it is the reason this PR is as small as it is.

No prerequisites, and not by luck

A MandiPrice select names the market it wants — market.marketCode, market.district, market.state — and carries a commodity code and a validity window, which is every parameter the upstream takes. The pack has no top-level location, so nothing has to turn a point into a market.

That matters because turning a point into a market is exactly what the provider backend needs a PostGIS query for, and the adapter may not talk to a database. The pack's shape and the upstream's contract happen to agree, so the plugin is data only.

The mapping — config/mappings/agmarknet/mandi-price.select.yaml

Where the actual work is. Three things in it are not obvious:

  • the upstream's records use Title Case keys containing spaces`Modal Price` — so they need backticks, and its prices are strings, so they need $number() before they satisfy the pack's numeric types. A verbatim capture from the provider backend's own documentation is pinned as a fixture, so neither can drift unnoticed.
  • dates convert twice. The pack speaks ISO, the upstream speaks dd-MM-yyyy, so the request half converts out and the response half converts back.
  • the pack requires none of the fields the upstream needs. An OnDemand select requires only supportedCommodities and supportedPriceFields, leaving market and validity optional, and defines market.district and market.state as "name or governed code". So a spec-valid select can still be unanswerable, and the required: block refuses those with its own message rather than earning a 400 from Agmarknet — or worse, an empty result that reads as "no prices".

Resource ids are built from codes rather than the names the upstream reports: Kasdol APMC and Paddy(Common) carry spaces and brackets, and an id a consumer may put in a URL should not.

Query-string auth

Agmarknet takes its token as a query parameter. authScheme: query for that arrives in PR #2, since it is machinery rather than anything mandi-specific. The config here holds the parameter's name and the name of the environment variable carrying the value, never the value.

Why

Two capabilities from two domain packages in one pipeline, dispatched by binding key read from the payload. Which one answers is decided by the request, not by a routing table, a URL or a domain — each step recognises its own key and passes through anything else.

It also settles a claim PR #2 could only assert: that adding a domain costs a small package and a mapping. It cost 58 lines of Go and one line in the build script.

Testing

go vet ./... clean and go test ./... green across 63 packages.

The shipped mapping is run through the real mapper and the real provider step against a captured Agmarknet response, rather than asserted about. Both directions validate with jsonschema — the select against beckn.yaml and MandiPrice v0.1 in OnDemand mode, the on_select against beckn.yaml and the pack in Direct mode — with zero errors.

End to end on the local stack, both capabilities loaded into one provider adapter:

  • a select naming the mandi provider returns one Direct resource per price record, prices converted from strings, dates in ISO, the offer's references rewritten to what was returned, and a record that reported no minimum or maximum coming back with those absent rather than zeroed
  • a select naming the weather provider still returns its per-day forecast, unaffected
  • a publish and a discover for each capability, since publishing is capability-agnostic and a catalogue entry advertises OnDemand while carrying no prices

The local Postman collection covers all of that: 8 requests, 39 assertions, no failures.

Notes for review

  • Tested against a mock that reproduces the awkward parts of the real API deliberately — Title Case keys with spaces, string prices, dd-MM-yyyy, and a query token it rejects when absent. The real API's credentials, host and exact record set are untested.
  • v1 only. Agmarknet's v2 endpoint takes a point rather than market codes and resolves the market itself, which fits our payload better — but it needs a POST token exchange, and that needs pre-call I/O whose design is still open. Not attempted here.
  • One behaviour the provider backend has and this does not: when a market-scoped query returns nothing it retries without marketcode, widening to the district. Our step makes one call, so an empty result stays empty. Left alone until the real API's behaviour is observed, along with the question of whether Agmarknet reports "no data" as an HTTP 400.

Closes #8

@ameersohel45
ameersohel45 force-pushed the feat/8-mandi-plugin branch 3 times, most recently from ede4859 to 033807d Compare September 7, 2026 06:12

@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.

Requesting changes

Small, focused PR and the mapping test is the strongest part of it — it asserts real output rather than just that the mapping compiles: outbound query params, on_select, DRAFT status, resource count, id shape, the six Direct-required fields, string→number price coercion, ISO arrival date, and the absent-not-zero partial-record case. That's the right kind of test.

Its blind spot is that every fixture is well-formed and code-bearing, which is exactly why the findings below survive it. None of them need much code — they need adversarial fixtures.

Verified before reviewing: go build ./..., go vet, gofmt -l, go test ./pkg/plugin/implementation/mandi/... and go test -race all pass. Findings 1, 2, 3 and 5 were confirmed empirically against the real mapper and the shipped mapping, and the observed outputs are quoted inline.

Should fix before merge

  1. mandi-price.select.yaml:120$resourceId omits Variety/Grade, so rows that differ only by those collide on one id. The PR's own fixture has such a pair, saved only by differing dates. District-wide queries collapse every market onto one id too.
  2. :130$priced checks existence but not castability. Agmarknet's NR / - markers make $number() throw D3030, so one unreported cell 500s the entire multi-row answer. Line 193's bare $number on modal is the same hazard and contradicts the file's stated rule.
  3. :37 — the guard's message says "governed state and district codes" but the check is bare $exists, and the pack permits names. Names get sent as codes, Agmarknet returns nothing, and the caller gets a signed "no prices" for a market that had prices — verbatim the failure lines 20-26 say the guard prevents.
  4. :68 — only supportedCommodities[0] is ever read, so a three-commodity request is answered for one with no complaint. Same class as the oanbinding refusal, without the guard.
  5. :189 — a missing Arrival Date produces arrivalDate: "--" and a degenerate resource id. Weather guards this exact case at weather-observation.select.yaml:248 ("Absent is honest; empty is a lie in the shape of an answer") — an accidental divergence between siblings.
  6. :171subjectCategories echoed from the caller rather than stated from the pack, where weather hardcodes ["Weather"]. Unvalidated, so a caller can make the adapter republish the wrong category under its own signature.

Lower severity

:185 mixes request scope with record identity (marketCode from the request, everything else from the record), so district-wide answers name several markets with no code. :191 ignores supportedPriceFields entirely.

Reuse

mandi/cmd/plugin.go is a verbatim 104-line copy of weather/cmd/plugin.go. Since mandi.Config = upstream.Config, none of it is domain-specific. It has already cost something concrete: mandi/cmd ships with no tests while weather/cmd has five covering parseConfig's rejection paths, so the copied error handling is unexercised. Sharing the helper closes the gap rather than requiring a second copy of the tests.

Scope

Clean. The +25/-1 in oan-provider-adapter.yaml is ~6 lines of real config plus ~19 of comment prose matching the file's house style, and the steps addition is required. install/build-plugins.sh +1 is the only other registration point — I checked every place weather appears for a missed one. No dead code, no unrelated edits, no speculative knobs.

One cross-PR note inline: registering a second provider step is what makes PR #2's "arity refused before ownership" issue live rather than theoretical. Not a defect here, but it should be fixed on #2 first.

Sequencing

This targets feat/41-oan-adapter-plugins, so PR #2 needs to land first — and #2 has two blockers of its own. Worth resolving those before merging this.

Comment thread config/mappings/agmarknet/mandi-price.select.yaml
Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
Comment thread config/mappings/agmarknet/mandi-price.select.yaml
Comment thread config/mappings/agmarknet/mandi-price.select.yaml
Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
@@ -0,0 +1,104 @@
// Command plugin builds the mandi provider step as a loadable plugin.

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.

This whole 104-line file is a verbatim copy of weather/cmd/plugin.go, comments includedparseConfig, New, splitList, the maxResponseBytes parsing and validation, and the Provider/var _ declarations are byte-identical modulo four identifier substitutions.

Since mandi.Config is already = upstream.Config (mandi.go:31), nothing in here is domain-specific. It belongs in a shared internal/upstream helper taking a package name and a New func — otherwise every future domain plugin re-copies it, and any fix to the config parsing has to be applied N times.

This already has a concrete cost: go test reports pkg/plugin/implementation/mandi/cmd [no test files], while weather/cmd/plugin_test.go has five test funcs covering parseConfig — including the maxResponseBytes non-integer and non-positive rejections at lines 47-53. So the copied error-handling path is entirely unexercised in mandi.

Sharing the helper closes the test gap instead of requiring a second copy of the tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed on the substance, and holding this one rather than doing it now — flagging so it isn't mistaken for handled. Leaving the thread open.

One correction to the shape of the fix, though. plugin.go can't move to internal: each plugin is built as its own .so from ./pkg/plugin/implementation/<dir>/cmd/plugin.go, and the loader does pgn.Lookup("Provider"), so every domain plugin needs its own package main exporting that symbol. Move it and there's nothing to build a .so from.

What can move is the ~45 lines that aren't domain-specific — parseConfig and splitList. plugin.go would stay put as a ~20-line shim calling a shared upstream.ParseConfig. Domain-specific parts are only the provider type, the New it delegates to, and the exported symbol.

Your concrete-cost point is the reason it's worth doing at all, and I verified it: mandi/cmd has 0 test files against weather/cmd's 5 test funcs, so the maxResponseBytes bounds checking and the auth-scheme validation are untested in this copy. Shared, they'd be tested once and every future plugin would inherit that.

Also confirmed your "verbatim modulo identifiers" claim — after substituting mandiweather the diff is comment-only.

Comment thread config/oan-provider-adapter.yaml

@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.

Follow-up review: the three standing checks

Second pass against the defaults — no hardcodes (placeholder + example, read from config), proper logs, no unused methods or logic. Additions to my earlier review; the findings there still stand.

1. No hardcodes — one real operability gap

oan-provider-adapter.yaml:257MANDI_TOKEN appears exactly once in the entire repository outside tests: the line that references it. No .env.example, no README section, no deploy manifest, nothing in install/build-plugins.sh.

The result is a plugin that loads, registers, and validates cleanly, then fails every request permanently (doNotRetry), with the only clue being a runtime string naming a variable nobody was told to set. Detail and a suggested .env.example entry inline.

There is an irony worth fixing while you are there: the only env vars this config documents (MAUSAMGRAM_X_API_KEY, MAUSAMGRAM_TOKEN at lines 228/232) are commented-out examples that nothing reads, while the one variable actually required at runtime is undocumented.

Otherwise the hardcode picture here is good. The secret itself is correctly read from the environment rather than committed, and the pack-level constants (on_select, DRAFT, the @type values) are legitimately literal. One judgement call inline at mandi-price.select.yaml:194 about "currency": "INR".

2. Proper logs — nothing to fix in this PR

mandi and mandi/cmd follow the same convention as their weather counterparts (log.Debugf/Infof/Warnf(ctx, ...), no fmt.Println, no payload bodies logged), and all the request-path logging lives in internal/upstream, which this PR does not touch.

One thing this PR inherits rather than causes: I found a credential-redaction bug in internal/upstream that writes the auth token to the log in recoverable form at info level on every successful call, because the redaction string-matches the raw env value while the URL carries it percent-encoded. It affects MANDI_TOKEN specifically — mandi is the only step configured with the query auth scheme, so this PR is what makes the bug reachable in production. I have filed the detail on PR #2, where the code lives. Worth tracking as a dependency of this PR rather than fixing here.

3. No unused method or logic — clean

Swept the mandi packages for unexported functions with no production caller, config fields never read, and consts never referenced. Nothing. mandi.Config is a type alias to upstream.Config so it has no unread fields of its own, and every helper in mandi/cmd/plugin.go is called.

The related point stands from my earlier review: that file is not unused, but it is a verbatim 104-line copy of weather/cmd/plugin.go, and the duplication is why mandi/cmd ships with no tests while weather/cmd has five.

Comment thread config/oan-provider-adapter.yaml
Comment thread config/mappings/agmarknet/mandi-price.select.yaml

@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.

Spec-alignment review: MandiPrice v0.1

Reviewed the mapping against the actual pack in OpenAgriNet/network-specs (branch schema-packs-v0.1, schema/MandiPrice/v0.1/), and validated the mapping's real output shapes against the pack's own subschemas with a JSON Schema validator. The response half is in good shape; the request half is built on fields the pack forbids.

1. Blocker: the OnDemand branch forbids market and validity, and two guards require them

The comment at :19-23 reads the pack as leaving market and validity optional. It excludes them — not/anyOf over required is an exclusion list, covering source, commodity, commodityGroup, grade, variety, market, arrivalDate, prices, generatedAt and validity.

payload satisfying all three of this file's guards   -> VIOLATES PACK
the pack's OWN OnDemand example (coverageAreas)      -> CONFORMS

The adapter refuses the pack's own example, and every payload it accepts is spec-invalid. This is structural: statecode, districtcode, from_date, to_date and marketcode all come from those fields.

The pack's OnDemand scope mechanism is coverageAreas ({codeScheme: ISO-3166-2, areaCode: IN-TS, areaLevel: State}). Mapping that onto Agmarknet's numeric codes needs a gazetteer lookup rather than a JSONata expression — which is presumably why the shortcut was taken. Detail and a suggested guard inline at :19 and :37.

2. A real gap in the pack, not in this PR

The date-range guard cannot be made conformant today. validity is forbidden in OnDemand, and the available OnDemand fields (historicalDataAvailable, historyPeriod, updateFrequency) all describe provider capability, not caller intent. Since this upstream requires from_date/to_date, MandiPrice v0.1 has no way to express what this integration needs.

That is worth taking to the spec repo — a requestedPeriod, or permitting validity in OnDemand as the requested window. I would not hold this PR for the pack change, but the file should say the guard is knowingly non-conformant rather than let it read as satisfied. Inline at :43.

3. Validation failures confirmed against the pack

Line Case Result
:195 upstream omits Price Unit INVALID: 'unit' is a required property
:189 record has no Arrival Date INVALID: '--' is not a 'date'
:171 caller echoes ["Weather"] VALID — but asserts the wrong subject
:171 caller echoes [] INVALID: [] should be non-empty

These sharpen three findings from my earlier review from "looks wrong" to "fails the pack".

4. Hardcodes — you asked for fewer; two here should stay, one should be added

Applying the rule that makes this decidable — pack constants belong in the mapping, deployment values belong in config — this file is mostly on the right side:

  • "currency": "INR" (:194) — keep. Pack requires ^[A-Z]{3}$, Agmarknet reports only INR, file is Agmarknet-specific. This supersedes my earlier suggestion to move it.
  • "@type" (:169) — keep. Pack constant.
  • subjectCategories (:171) — should become a hardcode: ["Market"]. It is a closed-enum pack value that both of the pack's own examples state, and the weather mapping already hardcodes its equivalent. Echoing it is what lets a caller push the wrong subject through the adapter's signature.

The deployment values worth moving to configuration are the ones I flagged on PR #2 (subscriberId, registry URL) and the undocumented MANDI_TOKEN — not these.

5. Where the mapping is right

The response half checks out field-for-field against the pack: every key it emits is declared, correctly placed, and inside the closed market and prices property sets. variety and grade are populated (:181-182) where a lazier mapping would have dropped them — which is also why using them in $resourceId is a small change, since the data is already in hand. Two optional refinements inline at :13 (sourceId prefix convention, missing sourceUri).

Verification

Schema facts come from schema/MandiPrice/v0.1/attributes.yaml and schema/AgricultureResource/v0.1/attributes.yaml at 200d963, checked with jsonschema (Draft 2020-12) against the pack's own conditional clauses and its self-contained prices / market / arrivalDate / subjectCategories subschemas. No external $ref was stubbed for any result quoted here.

Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
Comment thread config/mappings/agmarknet/mandi-price.select.yaml
Comment thread config/mappings/agmarknet/mandi-price.select.yaml
Comment thread config/mappings/agmarknet/mandi-price.select.yaml
Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
Comment thread config/mappings/agmarknet/mandi-price.select.yaml Outdated
@github-actions

github-actions Bot commented Sep 8, 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 Sep 8, 2026

Copy link
Copy Markdown

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

Comment thread install/build-plugins.sh

@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.

@ameersohel45 - Please don't use the OAN in the file names

@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. The doc comment describing $resourceId's rationale ("bound once because it is used twice", "built from CODES, not the names the upstream reports") sits above the unrelated $iso date-conversion function instead of above $resourceId itself (defined later, with its own different comment already in place). The comment that actually belongs to $iso is left orphaned further down with no code beneath it. Since this file's commit history shows a resource-id collision had to be fixed once already, a future edit to $resourceId risks missing this rationale and reintroducing that bug.

$ra := $selected.resources[0].resourceAttributes;
/* Bound once because it is used twice -- for a resource's own id and for
the offer's reference to it. Two copies of one expression is how a
dangling reference gets reintroduced. */
/* Built from CODES, not the names the upstream reports. A market name
carries spaces and a commodity name carries brackets -- "Kasdol APMC",
"Paddy(Common)" -- and an identifier that a consumer may put in a URL or
a filter should not. The codes are already in the payload, so they cost
nothing, and they are stable where a display name is not.
The market code is optional: without it the query widened to the whole
district, so the district code is what identifies the scope. */
$iso := function($ddmmyyyy) {
$substring($ddmmyyyy, 6, 4) & "-" & $substring($ddmmyyyy, 3, 2) & "-" & $substring($ddmmyyyy, 0, 2)
};

The orphaned $iso comment:

/* dd-MM-yyyy back to ISO, so the answer speaks the pack's date format
rather than the upstream's. */
/* Absent rather than present-and-empty: a consumer must be able to tell
"the market reported no minimum" from "the minimum was zero".

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@ameersohel45

Copy link
Copy Markdown
Collaborator Author

Agreed, and already done in #17 — which targets this branch, so it lands here on merge. Nothing needed on this PR.

This branch has 11 such paths; after #17 the count is zero:

on this branch after #17
pkg/plugin/implementation/oanregistry/ sunbirdRegistry/
pkg/plugin/implementation/internal/oanbinding/ internal/capabilitybinding/
config/oan-provider-adapter.yaml config/provider-adapter.yaml

Each is named for what it is rather than for the network it was written for: the registry client speaks SunbirdRC, and the binding package resolves a capability binding. Same reasoning as the capability plugins in your other comment.

The comments were done too, not just the paths — nothing in the Go file or directory names carries the network name, and #17 has a commit for the wording left in the reference config.

@ameersohel45

Copy link
Copy Markdown
Collaborator Author

Fixed in 80dd82d — and your reasoning about why it matters is the part I'd have missed.

Confirmed all three placements. $resourceId's two blocks — "bound once because it is used twice" and "built from CODES, not the names the upstream reports" — sat above $iso, which converts dd-MM-yyyy to ISO and has nothing to do with either. $iso's own comment was orphaned further down with no code beneath it at all. $resourceId did already have its own comment, so the two now join it.

Your point about the history is exactly the risk: a resource-id collision had to be fixed in this file once already, so someone editing $resourceId next and reading the comment directly above it would have found a date-conversion rationale and none of the reasons the id is shaped the way it is.

One judgement call beyond a straight move. The market-code paragraph — "without it the query widened to the whole district, so the district code is what identifies the scope" — went to $scope rather than travelling with the rest, because it describes that binding specifically rather than how the id is composed. Say the word if you'd rather it stayed with $resourceId.

No expression changed. The shipped-mapping tests serve this exact file over HTTP, so they'd have caught it if one had.

Serves openagrinet:MandiPrice against Agmarknet's Vistaar select, as a sibling
to weather: a domain package of 58 lines wrapping internal/upstream, which
needed no change for it. That was the test of whether the machinery and domain
split from 2b3cab1 actually held, and it did.

The package has NO prerequisites, and the reason is the pack rather than luck.
A MandiPrice select names the market it wants -- market.marketCode,
market.district, market.state -- and carries a commodity code and a validity
window, which is every parameter the upstream takes. There is no top-level
location in the pack, so nothing has to turn a point into a market, which is
the one thing the provider backend needs a spatial SQL query for and the one
thing this adapter may not do.

The mapping carries the whole contract. Three things in it are not obvious:

  the upstream's records use Title Case keys WITH SPACES -- `Modal Price` --
  so they need backticks, and its prices are STRINGS, so they need $number
  before they satisfy the pack's numeric types. Both are pinned by a verbatim
  capture from the provider backend's own documentation.

  dates convert twice. The pack speaks ISO, the upstream speaks dd-MM-yyyy, so
  the request half converts out and the response half converts back.

  the pack requires none of the fields the upstream needs -- an OnDemand
  select requires only supportedCommodities and supportedPriceFields, leaving
  market and validity optional. So a spec-valid select can be unanswerable,
  and the mapping's required: block refuses those with its own message rather
  than earning a 400 or, worse, an empty result that reads as "no prices".

Resource ids are built from codes rather than the names the upstream reports:
"Kasdol APMC" and "Paddy(Common)" carry spaces and brackets, and an id a
consumer may put in a URL should not.

Verified: the shipped mapping run through the real mapper and the real step
answers two records as two Direct resources with their prices converted, the
offer's references rewritten to match, and absent min/max left absent rather
than zeroed. Both directions validate -- the select against beckn.yaml and
MandiPrice v0.1 in OnDemand mode, the on_select against beckn.yaml and the
pack in Direct mode, with no errors.
Adds mandi alongside weather in config/oan-provider-adapter.yaml, so the
reference shows the thing that is actually interesting about this design: two
domain packages in one pipeline, sharing the module, the registry client and
the mapper, and sharing nothing else.

The whole cost of the second capability is one providerSteps entry and one
line in steps. No routing table, no new module, no new port. Which one answers
is decided by the payload -- each step builds a binding key from it, serves the
request if the key is its own, and passes it through untouched if not -- so the
order they appear in does not matter either.

mandi uses authScheme query, because Agmarknet's Vistaar API takes its token as
a query parameter. The adapter holds the parameter's name and the name of the
environment variable carrying the value, never the value, and redacts it from
the URL it logs -- so a token cannot reach the log by way of the request.

Verified by booting this config in an image that has both plugins: both
ProviderStep plugins load, the pipeline initialises as
[validateSign validateSchema weather mandi signAck], and the module registers
at /. Worth noting the published adapter image does NOT yet carry mandi.so, so
against that image this config fails at startup with "plugin mandi not found"
until it is rebuilt from this branch.
…oo [#8]

Same change as the weather mapping: the response half read a hardcoded pack URL
and now takes it off the incoming select, so the file never has to know which
URL is current and cannot disagree with the caller.
Agmarknet writes an unreported price as a marker rather than omitting the
field -- "NR", "-", "". $exists() is true for all of them, so the
existence-only guard handed them to $number() and it threw:

  D3030: unable to cast value to a number: "NR" (argument 1)

That failed the WHOLE response half. One unreported cell in one row turned a
good multi-row answer into an adapter error -- a 500 where the honest answer
is "this row has no minimum", which is exactly the distinction the comment
above the function says it exists to preserve.

modal had no guard at all: it went through a bare $number while minimum and
maximum went through $priced. It uses $priced now, so the three agree.

The number branch in the new guard is not redundant, and I checked rather than
assumed: $match throws T0410 on a non-string, so testing the regex first would
break the day this upstream sends a real number instead of a quoted one.
Verified every input shape -- absent, "1441", "1441.50", 1441, "NR", "-", "",
"1,441" -- and only the numeric ones survive as numbers.

Test drives the shipped mapping with markers in all three price fields of one
row and real prices in another. It asserts the good row still arrives with
numbers AND that the markers are absent rather than zero. Confirmed it fails
against the old guard, reporting the D3030 above verbatim.

runShipped is split so a test can vary what the upstream returns; the existing
callers are unchanged.
…ce [#8]

Three findings, one rule. A record that cannot be made conformant is dropped
rather than emitted with a degenerate value -- absent is honest, and
present-and-wrong is a lie in the shape of an answer that this adapter then
signs.

  no Arrival Date   $iso is substring-and-concatenate and JSONata casts
                    undefined to "", so an absent date became the string "--".
                    The pack declares format: date, so that is invalid, not
                    merely ugly -- and arrivalDate is on the Direct required
                    list, so it cannot be omitted either. The resource id
                    degraded with it: res:agmarknet:2056:2:--
  no Price Unit     prices.required is [currency, unit], and JSONata drops an
                    absent key rather than emitting null, so the unit silently
                    vanished and took the resource's validity with it. NOT
                    defaulted: Rs./Qtl and Rs./Kg are both real, so inventing
                    one would misreport a price by a factor of a hundred.
  no usable price   prices carries anyOf [minimum, maximum, modal], so a row
                    whose three prices are all unreported markers has nothing
                    to report and cannot satisfy it.

Dropped rather than refused, because the other rows in the same answer are
good and failing the request would discard them too -- the mistake the price
guard in 55ca67d just fixed. The offer's resourceIds are filtered with them,
so dropping a record cannot leave a dangling reference; that is asserted.

TWO THINGS I GOT WRONG AND THE TESTS CAUGHT, both worth recording.

The first version used $exists($match(...)) for the date. This engine returns
an EMPTY ARRAY from $match when nothing matches, and $exists([]) is true -- so
the predicate was constant-true and the filter did nothing. It reads correctly
and does nothing at all, which is the worst combination.

Worse, I had verified it against the WRONG ENGINE. My probes used the
JavaScript jsonata package; the adapter uses github.com/jsonata-go/jsonata,
where $exists([]) differs. Verified in the real engine this time: bare $match
in a ternary and $count(...) > 0 both behave, only the $exists form does not.

And the filter made the previous commit's test vacuous -- its all-markers row
is now dropped, so the loop asserting "markers are absent" never ran. That
test now uses a row with one real price and two markers, so it still asserts
the distinction, plus a third row that is dropped for having none.
…uest [#8]

Two echoes, both of them claims this adapter signs.

subjectCategories was $ra.subjectCategories. It is a closed enum on
AgricultureResource and "Market" is what a MandiPrice resource IS -- the pack
states it in both of its own examples, and the sibling weather mapping has
always stated ["Weather"]. Echoing meant a caller sending ["Weather"] on a
MandiPrice select got it faithfully republished over this adapter's signature,
and nothing caught it: ["Weather"] is enum-legal, so it validates. Verified
that -- it comes back VALID against the pack, which is what made the echo
dangerous rather than merely untidy.

market took marketName, district and state from the record but marketCode from
the REQUEST. The review suggested taking the code from the record too; there
is nothing to take. This upstream TAKES a market code as a query parameter and
reports none back -- there is no such field anywhere in its response, which I
checked against the PR's own fixture. So the code is dropped rather than
sourced differently:

  - restating the requested code against a returned row asserts something
    unverified, and would misreport a provider that answered about a different
    market instead of surfacing it;
  - district-wide there is no requested code at all, while the rows come from
    several markets, so the single code was wrong for most of them.

The pack requires only marketName and calls marketCode "when available". Here
it is not available, and absent is the honest answer.

One test covering both, driven by a request that states the WRONG category, so
it fails on an echo of either field. Confirmed it fails against both old
forms.
…sked [#8]

Four findings in the mandi mapping.

RESOURCE IDS COLLIDED. The id was scope:commodity:date, and Agmarknet reports
several rows for the same market, commodity and date differing only by Variety
and Grade -- this package's own fixture is exactly that pair, saved from
collision only by having different arrival dates. Two distinct resources then
shared one id and the offer referenced it twice, so a consumer resolving
resourceIds could not tell which price it had. Market is in the id too:
district-wide $scope is the district and the rows come from several markets
inside it. Built with $join over a list, so an absent Variety or Grade drops
out rather than leaving an empty segment.

ONE COMMODITY, NOT THE FIRST OF SEVERAL. The guard, the outbound query and the
commodity stamped on each resource all read supportedCommodities[0], so a
caller sending three passed validation, was queried for one, and got a
confident signed answer to a third of what it asked. Refused now, with the
message saying to send them separately -- the same answer oanbinding gives at
the commitment level.

THE MARKET GUARD CHECKS WHAT IT PROMISED. Its message said "governed codes"
while the check was $exists, and the pack describes district and state as
"name or governed code" -- so the names form is pack-legal, passed, and went
to Agmarknet verbatim. Agmarknet answered nothing and the caller received a
signed, spec-valid "no prices" for a market that had prices. Now checks shape:
a numeric district and a short alphabetic state, which is what this upstream
takes.

supportedPriceFields IS HONOURED. It was validated on the way in and ignored
on the way out, so a caller asking for Modal alone got all three. Emitted
conditionally now, and the conformance filter judges usability over the
REQUESTED fields -- a row whose Modal is a marker cannot serve a request for
Modal alone even though its Minimum is fine.

Every construct was checked in the engine that actually runs it,
github.com/jsonata-go/jsonata, after the $exists([]) surprise in a77b70f:
$replace, $join over a list with gaps, `in`, a ternary with no else dropping
its key, and $match discriminating 96 from Balodabazar and CG from
Chattisgarh.

Four tests, each confirmed to fail against the old form -- the id one reports
both rows sharing res:agmarknet:2056:2:2025-08-20, and the price-fields one
reports minimum and maximum arriving unasked. One existing expectation moved
from "state and district" to "codes in market", which the new message carries.
MANDI_TOKEN was documented nowhere. It appeared exactly once outside tests --
on the config line that names it -- with no README, no env example, no
manifest. The failure mode is a plugin that loads cleanly, registers cleanly,
passes startup validation, and then fails 100% of requests, because the step
refuses to call an upstream unauthenticated and marks that as permanent.

There cannot be a default: a credential is not something this file may hold.
So the note sits at the point of use and says how to supply it under docker,
kubernetes and a local run.

This got MORE important since the review, not less. The error used to name the
variable; 5bd4021 moved that name off the wire, because a network peer has no
business learning which variables this deployment reads. The name is in the
adapter's own log at error level now, so the config is the only place a
deployer will find it in advance.

TWO CLAIMS ABOUT THE PACK WERE WRONG, and both were ours rather than the
pack's.

The header said "NOTHING HERE IS OUTSIDE THE PACK". True of the response half
-- every field it sets is declared and correctly placed, inside the closed
market and prices sets -- and not true of the request half, which reads market
and validity. Split, and the request side now says why: OnDemand describes
what a provider can obtain, Direct describes an obtained reading, and a
request is neither.

The guard note said the pack "leaves market and validity optional". It
excludes them. Corrected, with the consequence spelled out -- a payload that
satisfies these guards cannot validate, and one that validates cannot satisfy
them -- and with the reason nothing breaks today: the exclusion sits under
if/then, which the validator parses and never evaluates. An accident to rely
on rather than a design, now written down as such.

Also notes which constants are the pack's and which are this provider's, since
INR is stated while the unit beside it is read: Agmarknet reports a unit and
no currency, and the pack requires currency, so it cannot be read or omitted.
]

The rebase onto development brought a changed-file coverage gate, and this
branch failed it: mandi/cmd/plugin.go was the only changed non-test Go file and
sat at 0%, putting the diff at 3% against an 80% minimum.

The gap is the one the review already identified. mandi/cmd is a near-verbatim
copy of weather/cmd, but weather/cmd has five test functions and this had none
-- so the response cap's bounds checking and the auth-scheme rejection were
untested in this copy while passing in the other.

Not a copy of weather's tests, because the two files are not identical where it
counts: mandi carries queryName and queryValueEnv, and query auth is the whole
reason this capability needs its own entry -- Agmarknet's Vistaar API takes its
token as a query parameter. That case is covered explicitly.

Beyond weather's set: splitList is tested directly rather than only through
bindingKeys, an empty maxResponseBytes is asserted to read as unset rather than
as malformed (a rendered config with an unset variable produces exactly that),
and New's success path asserts the closer it returns actually reaches the
step's own closer.

parseConfig, New and splitList are now at 100%; the changed-line gate reports
100%.

This does not deduplicate the two cmd packages, which is the standing review
suggestion and is deliberately still open.
Three blocks had drifted from what they document, and the file's own history is
the reason it matters: a resource-id collision had to be fixed here once
already, so a future edit to $resourceId reading the comment above it would
have found a date-conversion rationale instead.

$resourceId's two blocks -- "bound once because it is used twice" and "built
from CODES, not the names the upstream reports" -- sat above $iso, which
converts dd-MM-yyyy to ISO and has nothing to do with either. They now join the
comment $resourceId already had.

$iso's own comment was orphaned further down with no code beneath it at all. It
is back above $iso.

The market-code paragraph moved to $scope rather than travelling with the rest:
"without it the query widened to the whole district, so the district code is
what identifies the scope" describes the $scope binding specifically, not how
the id is composed.

No expression changed -- the shipped-mapping tests serve this exact file over
HTTP and still pass.
@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Merge-readiness re-check (head 80dd82d55f012a29ae4eace3a3335fc9ff2b05bc)

All 8 issues from the three CHANGES_REQUESTED reviews are verified fixed in the current code (resource-id collisions, unguarded price casts, name-vs-code guards, silently-dropped multi-commodity requests, degenerate arrival dates, echoed subjectCategories, MANDI_TOKEN docs) — confirmed by reading the file at head and running the mandi test suite (all passing), not just the commit messages. The prior automated review's finding (misplaced $resourceId/$iso doc comments) is also fixed, in 80dd82d.

Two items remain open, both non-blocking:

  • mandi/cmd/plugin.go is still a near-verbatim copy of weather/cmd/plugin.go — explicitly acknowledged and deferred by the author, thread left open on purpose.
  • The market/validity guard vs. OnDemand mode is a documented, external pack-spec gap, not fixable in this repo.

No new issues found. From a code standpoint this looks good to merge; the outstanding CHANGES_REQUESTED reviews predate these fix commits and would just need re-approval.

🤖 Generated with Claude Code

@manjudr
manjudr merged commit 006dec8 into feat/41-oan-adapter-plugins Sep 8, 2026
2 checks passed
@manjudr
manjudr deleted the feat/8-mandi-plugin branch September 8, 2026 11:46
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