Skip to content

Merge NetworkofNetwork into development [#72] - #109

Merged
manjudr merged 52 commits into
developmentfrom
NetworkofNetwork
Oct 30, 2026
Merged

manjudr merged 52 commits into
developmentfrom
NetworkofNetwork

Conversation

@tdprajwal

Copy link
Copy Markdown

What

Merges the federation work (#82, #86, #90, #97) into development:

  • Registry schema: federation fields on Participant (chart registry 0.6.0)
  • quick-start: hosts the network's Descriptor and JWKS, admits peer networks, crawls them
  • quick-start: two networks can run on one machine (quick-start/FEDERATION.md)
  • quick-start: Keycloak removed

Breaking

  • role in Participant is now an array. Rewrite existing rows after upgrading; steps are in charts/registry/CHANGELOG.md.
  • quick-start: re-create .env from .env.example.

Testing

  • test_oanfed.py: 40 tests pass
  • check-registry-schema.py: all 18 cases pass

Closes #72

tdprajwal and others added 30 commits October 30, 2026 13:00
…86)

* feat: add federation fields to the Participant registry schema [#71]

Federation needs the registry to record peer networks: our own entry
builds the Network Descriptor (#74) and admitting a peer writes one (#75).

- role accepts a list as well as a single value, so one participant can
  serve several layers; a single string stays valid for existing rows
- federationRole (self/source/requester/both): our admission decision,
  never read from a peer's Descriptor; added to indexFields
- revision: the Descriptor revision accepted; only increases
- schemaPacks: the peer's declared schema pack URLs, https only
- projectionTtl: seconds we may retain a peer's data; 0 means live query

All fields are optional and no existing rule is tightened, so stored
records keep their osid and stay editable.

* fix: let a node's keys be edited by declaring the registry's system fields [#71]

On a PUT, Sunbird RC merges osid and the os* audit fields into each
stored key before re-validating. PublicKey did not declare them, so
every update of a node failed, including the revision bump that
refreshes an admitted peer.

Declare those five fields and keep additionalProperties false, so typos
and unknown key fields are still refused. osOwner is left out: the
registry never sets it on a key.

* fix: drop self from federationRole, identify our own entry from config [#71]

A network recognises its own entry from its configuration, so a stored
self value is redundant. federationRole now only records our decision
about a peer: source, requester or both.

* feat: make federationRole a per-direction admission record [#71]

federationRole is now an object with optional source and requester
halves instead of a source/requester/both string; both halves present
replaces "both". Each half carries its own schemaPacks (moved from the
top level), descriptorRevision, admittedAt and updatedAt, since the
design doc stores admission independently for each direction.
federationRole drops out of indexFields, as it is no longer a scalar.

* docs: shorten federation field descriptions [#71]

* fix: require at least one schema pack per federation direction [#71]

An agreement that names no packs covers nothing, yet the registry
accepted one, so the mistake only showed up later as refused calls.
Both directions now need a schemaPacks list with at least one entry.

* fix: address review on federation fields [#71]

* fix: sync the registry chart schema [#71]

* docs: add ADR-0002 for the federation admission record [#71]

* docs: record how the registry validates updates in ADR-0002 [#71]

* fix: address second review on federation fields [#71]

* fix: validate the registry schema in CI and verify the role migration [#71]

The registry-schema workflow only compared the two copies of
Participant.json, so two identically wrong files passed and nothing in the
repo ever ran a payload through the schema.

scripts/check-registry-schema.py now does both: the copies still have to
match, and sixteen cases in scripts/registry-schema-cases.json assert what
the schema accepts and refuses -- an empty federationRole, projectionTtl on
source but not requester, a suspended direction without suspendedAt, a typo
in a member name, role as a bare string.

The role migration in the chart CHANGELOG was written but never run. It has
now been rehearsed end to end against sunbird-rc-core v2.0.0, the image the
quick-start stack runs: a 0.4.1 row reads but refuses its next update, the
script rewrites it without losing its keys, and the rewritten row can then be
admitted as a federation peer. The CHANGELOG records the error you see before
the rewrite and the check to run after it.

projectionTtl is accepted both at the entry's root and under source. The
transition was described in ADR-0002 and owned by nobody; #102 owns it now,
and the ADR and the schema point at it.

* test: cover an ordinary node in the registry schema cases [#71]

The cases covered peers and upstreams but not the row the registry holds
most of: one of our own consumers or providers, a node with a baseUrl and
no federationRole. Add it, plus the matching refusal of a node reached
over plain http, which nothing exercised either.

* docs: correct what the registry validates on an update [#71]

ADR-0002 said Sunbird re-validates the whole merged document on a PUT. It
does not: it validates the request body, then merges. Checked on a live
sunbird-rc-core v2.0.0 with the same row twice -- a body of just
federationRole was refused by a rule reading type and role, and the same
body carrying type and role passed.

The migration note followed from the same mistake. An update that does not
mention role succeeds against a row holding the 0.4.1 string and leaves the
string in place, so the first write after the upgrade does not force the
rewrite. Say so, and say the rewrite has to be run deliberately.

---------

Co-authored-by: Manjunath Davanam <manjunathdavanam@gmail.com>
…nAgriNet/network-adapter#74] (#82)

* fix: seed schema packs from openagrinet.github.io [OpenAgriNet/network-adapter#74]

* feat: seed one registry entry for the network [OpenAgriNet/network-adapter#74]

* feat: host the Network Descriptor, JWKS and schema packs [OpenAgriNet/network-adapter#74]

* fix: keep the descriptor revision on the registry entry, publish every key [OpenAgriNet/network-adapter#74]

* fix: address review on descriptor hosting [OpenAgriNet/network-adapter#74]

* docs: add ADR-0003 and ADR-0004 for the network identity and schema-pack list [OpenAgriNet/network-adapter#74]
* feat: admit, refresh and suspend a peer network [OpenAgriNet/network-adapter#75]

* fix: suspend a direction by status in one write [OpenAgriNet/network-adapter#75]

* fix: file a peer's discovery URL as discoveryUrl, not baseUrl [OpenAgriNet/network-adapter#75]

* fix: address review on peer admission [OpenAgriNet/network-adapter#75]
Federation needs the registry to record peer networks: our own entry
builds the Network Descriptor (#74) and admitting a peer writes one (#75).

- role accepts a list as well as a single value, so one participant can
  serve several layers; a single string stays valid for existing rows
- federationRole (self/source/requester/both): our admission decision,
  never read from a peer's Descriptor; added to indexFields
- revision: the Descriptor revision accepted; only increases
- schemaPacks: the peer's declared schema pack URLs, https only
- projectionTtl: seconds we may retain a peer's data; 0 means live query

All fields are optional and no existing rule is tightened, so stored
records keep their osid and stay editable.
…ields [#71]

On a PUT, Sunbird RC merges osid and the os* audit fields into each
stored key before re-validating. PublicKey did not declare them, so
every update of a node failed, including the revision bump that
refreshes an admitted peer.

Declare those five fields and keep additionalProperties false, so typos
and unknown key fields are still refused. osOwner is left out: the
registry never sets it on a key.
#71]

A network recognises its own entry from its configuration, so a stored
self value is redundant. federationRole now only records our decision
about a peer: source, requester or both.
federationRole is now an object with optional source and requester
halves instead of a source/requester/both string; both halves present
replaces "both". Each half carries its own schemaPacks (moved from the
top level), descriptorRevision, admittedAt and updatedAt, since the
design doc stores admission independently for each direction.
federationRole drops out of indexFields, as it is no longer a scalar.
An agreement that names no packs covers nothing, yet the registry
accepted one, so the mistake only showed up later as refused calls.
Both directions now need a schemaPacks list with at least one entry.
The federated half of the catalog crawler, in both deployment modes. Off by
default, so a stack that admits no network behaves as it did.

No dbDsn: a database is only needed by work that keeps a record between
passes, and crawling other networks keeps none.
…ss [#72]

The crawler runs on the network adapter, and that adapter serves /publish
itself in both modes -- as its own mount in single, as an action on / in
multi, whose routing already lists publish. Sending a multi-mode crawl to
the provider adapter made it travel back out and in again for nothing.
…we answer [#72]

quick-start ran entirely unsigned: no mount signed outbound, none verified
inbound. With admission now writing agreements into the registry, nothing
read them -- the agreement check hangs off a verified caller, and there was
none.

The consumer and publish mounts sign on the way out. The network and provider
mounts verify, then run checkCrossNetworkRequester, which refuses a caller we
never agreed to answer and, on a discover, one asking outside the agreed packs.

Every mount already configured the signer, signValidator and keyManager it
needed; only the step lists were missing. Needs an adapter image carrying
checkCrossNetworkRequester (network-adapter#78).
A verifier filters the registry by participantId and then walks that record's
keys matching on THEIR osid. Naming the entry's osid instead resolved no key,
so every signed request failed as AUT_SUBSCRIBER_NOT_FOUND -- invisible until
quick-start started signing, because nothing had looked a key up before.

So setup.py renders the key's osid, publish signs the descriptor with the osid
of the key it actually used, and admission files a peer's key under the kid
rather than putting the kid on the entry around it.

A rotated key is a new kid, and refresh now follows it. The entry-osid
immutability guard went with it: the entry osid is no longer what a signature
names. The duplicate-osid guard went too -- a verifier filters by participantId
first, so two participants holding the same key osid cannot collide.
… token [#72]

The registry is reached only from inside the stack -- bound to loopback, never
on the proxy -- so a bearer token bought nothing and cost a container, a realm
import, eleven environment variables and two published ports.

It now runs with authentication_enabled=false and no identity provider. Both
scripts call it plainly: setup.py loses token() and the bearer threaded through
every write, oanfed loses the Authorization header.

Checked that the registry still boots with the SSO settings absent -- the
warning about the security filter chain applied only with authentication on --
and that an unauthenticated create returns SUCCESSFUL.

The README says plainly what now keeps the registry private: the loopback
binding and its absence from oan-edge, and nothing else. A proxy host pointed
at it would publish a writable registry outright.
Everything a second stack would collide over is now a variable: the compose
project, thirteen container names and two docker networks. STACK_NAME and
STACK_PREFIX default to what they have always been, so one network is
unchanged. Ports were already variables.

stack.sh inspected two containers by bare name; it asks container() now.

Step 1 waits for the registry's own healthcheck. docker compose up -d waits for
a service's dependencies and never for the service itself, and the registry used
to depend on Keycloak being healthy -- minutes, by which time it was ready too.
Removing Keycloak removed that accidental wait, and setup.py started racing the
registry's first boot and dying on a connection reset.

Appendix O walks two networks end to end, Appendix B says what differs between
a laptop and anywhere reachable, and two failures that cost real time are in
Appendix F: SunbirdRC cannot load its schemas twice, and a transient pack fetch
reads as a schema error.

Tested with two stacks side by side, each answering a discover with its own
catalog, sharing no container, project, network or volume.
…make [#72]

oanfed.py grew admit, refresh and suspend, and nothing wrapped them -- an
operator had to assemble the flags by hand, and the README never said how.

Four targets now fill them from .env and name only what is actually a
decision: the peer, the direction, and the packs the agreement covers.
RESOLVE and CA_FILE are there for two networks on one machine.

A missing or wrong value says which one and shows the form, rather than
reaching argparse.
make setup did everything in one pass: keys, five participants, four bindings
and every adapter config. A wrong value in .env was therefore a wrong row in a
registry that creates only, and the blast radius was the whole network.

It splits into make keys, make register-network, make register-provider
PROVIDER=, and make configure-adapter. make up now stops at the containers --
it writes nothing and starts no adapter, because an adapter config is a
bind-mounted file and Docker answers a missing one by creating a directory.
configure-adapter is what starts them, so a rendered config is never left
unpicked-up.

configure-adapter renders from the registry's own ProviderSchema rows rather
than from .env, so the binding key a provider step is configured with and the
one it is called with are the same string. A provider with no row contributes
no block. That took the four capability blocks out of the two adapter templates
and into config/adapters/providers/<name>.yaml.tmpl, one per provider, holding
the part the registry deliberately does not: how to authenticate to it.

It also asks for the deployment mode and whether to crawl other networks, and
writes both answers back to .env -- settings that were only ever an environment
variable prefixed to the command. Neither is asked when it was given as an
environment variable, or when there is no terminal.

Three template comments said signing was off, which stopped being true when
quick-start started signing. MODE= never worked despite the help text saying so.
configure-adapter had no wait for the registry, so a stack still starting gave
a traceback rather than the message that names the cause.
SchemaRegistry was the only entity whose root set additionalProperties false.
The registry merges its own osCreatedAt, osUpdatedAt, osCreatedBy and
osUpdatedBy into a record before validating it, so every PUT was rejected over
fields the registry itself had added -- a 500 no caller could avoid. Sending a
body with none of them in it failed the same way.

Measured against a live registry: Participant and ProviderSchema both update
cleanly and neither sets it. A capability's name, version and pack URL were
permanent for no stated reason.
admit filed another network's key under the kid its descriptor signs with, and
then refused its own write: "the registry did not store net-b.oan.local's key
as 1-a74e3ca6-...". Sunbird prepends "1-" to any osid supplied on create,
including a nested one, so passing the kid whole stored 1-1-a74e3ca6-... and no
signature naming the kid would have resolved to a key.

The entry's osid already had this strip; the key's did not, because the kid
moved from the entry to the key without it. Found on two networks admitting
each other, not in the tests -- the registry double kept a supplied nested osid
verbatim, which is not what the registry does. It now prepends, as measured.

Both directions now store a key osid equal to the other network's own kid.
The descriptor advertises NETWORK_BASE_URL + /discover. In single mode /discover
matched the `/` mount -- the consumer tier, whose steps are validateSchema,
addRoute, sign. No validateSign, no checkCrossNetworkRequester. The tier that
checks both sat at /_internal/network/, a path the descriptor never named and
the edge explicitly denied.

So another network's call arrived at the one door that checks nothing, while
the door that checks was sealed off. Measured on two networks admitting each
other: an unsigned POST to /discover was 500 with no authentication attempted,
the same payload to /_internal/network/discover was 401 AUT_SIGNATURE_MISSING.

The three tiers are now /consumer/, /network/ and /provider/ in BOTH deployment
modes, as networkofnetwork already had them, and NETWORK_BASE_URL ends in
/network either way. The edge denies /consumer and /crawl in place of
/_internal; /network and /provider reach the adapter normally and both check
the signature and the agreement.

The crawler's push target is now /network/publish, one address in both modes
rather than an action on / in multi and a mount in single.

Verified on a clean stack, single and multi: unsigned to /network/discover and
/provider/select is 401, /discover is 404, and all three containers start with
no restarts.
ADR-0002 records why the tiers moved to /consumer/, /network/ and /provider/,
and how the paths callers see stay what ADR-0001 promised: the edge rewrites
/discover onto /network/ and the transaction actions onto /provider/, so the
mount names are ours and nobody else carries them. NETWORK_BASE_URL goes back
to no path.

The chart banned snippet, rewrite and regex annotations outright, because one
could reach /_internal or /publish. That ban also forbids the mapping this
needs. It is replaced by a check on the rewrite TARGET, refusing only /publish
and /consumer -- the two mounts that take unsigned calls. Anything else is the
deployment's business, so a mount added later needs no chart release. No
ingress controller is named: Kong, ingress-nginx and ALB spell a rewrite
differently and the annotations come from values.

quick-start does the same job in nginx, but on the individual proxy host rather
than server_proxy.conf: that file is included in every server block and the
upstream differs per host -- in multi mode /select belongs to provider-adapter
and /discover to network-adapter, which no shared rule can express. The shared
file keeps what is true everywhere, denying every mount by name. Appendix B
carries the per-host rule.

Verified: the descriptor advertises /discover again, the adapter answers 401 on
/network/discover and /provider/select, 404 on /discover, and the chart accepts
a rewrite onto /network/ while refusing one onto /publish.
Nothing in the charts served .well-known, so a cluster could neither tell
another network who it was nor read anyone else's answer. Federation existed
only in quick-start.

A Job signs the descriptor, JWKS and schema packs from this network's registry
entry and writes them into a ConfigMap; a small server mounts that ConfigMap
and answers for exactly those three paths. Kong routes to it.

The Job mounts the SAME Secret the adapter reads. oanfed refuses to sign with a
key the registry does not publish for this network, so sharing it is what
guarantees the descriptor names the key that signs traffic -- a mismatch there
surfaces much later, as another network failing to verify anything we send. The
private key exists only inside the Job; what reaches the ConfigMap is public.

Each run publishes the next revision, read from what the ConfigMap already
holds. oanfed refuses a revision that does not increase on the one served, and
so does every network holding the old one, so the number cannot come from a
value in git that a re-run would repeat.

Admissions are values, one Job each, so who we agreed to talk to is reviewable
rather than a command somebody ran once. They need no key: admission reads the
other network's public documents and writes a row in our registry.

oanfed's open_registry imported quick-start's setup.py and read .env, which a
Job has neither of. It now takes REGISTRY_URL and BECKN_NETWORK_ID from the
environment when both are set and falls back to setup.py otherwise, so the same
file runs in both places.

The server holds nothing secret and serves nothing else: 404 for any other
path, and the Ingress template refuses one outside /.well-known/.
]

The Advanced-tab rule covered discover, select, init, confirm and status. The
Beckn v2 spec has nine request paths a network sends us -- track, cancel,
update, rate and support as well -- and publish, which goes to the network tier
like discover does.

The on_* callbacks are deliberately absent. A callback returns to whoever SENT
the request, so it belongs to the consumer tier, which takes unsigned calls and
is never public.

Only select answers today: ensure_binding writes one action per ProviderSchema
row and that action is select. The other eight are routed anyway, because the
path belongs to this network either way -- a 404 from the mount that would
serve it says "not bound yet", where no route at all says "wrong address".
make host-descriptor writes .well-known/ per deployment -- a descriptor, a
public key and a pack list that belong to one network, not to the repo.
1390 lines, of which 1008 were fifteen appendices. Nobody getting started reads
that, and the detail they held sits next to the thing it describes anyway:
POCRA's rate limit and the shape budget in .env.example, the startup order in
stack.sh, what the registry holds in setup.py.

93 lines now: what you need, the commands in order, how to check it worked,
talking to another network, and the five things that actually break. The old
text is in git history.

Three files pointed at appendix letters that no longer exist.
The observability docs name every container whose logs land in ClickHouse.
Replacing Keycloak with the registry left five sentences starting lowercase.
Its Appendix H pointer is about telemetry, not about this work.
They name Keycloak as a log source. That it is gone is a telemetry change, not
this one.
Suspending the last direction left the participant active with its keys, so it passed signature validation and the admission step exactly as before. An inactive participant is refused before any federation rule is read.
The crawler pushed to /network/publish, whose first step is validateSign, carrying no Authorization header at all. Every crawled catalog was refused. /publish signs and forwards it on, and lives on the provider adapter in multi mode.
The rule that makes another network's /discover reach the adapter lived only in a textarea in NPM's database. It follows the npm-advanced paste-job pattern now, and the README says to paste it.
All three writes printed the status and carried on, so make reported success with the registry half filled and the first symptom was a crawl collecting nothing.
…d network's address [#72]

Four references survived the README cut and point at nothing. The second-network list also left out NETWORK_BASE_URL, which is the address the descriptor publishes.
EXPLORE.md listed sunbird-registry-keycloak as a live service, including a query filtered on it, after the registry stopped needing it.
A direction is dropped by clearing all of them and writing back what stays, because the registry merges. A failure in that window left the network with no admission and surfaced as the write's own error.
Four docstrings and the script header argued for decisions instead of saying what the code does. One had lost its subject and verb to an edit nobody noticed, which is the case for moving them.
our_schema_packs took a whole argparse namespace to read one field. The record peer_record returns had two names two lines apart, and file_peer's read-back is its own step.
… changed [#72]

The rebase pulled in non-conflicting hunks from upstream -- discoveryUrl on Participant, a mkdir in stack.sh, the registry chart. This branch's behaviour is the reviewed one, so its versions stand.
The record, the cases and ADR-0002 described a schema this branch does not carry. They say what the code does now, including that suspend writes a federationRole the schema refuses.
release-0.2.0 is where the networkId one-or-many and discover pagination live; the beckn tag predates both.
configure-adapter demanded every provider's token URL, so a deployment
serving one capability was refused over a credential it would never use.
token_url reads a value only when that provider is being served.

Also fixes a NameError in registered_providers: an earlier rename left the
loop variable as `p` while the comprehension read `provider`.
Federation locally needed TLS that nothing here provided: the registry pins
baseUrl to ^https://, oanfed refuses to admit a network whose discovery URL
is not https, and the crawler verifies with Go's default trust store and has
no way to skip it. So a local run could not federate at all.

- network-certs.sh: one CA outside the repo, shared by every network on the
  machine, and a certificate per network. Issued by `make up`, re-issued when
  BECKN_NETWORK_ID changes.
- config/reverse-proxy/federation/: the front door, answering on the
  networkId. include.conf is mounted where NPM already includes it, so this
  needs no proxy host and no certificate in the admin UI. The rendered block
  lives beside it, out of the directory NPM chowns to root.
- docker-compose.yml: the shared oan-federation bridge, NPM aliased to the
  networkId on it, and SSL_CERT_FILE on the network adapter alone.
- stack.sh: `certs`, the prerequisites in preflight, rendering the front door
  during host-descriptor, and recording where each network answers so admit
  and verify need no address.

FEDERATION.md is the whole run, start to finish. README no longer tells you
to paste a proxy-host config: the front door serves /discover itself.

.env.example also corrects every mapping URL, which pinned a tag that was
never pushed and made every select fail, and pins the current image tags.
development registers PM-KISAN inline in the adapter templates, seeded in bulk.
This branch moved every provider out into its own fragment, registered one at a
time, so the rebase had nowhere to put it and would have dropped it.

PM-KISAN does not fit the one-binding shape the other four use: two
capabilities on two hosts, each answering several actions, and the OTP taken
for a SchemeApplication is what a Grievance then presents. So:

- providers/pmkisan.yaml.tmpl is one step carrying both binding keys, because
  they refer to each other.
- provider_steps groups rows by provider, so a provider serving two
  capabilities renders one block rather than two. One binding each is
  unchanged.
- ensure_actions_binding writes a binding that answers more than select, and
  takes a baseUrl for a capability living on another host.
- PROVIDERS carries pmkisan's capabilities, paths and mappings. No capability
  schema rows: neither pack is published on network-specs yet.

`make register-provider PROVIDER=pmkisan` is how it is registered now, and it
refuses until both hosts are filled in.
A newcomer's first question is which provider to register and what it will
demand. The README now answers both in a table, names pocra as the one that
needs nothing, and warns about a mapping URL pinned to a tag that does not
exist -- which breaks every select while naming something else.

FEDERATION.md points at that table rather than repeating it.
@tdprajwal tdprajwal changed the title feat: network of networks [#72] Merge NetworkofNetwork into development [#72] Oct 30, 2026
@manjudr
manjudr merged commit 6140eb2 into development Oct 30, 2026
2 checks passed
@manjudr
manjudr deleted the NetworkofNetwork branch October 30, 2026 11:07
@ameersohel45 ameersohel45 mentioned this pull request Nov 2, 2026
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.

3 participants