feat: add the OAN registry, JSONata mapper and weather provider plugins - #2
Conversation
manjudr
left a comment
There was a problem hiding this comment.
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
upstream.callretries everything with no backoff — a 400 is retried 6x in 1.77ms; a missing credential is retried 4x and then reported as502 NET_DOWNSTREAM_UNAVAILABLE; a cancelled request is retried 5x.- jsonmapper stops caching permanently once
MaxCacheEntriesdistinct 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. - A withdrawn capability returns 500, not 404 — which contradicts the reasoning this PR adds to the no-route path in
stdHandler.go.
Should fix
config/oan-provider-adapter.yamlsetscacheTTL: 60swith nocache:plugin, so caching is silently off and every message costs three registry round trips.- Response-leg mapping failures are classified
400, blaming the caller for the provider's answer. hasProviderStepsis derived from config presence, not from the step being wired intosteps:— a missingsteps:entry 404s the whole module with no startup error.- A payload with several commitments naming the same capability passes binding derivation, then loses all but
commitments[0]in the mapping. - The generated
on_selectis signed and sent without schema validation. Concrete case: if the provider omitslocation, JSONata drops the undefined values andcoordinatesbecomes[]— 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.
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.
manjudr
left a comment
There was a problem hiding this comment.
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
-
oanbinding.go:59— the multi-commitment guard doesn't do what its own comment says. It counts provider-id values resolved, not commitments, andwalkreturns nothing for a missing or non-string leaf. A two-commitment payload whose second commitment omitsoffer.provider.idpasses the guard, resolves offcommitments[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. -
oanregistry.go:356— ambiguous cache key on the signing-key path.oan_lookup_%s_%sjoined on_collides for participantsa/keyb_canda_b/keyc, andcached()re-checks shape but never identity. Inbound signatures claiming to be one participant then verify against the other's public key. Latent whilecacheTTLis 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— unboundedio.ReadAllon the per-request signature-validation path, against a plain-HTTP registry. Sibling code in this PR (upstream.go:588,jsonmapper.go:487) correctly usesio.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:335already 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 200application/json.upstream.go:480— registry-suppliedretryMax/timeoutMshave no ceiling; one row can pin a goroutine and an inbound connection for ~17 hours per request.stdHandler.go:695—hasProviderStepscomes 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
developmentgate on upstream branch names that don't exist in this flow —ci.ymlon[beck-onix-v1.0-develop, beck-onix-v1.0],beckn_ci.ymlandbeckn_ci_test.ymlon[beckn-onix-v1.0-develop]. A PR intodevelopmentmatches none. - Even if
beckn_ci.ymlfired, it setsAPP_DIRECTORY: "shared/plugin", while these plugins live underpkg/plugin/implementation/— so it would test the wrong tree and pass vacuously. build-and-deploy-plugins.ymlisworkflow_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.
manjudr
left a comment
There was a problem hiding this comment.
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 inoanregistry/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 forcmd.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:
redactStringmatches the raw env value whileauthenticatewrites the credential throughquery.Encode(). Any token containing+,/,=or a space — every standard-base64 token — is not redacted, and goes to the log in recoverable percent-encoded form atinfolevel on every successful call.redact()has the same hole atwarn. I verified this by running the realauthenticate->redactStringsequence; 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:
:62subscriberId: 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.:81url: 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.
manjudr
left a comment
There was a problem hiding this comment.
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@typeis hardcoded while@contextis taken from the caller, though the pack pins both together inx-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.unitvalues ("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.
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.
Code reviewFound 1 issue:
Verified against 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: network-adapter/core/module/handler/stdHandler.go Lines 234 to 240 in da6480d The refusal path is the one that reverses that order: network-adapter/core/module/handler/responsestep.go Lines 58 to 67 in da6480d network-adapter/core/module/handler/responsestep.go Lines 334 to 345 in da6480d 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 Also worth noting: 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 ( 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Code review (follow-up)Four documentation-vs-code contradictions and one refactor leftover, all verified against the code at
network-adapter/config/oan-provider-adapter.yaml Lines 274 to 281 in da6480d network-adapter/pkg/plugin/implementation/internal/upstream/upstream.go Lines 662 to 667 in da6480d
network-adapter/pkg/plugin/implementation/internal/upstream/upstream.go Lines 650 to 662 in da6480d
network-adapter/pkg/plugin/implementation/jsonmapper/README.md Lines 187 to 192 in da6480d network-adapter/pkg/plugin/implementation/jsonmapper/jsonmapper.go Lines 590 to 604 in da6480d
network-adapter/pkg/plugin/implementation/oanregistry/README.md Lines 222 to 227 in da6480d network-adapter/pkg/plugin/implementation/oanregistry/providerrecord.go Lines 194 to 200 in da6480d
network-adapter/pkg/plugin/implementation/oanregistry/providerrecord.go Lines 138 to 145 in da6480d Checked and consistent: all five 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
Code review (follow-up 2)One more correctness issue, verified at
network-adapter/pkg/plugin/implementation/internal/upstream/upstream.go Lines 796 to 806 in da6480d It is applied not just to the URL but to the provider's response body, on the non-2xx path: network-adapter/pkg/plugin/implementation/internal/upstream/upstream.go Lines 659 to 662 in da6480d That body-echo case is one the change already treats as in scope. network-adapter/pkg/plugin/implementation/internal/upstream/upstream_test.go Lines 490 to 530 in da6480d The shipped config is the scheme that is not covered: network-adapter/config/oan-provider-adapter.yaml Lines 226 to 231 in da6480d Verified by calling An API gateway quoting the rejected The comment at line 228 ("Redacted from the URL the step logs") is accurate for 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
|
Please rebase your branch from the development, as there are CI changes applied |
Code review (follow-up 3)Three more, all verified against
network-adapter/config/oan-provider-adapter.yaml Lines 56 to 58 in da6480d network-adapter/config/oan-provider-adapter.yaml Lines 121 to 123 in da6480d
network-adapter/pkg/plugin/implementation/internal/upstream/upstream.go Lines 419 to 427 in da6480d
network-adapter/core/module/handler/responsebody_test.go Lines 176 to 196 in da6480d network-adapter/core/module/handler/stdHandler.go Lines 643 to 645 in da6480d network-adapter/core/module/handler/stdHandler.go Lines 684 to 689 in da6480d 🤖 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.
Both fixed — and the first one was mine to own1 · 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 Fixed with a helper rather than a Two things worth flagging:
2 · Corrected the three wrong ones rather than the behaviour, since the code and the Your closing point is the reason it drifted, and it's now addressed directly: nothing asserted 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: |
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
Merge-readiness re-check (head
|
|
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:
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 |
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
/searchendpoint. Serves two independent jobs: the sender's public key forvalidateSign(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/networkroles. 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:andresponse:, plus arequired: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,headerandquery. 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:
Core changes
StepContext.ResponseBodylets 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.sendResponsewrites that answer, andackSignersigns the same bytes — signing the ACK while sending an answer would put a valid signature over the wrong body.mapperandproviderStepsplugin kinds.providerStepsis separate fromstepsbecause they are handed a registry and a mapper, which the plainStepProvidercontract cannot do.ProviderRecordLookupandErrProviderRecordNotFoundindefinition/registry.go, obtained by type-asserting aRegistryLookup— the same pattern already used forRegistryMetadataLookup.The shipped mapping
config/mappings/mausamgram/weather-observation.select.yamlfollows theopenagrinet:WeatherObservationv0.1 pack: anOnDemandrequest, aDirectanswer with one resource per forecast day, ids derived from each date, and the offer'sresourceIdsrewritten 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, fcstday2sorts 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.codeisDRAFT, because the Beckn v2 enum isDRAFT/ACTIVE/CLOSEDand a quote is a draft commitment. And each resource carries aquantity, whichCommitment.resourcesrequires while the spec defines noquantityproperty and carries noQuantityschema 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,bppIdorbppUri: a mapping transforms a payload and has no business asserting network identity, and the twoUrifields 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 onfeat/8-mandi-plugin, which is 58 lines and touches none of it.Testing
go build ./...,go vet ./...andgo 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:WeatherObservationv0.1 withjsonschema— zero errors.End to end on a local stack (registry, discovery, three adapters, mock provider): a signed
/selectreturns anon_selectcarrying one resource per forecast day;/discoverstill answers;/publishreaches 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.yamland-bpp.yamlgained OAN plugin wiring. If the shipped samples should stay untouched, that is a clean revert — the OAN config also stands alone inconfig/oan-provider-adapter.yaml.authblock 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.Story: OpenAgriNet/engineering-tracker#41
Closes #1
Closes OpenAgriNet/engineering-tracker#46
Closes OpenAgriNet/engineering-tracker#63
Closes OpenAgriNet/engineering-tracker#66