From bfe436b1ec4e88657fed8b140a92918ab2d2951a Mon Sep 17 00:00:00 2001 From: Prajwal TD <157502711+tdprajwal@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:39:48 +0530 Subject: [PATCH 1/4] 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. --- .../config/registry/schemas/Participant.json | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/quick-start/config/registry/schemas/Participant.json b/quick-start/config/registry/schemas/Participant.json index c8cff80f..bda198e9 100644 --- a/quick-start/config/registry/schemas/Participant.json +++ b/quick-start/config/registry/schemas/Participant.json @@ -46,14 +46,53 @@ "pattern": "^https?://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*$" }, "role": { - "description": "What this party does on the network. consumer asks. provider answers. network exposes publish and discover, answering discover from published catalogs. Required on a node. Permitted on an upstream, where nothing reads it.", + "description": "What this party does on the network. consumer asks. provider answers. network exposes publish and discover, answering discover from published catalogs. A list when one participant serves several layers -- a network is one participant however many adapters it runs. A single string is still accepted: every row written before the list existed holds one, and a PUT re-validates the stored record, so refusing it would freeze those rows. Search with `contains`, which matches both shapes; `eq` misses a list. Required on a node. Permitted on an upstream, where nothing reads it.", + "oneOf": [ + { + "$ref": "#/definitions/Role" + }, + { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": { + "$ref": "#/definitions/Role" + } + } + ] + }, + "federationRole": { + "description": "What this entry is in relation to us, and our decision -- never read from a peer's Descriptor, so a peer publishing a new one cannot widen its own admission. self is this network's own entry, the one its Descriptor is built from. source is a peer we consume from. requester is a peer that consumes from us. both is admission in each direction. Absent on a participant that is not part of federation.", "type": "string", "enum": [ - "consumer", - "provider", - "network" + "self", + "source", + "requester", + "both" ] }, + "revision": { + "description": "The Descriptor revision this entry was built from. Only ever increases: a Descriptor at or below the stored revision is a replay and is refused. On the self entry, the revision this network last published.", + "type": "integer", + "minimum": 1 + }, + "schemaPacks": { + "description": "Complete published URLs of the OAN schema packs the network declares in its Descriptor, stored verbatim. Not SchemaRegistry rows: those hold raw.githubusercontent YAML URLs for our own capabilities, and a peer's pack URLs are its statement, not ours.", + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "maxLength": 2000, + "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*/?$" + } + }, + "projectionTtl": { + "description": "Seconds this peer permits us to keep what we crawled from it. The owning network decides how stale a copy may get, not the network holding it. 0 prohibits retention and requires a live query; a positive value permits serving the copy only within that lifetime.", + "type": "integer", + "minimum": 0 + }, "keys": { "description": "Plural for rotation: an overlap where old and new are both valid. The sender names which it used by osid in the Authorization header. Required on a node, since a node's signatures are verified against them. Permitted on an upstream, which does not sign anything this stack verifies.", "type": "array", @@ -159,6 +198,14 @@ "node", "upstream" ] + }, + "Role": { + "type": "string", + "enum": [ + "consumer", + "provider", + "network" + ] } }, "_osConfig": { @@ -168,7 +215,8 @@ "indexFields": [ "status", "type", - "baseUrl" + "baseUrl", + "federationRole" ], "privateFields": [], "roles": [ From eeb65f5460a4c15a36e394e2a013eeda871cafc1 Mon Sep 17 00:00:00 2001 From: Prajwal TD <157502711+tdprajwal@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:39:48 +0530 Subject: [PATCH 2/4] 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. --- quick-start/config/registry/schemas/Participant.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/quick-start/config/registry/schemas/Participant.json b/quick-start/config/registry/schemas/Participant.json index bda198e9..4a581dac 100644 --- a/quick-start/config/registry/schemas/Participant.json +++ b/quick-start/config/registry/schemas/Participant.json @@ -136,7 +136,7 @@ ] }, "PublicKey": { - "description": "A public key, held as material: it is public, so there is nothing to protect. Identified by the osid the registry assigns on write -- which is what a sender names in the Authorization header, and so the only id a verifier can look up. alg carries the purpose: ed25519 signs, x25519 encrypts.", + "description": "A public key, held as material: it is public, so there is nothing to protect. Identified by the osid the registry assigns on write -- which is what a sender names in the Authorization header, and so the only id a verifier can look up. alg carries the purpose: ed25519 signs, x25519 encrypts. Strict, but declares the registry's own system fields: each key is a nested entity, so on a PUT the registry merges the stored key -- osid and the four os* audit fields it stamped on -- into the body before re-validating, and an undeclared one refuses every update of a node, including the revision bump that refreshes an admitted peer. Declared rather than opening additionalProperties, so a typo such as validUnitl is still refused instead of stored and ignored. The registry sets these; they carry no constraint of ours. osOwner is deliberately absent: the registry never sets it on a key, so declaring it would only let a client write one.", "type": "object", "additionalProperties": false, "required": [ @@ -175,7 +175,12 @@ "active", "revoked" ] - } + }, + "osid": {}, + "osCreatedAt": {}, + "osUpdatedAt": {}, + "osCreatedBy": {}, + "osUpdatedBy": {} } }, "ParticipantId": { From 27e97ee1f40303eec8a34c5675f099e5db57ee05 Mon Sep 17 00:00:00 2001 From: Prajwal TD <157502711+tdprajwal@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:04:52 +0530 Subject: [PATCH 3/4] 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. --- quick-start/config/registry/schemas/Participant.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/quick-start/config/registry/schemas/Participant.json b/quick-start/config/registry/schemas/Participant.json index 4a581dac..75b6142d 100644 --- a/quick-start/config/registry/schemas/Participant.json +++ b/quick-start/config/registry/schemas/Participant.json @@ -63,17 +63,16 @@ ] }, "federationRole": { - "description": "What this entry is in relation to us, and our decision -- never read from a peer's Descriptor, so a peer publishing a new one cannot widen its own admission. self is this network's own entry, the one its Descriptor is built from. source is a peer we consume from. requester is a peer that consumes from us. both is admission in each direction. Absent on a participant that is not part of federation.", + "description": "What this entry is in relation to us, and our decision -- never read from a peer's Descriptor, so a peer publishing a new one cannot widen its own admission. This network's own entry is identified from its configuration, not by a value here. source is a peer we consume from. requester is a peer that consumes from us. both is admission in each direction. Absent on a participant that is not part of federation.", "type": "string", "enum": [ - "self", "source", "requester", "both" ] }, "revision": { - "description": "The Descriptor revision this entry was built from. Only ever increases: a Descriptor at or below the stored revision is a replay and is refused. On the self entry, the revision this network last published.", + "description": "The Descriptor revision this entry was built from. Only ever increases: a Descriptor at or below the stored revision is a replay and is refused. On this network's own entry, the revision it last published.", "type": "integer", "minimum": 1 }, From 2fa51d25fa0bc001e74bd4d215193852dde75966 Mon Sep 17 00:00:00 2001 From: Prajwal TD <157502711+tdprajwal@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:15:33 +0530 Subject: [PATCH 4/4] 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. --- .../config/registry/schemas/Participant.json | 122 +++++++++++++++--- 1 file changed, 102 insertions(+), 20 deletions(-) diff --git a/quick-start/config/registry/schemas/Participant.json b/quick-start/config/registry/schemas/Participant.json index 75b6142d..6735353c 100644 --- a/quick-start/config/registry/schemas/Participant.json +++ b/quick-start/config/registry/schemas/Participant.json @@ -63,30 +63,29 @@ ] }, "federationRole": { - "description": "What this entry is in relation to us, and our decision -- never read from a peer's Descriptor, so a peer publishing a new one cannot widen its own admission. This network's own entry is identified from its configuration, not by a value here. source is a peer we consume from. requester is a peer that consumes from us. both is admission in each direction. Absent on a participant that is not part of federation.", - "type": "string", - "enum": [ - "source", - "requester", - "both" - ] + "description": "What this entry is in relation to us, and our decision -- never read from a peer's Descriptor, so a peer publishing a new one cannot widen its own admission. This network's own entry is identified from its configuration, not by a value here. source: a peer we consume from. requester: a peer that consumes from us. Both present is admission in each direction. Absent on a participant that is not part of federation. Strict, but declares the registry's system fields for the same reason PublicKey does: a nested object is merged with what the registry stamped on it before a PUT re-validates.", + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "source": { + "$ref": "#/definitions/FederationSource" + }, + "requester": { + "$ref": "#/definitions/FederationRequester" + }, + "osid": {}, + "osCreatedAt": {}, + "osUpdatedAt": {}, + "osCreatedBy": {}, + "osUpdatedBy": {} + } }, "revision": { "description": "The Descriptor revision this entry was built from. Only ever increases: a Descriptor at or below the stored revision is a replay and is refused. On this network's own entry, the revision it last published.", "type": "integer", "minimum": 1 }, - "schemaPacks": { - "description": "Complete published URLs of the OAN schema packs the network declares in its Descriptor, stored verbatim. Not SchemaRegistry rows: those hold raw.githubusercontent YAML URLs for our own capabilities, and a peer's pack URLs are its statement, not ours.", - "type": "array", - "maxItems": 64, - "uniqueItems": true, - "items": { - "type": "string", - "maxLength": 2000, - "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*/?$" - } - }, "projectionTtl": { "description": "Seconds this peer permits us to keep what we crawled from it. The owning network decides how stale a copy may get, not the network holding it. 0 prohibits retention and requires a live query; a positive value permits serving the copy only within that lifetime.", "type": "integer", @@ -134,6 +133,90 @@ } ] }, + "FederationSource": { + "description": "Admission to consume from this peer.", + "type": "object", + "additionalProperties": false, + "required": [ + "descriptorRevision", + "admittedAt" + ], + "properties": { + "descriptorRevision": { + "description": "The peer's Descriptor revision this direction was admitted against. Distinct from the top-level revision, which is the latest one verified: a newer Descriptor does not silently widen admission, so a top-level revision above this one means the peer has changed since the decision and is due for review.", + "type": "integer", + "minimum": 1 + }, + "admittedAt": { + "description": "When this direction was admitted. Each direction is admitted on its own, so each keeps its own time, retained as admission evidence.", + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "description": "When the admission decision for this direction last changed -- re-admitted against a newer revision, or its schema packs revised. Set by us on the decision, unlike osUpdatedAt, which the registry stamps on any write.", + "type": "string", + "format": "date-time" + }, + "schemaPacks": { + "description": "The packs this peer declares in its Descriptor that we consume; a peer's pack URLs are its statement, not ours. Complete published URLs of OAN schema packs, stored verbatim. Not SchemaRegistry rows: those hold raw.githubusercontent YAML URLs for our own capabilities.", + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "maxLength": 2000, + "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*/?$" + } + }, + "osid": {}, + "osCreatedAt": {}, + "osUpdatedAt": {}, + "osCreatedBy": {}, + "osUpdatedBy": {} + } + }, + "FederationRequester": { + "description": "Admission for this peer to consume from us.", + "type": "object", + "additionalProperties": false, + "required": [ + "descriptorRevision", + "admittedAt" + ], + "properties": { + "descriptorRevision": { + "description": "The peer's Descriptor revision this direction was admitted against. Distinct from the top-level revision, which is the latest one verified: a newer Descriptor does not silently widen admission, so a top-level revision above this one means the peer has changed since the decision and is due for review.", + "type": "integer", + "minimum": 1 + }, + "admittedAt": { + "description": "When this direction was admitted. Each direction is admitted on its own, so each keeps its own time, retained as admission evidence.", + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "description": "When the admission decision for this direction last changed -- re-admitted against a newer revision, or its schema packs revised. Set by us on the decision, unlike osUpdatedAt, which the registry stamps on any write.", + "type": "string", + "format": "date-time" + }, + "schemaPacks": { + "description": "The packs of ours this peer may request. Complete published URLs of OAN schema packs, stored verbatim. Not SchemaRegistry rows: those hold raw.githubusercontent YAML URLs for our own capabilities.", + "type": "array", + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "string", + "maxLength": 2000, + "pattern": "^https://(?!.*\\.\\.)[A-Za-z0-9][A-Za-z0-9.:-]*(/[A-Za-z0-9._~%-]+)*/?$" + } + }, + "osid": {}, + "osCreatedAt": {}, + "osUpdatedAt": {}, + "osCreatedBy": {}, + "osUpdatedBy": {} + } + }, "PublicKey": { "description": "A public key, held as material: it is public, so there is nothing to protect. Identified by the osid the registry assigns on write -- which is what a sender names in the Authorization header, and so the only id a verifier can look up. alg carries the purpose: ed25519 signs, x25519 encrypts. Strict, but declares the registry's own system fields: each key is a nested entity, so on a PUT the registry merges the stored key -- osid and the four os* audit fields it stamped on -- into the body before re-validating, and an undeclared one refuses every update of a node, including the revision bump that refreshes an admitted peer. Declared rather than opening additionalProperties, so a typo such as validUnitl is still refused instead of stored and ignored. The registry sets these; they carry no constraint of ours. osOwner is deliberately absent: the registry never sets it on a key, so declaring it would only let a client write one.", "type": "object", @@ -219,8 +302,7 @@ "indexFields": [ "status", "type", - "baseUrl", - "federationRole" + "baseUrl" ], "privateFields": [], "roles": [