Skip to content

feat: enforce the schema packs on resourceAttributes, and rename the capability plugins - #17

Merged
manjudr merged 10 commits into
feat/41-oan-adapter-pluginsfrom
feat/16-capability-schema-conformance
Sep 8, 2026
Merged

feat: enforce the schema packs on resourceAttributes, and rename the capability plugins#17
manjudr merged 10 commits into
feat/41-oan-adapter-pluginsfrom
feat/16-capability-schema-conformance

Conversation

@ameersohel45

Copy link
Copy Markdown
Collaborator

Targets feat/8-mandi-plugin rather than development, because it changes both capability plugins that PR #13 introduces. That makes this the third in a stack — merge #2, then #13, then this one.

What

Extended schema validation, on for the first time, plus the rename that makes a plugin id read as the capability it serves. 13 files, +359 / -61, four commits.

The validator — pkg/plugin/implementation/schemav2validator/extended_schema.go

The one substantive code change, and the only reason the layer could not be switched on before.

validateReferencedObject stripped both @context and @type from every object before validating it, on the reasoning that they are JSON-LD plumbing the validator handles itself. The OAN packs disagree: they declare @type and list it in allOf[].required. So a conforming payload was rejected for a missing @type that it had sent and the validator had just removed, and nothing in configuration could bridge it.

The fix is not "stop stripping @type". That would break the other schema style, which the plugin's own tests use — a schema that closes itself with additionalProperties: false and never mentions either key rejects the payload if @type is left in. The two styles need opposite treatment, so the decision is made per key by asking the resolved schema whether it declares that key, as a property or in required, anywhere in its composition tree. The packs declare @type one level down inside allOf, so the walk covers allOf/anyOf/oneOf and the then/else branches.

not and if are deliberately not walked. Naming a property under not forbids it and under if only selects a branch, so a match in either says nothing about whether the key is permitted.

Keeping @type has a second effect worth noting: its const is now checked, so a resource claiming a @type its capability does not declare stops passing silently.

Where the schemas come from — no mount, no vendored copy

Each resource's @context names the published pack, and the validator swaps context.jsonld for attributes.yaml to fetch the schema beside it. So the pack revision a payload is judged against comes from the payload, and nothing in this repo can go stale against what the network publishes.

A local mounted directory was built first and then removed. It existed only because the @context the payloads carried did not resolve, and a failed fetch rejects the payload — but the published packs do serve context.jsonld, so pointing @context at them makes the fetch the validator already knew how to do work, and the mount, the fetch script and a startup dependency all became unnecessary.

extendedSchema_allowedDomains is narrowed to the host @context actually resolves to, and is now load-bearing rather than naming a host that never answered: an @context on any other host is refused before a fetch is attempted.

The rename — weatherWeatherObservation, mandiMandiPrice

Mechanical, but breaking for any deployment. A step name that is not one of the built-ins is looked up among the loaded plugins, and a plugin's id is the basename of its .so, so the directory rename carries the config ids with it: providerSteps and steps in config/oan-provider-adapter.yaml and the entry in install/build-plugins.sh move together with the packages. An adapter running this config against an image built before the rename exits with unrecognized step: WeatherObservation, which reads like a config typo and is not — config and image have to roll forward and back together.

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.

Why

Base validation checks the envelope and treats resourceAttributes as a free-form object, so until now nothing checked a capability's own attributes at all. A wrong unit, a missing required attribute or a field the pack does not define all passed, and became something a consumer discovered later. The plugin already had the machinery; it was configured and never ran.

Testing

Full suite green — 63 packages. Four new tests cover the per-key strip, including a pack-shaped schema that requires @type, one that rejects a wrong @type, and the composition walk.

End to end against a running stack, on an image built from this branch, with both plugins loading under their new ids: publish and select pass for both capabilities, the first payload logs fetching from network and every later one LRU cache hit, and the local collection is 51 of 51.

Negative controls, so a pass reads as real checking rather than an absence of it — each rejected with SCH_SCHEMA_VALIDATION_FAILED and the JSON path that failed:

  • a value outside an enum
  • a bare date in a date-time field
  • a field undefined in a closed sub-object, which resolves through a schema.beckn.io $ref

And an @context on a host outside the allowlist rejected with SCH_INVALID_JSONLD_CONTEXT, before any fetch.

Notes for review

  • if/then/else is not enforced, and a green result is misleading without knowing that. kin-openapi v0.144.0 has the struct fields, so the keywords look supported, but visitJSON never dispatches them. Verified empirically rather than read off the struct. Enforced: types, string formats, enum, const, required, minItems, additionalProperties, not, allOf/anyOf/oneOf.
  • A consequence of that, worth raising on the packs rather than working around here. The packs require informationMode and allow only OnDemand or Direct. OnDemand forbids exactly the fields a select must carry to be a question — location for weather, market and validity for mandi — while Direct requires answer data a consumer does not have. There is no query mode, so our select requests are not pack-conformant on paper and pass only because the forbidding sits under then.
  • One known non-conformance, not fixable here. The published weather mapping emits parameters[].aggregation, which the pack forbids: its parameter item is {parameter, value, unit} with additionalProperties: false, and the parameter enum has no minimum or maximum variants, so a daily minimum and maximum temperature cannot be distinguished at all. Nothing validates responses today, so it does not bite until a consumer checks. It needs a change to the packs.
  • Deployment side is done separately, in OpenAgriNet/helmcharts on feat/4-docker-compose: the renamed ids, the same validator settings, and a pinned adapter image so the shipped default cannot meet a pre-rename build.
  • Commit messages carry [#8] rather than [#16]. Issue Adapter: validate resourceAttributes against the schema packs, and name the plugins after their capabilities #16 was opened after the work, and the SHAs are referenced from it and from a published image tag, so rewriting them would cost more than the inconsistency.

Closes #16

@manjudr manjudr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review

Read the whole diff, then verified each substantive claim against the head tree and the published packs in network-specs.

The rename half is clean. No surviving weather/mandi plugin-id references anywhere in the head tree; install/build-plugins.sh, config/oan-provider-adapter.yaml (providerSteps + steps), package clauses and imports all move together; step resolution in core/module/handler/stdHandler.go:757 is an exact case-sensitive match; .github/workflows/build-and-deploy-plugins.yml derives the .so name from the directory basename, so it follows automatically. go vet and go test ./pkg/plugin/implementation/schemav2validator/... pass.

The per-key strip logic is sound. stripUnaccountedJSONLDKeys / schemaDeclaresProperty are cycle-guarded, non-mutating, and correctly exclude not and if. Asking the schema per key rather than adding a config switch is the right call, and the comment explaining why earns its length.

Every problem is in switching the layer on. Five findings inline — two on the config, one on the new test, two file-level on extended_schema.go (pre-existing code, but this PR is what makes it reachable, so it's in scope).

Suggested sequencing

The two config findings together mean the adapter rejects its own traffic the moment it deploys, and no test in the repo can catch it. Either:

  1. fix the captured payloads + packs and add schemas.openagrinet.global to the allowlist before merging, or
  2. land the validator code with extendedSchema_enabled: "false" and flip it in a follow-up, once a test exercises a real capture through the full schemav2validator path.

The $ref allowlist bypass should be fixed either way, independent of the flag.

extendedSchema_enabled: "false"
extendedSchema_cacheTTL: "86400"

extendedSchema_enabled: "true"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

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

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

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

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

The mandi capture fails the same way.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

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

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

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

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

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

Comment thread config/provider-adapter.yaml Outdated
# FAILS rejects the payload -- it does not skip validation, which
# is the right way round, but it does mean this adapter needs
# egress to the host allowed below.
extendedSchema_allowedDomains: "raw.githubusercontent.com"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This allowlist excludes the canonical @context host, so only non-canonical contexts can pass.

schemas.openagrinet.global is what the packs publish in their own x-jsonld and context.jsonld, and it is what every fixture on this branch uses — see WeatherObservation/mappings_test.go:62. With only raw.githubusercontent.com allowed, extended_schema.go:598 returns SCH_INVALID_JSONLD_CONTEXT before any fetch is attempted.

The net effect is inverted from the intent: a payload pointing at the canonical schema host is rejected, while a payload pointing at an arbitrary GitHub user's raw content is accepted. Add schemas.openagrinet.global, and keep raw.githubusercontent.com only if something actually needs it (see the file-level note on extended_schema.go).

@ameersohel45 ameersohel45 Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The diagnosis is right, and I checked every step of it: the fixtures on this branch do use schemas.openagrinet.global, the allowlist does permit only raw.githubusercontent.com, and extended_schema.go:598 does refuse before any fetch. Your "the net effect is inverted from the intent" is a fair reading of it.

Leaving the allowlist as it is for now, and leaving this thread open rather than closing it as handled. Config changes on this branch are being batched separately from code fixes, so this is deferred, not dismissed.

One thing that does change the remedy, though. I checked whether the canonical host serves anything, and schemas.openagrinet.global has no DNS at all, while raw.githubusercontent.com resolves.

So allowlisting the canonical host does not make those payloads validate — it converts an immediate, clearly-coded SCH_INVALID_JSONLD_CONTEXT into a fetch that waits out the download timeout and then fails as SCH_SCHEMA_ADAPTATION_FAILED. Same rejection, later and less legibly. In JSON-LD the @context is an identifier rather than a fetch target, which is why the packs can name a host that does not serve — but this validator resolves a schema from it, so it needs one that answers.

That makes it a choice between two things, and it is worth being explicit about which:

  • allowlist the canonical host and fix the fixtures to point at a host that resolves — the identifier stays canonical, the fetch target has to be real;
  • or keep the raw mirror as the @context and record in the config why the canonical host is deliberately absent.

Your point about arbitrary GitHub raw content stands under either, and it connects to the file-level $ref comment: raw.githubusercontent.com is world-writable, so allowlisting it trusts every GitHub account. Narrowing it to a path prefix or mirroring the packs somewhere we control would be the real fix, and neither is a one-line allowlist edit.

Also relevant to whatever gets chosen: the allowlist cannot shrink to one host once $refs are host-checked. Loading one pack pulls 15 documents across three hosts — the one named above plus two external spec hosts the packs $ref into — so all three have to be in it. Measured, not assumed; details in the reply on the $ref thread.

Comment thread pkg/plugin/implementation/schemav2validator/extended_schema_test.go Outdated
Comment thread pkg/plugin/implementation/schemav2validator/extended_schema.go
Comment thread pkg/plugin/implementation/schemav2validator/extended_schema.go

@manjudr manjudr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes

Formalising the previous review as a blocking one. The detail is in the inline comments there; this is the short version of what needs to change before merge.

Blocking:

  1. config/oan-provider-adapter.yaml:154 — with extendedSchema_enabled: "true", the adapter rejects this repo's own verbatim captured /select payloads: informationMode is missing (required at AgricultureResource/v0.1/attributes.yaml:17, on the root object, not under a then), and validity.startsAt/endsAt are bare dates against format: date-time. Both the weather and mandi captures fail. mappings_test.go never traverses schemav2validator, so CI stays green while the deployed adapter refuses the exact payload those tests assert it serves.

  2. config/oan-provider-adapter.yaml:174extendedSchema_allowedDomains omits schemas.openagrinet.global, the canonical @context host the packs publish and every fixture here uses. Only non-canonical contexts can pass.

  3. extended_schema.go:598 / :345 — the allowlist gates only the top-level @context; $refs in the fetched schema reach freshReadFromURI with no host or scheme check. Since raw.githubusercontent.com is writable by any GitHub account, an unauthenticated select can drive arbitrary outbound fetches or local file reads. Pre-existing code, but this PR is what makes it reachable — should be fixed regardless of the flag's state.

Non-blocking but worth addressing: the array-form @type silent skip and the unreachable wrong-@type test, both in the inline comments.

Either fix (1) and (2) before merge, or land the validator code with extendedSchema_enabled: "false" and flip it in a follow-up once a test pushes a real capture through the full validator path. The rename half and the per-key JSON-LD strip logic are good to go as-is.

@ameersohel45
ameersohel45 force-pushed the feat/16-capability-schema-conformance branch from f2130e6 to 59f2d3c Compare September 7, 2026 19:03
@ameersohel45

ameersohel45 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Review round: what is fixed here, and what is deliberately not

All five comments were genuine — each verified against the code and the packs before changing anything, and each fix has a test that fails without it.

Fixed in this branch

Finding Commit
$ref resolution bypassed the allowlist, allowing arbitrary local-file reads 63fc9bf
List-form @type was silently skipped, so those attributes were validated by nothing 59421ba
The @type const test asserted on a state the real path cannot produce 59421ba

Worth recording one measurement from the first of those, since it decided the scope: loading a single capability pack pulls 15 documents across 3 hosts, and the allowlist was checked on exactly one of them. The local-file read is now closed unconditionally, with no config change.

Deliberately not fixed here — threads left open

Two findings need a config or payload change, which this branch is batching separately from code fixes. Their threads are left unresolved rather than closed, so they are not mistaken for handled:

  • the allowlist excluding the canonical @context host — and note schemas.openagrinet.global has no DNS, so allowlisting it alone would move the failure later rather than remove it;
  • informationMode missing and validity carrying bare dates in the captured payloads, plus the end-to-end validator test that would catch it.

One thing not done that a reader might expect. Enforcing the allowlist on $ref hosts, not just the entry @context, is the right shape and is not in this PR: the packs $ref two external spec hosts, so host-checking refs against a one-host allowlist stops every pack loading. Measured, not assumed. TestValidateReferencedObject_AllowsARefToAHostOutsideTheAllowlist pins the current behaviour and explains why, so the change cannot be made by accident.

Neither the schema packs nor the mappings are on a pinned version yet, so both are expected to move.

@ameersohel45
ameersohel45 force-pushed the feat/16-capability-schema-conformance branch from fa2d4d5 to 59421ba Compare September 8, 2026 05:21
@ameersohel45
ameersohel45 force-pushed the feat/16-capability-schema-conformance branch from 59421ba to d6033a8 Compare September 8, 2026 07:54
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

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

View full run

Go dependencies

No findings at CRITICAL,HIGH,MEDIUM,LOW.

Container image

No findings at CRITICAL,HIGH,MEDIUM,LOW.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

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

@ameersohel45
ameersohel45 force-pushed the feat/16-capability-schema-conformance branch from d6033a8 to 6f3ab95 Compare September 8, 2026 08:06
@ameersohel45
ameersohel45 force-pushed the feat/16-capability-schema-conformance branch from 6f3ab95 to c481e7a Compare September 8, 2026 09:38
@ameersohel45
ameersohel45 force-pushed the feat/16-capability-schema-conformance branch from c481e7a to 5fcfb2f Compare September 8, 2026 09:48
Extended validation removed @context and @type from every object before
validating it. That suited a schema which closes itself with
additionalProperties:false and never mentions either key, and it made a
schema pack that DECLARES @type and lists it in required impossible to
satisfy: the payload carried @type, the schema required it, and the
validator had just taken it out, so a conforming payload was rejected for
a missing field it had supplied.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also replaces the @type const test. It built obj.Type and Data["@type"]
disagreeing, which the real path cannot produce -- both are read off one map --
so it demonstrated nothing about a payload. Tests now run through
findReferencedObjects, and the const does have a payload-level case: the list
branch forbids a second openagrinet: type, which is caught only because @type
is kept in the data rather than stripped.
@ameersohel45
ameersohel45 force-pushed the feat/16-capability-schema-conformance branch from 5fcfb2f to 9a1594f Compare September 8, 2026 10:38
Base automatically changed from feat/8-mandi-plugin to feat/41-oan-adapter-plugins September 8, 2026 11:46
@manjudr

manjudr commented Sep 8, 2026

Copy link
Copy Markdown
Member

Code review

Found 2 issues (head 9a1594f32c1c45f99eeda9c7daf78332cabbfd28):

  1. SSRF risk in the new schema fetcher — the host allowlist only covers the entry @context, not $ref targets it leads to. payloadDirectedReader restricts scheme (http/https only) but not host, and the code's own comment says so: "This does NOT restrict which hosts may be reached; isAllowedDomain still guards only the entry @context." @context is read straight out of the request payload, and the shipped allowlist (config/provider-adapter.yaml) is raw.githubusercontent.com — a public, anyone-can-publish host. A payload can point @context at an attacker-controlled file on that host, pass the allowlist, and have the returned document $ref any other http(s) host (e.g. an internal service or metadata endpoint), causing this process to fetch it server-side.

//
// This does NOT restrict which hosts may be reached; isAllowedDomain still
// guards only the entry @context. Enforcing the allowlist here as well is the
// right shape, but the packs $ref two external spec hosts, so it needs those
// named in the allowlist or no pack loads at all.
func payloadDirectedReader(loader *openapi3.Loader, u *url.URL) ([]byte, error) {
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("refusing to read schema from %q: only http and https are read for a location a payload chose", u.String())
}
return freshReadFromURI(loader, u)
}

extendedSchema_enabled: "true"
# Resolution is a FETCH of the @context each resource declares,
# not a directory mounted here. The validator swaps context.jsonld
# for attributes.yaml to get the schema beside it:
#
# @context .../schema/MandiPrice/v0.1/context.jsonld
# fetched .../schema/MandiPrice/v0.1/attributes.yaml
#
# So a payload names the pack revision it wants to be judged
# against, and no copy of the schemas here can drift from the
# published ones. The packs' relative $refs (into
# AgricultureResource) resolve against that same base; their
# absolute ones resolve directly, against whichever host they name.
#
# Fetched once per @context and cached for the TTL below, so only
# the first payload after a restart pays for it. A fetch that
# FAILS rejects the payload -- it does not skip validation, which
# is the right way round, but it does mean this adapter needs
# egress to the host allowed below.
extendedSchema_allowedDomains: "raw.githubusercontent.com"

  1. Stale comment reference after the plugin rename — points at a path that no longer exists. weather/ was renamed to WeatherObservation/ in this PR, but this comment still says "the note in weather/prerequisites.go."

// An entry would be needed only for real I/O: a commodity name to resolve to a
// code, a token to exchange, a point to turn into a market. Each of those is a
// different upstream than the one this was written against, and each would
// bring the question of where the provider-to-function binding belongs -- see
// the note in weather/prerequisites.go and prefer keeping the payload explicit
// over adding an entry here.

Separately, note that manjudr's CHANGES_REQUESTED review is still open, and its two stated blocking conditions are both unresolved at this head: extendedSchema_allowedDomains still excludes the canonical schemas.openagrinet.global host every fixture uses, and extendedSchema_enabled is "true" while the adapter's own captured payloads reportedly fail the new validation (missing informationMode, date format mismatch).

🤖 Generated with Claude Code

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

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

Copy link
Copy Markdown
Collaborator Author

Both fixed — and the first one changed my mind about a decision I'd defended

1 · SSRF via $ref5132832. You're right, and I'd argued the other way, so it's worth saying why I was wrong.

I had left this deliberately and pinned it with a test, on the grounds that host-enforcing refs breaks pack loading. That reasoning was sound about the mechanism and wrong about the conclusion: I treated "the allowlist can't be one host" as a reason not to check refs, when it was a reason to fix the allowlist. Your framing — that the returned document is not trusted because a payload named it, on a host anyone can publish to — is the part I'd underweighted.

The reach is the argument for fixing rather than documenting. Loading one 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. I initially installed it on both branches and TestLoadSchemaFromPath_LocalFile caught it.
  • host — on both paths. localSchema falls back to the network for a ref it doesn't hold locally, so it had the same exposure by a longer route. That closes a gap neither of us had listed.

The allowlist had to grow, and this is a code+config pair that cannot land half-applied. Measured against the published packs, not assumed — every pack (WeatherObservation, MandiPrice, KnowledgeResource) touches raw.githubusercontent.com, schema.beckn.io and schema.nfh.global:

shipped 3-host allowlist  ->  loaded and validated
1-host allowlist          ->  SCH_SCHEMA_ADAPTATION_FAILED on every payload

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

What this does not fix, and I'd rather name it than let it read as closed: raw.githubusercontent.com is world-writable, so the allowlist still trusts every GitHub account for schema content. A payload can name a schema it authored and be judged by it. 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. It's written in the config as such.

One deployment coupling this creates: helmcharts sets the single-host allowlist in four places. Once an image carrying this code is built, those deployments need the widened value in the same pass or the provider adapter refuses every payload. The deployed image is deliberately pinned to an older commit today, so nothing is broken right now — flagging it so the two move together.

2 · Stale path after the rename — cca7ff2. Correct. MandiPrice/prerequisites.go still pointed at weather/prerequisites.go. Fixed, and I checked it was the last stale plugin path in any Go comment.

On the two open blocking conditions — both still stand, unchanged and deliberately:

  • extendedSchema_allowedDomains excludes schemas.openagrinet.global, which has no DNS. Allowlisting it would convert an immediate coded rejection into a timeout, not a pass — so the fixtures need a host that resolves, or the config needs to record why the canonical host is absent.
  • extendedSchema_enabled: "true" while the captured payloads omit informationMode (required at the root of AgricultureResource, so genuinely enforced) and send bare dates where validity wants date-time.

Both are payload/config work, batched separately from code fixes and tracked by the two unresolved threads above.

Verified: make test green — 64 packages, 0 failures, 0 races — changed-line coverage 92%, and both real packs load and validate through the new path.

@manjudr
manjudr merged commit bcd10b3 into feat/41-oan-adapter-plugins Sep 8, 2026
2 checks passed
@manjudr
manjudr deleted the feat/16-capability-schema-conformance branch September 8, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants