Skip to content

feat: add the OAN registry, JSONata mapper and weather provider plugins - #2

Merged
manjudr merged 68 commits into
developmentfrom
feat/41-oan-adapter-plugins
Sep 8, 2026
Merged

feat: add the OAN registry, JSONata mapper and weather provider plugins#2
manjudr merged 68 commits into
developmentfrom
feat/41-oan-adapter-plugins

Conversation

@ameersohel45

@ameersohel45 ameersohel45 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

What

Three plugins that let this adapter serve an OAN provider capability end to end, plus the core changes they need. Additive: nothing existing changes behaviour.

oanRegistry plugin — pkg/plugin/implementation/oanregistry/

Reads the OAN registry over its public /search endpoint. Serves two independent jobs: the sender's public key for validateSign (RegistryLookup), and a capability binding resolved into a call plan (ProviderRecordLookup).

Two caches under one operator-set TTL, off by default — the TTL is exactly how long a suspended participant keeps verifying.

It tracks the registry's current contract: bare base64 key material with no encoding label, keys identified by the osid the registry assigns rather than a friendly id, and consumer/provider/network roles. A response captured from a live registry is pinned as a test, alongside an older capture, because the registry is append-only — a row written under the previous contract keeps its old shape forever and both must keep reading.

Mapper plugin — pkg/plugin/implementation/jsonmapper/

Generic JSONata mapper: fetches a mapping at runtime, compiles, caches, runs. Knows nothing about any provider.

One file per binding-action carries both directions under request: and response:, plus a required: block of preconditions. Everything compiles on one fetch, and each part owns its own failure, so a typo in one half does not disable the other.

required: is what lets a capability state what it cannot serve. The schema packs are deliberately permissive — a spec-valid payload can still be unanswerable by a given upstream — so the mapping refuses those with its own message rather than sending a broken request and reporting whatever the provider says.

Provider step: machinery and domain

Split, because none of the calling is domain-specific:

  • pkg/plugin/implementation/internal/upstream/ — the machinery. Recognise the capability, resolve the call plan, translate out, call, translate back, with auth, retries and a response size cap. Holds nothing about any provider or domain.
  • pkg/plugin/implementation/weather/ — the domain. 32 lines: its name, and the prerequisite work a mapping cannot express.

Dispatch is by binding key (<participantId>|<capabilityCode>) read from the payload, with pass-through when it is not this step's capability, so several provider steps share one pipeline and adding a provider touches no routing table. A step serves a list of capabilities, and where the two halves of a binding key sit in a payload is overridable for a spec change, defaulting to the Beckn v2 convention.

Auth covers none, basic, header and query. The query scheme exists because some upstreams take their credential as a query parameter; the config holds the parameter's name and the name of the environment variable carrying the value, never the value. It also redacts: Go quotes the full URL in a transport error, so without that one unreachable host writes the credential into the log.

Every attempt logs what was asked and what came back, at info, with the credential removed:

upstream: GET http://host/v1/x?statecode=CG&token=REDACTED -> 200 OK, 492 bytes

Core changes

  • StepContext.ResponseBody lets a step return its own answer instead of the generated ACK. Empty means "generate the ACK", so existing modules are unaffected, and it is only consulted on the no-route path.
  • sendResponse writes that answer, and ackSigner signs the same bytes — signing the ACK while sending an answer would put a valid signature over the wrong body.
  • A module that answers capabilities itself refuses an unanswered request rather than ACKing it. An ACK with no callback tells the caller "accepted, answer follows" and leaves it waiting, which is how a stale binding key hides as a healthy response.
  • New mapper and providerSteps plugin kinds. providerSteps is separate from steps because they are handed a registry and a mapper, which the plain StepProvider contract cannot do.
  • ProviderRecordLookup and ErrProviderRecordNotFound in definition/registry.go, obtained by type-asserting a RegistryLookup — the same pattern already used for RegistryMetadataLookup.

The shipped mapping

config/mappings/mausamgram/weather-observation.select.yaml follows the openagrinet:WeatherObservation v0.1 pack: an OnDemand request, a Direct answer with one resource per forecast day, ids derived from each date, and the offer's resourceIds rewritten to the days actually returned rather than echoed from the request.

It answers however many days the provider sent, sorted on the numeric suffix rather than the key — fcstday1, fcstday10, fcstday2 sorts wrongly as text, and a ten-day forecast delivered in that order would be wrong in a way nothing downstream could detect.

Two details are spec conformance rather than taste. status.descriptor.code is DRAFT, because the Beckn v2 enum is DRAFT/ACTIVE/CLOSED and a quote is a draft commitment. And each resource carries a quantity, which Commitment.resources requires while the spec defines no quantity property and carries no Quantity schema at all — a defect upstream, but an answer without it fails validation for any consumer who validates.

The response context carries only correlation ids. It does not echo bapId, bapUri, bppId or bppUri: a mapping transforms a payload and has no business asserting network identity, and the two Uri fields were whatever the caller sent — a container-internal address, in a deployed stack, republished as though it were ours. Identity on an answer is the signature over it.

Why

An OAN provider is now a plugin plus a registry row, rather than another per-usecase backend service. The registry says which provider to call, how, and which mapping translates it; the plugin does only the work a mapping cannot express. Adding a provider touches no routing table and no shared code.

The machinery and domain split is what makes that claim testable: a second domain package should need no change to internal/upstream. It didn't — see the Mandi plugin on feat/8-mandi-plugin, which is 58 lines and touches none of it.

Testing

go build ./..., go vet ./... and go test ./... clean across 63 packages; race detector clean on the new packages.

The shipped mapping is run through the real mapper and the real provider step, against a captured provider response, rather than asserted about. Payloads in both directions are validated against the pinned Beckn v2 LTS spec and against openagrinet:WeatherObservation v0.1 with jsonschema — zero errors.

End to end on a local stack (registry, discovery, three adapters, mock provider): a signed /select returns an on_select carrying one resource per forecast day; /discover still answers; /publish reaches the discovery service through the network layer. A payload naming a provider this module is not configured for is refused rather than silently ACKed, and one whose registry row is missing reports the binding key that has no call plan.

Notes for review

  • config/local-beckn-one-bap.yaml and -bpp.yaml gained OAN plugin wiring. If the shipped samples should stay untouched, that is a clean revert — the OAN config also stands alone in config/oan-provider-adapter.yaml.
  • The registry's auth block is deliberately not read: an upstream's credential is the provider plugin's own configuration, and reading both would create two places that can disagree about how to authenticate a call.
  • A provider step matches the whole binding key, so it serves only the providers named in its config. Matching on the capability alone — which would let a provider be onboarded by registry writes alone — was built, tested and reverted: it silently skips the pre-call work of a provider nobody has written code for, and that defect exists today rather than being introduced by the change. Parked for discussion.

Story: OpenAgriNet/engineering-tracker#41

Closes #1
Closes OpenAgriNet/engineering-tracker#46
Closes OpenAgriNet/engineering-tracker#63
Closes OpenAgriNet/engineering-tracker#66

@ameersohel45 ameersohel45 changed the title Add the OAN registry, JSONata mapper and Mausamgram provider plugins feat: add the OAN registry, mapper and Mausamgram provider plugins Aug 31, 2026

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

Review

Reviewed all three plugins and the core changes at e64a1fe.

Baseline: go build ./... and go vet ./... clean, full go test ./... passes, go mod tidy is a no-op. Coverage on the new packages is 88-100%. The commenting discipline is unusually good — most of what follows I found because a comment states an intent the code does not quite deliver.

The top findings were verified with throwaway tests against this branch rather than asserted from reading; those tests are not part of this review.

Blocking

  1. upstream.call retries everything with no backoff — a 400 is retried 6x in 1.77ms; a missing credential is retried 4x and then reported as 502 NET_DOWNSTREAM_UNAVAILABLE; a cancelled request is retried 5x.
  2. jsonmapper stops caching permanently once MaxCacheEntries distinct refs have been seen — expired entries are never purged and the cap then refuses every new ref. Verified: 5 requests to one new ref produced 5 fetches.
  3. A withdrawn capability returns 500, not 404 — which contradicts the reasoning this PR adds to the no-route path in stdHandler.go.

Should fix

  1. config/oan-provider-adapter.yaml sets cacheTTL: 60s with no cache: plugin, so caching is silently off and every message costs three registry round trips.
  2. Response-leg mapping failures are classified 400, blaming the caller for the provider's answer.
  3. hasProviderSteps is derived from config presence, not from the step being wired into steps: — a missing steps: entry 404s the whole module with no startup error.
  4. A payload with several commitments naming the same capability passes binding derivation, then loses all but commitments[0] in the mapping.
  5. The generated on_select is signed and sent without schema validation. Concrete case: if the provider omits location, JSONata drops the undefined values and coordinates becomes [] — an invalid Point, signed and delivered.

Nits

Stale "Mausamgram" naming in two places after the rename; an orphaned doc comment in weather/cmd/plugin.go; a blank line detaching the parseConfig comment in jsonmapper/cmd/plugin.go:21; oanregistry/cmd missing the var _ definition.RegistryLookupProvider = Provider assertion both other new plugins added; an ambiguous lookup cache key; | not rejected inside either half of a binding key; CONFIG.md not updated for the new mapper and providerSteps keys; the 27-line commented registry block duplicated 5x across the two sample configs; no single-flight in jsonmapper.compiled, so a just-expired popular mapping is fetched and compiled once per concurrent request; and no README for weather / internal/upstream while both other new plugins have a good one.

What is good

The registry plugin's security posture is genuinely careful: resolveStatus built as an allow-list because IsKeyStatusUsable is a deny-list, per-key status checked separately from participant status, cache entries re-validated on read because a shared cache outlives a deploy, the unclamped Retry-After clamp, and caching only usable results. Splitting ProviderRecordLookup from RegistryLookup and asserting the narrowing at startup in loadProviderStep is the right call. And the ackSigner/sendResponse pairing — signing exactly the bytes that will be written — is the subtle bug this PR could easily have shipped and did not.

Comment thread pkg/plugin/implementation/internal/upstream/upstream.go
Comment thread pkg/plugin/implementation/jsonmapper/jsonmapper.go
Comment thread pkg/plugin/implementation/internal/upstream/upstream.go
Comment thread config/oan-provider-adapter.yaml Outdated
Comment thread pkg/plugin/implementation/jsonmapper/jsonmapper.go Outdated
Comment thread core/module/handler/stdHandler.go
Comment thread pkg/plugin/implementation/oanregistry/oanregistry.go Outdated
Comment thread pkg/plugin/implementation/internal/upstream/upstream.go Outdated
Comment thread pkg/plugin/implementation/weather/cmd/plugin.go Outdated
@ameersohel45 ameersohel45 changed the title feat: add the OAN registry, mapper and Mausamgram provider plugins feat: add the OAN registry, JSONata mapper and weather provider plugins Sep 3, 2026
ameersohel45 added a commit that referenced this pull request Sep 3, 2026
Seven findings from the review at e64a1fe, in one commit because they are one
pass over the same three packages. Two of them were things a comment claimed
and the code did not do.

RETRY CLASSIFICATION. The loop retried every non-nil error at full rate with
no wait, so a 400 burned a retryMax of 5 inside two milliseconds and a missing
credential was retried four times and then reported as 502
NET_DOWNSTREAM_UNAVAILABLE -- an operator's unset environment variable
laundered into "the provider is down", which points the investigation at the
wrong system. Failures no further attempt can change are now marked and not
repeated: a request this step could not build, a credential it could not read,
a response over the size cap, and any 4xx other than 429. 5xx and 429 still
retry, because those are the provider asking to be. Between attempts there is
exponential backoff from 50ms capped at 800ms, and a dead context breaks the
loop before the call rather than after it.

THE MAPPER STOPPED CACHING PERMANENTLY. cached() treated an expired entry as a
miss but left it in the map and nothing ever deleted one, so the count only
grew; once it reached MaxCacheEntries the cache refused every reference it was
not already holding, for the life of the process. The comment said it "just
pays to compile again next time" -- it paid every time, for every mapping the
deployment had. Expired entries are now swept before the cap is measured. The
test reproduces the reviewer's observation: five requests for a fourth
reference produced five fetches before the fix and one after.

A WITHDRAWN CAPABILITY REPORTED 500. ErrProviderRecordNotFound was wrapped in
a plain fmt.Errorf, which lands in the unclassified 500 path, so "binding
withdrawn" and "provider suspended" read as this adapter failing. It is a 404
now, with the sentinel still wrapped so errors.Is keeps matching. A registry
that could not be consulted stays a 500, because that one is us.

A RESPONSE-LEG MAPPING FAILURE BLAMED THE CALLER. Both directions returned 400
SCH_SCHEMA_ADAPTATION_FAILED, justified as the caller's payload being wrong.
That holds for the request leg. On the response leg the input is the
provider's answer, so a provider that changed shape sent the caller off to fix
a request that was fine. The response leg is a 502.

SEVERAL COMMITMENTS WERE HALVED. A payload naming the same provider and
capability across N commitments resolved to one binding key without complaint,
and the mapping then read commitments[0] -- so the caller received a
confident, signed, spec-valid answer to part of what it asked. Refused, with
the count and the advice to send them separately. The test that asserted the
old behaviour is replaced by its inverse.

A TTL THAT CACHED NOTHING. config/oan-provider-adapter.yaml set cacheTTL with
no cache plugin, so caching was silently off and every message made three
registry calls inside signature validation's budget -- while the commented
block this PR added to the bpp sample warns about exactly that. The TTL is
commented out with the reason, and the registry plugin now says so at startup
when a TTL is set without a cache, so it cannot recur quietly.

AN INVALID POINT, SIGNED. If the provider answered without echoing its
location, JSONata dropped the undefined values and the mapping emitted
"coordinates": [] -- an invalid Point, signed and delivered. It now emits
location only when both coordinates exist. Absent is honest; empty is a lie in
the shape of an answer.

Also the two stale Mausamgram names after the rename to weather, and an
orphaned doc comment that documented var Provider from 25 lines away.

Not taken, both by decision: requiring every declared providerStep to appear
in steps: -- declare-without-wiring is how every plugin behaves and the
resulting 404 is truthful, though a startup line naming which are wired would
have saved some debugging. And validating the generated response before
signing it, which touches the handler pipeline and is its own change; the one
demonstrated case it would have caught is fixed above in the mapping instead.
Comment thread config/mappings/mausamgram/weather-observation.select.yaml Outdated
Comment thread config/oan-provider-adapter.yaml Outdated
Comment thread config/oan-provider-adapter.yaml Outdated
Comment thread config/oan-provider-adapter.yaml Outdated

@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

This is a well-built PR, and I want to lead with two things I verified rather than assumed:

The tests are real. ~131 assertions across 55 tests in upstream_test.go, ~122 across 38 in oanregistry_test.go, table-driven, with named regression tests for the instrumentor copy-back, signing the answer vs the generated ACK, duplicate provider-step ids, and cache-cap thrash. For ~5,500 lines of new test code that is a genuinely strong suite, not decoration.

It builds and passes. I cloned the branch and ran it, because CI doesn't (see below): go build ./..., go vet ./..., go test ./core/... ./pkg/..., and go test -race over core plus the three new plugin trees are all clean. The function main is undeclared in the main package lines are the pre-existing -buildmode=plugin pattern — they appear for cache/registry/signer/router too — not a regression.

Scope is tight, which I appreciate on an 11k-line PR. The only pre-existing files touched are core/module/handler/*, pkg/model/model.go, pkg/plugin/{manager.go,definition/*} and install/build-plugins.sh, and all of it is plumbing the new plugins genuinely need. One duplication note inline (servedActions).

Detailed findings inline. Summarising what needs action.

Blockers

  1. oanbinding.go:59 — the multi-commitment guard doesn't do what its own comment says. It counts provider-id values resolved, not commitments, and walk returns nothing for a missing or non-string leaf. A two-commitment payload whose second commitment omits offer.provider.id passes the guard, resolves off commitments[0], and serves half the request — verbatim the "confident, signed, spec-valid answer to part of what it asked" the comment exists to prevent. The tests only cover the single-commitment case, so the suite can't catch it.

  2. oanregistry.go:356 — ambiguous cache key on the signing-key path. oan_lookup_%s_%s joined on _ collides for participants a/key b_c and a_b/key c, and cached() re-checks shape but never identity. Inbound signatures claiming to be one participant then verify against the other's public key. Latent while cacheTTL is 0, arms the moment caching is enabled.

Should fix before merge

  • oanbinding.go:60 + upstream.go:266 — arity refused before ownership, so one provider step 400s multi-commitment payloads addressed to a different step. As written the documented pass-through dispatch only works with exactly one provider step configured.
  • providerrecord.go:328 — unbounded io.ReadAll on the per-request signature-validation path, against a plain-HTTP registry. Sibling code in this PR (upstream.go:588, jsonmapper.go:487) correctly uses io.LimitReader.
  • upstream.go:602 — up to 300 bytes of the provider's body, and internal env-var names, are echoed to the network caller in the NACK. providerrecord.go:335 already gets this right and explains why.
  • responsestep.go:52 — the mapped response is never required to be a JSON object, so a scalar JSONata result ships as a signed 200 application/json.
  • upstream.go:480 — registry-supplied retryMax/timeoutMs have no ceiling; one row can pin a goroutine and an inbound connection for ~17 hours per request.
  • stdHandler.go:695hasProviderSteps comes from declaration rather than the executed step list, so a declared-but-unlisted provider step puts the whole module into 404 mode.

Lower severity

upstream.go:541 (backoff shift overflows past attempt 59 → tight retry loop), :498 (redact discards the wrap chain, breaking errors.Is downstream), :565 (method not normalised, so post → 405 → a 502 blaming the provider), :720 (baseURL never validated; a # in path silently drops the query string and produces a plausible wrong answer), jsonmapper.go:312 (no single-flight; at the cache cap every request re-fetches the mapping over HTTP indefinitely).

Separately — this PR has no CI validation at all

Not a defect in the diff, but it's why I ran the toolchain by hand, and it's worth fixing alongside:

  • All three Go workflows on development gate on upstream branch names that don't exist in this flow — ci.yml on [beck-onix-v1.0-develop, beck-onix-v1.0], beckn_ci.yml and beckn_ci_test.yml on [beckn-onix-v1.0-develop]. A PR into development matches none.
  • Even if beckn_ci.yml fired, it sets APP_DIRECTORY: "shared/plugin", while these plugins live under pkg/plugin/implementation/ — so it would test the wrong tree and pass vacuously.
  • build-and-deploy-plugins.yml is workflow_dispatch-only, so the new plugins have no automated build path either.
  • The one check that does run, "Terraform Plan Only", fails on pre-existing Gerrit auth (git clone … UNAUTHENTICATED), unrelated to this PR.

So +11,205 lines are landing with no automated proof they compile. PR #14 adds proper test/security workflows but gates them on main only, so it doesn't close this gap — adding development to those filters would.


Findings 1 and 2 are the ones I'd hold the merge on; the rest are straightforward. Happy to re-review quickly once those are addressed.

Comment thread pkg/plugin/implementation/internal/oanbinding/oanbinding.go Outdated
Comment thread pkg/plugin/implementation/internal/oanbinding/oanbinding.go Outdated
Comment thread pkg/plugin/implementation/oanregistry/oanregistry.go Outdated
Comment thread pkg/plugin/implementation/oanregistry/providerrecord.go Outdated
Comment thread pkg/plugin/implementation/oanregistry/providerrecord.go Outdated
Comment thread pkg/plugin/implementation/internal/upstream/upstream.go Outdated
Comment thread pkg/plugin/implementation/internal/upstream/upstream.go
Comment thread pkg/plugin/implementation/jsonmapper/jsonmapper.go
Comment thread core/module/handler/responsestep.go
Comment thread core/module/handler/stdHandler.go

@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

Reviewing again against the defaults you asked for — no hardcodes (placeholder + example, read from config), proper logs, no unused methods or logic. These are additions to my earlier review; the two blockers there (oanbinding.go:59 commitment arity, oanregistry.go:356 cache key) still stand.

1. No unused method or logic — clean

I swept mandi, weather, internal/upstream, internal/oanbinding, oanregistry, jsonmapper and their cmd packages for unexported functions with no production caller, config fields never read, and consts never referenced. Nothing real. The two candidates my scan raised both dissolved on checking:

  • Default{RetryMax,RetryWaitMin,RetryWaitMax} are consumed in oanregistry/cmd/plugin.go:23-25; my scan was package-scoped and missed it. The declaration comment already explains they are exported to be the single source of truth for cmd.
  • jsonmapper.cachedCount() is a documented test accessor, which is a legitimate pattern.

No dead code, no speculative knobs, no orphaned helpers. Reporting this as a pass rather than inventing findings.

2. Proper logs — one security bug, one gap

The logging style is right: log.Debugf/Infof/Warnf(ctx, ...) matching the repo's house convention, with levels chosen deliberately (debug for pass-through, info for what was asked and answered, warn for a failed attempt). No fmt.Println, no logging in tight loops, no logged payload bodies.

But one finding is a blocker, inline at upstream.go:706:

redactString matches the raw env value while authenticate writes the credential through query.Encode(). Any token containing +, /, = or a space — every standard-base64 token — is not redacted, and goes to the log in recoverable percent-encoded form at info level on every successful call. redact() has the same hole at warn. I verified this by running the real authenticate -> redactString sequence; output quoted inline.

The existing test passes only because its token is s3cr3t, whose encoded and raw forms are identical — the one input class that cannot show the bug.

The gap: oanbinding has zero log calls across 205 lines despite being the component that rejects traffic, so refusals are unattributable to a subscriber or transaction. Inline at oanbinding.go:74.

3. No hardcodes — two committed environment values

Two values in oan-provider-adapter.yaml are deployment-specific but committed as literals, inline above:

  • :62 subscriberId: provider-network-vistaar.da.gov.in — a real network identity, and duplicated at :113. This is what signatures are checked against, so an operator who edits one occurrence and misses the other signs as the wrong subscriber.
  • :81 url: http://registry:8081/api/v1 — a Compose service name and port that resolves in exactly one deployment topology.

Both want the pattern you asked for: a ${VAR} placeholder with the working value kept as a commented example.

Credit where due — the auth scheme itself already does this correctly. queryValueEnv: MANDI_TOKEN reads the secret from the environment rather than committing it, and lines 217-232 document each scheme with examples. The request is to extend that same discipline to the identity and endpoint values.

The magic numbers I checked are fine: explainLimit = 300 and DefaultMaxCacheEntries = 200 are named, defaulted, and overridable — the correct pattern, not hardcodes.

Comment thread pkg/plugin/implementation/internal/upstream/upstream.go Outdated
Comment thread pkg/plugin/implementation/internal/upstream/upstream.go
Comment thread config/provider-adapter.yaml
Comment thread config/provider-adapter.yaml
Comment thread pkg/plugin/implementation/internal/oanbinding/oanbinding.go Outdated

@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: WeatherObservation v0.1

Reviewed the mapping against the actual schema pack in OpenAgriNet/network-specs (branch schema-packs-v0.1, schema/WeatherObservation/v0.1/), and validated the mapping's real output shapes against the pack's own subschemas with a JSON Schema validator. Two blockers, both empirically confirmed and quoted inline.

1. The required: guard demands a field the pack forbids — every conformant request is refused

WeatherObservation OnDemand does not leave location optional; its not/anyOf clause excludes it, along with validity, parameters, observationType, source, generatedAt, observedAt and modelRunAt.

payload satisfying the mapping's Point-location guard   -> VIOLATES PACK
the pack's OWN OnDemand example (coverageAreas)         -> CONFORMS

The adapter therefore rejects the pack's own example with "this capability needs a Point location", and every payload it does accept is one a conformant validator would refuse. The request half reads $ra.location.coordinates from the same forbidden field, so this is structural rather than cosmetic.

The pack's mechanism for OnDemand geography is coverageAreas, whose items are oneOf [AdministrativeAreaReference, CompleteGeoJSONGeometry]a Point geometry fits there — with geographicGranularities stating the granularity. Suggested rewrite inline at :83.

2. aggregation invalidates every parameter entry, and the comment claiming otherwise is wrong

The comment at :47-52 says "The pack sets no additionalProperties, so it validates." The pack sets additionalProperties: false on parameters.items:

pack allows keys = ['parameter', 'unit', 'value']
$reading('Temperature','Maximum','Cel',31.4)  -> INVALID  ('aggregation' was unexpected)
$alert('Heavy rain expected')                 -> VALID

All six $reading call sites pass a non-empty aggregation, so every rainfall/temperature/humidity/wind entry is invalid — i.e. every normal response. The need is real (the provider reports min and max), but the fix belongs in the pack: propose aggregation as an optional enum, or get distinct parameter values added. Detail and options inline at :48.

3. Fewer hardcodes — and one place that needs more

You asked for fewer hardcodes; on this file the specific ones are mostly defensible, and one runs the other way:

  • @context (:164) should be stated, not echoed. Right now @type is hardcoded while @context is taken from the caller, though the pack pins both together in x-jsonld. Echoing reflects a caller's stale or absent context back inside a response the adapter signs. Inline.
  • "subjectCategories": ["Weather"] (:237) is correct as a literal — it is a pack enum value ([Crop, Livestock, Weather, Market, Scheme, Practice]) and a property of the capability, not of the deployment. Keep it.
  • unit values ("Cel", "mm", "%", "m/s", "1") are correct as literals — they describe what this provider actually returns, and "1" for unitless is what the pack prescribes.

The values genuinely worth moving to configuration are the deployment ones I flagged separately (subscriberId, the registry URL), not these. Worth stating the rule explicitly somewhere: pack constants belong in the mapping; deployment values belong in config. By that rule this file is close to right once @context moves to the stated side.

Verification

Schema facts above come from schema/WeatherObservation/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 the self-contained parameters.items subschema. No external $ref was stubbed or approximated for any result quoted here.

Comment thread config/mappings/mausamgram/weather-observation.select.yaml
Comment thread config/mappings/mausamgram/weather-observation.select.yaml Outdated
Comment thread config/mappings/mausamgram/weather-observation.select.yaml
ameersohel45 added a commit that referenced this pull request Sep 7, 2026
compiled() checked the cache, missed, and fetched -- with nothing coordinating
callers. On a cold start every concurrent request for the same capability
fetched the mapping over HTTP and compiled it. Measured: 25 concurrent misses
made 25 fetches.

The existing comment acknowledged the duplicate compile. The round trip is the
larger cost and was not mentioned: it is bounded by fetchTimeout, so N
requests arriving together waited N times for one document, and the publisher
saw N identical reads every time a capability came up.

singleflight.Group, which is already a direct dependency. The reference is
re-checked inside the group, because a store can land between the miss and the
turn to run, and reusing it is cheaper and more consistent than fetching a
second copy.

The trade is written down rather than left to be discovered: the shared call
inherits the FIRST caller's context, so if that caller goes away the work is
cancelled for everyone waiting. Bounded by fetchTimeout, and the losers get a
cancellation they can retry rather than a wrong answer.

THE REVIEW'S SHARPER POINT IS ALREADY FIXED, and I checked before writing
anything. It said that at the cap remember() refuses to store, so a mapping
past it would fetch and compile forever. purgeExpired() now runs BEFORE the cap
is measured, and the comment above it describes exactly that failure --
introduced by 4885635, "address the review findings on PR #2". So that half of
the thread is answered; this commit is only the single-flight half.

Two tests. The first asserts one fetch for 25 overlapping callers, with a slow
handler so they genuinely overlap, and it reports 25 without the fix. The
second guards the two things single-flight could plausibly break: a later
request must still be served from cache rather than refetching, and a
different reference must not wait behind or inherit an unrelated one. Clean
under -race.
@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. The envelope refusal added in 2b2fd45 ships a NACK body under a Signature header computed over the rejected answer. ackSignerStep.RunOnResponse runs as a response step and signs ctx.ResponseBody, setting the header on the live ResponseWriter map; sendResponse then runs verifyEnvelope and, on failure, calls sendNack, which does no signing. signNackResponse is a stdHandler method and is unreachable from the package-level sendResponse.

Verified against da6480d with a step answering 28.5:

status    = 500
body      = {"message":{"status":"NACK","messageId":"msg-1","error":{"code":"NET_INTERNAL_ERROR",...}}}
Signature = ...signature="sig-over-the-scalar"
signed bytes = "28.5"

This is the invariant the change states for itself one path over — the 404 case is checked before the response steps precisely so this cannot happen:

//
// Checked before the response steps rather than after, because
// ackSigner signs the body it expects to be written; NACKing later
// would ship a signature over the ACK with a NACK body.
//
// Only for modules with provider steps. Elsewhere an unanswered
// request is the publisher path doing exactly what it should.

The refusal path is the one that reverses that order:

// where the mapping is, and the NACK names the step so it is findable.
if err := verifyEnvelope(ctx.ResponseBody); err != nil {
log.Errorf(ctx, err, "a step produced a response that is not a Beckn envelope; refusing to sign it")
// A plain error on purpose: nackBecknError's default branch turns it
// into a generic 500, so the caller learns the answer failed without
// being handed the internals of a mapping it does not own. The detail
// is in the log line above, where the operator is.
return sendNack(ctx, w, err)
}
return writeJSONResponse(ctx, w, ctx.ResponseBody)

// ACK. Either way this signs exactly what sendResponse will write -- signing
// the ACK while sending an answer would put a valid signature over the wrong
// bytes.
ackBody := ctx.ResponseBody
if len(ackBody) == 0 {
built, err := buildAckBody(ctx.ProtocolVersion, ctx.MessageID)
if err != nil {
return fmt.Errorf("ackSigner: failed to build ack body: %w", err)
}
ackBody = built
}
// signBodyAndSetHeader writes to ctx.RespHeader which IS the http.ResponseWriter

A peer that verifies the signature on the NACK sees a digest mismatch, so a mapping bug reaches it as suspected tampering rather than as the 500 the code intends. The len(envelope) == 0 branch ({}) behaves the same way. Moving the verifyEnvelope check into ServeHTTP ahead of the response-step loop, next to the existing hasProviderSteps guard, would put it back on the same side of signing as the 404.

Also worth noting: TestAckSignerSignsTheAnswerThatWillBeSent pins that the answer gets signed, but no test covers a non-envelope answer together with the signature header, which is why the gap is invisible to the suite.

Otherwise clean on the areas I checked: query-credential redaction covers both raw and percent-encoded forms and is correctly scoped to the query scheme (basic/header never reach the URL); jsonmapper guards err/hasTransform before evaluate, so the nil evaluating mutex on a compile-failure mapping is unreachable; plugin closers for both new kinds are registered on m.closers per the existing pattern; cacheTTL: 0 disables caching rather than writing zero-TTL entries, and warns when a TTL is set with no cache plugin; ResponseBody is propagated through InstrumentedStep; the fcstday sort keys on the numeric suffix as documented.

🤖 Generated with Claude Code

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

@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Code review (follow-up)

Four documentation-vs-code contradictions and one refactor leftover, all verified against the code at da6480d. Separate from the signing issue above; none of these is a runtime defect, but in a change where the rationale comments are part of the deliverable, each one tells the next reader the opposite of what the code does.

  1. The reference config states that 4xx is never retried; 429 is retried. resp.StatusCode != http.StatusTooManyRequests is an explicit exception, and upstream.go says so two lines up.

#
# Retry classification is not configurable at all: 4xx is permanent and is not
# retried, 5xx and transport errors are, and the backoff rises from 50ms to a
# 800ms ceiling. A non-2xx reaches the caller as an error with the provider's
# own response included, and the mapping never runs on it -- which is why an
# upstream that signals "no data" with a 4xx surfaces as a failure rather than
# an empty result.
# ----------------------------------------------------------------------------

err := fmt.Errorf("provider returned %s", resp.Status)
// 5xx and 429 are the provider asking to be tried again. Every other
// 4xx is a statement about the request, which will not improve.
if resp.StatusCode < http.StatusInternalServerError && resp.StatusCode != http.StatusTooManyRequests {
return nil, doNotRetry(err)
}

  1. Same block: "A non-2xx reaches the caller as an error with the provider's own response included" is backwards, and in the direction that matters. The error is fmt.Errorf("provider returned %s", resp.Status) — status only. The body goes to the log, deliberately, because "what a provider puts in a failure body is its own business -- a stack trace, an internal hostname, a database error." An operator reading the config would expect the upstream body to surface to the BAP; the code prevents exactly that.

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
// The body is logged, not returned. It goes into a 502 that is signed
// and sent to the network caller, and what a provider puts in a failure
// body is its own business -- a stack trace, an internal hostname, a
// database error. The status is the caller's business and stays; the
// body is the operator's, and the log is where the operator looks.
// Redacted on the way to the log too. A provider that rejects a
// request often quotes it back, credential and all -- so the body is
// exactly where a query-string token turns up, and moving it from the
// error to the log would only move the leak.
log.Warnf(ctx, "upstream: provider returned %s for %s %s: %s",
resp.Status, method, requested, s.redactString(explain(body)))
err := fmt.Errorf("provider returned %s", resp.Status)

  1. The mapper README makes SCH_SCHEMA_ADAPTATION_FAILED a blanket bad request. Only the request leg is a 400; a response-leg failure is a 502, for the reason the code gives — the input there is the provider's answer, not the caller's request. An integrator classifying that code as 4xx for retry or alerting will misfile upstream-shape failures as client errors.

## Failure
A mapping that cannot be fetched, parsed or compiled is an operator or registry
fault and surfaces as a plain error. A mapping that ran but could not be applied
is the payload's shape being wrong, and surfaces as a `SCH_SCHEMA_ADAPTATION_FAILED`
bad request.

if err != nil {
log.Errorf(ctx, err, "JSON mapping %s %s half failed to evaluate: %v", mappingRef, direction, err)
wrapped := fmt.Errorf("mapping %q %s half could not be applied: %w", mappingRef, direction, err)
if direction == definition.DirectionResponse {
// The input here is the PROVIDER's answer, not the caller's
// request. A provider that changed shape, or a bug in the response
// half, is nothing the caller did -- reporting 400 sends them off
// to fix a request that was fine. 502: the upstream exchange is
// what failed.
return nil, model.NewCodedErr(http.StatusBadGateway, codeAdaptationFailed, wrapped)
}
// On the request leg the mapping is valid and the payload is not what it
// expected, so this is the caller's request being wrong.
return nil, model.NewBadReqErr(codeAdaptationFailed, wrapped)
}

  1. The registry README's operation=provider_record outcome list reads as exhaustive and omits binding_no_actions, which is live and reachable. Dashboards built off the documented enumeration would silently undercount the active-and-owned-but-serves-nothing refusal — the case the code calls out as worth keeping distinct.

Provider-record lookups report under `operation=provider_record`, with their own
outcomes: `binding_not_found` · `binding_inactive` · `binding_unowned` ·
`participant_not_found` · `participant_inactive` · `no_upstream_url` ·
`no_binding_key`. Each refusal is kept distinct: they all deny the call, but a
withdrawn capability and a suspended provider are different operational events.

}
if len(servableActions(binding)) == 0 {
// Active, owned, and callable for nothing. Refusing here says so, rather
// than letting every action fail one at a time further down.
log.Errorf(ctx, nil, "OAN registry capability binding bindingKey=%s serves no active actions", bindingKey)
return providerBinding{}, outcomeBindingNoActions, nil
}

  1. Leftover from dc3a1af: the servedActions doc comment survived the hoist onto model.ProviderRecord and now sits directly above refuse, documenting nothing in this file. Two functions deep, it reads as refuse's own comment.

return plan, nil
}
// servedActions lists the actions a plan covers, sorted so the same record logs
// the same way twice.
// refuse records a deliberate denial and returns the caller's sentinel. The
// registry answered; the answer was no.

Checked and consistent: all five jsonmapper config keys and defaults (fetchTimeout 5s, cacheTTL 1h, negativeTTL 1m, maxMappingBytes 262144, maxCacheEntries 200) and all eight oanregistry keys match their struct tags and parsing; TimeoutMs/RetryMax pass through without local default substitution as documented; BindingKey is carried verbatim, never reconstructed; the "providerIdAt/capabilityCodeAt: both or neither, refused at startup" claim holds; ProviderStep returns nil rather than an error for a capability it does not serve, on both the ErrNoBinding and !serves() paths; StepContext.ResponseBody is only read when rctx == nil, so the routing path genuinely never consults it; the oanbinding path-walk []-only semantics match their doc comment. No TODO or FIXME in the new Go files.

🤖 Generated with Claude Code

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

@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Code review (follow-up 2)

One more correctness issue, verified at da6480d.

  1. Body-echo credential redaction covers only the query scheme, so a basic or header credential echoed back by a provider is logged in the clear at warn level. redactString hard-returns on anything that is not AuthSchemeQuery:

// first thing anyone wants when a provider misbehaves. This is what makes that
// safe to do at info level.
func (s *Step) redactString(text string) string {
if s.config.AuthScheme != AuthSchemeQuery {
return text
}
value := os.Getenv(s.config.QueryValueEnv)
if value == "" {
return text
}
text = strings.ReplaceAll(text, value, redactedMarker)

It is applied not just to the URL but to the provider's response body, on the non-2xx path:

// error to the log would only move the leak.
log.Warnf(ctx, "upstream: provider returned %s for %s %s: %s",
resp.Status, method, requested, s.redactString(explain(body)))
err := fmt.Errorf("provider returned %s", resp.Status)

That body-echo case is one the change already treats as in scope. TestRunRedactsACredentialEchoedInABody mocks "a provider quoting the request it rejected, credential and all" and asserts on step.redactString(explain(...)) precisely so that "moving the body from the error to the log cannot quietly move the leak with it" — but it only ever exercises AuthSchemeQuery:

// provider that echoes the query string back would defeat it.
func TestRunRedactsACredentialEchoedInABody(t *testing.T) {
// No t.Parallel: t.Setenv forbids it.
t.Setenv("TEST_ECHO_TOKEN", "s3cr3t")
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
// A provider quoting the request it rejected, credential and all.
fmt.Fprintf(w, `{"rejected":%q}`, r.URL.RawQuery)
}))
defer upstream.Close()
mapper := &stubMapper{requestResult: []byte(`{}`), responseResult: []byte(`{}`)}
step := newStep(t, &stubRegistry{plan: testPlan(upstream.URL, http.MethodGet)}, mapper,
func(c *Config) {
c.AuthScheme = AuthSchemeQuery
c.QueryName = "token"
c.QueryValueEnv = "TEST_ECHO_TOKEN"
})
_, err := runStep(t, step, selectBody)
if err == nil {
t.Fatal("expected the failure to be reported")
}
if strings.Contains(err.Error(), "s3cr3t") {
t.Errorf("the credential leaked through the quoted body: %v", err)
}
// The body no longer reaches the error at all, so its absence from the
// wire is not what needs proving here -- the LOG is where it goes now, and
// a provider echoing the request back is exactly where a query-string
// token turns up. Assert on the same expression the code logs, so moving
// the body from the error to the log cannot quietly move the leak with it.
echoed := `{"rejected":"token=s3cr3t"}`
logged := step.redactString(explain([]byte(echoed)))
if strings.Contains(logged, "s3cr3t") {
t.Errorf("the credential survives into the log line: %s", logged)
}
if !strings.Contains(logged, "REDACTED") {
t.Errorf("logged body = %q, want the credential replaced", logged)
}
}

The shipped config is the scheme that is not covered:

# header a named header; headerName + headerValueEnv
# query a named query parameter; queryName + queryValueEnv.
# Redacted from the URL the step logs.
authScheme: basic
usernameEnv: MAUSAMGRAM_USER
passwordEnv: MAUSAMGRAM_X_API_KEY

Verified by calling redactString(explain(body)) on a step configured exactly as the sample config does, with the credential in the body:

basic  (wire form)  {"error":"invalid Authorization: Basic bWF1c2FtOnMzY3IzdA=="}   -> unchanged
basic  (raw pass)   {"error":"bad password s3cr3t"}                                 -> unchanged
header              {"error":"bad X-API-Key: hdr-k3y"}                              -> unchanged

An API gateway quoting the rejected Authorization header is the ordinary shape of a 401/403 body, and a wrong-credential 4xx is not retried, so it lands in the log once per request for as long as the credential is wrong. Worth noting the fix is not symmetric with the query case: for basic the leaked value is base64(user:pass), so redacting $MAUSAMGRAM_USER and $MAUSAMGRAM_X_API_KEY individually would miss the wire form — it needs the encoded pair too, the way the query path already handles url.QueryEscape.

The comment at line 228 ("Redacted from the URL the step logs") is accurate for query and is the only scheme that says anything about redaction, so the config is not making a false promise here — but the asymmetry is invisible to an operator choosing basic.

🤖 Generated with Claude Code

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

@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Please rebase your branch from the development, as there are CI changes applied

@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Code review (follow-up 3)

Three more, all verified against da6480d.

  1. da6480d's message says the subscriber id was placeholdered, but it was not. The message reads "Same treatment as the subscriber id, and for the same reason: a reference config should show what a value looks like without presenting one deployment's as everyone's."da6480d only changed url: http://registry:8081/api/v1 to url: <>. Across ced4555..da6480d the subscriberId lines were only ever added, never placeholdered, and both still carry provider-network-vistaar.da.gov.in. The hardcode itself was raised earlier; the new information is that the commit message asserts a fix that isn't in the tree, so it reads as done when it isn't.

role: bpp
subscriberId: provider-network-vistaar.da.gov.in

config:
subscriberId: provider-network-vistaar.da.gov.in

  1. Second orphaned servedActions doc comment, this one in upstream.go. Same dc3a1af hoist leftover as the one already reported in providerrecord.go — the comment describes servedActions, but sits between the end of one function and // buildRequest produces what the provider is sent., so it documents nothing.

log.Infof(ctx, "upstream: served %s in %d bytes", plan.BindingKey, len(becknResponse))
return nil
}
// servedActions lists the actions a capability covers, sorted so the same
// record reads the same way twice.
// buildRequest produces what the provider is sent.
//

  1. TestInitStepsRefusesTwoProviderStepsWithTheSameID passes with the duplicate-ID guard deleted. This is not a coverage nitpick — the test gives false assurance about logic this PR adds. h := &stdHandler{moduleName: "test-module"} never sets h.mapper, so loadProviderStep fails on the first config entry with failed to load ProviderStep plugin (mausamgram): Mapper plugin not configured, and the assertion strings.Contains(err.Error(), "mausamgram") matches the id embedded in that mapper error rather than in the duplicate-id error. Removing the if _, taken := steps[c.ID]; taken block entirely and rerunning still gives --- PASS. The guard has zero reaching coverage.

// id is now a mistake rather than the way to configure a second one.
func TestInitStepsRefusesTwoProviderStepsWithTheSameID(t *testing.T) {
h := &stdHandler{moduleName: "test-module"}
cfg := &Config{
Plugins: PluginCfg{
ProviderSteps: []plugin.Config{
{ID: "mausamgram", Config: map[string]string{}},
{ID: "mausamgram", Config: map[string]string{}},
},
},
}
err := h.initSteps(context.Background(), noopPluginManager{}, cfg)
if err == nil {
t.Fatal("expected two provider steps with the same id to be refused")
}
if !strings.Contains(err.Error(), "mausamgram") {
t.Errorf("error %q should name the id that repeats", err)
}
}

if h.mapper == nil {
return nil, fmt.Errorf("failed to load ProviderStep plugin (%s): Mapper plugin not configured", cfg.ID)
}

// rather than by appearing twice.
if _, taken := steps[c.ID]; taken {
return fmt.Errorf("provider step %q is configured more than once; "+
"a step serving several capabilities lists them in its own config", c.ID)
}
step, err := h.loadProviderStep(ctx, mgr, &c)

🤖 Generated with Claude Code

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

Resolves two different things from the OAN Registry, a SunbirdRC deployment,
and keeps them apart because they answer different questions about different
parties.

RegistryLookup answers "who sent this": given the subscriber and key named in
an inbound Authorization header, it returns that sender's signing key so the
signature can be verified. This runs inside signature validation on every
inbound message, so its timeout and retry budget are deliberately tighter than
the sibling registry plugins' -- timeout x (retry_max + 1) is time a request
spends waiting before it can even be rejected.

ProviderRecordLookup answers "who do I call next" [beckn#63]: given a capability
binding taken from a request body, it reads the binding and the participant
that owns it, and joins them into one call plan -- where the provider is, and
per Beckn action, how to reach it. Every way of saying "this capability cannot
be served" returns one sentinel, because a caller does the same thing with all
of them; a registry that could not be CONSULTED returns its own error, since an
outage is not an answer.

Several decisions here were forced by the deployed registry rather than chosen:

  - records are read from the nested shape the registry actually serves, with
    keys under node.keys[] rather than flat on the record
  - a key is matched by its osid, which is what an Authorization header carries;
    the friendly keyId identifies nothing the registry indexes
  - the "base64:" label is stripped from key material, because
    model.Subscription carries the bare value signvalidator feeds straight to
    base64.StdEncoding.DecodeString
  - status is checked at both levels, since a participant stays active while one
    of its keys is retired
  - actions are read as an array: the registry treats every nested object as an
    entity and injects osid into it, which a map cannot carry

Status is an allow-list throughout, not a deny-list. model.IsKeyStatusUsable
treats anything it does not recognise as usable, so passing the registry's own
vocabulary through unchanged would let a suspended participant's signature
verify.

Verified against a live registry and the recorded response it serves.
Providers do not speak Beckn. The old provider backend answered that with one
hand-written service per provider -- around 6,900 lines across eight of them,
most of it building catalog JSON field by field. This makes the translation
configuration instead: a new provider ships mapping files, not another
transformation routine.

The plugin is domain-free by design. It knows nothing about who is calling,
nothing about what a mapping says, and nothing about the payloads passing
through: it is handed a reference and an input, and it fetches, compiles,
caches and runs whatever is there. Anything specific to a network or a provider
belongs in the caller, which is what lets one mapper serve all of them.

A mapping file carries every action one capability serves, keyed by action name.
Request files are keyed by the action they translate, response files by the one
they produce -- so a select mapping sits under "select" and its answer under
"on_select", and each file names the Beckn actions it actually deals in. One
file per direction rather than per action means a transaction walking select
then confirm pays one fetch, not one per step.

An action may be declared with an empty value. That is a statement rather than
an omission: this action needs no document built, because the caller supplies
the request itself -- a provider taking two query parameters is the ordinary
case, and passing already-resolved values through a fetch and a compile to
arrive at the same two fields buys nothing. Declared-but-empty and absent are
deliberately different, and reported differently: the first says "I serve this,
build it yourself", the second says "I do not serve this at all". Collapsing
them would send an empty request where a refusal was owed, answered with a 200
and the wrong data.

Two things the race detector settled rather than the design:

  - jsonata.Expression.Evaluate MUTATES the expression it is called on, binding
    into its own frame, so a cached compiled expression cannot serve two
    requests at once. Evaluation takes a per-mapping lock rather than
    recompiling: measured, evaluation is ~22us against ~184us to compile, and
    both are dwarfed by the upstream call that follows
  - a compile failure is held against its own action, so a typo in confirm is no
    reason for select to stop being served

References arrive from the registry, which makes them external input: anything
that is not an http(s) URL with a host is refused, and reads are capped in both
time and size.
The last piece: a step that recognises its own capability, resolves what the
provider needs beyond the Beckn payload, calls it, and lets the mapper translate
both ways. With the registry supplying the call plan and the mapper the
translation, adding a provider is now a plugin with two short methods, two
mapping files and a registry row.

Dispatch turned out to need no mechanism at all. A provider step handed a
request for a capability it does not serve does nothing and returns nil, so
several sit in one pipeline and each recognises its own work. There is no
routing table to keep in step with the registry, and no filename convention --
which matters because a binding key contains | and :, and a plugin id is its .so
basename. Keying on the binding key rather than the participant is deliberate:
one provider can serve several capabilities with different logic, as
gfr-crop-registry and gfr-crop-recommendation did in the old backend.

Three supporting pieces:

  - definition.ProviderStepProvider, because a provider step needs a registry
    and a mapper handed to it, which the plain StepProvider contract cannot do.
    Same shape as PolicyCheckerProvider taking a ManifestLoader
  - internal/oanbinding derives the binding from a payload. Shared, because the
    binding is a property of OAN's payloads and not of any provider. A payload
    naming more than one distinct provider or type is refused rather than
    resolved to its first: one binding key describes one upstream call, so
    guessing would silently serve part of the request
  - model.StepContext.ResponseBody, so a step that has already obtained an
    answer has somewhere to put it. Without it the no-route path writes a fixed
    ACK and ignores the body entirely, so the answer was discarded and the
    caller got an ACK for data it asked for synchronously

That last one has four call sites, every one gated on the field being
non-empty, so no existing module changes behaviour by a byte. The gate that
matters least visibly is in the step instrumentor: it shallow-copies the context
in but copies only named fields out, so without one line there an answer written
by an instrumented step vanishes -- and instrumentation is the default path,
meaning it would work unwrapped and fail wrapped.

signAck signs whichever body will actually be written. Signing the generated ACK
while sending an answer would put a valid signature over the wrong bytes, which
is the one failure here that looks fine in testing and is rejected by every
peer.

Mausamgram itself is small: its prerequisite reads a point from the request, and
coordinates are GeoJSON order -- [lon, lat] -- which read the other way round
yields a valid request for the wrong hemisphere, so there is a test for exactly
that. Auth is configured by scheme naming the ENVIRONMENT VARIABLE to read,
never the credential: the secret reaches the process through its environment and
nothing else, and never through the registry. A configured credential that is
absent fails the request rather than calling the provider unauthenticated.

Verified end to end against a live registry, mappings served over HTTP and a
mock provider: a signed select in, a valid on_select out.
…ng-tracker#41]

The registry schemas at OpenAgriNet/discovery-service docs/registry/schemas.md
changed shape. This reads the new one.

Participant is flat. type -- node or upstream -- decides which fields apply,
where the old shape wrapped them in a "node" or "upstream" object. baseUrl is
one field for both, and role (BAP/BPP/NETWORK) is separate from type.

A binding's actions carry their own mappings and their own status. Retiring one
action is now one field on one entry, leaving the capability and every other
action live; an inactive entry is skipped exactly as an absent one is.

One mapping file per binding-action, holding both directions, replacing the
per-direction pair. The halves are not independent -- the response mapping reads
what the request mapping resolved into _local -- and two references hid that. A
half that is absent or empty reports ErrNoTransform, which is a statement rather
than an omission; a half that will not compile is an error, and the two must not
collapse or an unmapped upstream answer would go out as a Beckn response.

mappings stays a fully-qualified URL, carried verbatim. The documented contract
specifies a repo-relative path resolved against an operator-configured root, and
that is the safer shape -- it stops a registry row choosing which host this
adapter fetches, compiles and runs a mapping from. It is deliberately not adopted
yet: the network has not settled on a fixed location for published mappings, so
the URL stays in the record and the local ProviderSchema pattern is relaxed to
match. Who may write a registry row is therefore part of the mapper's threat
model, and that is written down where the check lives.

Also the contract's action budget defaults: timeoutMs 15000 and retryMax 0, and
retryMax now counts retries rather than total attempts -- so an action that does
not ask for retries is called exactly once. A retry on a non-idempotent action
is a second booking.

The registry's auth block is still not read, deliberately: an upstream's
credential is the provider plugin's own configuration, and reading both would
create two places that can disagree about how to authenticate a call.

The captured-registry test is re-captured from the live registry in the new
shape, and the end-to-end select through the local stack returns three mapped
forecast resources.
…acker#66]

Two things a mapping did not need.

_local is gone. A mapping is handed the inbound payload, and on the way back the
provider's answer, and nothing else. The values a provider plugin resolves before
a call were also being passed in, which was a detour: the plugin holds them and
used them to make the call, so a mapping reading them back was a second name for
the same data. Where an answer needs one, it takes it from what the provider
echoed -- the shipped mapping now reads response.location for the coordinates it
was reading out of _local.

ErrNoTransform is gone. A half that is absent or empty produces nothing, with no
error, and what nothing means belongs to the caller rather than to a sentinel the
mapper invents. On the request leg it means there is no document to send: for a
method with no body the plugin sends the values it resolved, because it knows its
provider and does not need the mapping's permission to call it; for a method with
a body it sends no body, which is what an empty mapping says.

A half that will not compile still reports an error, and that distinction is now
carried by a test of its own: reading a broken half as "nothing" would send an
unmapped upstream answer out as a Beckn response.

One failure path is new. A response half that produces nothing leaves no Beckn
answer, so the step fails rather than returning the provider's own shape under a
valid signature. Its message says what was observed rather than guessing whether
the transform was absent or simply matched nothing.

Verified end to end: a signed /select returns three mapped forecast resources
with the coordinates intact, which is what proves the mapping no longer needs
_local to produce them.
A module that serves capabilities itself has no proxy behind it. When no step
answered and no route was set, nothing ever will: there is no route to forward
the request and nobody to send a callback. ONIX answered that with an ACK, which
tells the caller "accepted, answer follows" and leaves it waiting for a message
that is never coming.

That is not a theoretical case. It is what a stale binding key looks like: the
provider step reads a binding from the payload, does not recognise it, passes
through -- which is the dispatch mechanism working correctly -- and the request
falls out of the pipeline unanswered. The adapter then reports success. It cost
two rounds of confusion during this work before the ACK was read as a symptom
rather than as expected behaviour.

So a module with provider steps now refuses an unanswered no-route request with
404 NET_ENTITY_NOT_FOUND. Modules without provider steps are untouched: an
unanswered request there is the publisher or proxy path doing exactly what it
should.

404 rather than AckNoCallbackErr, which exists for this shape and was the
obvious pick. It maps to 202 Accepted, and a 2xx is what let this hide in the
first place; it is also for a business outcome -- no inventory, provider closed
-- where this is "nothing here serves that", which is what a 404 says.

The check sits before the response steps, not after: ackSigner signs the body it
expects to be written, so NACKing later would ship a signature over the ACK with
a NACK body.

No single provider step could make this decision. Several sit in one pipeline
and each passes through what is not its own, so a step seeing a foreign binding
cannot know whether a later step will serve it. Only the handler knows, once
every step has run, that nobody did.

Verified end to end: a select naming an unserved provider now returns 404 with
NET_ENTITY_NOT_FOUND where it previously returned 200 ACK, and a select for the
served capability still returns on_select with three mapped resources.
on_select minted a resource per forecast day, with ids derived from the date. The
offer, echoed from the request, still referenced the id the consumer selected --
so offer.resourceIds pointed at something that appeared nowhere in the answer.
The spec says resourceIds are "references to resources covered by this offer",
and ours resolved against nothing.

The answer now quotes ONE resource, carrying the id the request selected, with
the forecast days under resourceAttributes.observations. That is the better model
independently of the bug: the consumer asked for a quote on one resource, so that
is what is quoted, and the days are content of it rather than resources of their
own. It also fixes the reference by construction rather than by patching
resourceIds to match invented ids.

Fields that are the same for every day -- the point, the source, the observation
type -- now appear once at the top instead of being repeated per resource. Only
what varies per day repeats.

Mapping file and its test only; no code. Both ends of the translation are data,
which is what makes a response-shape change configuration.

Left alone deliberately: Commitment.resources requires "quantity", but the spec
defines no quantity property on Resource and no Quantity schema, so any value
would satisfy it. Inventing one would commit us to a shape the spec has not
chosen.

Verified end to end against the published mapping: one resource carrying the
requested id, three observations inside it, offer.resourceIds resolving, no
errors.
The mapping now produces what openagrinet:WeatherObservation v0.1 requires in
Direct mode. The pack lives in OpenAgriNet/network-specs and had not been read
when the mapping was first written; four things were missing or wrong.

generatedAt was absent, and Direct mode requires it. So was a resource-level
validity, which now spans the first forecast day to the last.

A warning was a field of its own called "advisory". The pack has no such
property, but its parameter enum carries Alert -- so a warning is now a
parameter like any other reading, with unit "1", which is what the pack
prescribes for a value that has no unit. It inherits the same $exists guard, so
a day the provider gave no warning for carries no Alert entry rather than an
empty one.

The published catalog resource, which carried no informationMode at all, becomes
OnDemand: supportedObservationTypes, supportedParameters, forecastHorizon,
updateFrequency and geographicGranularities, and deliberately no parameters,
which that mode forbids. One @type therefore serves both the catalog and the
answer, and informationMode selects which half of the contract applies -- so a
discover filtering on the outcome type finds the catalog.

TWO FIELDS REMAIN OUTSIDE THE PACK, both deliberate, both recorded in the
mapping's header. The pack sets no additionalProperties, so they validate; they
are simply not governed.

  observations   The pack carries one validity and one flat parameters array per
                 resource, and every one of its examples is a single period. It
                 cannot express a five-day forecast in the one resource the
                 request selected. Splitting into five resources would return
                 ids the consumer never selected and break the correlation the
                 Contract model rests on, so the days stay inside.
  aggregation    The pack's parameter entry is parameter/value/unit only. This
                 provider reports a minimum AND a maximum for temperature and
                 humidity, indistinguishable without it.

@context stays the canonical schemas.openagrinet.global identifier. In JSON-LD
that is a name, not a fetch target: it need not resolve today, and substituting
a raw git URL that does would put an implementation detail on the wire and break
every consumer when the branch is renamed.

Mapping file and its test only; no code. Verified end to end against the
published mapping: one resource carrying the requested id, three observations,
Direct's required fields present, and the warning carried as an Alert parameter.
The step used to read the coordinates out of the payload itself and send them.
That put the choice of which payload fields reach the provider in Go, so a
provider wanting one more query parameter meant editing a struct, rebuilding and
redeploying. It is the mapping's decision now: whatever the request half
produces IS the request.

resolvePoint, the point struct and flatPair are gone. What replaced them reads
one field:

    if location.Type == geometryPoint { return nil }

verifyGeometry exists only because a mapping cannot refuse. A Polygon's
coordinates are nested, so JSONata would build a query parameter that is not a
scalar and fail with an error naming neither the geometry nor the reason. The
guard turns that into a 400 that says "request carries Polygon; this capability
needs a Point". It reads the geometry's type and nothing else, so which fields
reach the provider stays entirely configuration.

One behaviour change: an empty request half now means an empty request -- no
query parameters, no body. Nothing is substituted, because the step no longer
holds anything to substitute.

TestRunGuardsGeometryWithoutReadingCoordinates pins the boundary. It sends a
Point whose coordinates are [1.0, 2.0] and a mapping producing
{"station":"NASHIK-1"}, then asserts the query carries station and NOT lat or
lon. Reintroducing extraction in Go fails that test.

Verified end to end. Adding a date range to the published mapping -- two lines,
no rebuild, no restart, no registry change -- reached the provider as
?from=2026-08-30&lat=19.9975&lon=73.7898&to=2026-09-03, and was reverted after.
The geometry guard still answers 400 for Polygon, LineString, MultiPoint and an
absent location, and 200 for a Point.
A mapping decides what a provider is asked for, but it could not decide that a
request cannot be served at all. That judgement stayed in Go, so a capability
with its own rule needed its own build -- and the rule sat in a different file
from the extraction it guarded.

Mappings now carry an optional block, checked before either half runs:

    required:
      - check: |
          ( $ra := beckn.message...resourceAttributes;
            $exists($ra.location) and $ra.location.type = "Point" )
        message: "this capability needs a Point location"

Named check/message so each field says what it is. "test" says nothing about
which way the predicate must answer, and "otherwise" reads like an alternative
value rather than an error.

Verify is a method of its own rather than folded into Transform. Transform
returns early for a half with no transform, so a mapping with an empty request
half would have skipped its own preconditions -- a trap that cannot arise when
asking is a separate call.

Four ways this refuses rather than passing quietly: a false predicate returns
its message as a 400; a predicate answering anything but true or false is a
mapping fault, NOT permission, because a typo yielding nothing would otherwise
wave through every request the check existed to stop; a predicate with no
message is a fault, since refusing without saying why is what this avoids; and a
predicate that will not compile reports itself without taking the halves down.

verifyGeometry and geometryPoint are gone from the provider step, which now asks
and propagates. The consequence is worth stating: nothing in the adapter
enforces a payload rule any more. Whatever a mapping does not require, it
accepts. That is the point, and it is why the responsibility sits in the
published file.

The shipped mapping also stops naming five forecast days. The provider answers
fcstday1..fcstdayN and N is whatever the forecast ran to, so five truncated a
ten-day answer. Two things that testing caught and reasoning would not have:
the keys sort lexically as fcstday1, fcstday10, fcstday2, so the days are sorted
on the numeric suffix; and JSONata collapses a one-element sequence to a bare
value, so the list is forced to an array or a single-day forecast answers with
an object where every other N answers with a list. That second bug was present
in the hardcoded version too, masked because the mock always sent three.

Verified end to end. A Point is served; a Polygon, a LineString, a MultiPoint
and an absent location are each refused with the mapping's own message. With the
provider sending one, three and five days the answer carries one, three and five
observations, and the resource validity window follows.
A provider can serve more than one capability. The registry contract says so
outright: a provider serving two capabilities is one Participant and two
ProviderSchema rows. The adapter could not.

bindingKey was a single string, so a second capability meant a second
providerSteps entry with the same plugin id -- and those collide in the
handler's id-keyed step map. The second silently overwrote the first, the step
list could only name it once, and a capability disappeared with no error at
startup or at request time.

bindingKeys is a list now, and the step checks membership. Comma-separated,
because a plugin config value is a string: the convention reqpreprocessor and
schemav2validator already use, and unambiguous here because a binding key
separates its own halves with a pipe. Nothing else was needed -- what differs
per capability is the endpoint, the mapping and the budget, and all three come
from the registry.

Configuring the same provider step id twice is now refused at startup rather
than quietly losing one. The message says where the capabilities belong instead,
because the mistake is easy to make and impossible to see:

    provider step "mausamgram" is configured more than once; a step serving
    several capabilities lists them in its own config

Widening the config must not widen the dispatch, so a test pins that a
capability the step is NOT configured for still passes through untouched. That
is the mechanism several provider steps depend on to coexist.

Verified end to end, including the duplicate-id case: with two entries sharing
an id the adapter refuses to start, where before it started and served one
capability fewer.
mausamgram held two things that had grown apart: the machinery for calling an
API that has never heard of Beckn, and the fact that it was IMD's weather
forecast. By the time preconditions and extraction had moved into the mapping,
the second was nothing but a package name -- every remaining line was generic.

internal/upstream is the machinery. "upstream" is the registry's own word for
such an API, a Participant of type upstream as against a node that speaks Beckn,
so the package says what it does in the network's vocabulary rather than a new
one. It recognises a capability, resolves the call plan, authenticates, calls
with the registry's budget, and translates in both directions. None of that
differs by domain.

weather is the domain package, and it is 56 lines. One package per schema pack
family, so which plugin owns a capability is readable from its binding key:
openagrinet:WeatherObservation and openagrinet:WeatherAdvisory are weather's,
openagrinet:MandiPrice will not be. A market or knowledge plugin is now a
package of the same size and a cmd directory.

What a domain package owns is its name and its prerequisites -- the work a
mapping cannot express, keyed by binding key. The map is empty, deliberately:
every capability so far is served by reading the payload, which the mapping
does. An entry is needed only for real I/O, a station id from a spatial lookup
or a token from an exchange, because no expression language should be able to do
those. Adding one is a function and a line, and nothing else in the package
moves.

So _local returns, and this time it earns the name: it carries whatever a
prerequisite produced, and an empty map when there is none. A mapping reading
_local.stationId on a capability without one finds a missing field rather than
failing.

There is no default binding key any more. A package serving a family cannot
guess which of its capabilities a deployment has providers for, so naming one
would be wrong for every other domain built on the same machinery -- and
silently wrong. It is required, and refused at startup.

The mausamgram name survives where it belongs: as a provider id in fixtures, in
resource ids, and as the directory its mapping lives in. That is the provider,
not the plugin.

Verified end to end: the step loads as "weather", serves the capability its
config names, and the mapping's own preconditions still refuse a Polygon and an
absent location with the message the mapping supplies.
Where the two halves of a binding key sit in a payload was a typed struct, so a
Beckn shape change meant editing Go, rebuilding and redeploying every adapter on
the network at once.

oanbinding.BecknV2 is that shape as data, and From walks it. A deployment can
override both paths, which exists for one situation: the spec moves a field and
someone needs to track it without waiting for a release.

It is a DEFAULT, not a setting, and the distinction matters. Where a binding key
lives is a network convention -- every participant has to agree, or two adapters
disagree about what a binding key even is and requests silently fail to match,
with no error anywhere to say why. So absent means correct, and overriding is
something an operator types deliberately. Both halves or neither, refused at
startup: overriding one and leaving the other on the convention matches nothing,
and would do so silently on every request.

The walk understands two things: dotted segments, and [] meaning "this is an
array, look in each element". No wildcards, no filters, no indices. Each of
those would be another way to write something subtly wrong in configuration
nobody reviews, to buy an expressiveness no payload shape has needed. It is an
escape hatch, not a query language -- and it is 40 lines rather than a
dependency, which for an escape hatch is the right trade.

From now takes the paths and walks a generic document rather than unmarshalling
a typed one. Every existing test runs against BecknV2, which is the regression
guard: the default has to give exactly the answers the typed walk gave.

Verified live. With the paths pointed somewhere the payload does not use them,
the request stops matching and falls out of the pipeline as a 404 -- the
unanswered-request guard catching the consequence, which is what proves the
override is genuinely in effect rather than ignored.
…cs [#1]

The unlock was written after the call rather than deferred, so a panic inside
Evaluate left the mutex held with nothing to release it. Nothing in the request
path recovers, so the lock stays taken for the life of the process.

This is a defect the previous commit introduced, and it is worse than it looks
because of what that commit did: the lock is now package-wide. A wedged mutex
is no longer one broken mapping, it is EVERY mapping for EVERY provider step,
cleared only by a restart. Widening the lock widened the blast radius, and the
unlock should have been deferred in the same change.

A helper rather than a defer at each of the two call sites, because the
precondition loop evaluates once per check: a defer there releases only when
the whole loop returns, which would hold the lock across every check in the
file -- a smaller version of the same mistake.

The panic is converted to an error rather than re-raised. net/http recovers per
connection, so re-raising costs the caller its connection with no NACK and
nothing in our log naming the mapping, for what is indistinguishable from a
mapping that could not be applied. Reported as one, so it lands where the fault
is.

Testable because jsonata.Expression is an interface: the test injects an
expression that panics, asserts the error, then takes the lock from another
goroutine with a timeout. Un-defer the unlock and it fails on that timeout
rather than hanging the suite.
Four places described the mapping's inputs and three of them were wrong. The
code passes _local on both legs, and Prerequisites documents it as "handed to
the mapping as _local" -- while Mapper.Transform said input "deliberately does
not carry values the caller resolved for itself", the README's "what a mapping
can read" table omitted it, upstream.go's own comment said "what each party
sent and nothing else", and the mausamgram mapping said "nothing else is in
scope".

Harmless today only because every shipped plugin declares an empty
Prerequisites map. The first provider to add a real one would have been told by
three sources that its resolved values are unavailable to the mapping, when
they are already being passed.

The code is right, so the three claims are corrected rather than the behaviour.
The distinction they were reaching for is kept, because it is worth keeping:
_local is for what a payload cannot carry and a mapping cannot obtain -- a code
looked up from a name, a point resolved to a market -- and NOT for values the
plugin already holds and merely used to make the call. Reading those back is a
second name for the same data.

Also adds the tests that were missing, which is why this drifted: nothing
asserted _local at all, so the whole path was dead code. One test resolves two
values through a real Prerequisites entry and checks they arrive on both legs;
another checks that no prerequisites yields an empty _local rather than an
absent one, so a mapping referring to it reads nothing rather than failing.
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.
Extended validation removed @context and @type from every object before
validating it. That suited a schema which closes itself with
additionalProperties:false and never mentions either key, and it made a
schema pack that DECLARES @type and lists it in required impossible to
satisfy: the payload carried @type, the schema required it, and the
validator had just taken it out, so a conforming payload was rejected for
a missing field it had supplied.

Decide per key, by asking the schema whether it declares that key -- as a
property or in required, anywhere in its composition tree, since the OAN
packs declare @type one level down in allOf. Both schema styles then
validate without a config switch and without either having to know about
the other.

Keeping @type also means its const is now checked, so a resource whose
@type is not the one the capability declares no longer passes silently.

[#16] Please enter the commit message for your changes. Lines starting
[#16] with '#' will be ignored, and an empty message aborts the commit.
[#16]
[#16] Date:      Mon Sep 7 13:22:43 2026 +0530
[#16]
[#16] interactive rebase in progress; onto 983affb
[#16] Last command done (1 command done):
[#16]    reword e19177c fix(schemav2validator): keep the JSON-LD keys a schema declares [#16]
[#16] Next commands to do (5 remaining commands):
[#16]    reword 247022c refactor(plugins): name the capability plugins after their capabilities [#16]
[#16]    reword ec87958 feat(config): validate resource attributes against their schema packs [#16]
[#16] You are currently editing a commit while rebasing branch 'tmp/rebase-16' on '983affb'.
[#16]
[#16] Changes to be committed:
[#16]	modified:   pkg/plugin/implementation/schemav2validator/extended_schema.go
[#16]	modified:   pkg/plugin/implementation/schemav2validator/extended_schema_test.go
[#16]
…es [#16]

weather and mandi named a domain; the payloads they serve name a
capability. Renaming the packages to WeatherObservation and MandiPrice
puts the two in the same vocabulary, so a binding key, a schema pack and
the plugin that answers for it all read the same.

The .so basename is the plugin id the adapter config refers to, so the
directory rename carries the ids with it -- providerSteps and steps in
config/oan-provider-adapter.yaml move together with the packages, and
build-plugins.sh with them. No behaviour changes: the whole suite passes,
and both plugins still build as loadable shared objects.

Both package docs claimed one package per schema pack FAMILY, which the
new names contradict. They now say what is true -- one package per
capability, named for the capability, with the binding keys it answers to
still configuration.

[#16] Please enter the commit message for your changes. Lines starting
[#16] with '#' will be ignored, and an empty message aborts the commit.
[#16]
[#16] Date:      Mon Sep 7 13:23:03 2026 +0530
[#16]
[#16] interactive rebase in progress; onto 983affb
[#16] Last commands done (2 commands done):
[#16]    reword e19177c fix(schemav2validator): keep the JSON-LD keys a schema declares [#16]
[#16]    reword 247022c refactor(plugins): name the capability plugins after their capabilities [#16]
[#16] Next commands to do (4 remaining commands):
[#16]    reword ec87958 feat(config): validate resource attributes against their schema packs [#16]
[#16]    reword bc8979b refactor(config): resolve capability schemas from the payload's @context [#16]
[#16] You are currently editing a commit while rebasing branch 'tmp/rebase-16' on '983affb'.
[#16]
[#16] Changes to be committed:
[#16]	modified:   config/oan-provider-adapter.yaml
[#16]	modified:   install/build-plugins.sh
[#16]	renamed:    pkg/plugin/implementation/mandi/mandi.go -> pkg/plugin/implementation/MandiPrice/MandiPrice.go
[#16]	renamed:    pkg/plugin/implementation/mandi/cmd/plugin.go -> pkg/plugin/implementation/MandiPrice/cmd/plugin.go
[#16]	renamed:    pkg/plugin/implementation/mandi/mappings_test.go -> pkg/plugin/implementation/MandiPrice/mappings_test.go
[#16]	renamed:    pkg/plugin/implementation/mandi/prerequisites.go -> pkg/plugin/implementation/MandiPrice/prerequisites.go
[#16]	renamed:    pkg/plugin/implementation/weather/weather.go -> pkg/plugin/implementation/WeatherObservation/WeatherObservation.go
[#16]	renamed:    pkg/plugin/implementation/weather/cmd/plugin.go -> pkg/plugin/implementation/WeatherObservation/cmd/plugin.go
[#16]	renamed:    pkg/plugin/implementation/weather/cmd/plugin_test.go -> pkg/plugin/implementation/WeatherObservation/cmd/plugin_test.go
[#16]	renamed:    pkg/plugin/implementation/weather/mappings_test.go -> pkg/plugin/implementation/WeatherObservation/mappings_test.go
[#16]	renamed:    pkg/plugin/implementation/weather/prerequisites.go -> pkg/plugin/implementation/WeatherObservation/prerequisites.go
[#16]
…#16]

The base validator treats resourceAttributes as a free-form object -- the
envelope is correct whatever a capability puts inside it. Extended
validation checks the inside: it walks the payload for objects carrying
@context and @type, resolves the schema @type names, and validates
against it. Until the JSON-LD strip was fixed no pack could pass, so the
keys were configured and the layer was off.

Resolution is local, not a fetch per payload. Every schema under
extendedSchema_localSchemaPath is loaded at startup and looked up by
@type, so a select costs no network call and works with no egress. The
schemas are published elsewhere and deliberately not copied in here; a
copy would drift, and pinning a revision in adapter config would make a
deployment decision on the deployment's behalf. The comment states the
layout to mount, $ref targets included.

A missing directory fails startup rather than degrading, which is the
behaviour to want -- the alternative is accepting unvalidated payloads
because a mount was forgotten. The allowlist is narrowed from
raw.githubusercontent.com to the host the packs' own @context names, so a
local miss fails loudly instead of quietly fetching from elsewhere.

Recorded in the comment because a green result is otherwise misleading:
the validator library parses if/then/else but never evaluates it, so a
pack's conditional rules are not enforced. In the OAN packs that is
everything predicated on informationMode.

[#16] Please enter the commit message for your changes. Lines starting
[#16] with '#' will be ignored, and an empty message aborts the commit.
[#16]
[#16] Date:      Mon Sep 7 13:23:24 2026 +0530
[#16]
[#16] interactive rebase in progress; onto 983affb
[#16] Last commands done (3 commands done):
[#16]    reword 247022c refactor(plugins): name the capability plugins after their capabilities [#16]
[#16]    reword ec87958 feat(config): validate resource attributes against their schema packs [#16]
[#16] Next commands to do (3 remaining commands):
[#16]    reword bc8979b refactor(config): resolve capability schemas from the payload's @context [#16]
[#16]    reword 6385051 refactor: name the registry and binding packages for what they are [#16]
[#16] You are currently editing a commit while rebasing branch 'tmp/rebase-16' on '983affb'.
[#16]
[#16] Changes to be committed:
[#16]	modified:   config/oan-provider-adapter.yaml
[#16]
 [#16]

Extended validation was pointed at a mounted directory of schema files,
because the @context the payloads declared -- schemas.openagrinet.global --
does not resolve, and a failed fetch rejects the payload. That put the burden
on every deployment to place the right files at the right path, and made the
adapter refuse to start when one did not.

The published packs do serve context.jsonld, so the fetch the validator
already knows how to do works once @context names them:

    @context .../network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/context.jsonld
    fetched  .../network-specs/schema-packs-v0.1/schema/MandiPrice/v0.1/attributes.yaml

So localSchemaPath is gone, and with it the mount, the directory and the
script that filled it. The revision a payload is judged against now comes
from the payload, which is a better place for it than adapter config: nothing
here can go stale against what the network publishes.

The allowlist moves to raw.githubusercontent.com, the host that @context now
resolves to. It is doing real work rather than naming a host that never
answered -- an @context on any other host is refused before a fetch is
attempted.

Two costs, both stated in the config: this adapter now needs egress to that
host, and the first payload after a restart pays for the fetch. Measured at
about 2s, cached for 24h after that.

Verified in oan-local: publish and select both pass, the first payload logs
"fetching from network" and later ones "LRU cache hit", a foreign @context is
refused with SCH_INVALID_JSONLD_CONTEXT, and the collection is 51 of 51.

[#16] Please enter the commit message for your changes. Lines starting
[#16] with '#' will be ignored, and an empty message aborts the commit.
[#16]
[#16] Date:      Mon Sep 7 14:17:15 2026 +0530
[#16]
[#16] interactive rebase in progress; onto 983affb
[#16] Last commands done (4 commands done):
[#16]    reword ec87958 feat(config): validate resource attributes against their schema packs [#16]
[#16]    reword bc8979b refactor(config): resolve capability schemas from the payload's @context [#16]
[#16] Next commands to do (2 remaining commands):
[#16]    reword 6385051 refactor: name the registry and binding packages for what they are [#16]
[#16]    reword 9ebb5c1 docs(config): placeholder the subscriber id, keep the old value in a comment [#16]
[#16] You are currently editing a commit while rebasing branch 'tmp/rebase-16' on '983affb'.
[#16]
[#16] Changes to be committed:
[#16]	modified:   config/oan-provider-adapter.yaml
[#16]
Three renames, and one rule applied across the tree.

  pkg/plugin/implementation/oanregistry          -> sunbirdRegistry
  pkg/plugin/implementation/internal/oanbinding  -> internal/capabilitybinding
  config/oan-provider-adapter.yaml               -> config/provider-adapter.yaml

capabilitybinding rather than keybinding, which was the suggestion: this
package derives a {ParticipantID, CapabilityCode} pair and renders it as the
participant|capability key the registry indexes on. "Capability binding" is
already the term the code, the registry schema and the config all use, so the
package now says it. "Key" would have been actively misleading -- this codebase
uses key for signing keys, which is a different thing resolved by a different
plugin.

The organisation name is gone from every filename, identifier, comment, error
string and trace span name. Two categories were deliberately left alone
because they are DATA rather than naming:

  - openagrinet:WeatherObservation and its siblings are capability codes on
    the wire. Changing them would change the protocol.
  - participant ids and names inside captured registry fixtures
    (provider.oan.local, "OAN provider layer adapter") are verbatim responses.
    Editing them would make the fixture stop matching what a registry returns,
    which is the only reason the fixture is worth having.

Two consequences worth knowing. The .so basename is the plugin id, so
`id: oanregistry` becomes `id: sunbirdRegistry` and any deployment's config
moves with the image. And pluginID is a telemetry attribute, so traces and
metrics from this plugin now report sunbirdRegistry -- it follows the rename
rather than reporting a name that no longer exists.

The two cache key prefixes changed with it, oan_lookup_ and oan_provider_ to
registry_lookup_ and registry_provider_. They are cache namespaces, not metric
names, so the cost is one cold cache cycle.

Full suite green at 63 packages, vet clean, and sunbirdRegistry.so builds.

[#16] Please enter the commit message for your changes. Lines starting
[#16] with '#' will be ignored, and an empty message aborts the commit.
[#16]
[#16] Date:      Mon Sep 7 17:15:52 2026 +0530
[#16]
[#16] interactive rebase in progress; onto 983affb
[#16] Last commands done (5 commands done):
[#16]    reword bc8979b refactor(config): resolve capability schemas from the payload's @context [#16]
[#16]    reword 6385051 refactor: name the registry and binding packages for what they are [#16]
[#16] Next command to do (1 remaining command):
[#16]    reword 9ebb5c1 docs(config): placeholder the subscriber id, keep the old value in a comment [#16]
[#16] You are currently editing a commit while rebasing branch 'tmp/rebase-16' on '983affb'.
[#16]
[#16] Changes to be committed:
[#16]	modified:   config/mappings/agmarknet/mandi-price.select.yaml
[#16]	renamed:    config/oan-provider-adapter.yaml -> config/provider-adapter.yaml
[#16]	modified:   install/build-plugins.sh
[#16]	modified:   pkg/plugin/definition/mapper.go
[#16]	modified:   pkg/plugin/implementation/WeatherObservation/mappings_test.go
[#16]	renamed:    pkg/plugin/implementation/internal/oanbinding/oanbinding.go -> pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding.go
[#16]	renamed:    pkg/plugin/implementation/internal/oanbinding/oanbinding_test.go -> pkg/plugin/implementation/internal/capabilitybinding/capabilitybinding_test.go
[#16]	renamed:    pkg/plugin/implementation/internal/oanbinding/paths.go -> pkg/plugin/implementation/internal/capabilitybinding/paths.go
[#16]	modified:   pkg/plugin/implementation/internal/upstream/upstream.go
[#16]	modified:   pkg/plugin/implementation/internal/upstream/upstream_test.go
[#16]	modified:   pkg/plugin/implementation/jsonmapper/README.md
[#16]	modified:   pkg/plugin/implementation/schemav2validator/extended_schema.go
[#16]	modified:   pkg/plugin/implementation/schemav2validator/extended_schema_test.go
[#16]	renamed:    pkg/plugin/implementation/oanregistry/README.md -> pkg/plugin/implementation/sunbirdRegistry/README.md
[#16]	renamed:    pkg/plugin/implementation/oanregistry/cmd/plugin.go -> pkg/plugin/implementation/sunbirdRegistry/cmd/plugin.go
[#16]	renamed:    pkg/plugin/implementation/oanregistry/cmd/plugin_test.go -> pkg/plugin/implementation/sunbirdRegistry/cmd/plugin_test.go
[#16]	renamed:    pkg/plugin/implementation/oanregistry/providerrecord.go -> pkg/plugin/implementation/sunbirdRegistry/providerrecord.go
[#16]	renamed:    pkg/plugin/implementation/oanregistry/providerrecord_test.go -> pkg/plugin/implementation/sunbirdRegistry/providerrecord_test.go
[#16]	renamed:    pkg/plugin/implementation/oanregistry/oanregistry.go -> pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry.go
[#16]	renamed:    pkg/plugin/implementation/oanregistry/oanregistry_test.go -> pkg/plugin/implementation/sunbirdRegistry/sunbirdRegistry_test.go
[#16]
…nts [#16]

The plugins are generic and their comments should read that way: jsonmapper
knows nothing about any provider, and the validator's if/then gap applies to any
pack, not to one network's.

This commit used to also placeholder the subscriberId. That work is now in the
base branch, so what is left here is the comment wording alone, and the message
says so rather than claiming a change that is no longer in the diff.
… schema [#16]

The allowlist was consulted once, on the top-level @context. Every document
fetched to resolve that document's $refs met no check at all, and
freshReadFromURI falls through to os.ReadFile for any scheme but http and
https. So a $ref of "file:///etc/passwd" -- or a bare path, which parses with
no scheme -- was an instruction from the network to open this container's disk
and parse it as a schema. Measured: one capability pack pulls 15 documents, so
14 of the 15 reads were unchecked.

A loader on this path now refuses anything but http and https, for the entry
document and every $ref under it. The base spec loader keeps the file
fallthrough deliberately: its location is operator-configured, where a local
file is the point. localSchema mode keeps it for the same reason.

This does not restrict which HOSTS a $ref may reach, and that is deliberate
rather than missed: the packs $ref two external spec hosts, so enforcing the
allowlist on refs needs those named in it as well, or no pack loads at all.
TestValidateReferencedObject_AllowsARefToAHostOutsideTheAllowlist pins that,
so the wider change cannot be made without noticing.

Two existing tests asserted the old behaviour rather than a requirement, and
say the new one now; several others used a local temp file as a fixture and
either load it in operator mode or serve it over http, which is what
production does anyway.
…it cannot read [#16]

findReferencedObjects required a string for both JSON-LD keys, so an object
carrying the list form matched neither assertion, was never appended, and its
resourceAttributes went to no schema at all -- while the extended layer
reported a pass over an object it had not looked at.

The list form is not exotic. The packs declare @type as a oneOf whose second
branch is a list carrying the canonical OAN type alongside provider-defined
ones, so a conforming payload could use it and be skipped. Which entry names
the capability is not fixed either, so the document now decides: the first
@type it declares a schema for wins, rather than the payload's ordering.

@context takes the list form too, using the first string in it, because only a
URL locates a schema and an inline object names no document to fetch.

Skipping is replaced by rejection wherever the object claims a type this
validator cannot read. @type ABSENT is left alone and still passes over: an
object with a context and no type makes no claim about which schema applies,
and there is nothing to validate it against.

Also replaces the @type const test. It built obj.Type and Data["@type"]
disagreeing, which the real path cannot produce -- both are read off one map --
so it demonstrated nothing about a payload. Tests now run through
findReferencedObjects, and the const does have a payload-level case: the list
branch forbids a second openagrinet: type, which is caught only because @type
is kept in the data rather than stripped.
@ameersohel45

Copy link
Copy Markdown
Collaborator Author

Both fixed — and the first one was mine to own

1 · evaluating.Unlock() not deferred — 82a5f1e. Correct, and worse than the comment lets on, because of what my previous commit did.

The lock used to be per mapping. I widened it to package scope to fix a real data race, and should have deferred the unlock in that same change. Without it, a panic inside Evaluate doesn't wedge one mapping — it wedges every mapping for every provider step, for the life of the process. I widened the correctness fix and the blast radius together, and only did the first half.

Fixed with a helper rather than a defer at each call site, because the precondition loop evaluates once per check: a defer there releases only when the whole loop returns, which would hold the lock across every check in the file — a smaller version of the same mistake.

Two things worth flagging:

  • The panic is converted to an error, not re-raised. net/http recovers per connection, so re-raising costs the caller its connection with no NACK and nothing in our log naming the mapping — indistinguishable, from the caller's side, from a mapping that could not be applied. So it's reported as one. Push back if you'd rather it propagated.
  • It's testable because jsonata.Expression is an interface. The test injects an expression that panics, asserts the error, then takes the lock from another goroutine with a timeout. Un-defer the unlock and it fails on that timeout rather than hanging the suite — verified.

2 · _local contradicting its own contract — 214a793. Verified all four places. The code passes _local on both legs and Prerequisites documents it; Mapper.Transform, the README table, upstream.go's own comment and the mausamgram mapping all said the opposite.

Corrected the three wrong ones rather than the behaviour, since the code and the Prerequisites doc agree. The distinction they were reaching for is kept, because it's worth keeping: _local is for what a payload cannot carry and a mapping cannot obtain — a code looked up from a name, a point resolved to a market — and not for values the plugin already holds and merely used to make the call.

Your closing point is the reason it drifted, and it's now addressed directly: nothing asserted _local at all, so the whole path was dead code. Added a test resolving two values through a real Prerequisites entry and checking they arrive on both legs, plus one checking that no prerequisites yields an empty _local rather than an absent one — so a mapping referring to it reads nothing instead of failing. Both fail if the key is dropped.

This lands just in time to matter: the next capability under discussion may need a real prerequisite, which would have been the first thing to discover the gap at runtime.

Verified: make test green — 62 packages, 0 failures, 0 races on this branch, 64 on both stacked above it — and the changed-line coverage gate passes at 85%. Both branches above rebased and pushed.

manjudr and others added 4 commits September 8, 2026 17:16
feat: add the Mandi provider plugin
…ad [#16]

The allowlist guarded the entry @context only. The document that came back was
then trusted to name anything: its $refs resolved through the loader with no
host check, so a payload could name an attacker's document on the allowed host
and have this process fetch whatever that document pointed at -- an internal
service, a cloud metadata endpoint. Server-side request forgery, driven by an
unauthenticated field.

The reach was the argument for fixing it rather than documenting it: loading
one capability pack pulls 13-16 documents, of which exactly one is the entry.
The refs were never the corner case, they were the traffic.

The same allowlist is now checked on every read. Two refusals with different
scopes, because they answer different questions:

  scheme  http/https only, and only on the payload-directed path. An operator
          who set extendedSchema_localSchemaPath is asking for files to be
          read, so the refusal must not apply to them -- installing it on both
          branches broke exactly that, which TestLoadSchemaFromPath_LocalFile
          caught.
  host    on BOTH paths. localSchema falls back to the network for a ref it
          does not hold locally, so it had the same exposure by a longer
          route.

THE ALLOWLIST HAD TO GROW, and this is a code+config pair that cannot land
half-applied. Measured, not assumed: every pack -- WeatherObservation,
MandiPrice, KnowledgeResource -- touches raw.githubusercontent.com,
schema.beckn.io and schema.nfh.global. With the previous single host and refs
checked, the real packs fail with SCH_SCHEMA_ADAPTATION_FAILED on every
payload; with the three named, they load and validate. Both directions are
verified against the published packs.

The test that pinned the old behaviour is inverted rather than deleted: it now
asserts a cross-host $ref is refused, and a second test asserts the chain
loads once both hosts are named -- which is the case the packs need.

What this does NOT fix: raw.githubusercontent.com is world-writable, so the
allowlist still trusts every GitHub account for schema content. Narrowing to a
path prefix, or mirroring the packs on a host we control, is the real fix and
is not a one-line change. Said so in the config.
The prerequisites note pointed at weather/prerequisites.go, which this branch
renamed to WeatherObservation/. It was the last stale plugin path in a Go
comment.
…formance

feat: enforce the schema packs on resourceAttributes, and rename the capability plugins
@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Merge-readiness re-check (head bcd10b3927201c6855eb500322361815db43aea8)

Both findings from the prior automated review are fixed and verified against the diff, not just the commit messages:

  1. JSONata mutex deadlock82a5f1e adds an evaluateLocked helper in jsonmapper.go that wraps every Evaluate call with defer evaluating.Unlock() plus a recover() converting a library panic into a returned error. Both call sites (Verify's precondition check, evaluate) now use it.

// evaluateLocked runs one expression under the package lock.
//
// The unlock is DEFERRED, not written after the call. A panic inside the
// library -- and this is a library we have already found a data race in --
// would otherwise leave the mutex held with nothing to release it. Because the
// lock is package-wide, that is not one wedged mapping but every mapping in
// the process, for every provider step, until a restart. Widening the lock
// widened that blast radius, so the defer matters more here than it did when
// the lock was per mapping.
//
// A helper rather than a defer at each call site, because the precondition
// loop evaluates once per check: a defer there would release only when the
// whole loop returned, holding the lock across every check in the file.
//
// The panic is converted to an error rather than re-raised. net/http recovers
// a panic per connection, so re-raising costs the caller its connection with
// no NACK and nothing in our log naming the mapping -- for what is, from the
// caller's side, indistinguishable from a mapping that could not be applied.
// Reported as one, so it lands where the fault is.
func evaluateLocked(expr jsonata.Expression, document []byte) (result []byte, err error) {
evaluating.Lock()
defer evaluating.Unlock()
// Registered after the unlock so it runs BEFORE it: recover, name the
// failure, then release.
defer func() {
if recovered := recover(); recovered != nil {
result = nil
err = fmt.Errorf("jsonata evaluation panicked: %v", recovered)
}
}()
return expr.Evaluate(document, nil)

  1. _local contract contradiction214a793 brings all four previously-contradictory sources (mapper.go's Transform doc, upstream.go's comment, the jsonmapper README, and the mausamgram select.yaml comment) into agreement: _local carries prerequisite-resolved values, empty when a plugin declares none. Two new tests exercise the previously dead pass-through path (TestRunHandsResolvedPrerequisitesToTheMappingAsLocal, TestRunPassesAnEmptyLocalWhenThereAreNoPrerequisites in upstream_test.go).

// Which action the mapping serves is settled by the registry entry that
// named it, so only the direction is passed here.
//
// input carries what a party sent -- the inbound payload, and on the way
// back the provider's answer -- plus, under _local, any values the caller
// resolved before making the call.
//
// _local is for what a payload cannot carry and a mapping cannot obtain: a
// code looked up from a name, a point resolved to a market. A caller with
// nothing to add passes an empty map, so a mapping referring to _local
// reads nothing rather than failing. Values the caller already holds and
// merely used to make the call do NOT belong here -- routing those back
// through a mapping is a second name for the same data.
//
// A direction the file has no transform for produces nothing, with no error.
// What nothing means belongs to the caller: on the request leg it means there

No new issues found in either fix commit. All review threads on this PR are already marked resolved. The three CHANGES_REQUESTED reviews on record predate these fix commits (2026-09-08) and would just need re-approval.

From a code standpoint this looks good to merge.

🤖 Generated with Claude Code

@ameersohel45

Copy link
Copy Markdown
Collaborator Author

Thanks — and thank you for checking the diff rather than the commit messages. That distinction matters here, because one of these fixes was a defect I introduced with the previous one.

Confirming both against the merged head, independently:

  • evaluateLockedjsonmapper.go:125, defer evaluating.Unlock() registered before the recover() so the recover runs first, and both call sites go through it (:335 the precondition check, :640 the mapping half). A helper rather than a defer at each site because the precondition loop evaluates once per check, where a defer would hold the lock across every check in the file.
  • _local — all four sources now agree, and the two tests cover a path that was previously dead: nothing had ever asserted _local at all, which is why three of those four descriptions had drifted from the code.

Also confirming the thread state matches: 36 of 36 resolved.

With #13 and #17 merged in, this PR now carries the whole stack — the mandi capability and the schema-pack work included — so the diff is considerably larger than when the CHANGES_REQUESTED reviews were filed. Happy to walk any part of it if that helps the re-review.

@manjudr
manjudr merged commit 076500e into development Sep 8, 2026
2 checks passed
@manjudr
manjudr deleted the feat/41-oan-adapter-plugins branch September 8, 2026 14:20
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