Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions config/mappings/agmarknet/mandi-price.select.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ required:
this guard, the outbound query, and the commodity stamped on each
resource. So a caller sending three commodities passed validation, was
queried for the first, and got a confident signed answer to a third of
what it asked -- the same failure oanbinding refuses at the commitment
level, where guessing would silently serve part of a request. */
what it asked -- the same failure capabilitybinding refuses at the
commitment level, where guessing would silently serve part of a
request. */
$exists($ra.supportedCommodities[0].code)
and $count($ra.supportedCommodities) = 1
)
Expand Down
103 changes: 84 additions & 19 deletions config/oan-provider-adapter.yaml → config/provider-adapter.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# OAN provider adapter.
# Provider adapter.
#
# Serves the Beckn actions synchronously: verifies the sender, resolves the
# capability's call plan from the registry, calls the provider, and answers with
Expand All @@ -19,7 +19,7 @@
# serves. It does NOT hold where a provider lives or how long to wait for it --
# that is the registry's ProviderSchema row, read per request. Repointing a
# provider is a registry write, not an edit here and a restart.
appName: "oan-provider-adapter"
appName: "provider-adapter"

log:
level: debug
Expand Down Expand Up @@ -80,7 +80,7 @@ modules:

plugins:
# ------------------------------------------------------------------
# oanregistry -- the OAN Registry (SunbirdRC) client.
# sunbirdRegistry -- the SunbirdRC registry client.
#
# Serves both halves of the lookup: the sender's signing key for
# validateSign, and the capability call plans the provider steps
Expand All @@ -89,7 +89,7 @@ modules:
# other.
# ------------------------------------------------------------------
registry:
id: oanregistry
id: sunbirdRegistry
config:
# REQUIRED, and the only key with no default. Include the API
# version prefix; the plugin appends /{entity}/search.
Expand Down Expand Up @@ -154,27 +154,92 @@ modules:
signValidator:
id: signvalidator

# Base Beckn v2 schema validation against the pinned LTS spec. The
# extended layer fetches each resource's own @context and validates
# against that -- a network call per payload and a second thing that
# can fail -- so it is off, and the extendedSchema_* keys below only
# take effect if it is switched on.
# ------------------------------------------------------------------
# schemaValidator -- two layers, both on.
#
# BASE validates the envelope against the pinned Beckn v2 LTS spec.
# To it, resourceAttributes is a free-form object: the envelope is
# correct whatever a capability puts inside.
#
# EXTENDED validates that inside. It walks the payload for every
# object carrying both @context and @type, resolves the schema that
# @type names, and validates the object against it. That is what makes
# a wrong unit or a missing required attribute a rejected payload
# rather than a provider's problem to discover later.
#
# The schemas are not in this repository and are not mounted: they
# are fetched from the @context a payload declares, so the revision
# is the payload's choice and nothing here can go stale against it.
#
# What extended validation DOES enforce: types, string formats
# (date-time, duration, uri), enum, const, required, minItems,
# additionalProperties, not, and allOf/anyOf/oneOf.
#
# What it does NOT: if/then/else. The validator library parses those
# keywords but never evaluates them, so a pack's conditional rules --
# in the capability packs, everything predicated on informationMode
# -- are not checked. Worth knowing before treating a pass here as
# full conformance to a pack.
# ------------------------------------------------------------------
schemaValidator:
id: schemav2validator
config:
type: url
location: "https://raw.githubusercontent.com/beckn/protocol-specifications-v2/refs/tags/core-v2.0.0-lts/api/v2.0.0/beckn.yaml"
cacheTTL: "3600"
extendedSchema_enabled: "false"
extendedSchema_cacheTTL: "86400"

extendedSchema_enabled: "true"

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.

Enabling this rejects the repo's own verbatim captured /select payloads.

WeatherObservation/mappings_test.go:45 is labelled "the verbatim /select captured from the OAN network". Its resourceAttributes:

{
  "@context": "https://schemas.openagrinet.global/schema/WeatherObservation/v0.1/context.jsonld",
  "@type": "openagrinet:WeatherObservation",
  "subjectCategories": ["Weather"],
  "location": { "type": "Point", "coordinates": [73.7898, 19.9975] },
  "validity": { "startsAt": "2026-08-26", "endsAt": "2026-08-30" }
}

Two independent failures against AgricultureResource/v0.1/attributes.yaml:

  1. informationMode is missing. attributes.yaml:17 puts required: [informationMode] on the root object — not under a then, so the if/then/else gap this block honestly documents at lines 141-145 does not excuse it.
  2. validity.startsAt / endsAt are bare dates. attributes.yaml:138 and :143 declare format: date-time, and the validator is configured with EnableFormatValidation(). "2026-08-26" is not a date-time.

The mandi capture fails the same way.

What makes this worse than an ordinary bug: mappings_test.go calls the mapping step directly and never traverses schemav2validator, so CI stays green while the deployed adapter refuses the exact payload those tests assert it serves. Worth adding a test that pushes one capture through the full validator so this can't regress silently.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both failures are real. I verified each against the pack rather than the description, because "not under a then" is the whole load-bearing part of your first point:

  • informationModerequired: [informationMode] sits at the root of AgricultureResource, confirmed by walking the schema and printing every required with its path. Root, not under if/then. So the if/then gap does not excuse it, exactly as you say.
  • validity.startsAt/endsAtformat: date-time at pack lines 138 and 143, and EnableFormatValidation() is on at extended_schema.go:625. "2026-08-26" fails it.

Your point about why this is worse than an ordinary bug is the one I would most want kept: mappings_test.go calls the mapping step directly and never traverses schemav2validator, so CI stays green while the deployed adapter refuses the exact payload those tests assert it serves. That is a test suite that cannot see the failure it is meant to catch.

Not fixing it in this PR, and leaving this thread open. Payload and mapping changes are being batched separately from code fixes, and the end-to-end test you suggest belongs with them — a test pushing a capture through the full validator would fail today, so it lands with the payload fix or not at all.

One thing to settle before that batch, since it changes what the fix should be. Adding informationMode satisfies the required field, but the only permitted values are OnDemand and Direct, and OnDemand's then forbids location, market and validity — the exact fields a select must carry. So informationMode: "OnDemand" passes only because if/then is never evaluated, trading a visible non-conformance for an invisible one that a conformant validator would reject. The packs have no query mode; that gap is raised on PR #13 and is a spec change rather than ours.

Neither the pack nor the mappings are on a pinned version yet — the pack is a branch ref and MAPPING_URL points at a branch — so both are expected to move, and this gets revisited when they settle.


# Resolution is a FETCH of the @context each resource declares,
# not a directory mounted here. The validator swaps context.jsonld
# for attributes.yaml to get the schema beside it:
#
# @context .../schema/MandiPrice/v0.1/context.jsonld
# fetched .../schema/MandiPrice/v0.1/attributes.yaml
#
# So a payload names the pack revision it wants to be judged
# against, and no copy of the schemas here can drift from the
# published ones. The packs' relative $refs (into
# AgricultureResource) resolve against that same base; their
# absolute ones resolve directly, against whichever host they name.
#
# Fetched once per @context and cached for the TTL below, so only
# the first payload after a restart pays for it. A fetch that
# FAILS rejects the payload -- it does not skip validation, which
# is the right way round, but it does mean this adapter needs
# egress to every host allowed below.
#
# THIS IS THE WHOLE TRUST BOUNDARY, and it is checked on every
# read: the entry @context and every $ref under it. That matters
# because the document a payload names is NOT trusted -- it comes
# from a URL the payload chose, on a host anyone can publish to --
# so without the check its $refs could send this process at an
# internal service or a cloud metadata endpoint.
#
# ALL THREE HOSTS ARE REQUIRED. Loading one capability pack pulls
# 13-16 documents across exactly these three (measured, not
# assumed): the pack itself and AgricultureResource from the raw
# CDN, then Descriptor/GeoJSONGeometry/Location/Address from
# schema.beckn.io, which in turn $ref schema.nfh.global. Remove
# any one and no pack loads at all -- the failure is
# SCH_SCHEMA_ADAPTATION_FAILED on every payload, not a partial
# validation.
#
# Keep it as tight as the packs allow. raw.githubusercontent.com
# is world-writable, so this trusts every GitHub account for
# schema content; narrowing it to a path prefix, or mirroring the
# packs on a host we control, is the real fix and is not a
# one-line change.
extendedSchema_allowedDomains: "raw.githubusercontent.com,schema.beckn.io,schema.nfh.global"

extendedSchema_cacheTTL: "86400" # 24h
extendedSchema_maxCacheSize: "100"
extendedSchema_downloadTimeout: "30"
extendedSchema_allowedDomains: "raw.githubusercontent.com"

# ------------------------------------------------------------------
# jsonmapper -- the JSONata mapper.
#
# Generic, and named for what it is rather than for OAN: it knows
# Generic, and named for what it is rather than for a network: it knows
# nothing about any provider. It fetches whatever URL the registry's
# mappings field names, compiles the JSONata, caches the compiled
# form, and runs it in both directions.
Expand Down Expand Up @@ -228,7 +293,7 @@ modules:
# rather than calling the provider unauthenticated.
# ------------------------------------------------------------------
providerSteps:
- id: weather
- id: WeatherObservation
config:
# REQUIRED. Comma-separated, because a plugin config value is a
# string and one provider may serve several capabilities. A
Expand Down Expand Up @@ -278,9 +343,9 @@ modules:
# A second capability in the same pipeline, from a different domain
# package. Nothing about it is weather's business: a different
# upstream, a different mapping, a different set of prerequisites --
# and the same two registry rows. This entry, plus "mandi" in steps
# below, is the entire cost of adding it.
- id: mandi
# and the same two registry rows. This entry, plus "MandiPrice"
# in steps below, is the entire cost of adding it.
- id: MandiPrice
config:
bindingKeys: "agmarknet|openagrinet:MandiPrice"

Expand Down Expand Up @@ -319,8 +384,8 @@ modules:
steps:
- validateSign # the sender's key, from the registry
- validateSchema # the pinned Beckn v2 spec
- weather # openagrinet:WeatherObservation, or pass through
- mandi # openagrinet:MandiPrice, or pass through
- WeatherObservation # its binding key, or pass through
- MandiPrice # its binding key, or pass through
- signAck # signs whatever the step answered with

# ----------------------------------------------------------------------------
Expand Down
6 changes: 3 additions & 3 deletions install/build-plugins.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ plugins=(
"publisher"
"registry"
"dediregistry"
"oanregistry"
"sunbirdRegistry"
"jsonmapper"
"weather"
"mandi"
"WeatherObservation"
"MandiPrice"
"manifestloader"
"reqpreprocessor"
"otelsetup"
Expand Down
2 changes: 1 addition & 1 deletion pkg/plugin/definition/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const (

// Mapper transforms a document with a mapping fetched from a reference.
//
// It exists so that translating between OAN's Beckn payloads and a provider's
// It exists so that translating between the network's Beckn payloads and a provider's
// own shape is configuration rather than code: a new provider ships mapping
// files, not a new transformation routine. The mapper itself knows nothing
// about any provider, and nothing about what a mapping says -- it fetches,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Package mandi serves the network's market price capabilities.
// Package MandiPrice serves the network's market price capabilities.
//
// One package per schema pack family, so which plugin owns a capability is
// readable from its binding key: openagrinet:MandiPrice is mandi's,
// openagrinet:WeatherObservation is weather's.
// One package per capability, named for the capability it serves, so which
// plugin owns a payload is readable from its binding key without a lookup:
// openagrinet:MandiPrice is this one's, openagrinet:WeatherObservation is not.
//
// Almost nothing lives here, and that is the point. Recognising a capability,
// resolving the call plan, authenticating, calling with the registry's budget
Expand All @@ -15,7 +15,7 @@
// select takes governed codes for state, district, market and commodity plus a
// date range, all of which a MandiPrice payload carries. So the package is a
// name and nothing else: see prerequisites.go for why that is worth stating.
package mandi
package MandiPrice

import (
"context"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,20 @@ import (

"github.com/beckn-one/beckn-onix/pkg/log"
"github.com/beckn-one/beckn-onix/pkg/plugin/definition"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/mandi"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice"
)

// mandiProvider implements definition.ProviderStepProvider.
type mandiProvider struct{}

// newStepFunc creates a new step. Indirected for tests.
var newStepFunc = mandi.New
var newStepFunc = MandiPrice.New

// parseConfig turns the plugin config map into a typed Config. Anything absent
// is left zero: mandi.New applies the defaults and validates the auth scheme,
// is left zero: MandiPrice.New applies the defaults and validates the auth scheme,
// so those rules live in one place.
func (p mandiProvider) parseConfig(config map[string]string) (*mandi.Config, error) {
cfg := &mandi.Config{
func (p mandiProvider) parseConfig(config map[string]string) (*MandiPrice.Config, error) {
cfg := &MandiPrice.Config{
BindingKeys: splitList(config["bindingKeys"]),
// Absent means the Beckn v2 convention. See upstream.Config for why
// this is a default rather than something to set.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

"github.com/beckn-one/beckn-onix/pkg/model"
"github.com/beckn-one/beckn-onix/pkg/plugin/definition"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/mandi"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice"
)

type stubRegistry struct{}
Expand All @@ -32,15 +32,15 @@ func TestParseConfig(t *testing.T) {
testCases := []struct {
name string
config map[string]string
expected *mandi.Config
expected *MandiPrice.Config
expectedErr string
}{
{
// Everything absent is left zero: mandi.New defaults it, so the
// Everything absent is left zero: MandiPrice.New defaults it, so the
// rules are defined in exactly one place.
name: "leaves everything unset for New to default",
config: map[string]string{},
expected: &mandi.Config{},
expected: &MandiPrice.Config{},
},
{
// Query auth is why this capability has its own entry rather than
Expand All @@ -54,7 +54,7 @@ func TestParseConfig(t *testing.T) {
"queryName": "api-key",
"queryValueEnv": "MANDI_TOKEN",
},
expected: &mandi.Config{
expected: &MandiPrice.Config{
BindingKeys: []string{"agmarknet|openagrinet:MandiPrice"},
AuthScheme: "query",
QueryName: "api-key",
Expand All @@ -74,7 +74,7 @@ func TestParseConfig(t *testing.T) {
"queryValueEnv": "Q",
"maxResponseBytes": "2048",
},
expected: &mandi.Config{
expected: &MandiPrice.Config{
BindingKeys: []string{"other|capability"},
AuthScheme: "basic",
UsernameEnv: "U",
Expand Down Expand Up @@ -102,7 +102,7 @@ func TestParseConfig(t *testing.T) {
// as "unset" rather than failing startup.
name: "treats an empty response cap as unset",
config: map[string]string{"maxResponseBytes": ""},
expected: &mandi.Config{},
expected: &MandiPrice.Config{},
},
}

Expand Down Expand Up @@ -243,7 +243,7 @@ func TestNew(t *testing.T) {
closed := false
original := newStepFunc
newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper,
*mandi.Config) (definition.Step, func() error, error) {
*MandiPrice.Config) (definition.Step, func() error, error) {
return nil, func() error { closed = true; return nil }, nil
}
defer func() { newStepFunc = original }()
Expand All @@ -268,7 +268,7 @@ func TestNew(t *testing.T) {
original := newStepFunc
wanted := errors.New("upstream refused the config")
newStepFunc = func(context.Context, definition.ProviderRecordLookup, definition.Mapper,
*mandi.Config) (definition.Step, func() error, error) {
*MandiPrice.Config) (definition.Step, func() error, error) {
return nil, nil, wanted
}
defer func() { newStepFunc = original }()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package mandi_test
package MandiPrice_test

// mappings_test.go runs the shipped mandi mapping through the real mapper and
// the real provider step. It is the only test that proves the three pieces fit:
Expand All @@ -22,8 +22,8 @@ import (
"testing"

"github.com/beckn-one/beckn-onix/pkg/model"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/MandiPrice"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/jsonmapper"
"github.com/beckn-one/beckn-onix/pkg/plugin/implementation/mandi"
)

// mappingsDir is where the shipped mappings live, relative to this package.
Expand Down Expand Up @@ -191,8 +191,8 @@ func runShippedWith(t *testing.T, request, providerBody string) (url.Values, map
},
}}

step, closeStep, err := mandi.New(context.Background(), registry, mapper,
&mandi.Config{BindingKeys: []string{shippedBindingKey}})
step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper,
&MandiPrice.Config{BindingKeys: []string{shippedBindingKey}})
if err != nil {
t.Fatalf("failed to build the step: %v", err)
}
Expand Down Expand Up @@ -431,8 +431,8 @@ func TestShippedMappingRefusesWhatItCannotServe(t *testing.T) {
Mappings: mappings.URL + "/" + shippedMapping, TimeoutMs: 30000},
},
}}
step, closeStep, err := mandi.New(context.Background(), registry, mapper,
&mandi.Config{BindingKeys: []string{shippedBindingKey}})
step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper,
&MandiPrice.Config{BindingKeys: []string{shippedBindingKey}})
if err != nil {
t.Fatalf("failed to build the step: %v", err)
}
Expand Down Expand Up @@ -740,8 +740,8 @@ func TestShippedMappingRefusesPayloadsItCannotAnswer(t *testing.T) {
Mappings: mappings.URL + "/" + shippedMapping, TimeoutMs: 30000},
},
}}
step, closeStep, err := mandi.New(context.Background(), registry, mapper,
&mandi.Config{BindingKeys: []string{shippedBindingKey}})
step, closeStep, err := MandiPrice.New(context.Background(), registry, mapper,
&MandiPrice.Config{BindingKeys: []string{shippedBindingKey}})
if err != nil {
t.Fatalf("failed to build the step: %v", err)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package mandi
package MandiPrice

import "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstream"

Expand All @@ -15,6 +15,6 @@ import "github.com/beckn-one/beckn-onix/pkg/plugin/implementation/internal/upstr
// code, a token to exchange, a point to turn into a market. Each of those is a
// different upstream than the one this was written against, and each would
// bring the question of where the provider-to-function binding belongs -- see
// the note in weather/prerequisites.go and prefer keeping the payload explicit
// the note in WeatherObservation/prerequisites.go and prefer keeping the payload explicit
// over adding an entry here.
var prerequisites = upstream.Prerequisites{}
Loading
Loading