From f58ef9959acb8c6375aca6901abcaf603cfcb4a6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 25 Aug 2026 21:07:02 +0700 Subject: [PATCH 1/6] feat(discovery): harden consumer selection clients Add structural validation, adopter-owned acceptance, and unchanged-selection renewal across the Rust, Node.js, and Python clients. Keep the accepted handoff nominal in TypeScript and runtime-subscriptable in Python. Refs #816 Refs #817 Signed-off-by: Jeremi Joslin --- .../registry-discovery-client-node/README.md | 170 ++++++++-- .../__test__/surface.test-d.ts | 17 + .../__test__/surface.test.js | 264 ++++++++++++++- .../client.d.ts | 33 ++ .../registry-discovery-client-node/client.js | 51 ++- .../registry-discovery-client-node/index.d.ts | 4 + .../registry-discovery-client-node/index.js | 2 + .../registry-discovery-client-node/src/lib.rs | 35 +- crates/registry-discovery-client-py/README.md | 153 ++++++++- .../registry_discovery_client/__init__.pyi | 23 +- .../registry-discovery-client-py/src/lib.rs | 94 +++++- .../tests/python/test_client.py | 313 ++++++++++++++++++ .../tests/python/test_drift.py | 218 ++++++++++++ crates/registry-discovery-client/src/error.rs | 6 + crates/registry-discovery-client/src/lib.rs | 10 +- .../src/selection.rs | 266 ++++++++++++++- .../tests/native_journey.rs | 43 +-- 17 files changed, 1612 insertions(+), 90 deletions(-) create mode 100644 crates/registry-discovery-client-py/tests/python/test_drift.py diff --git a/crates/registry-discovery-client-node/README.md b/crates/registry-discovery-client-node/README.md index b433721e1..951faf316 100644 --- a/crates/registry-discovery-client-node/README.md +++ b/crates/registry-discovery-client-node/README.md @@ -25,51 +25,121 @@ binary. ```js const { DiscoveryClient, + acceptSelection, + renewUnchangedSelection, selectEvidenceAlternative, selectEvidenceService, - validateSelection, + validateSelectionStructure, } = require('@registrystack/discovery-client'); const { EvidenceClient } = require('@registrystack/evidence-client'); +// These exact pins are application configuration. They are not copied from +// Discovery metadata and do not define a Discovery-owned trust-store schema. +const expectedEvidence = Object.freeze({ + serviceKind: 'evidence', + serviceId: 'urn:example:service:evidence', + endpointUrl: 'https://evidence.example.invalid/', + legalIssuerId: 'urn:example:issuer', + technicalProviderId: 'urn:example:provider', + conformsTo: ['urn:example:evidence-profile'], + jurisdictions: ['urn:example:jurisdiction'], + evidenceTypeIds: ['urn:example:evidence-type'], + matchedCapability: { + kind: 'evidence-type', + id: 'urn:example:evidence-type', + }, + evidenceResolution: { + requirementId: 'urn:example:requirement', + jurisdiction: 'urn:example:jurisdiction', + mappingRevision: `sha256:${'1'.repeat(64)}`, + evidenceTypeListId: 'urn:example:evidence-type-list', + evidenceTypeIds: ['urn:example:evidence-type'], + mappingId: 'urn:example:mapping', + mappingAuthorityId: 'urn:example:mapping-authority', + }, +}); + +function sameOrderedStrings(actual, expected) { + return Array.isArray(actual) + && actual.length === expected.length + && actual.every((value, index) => value === expected[index]); +} + +function acceptsExpectedEvidence(candidate) { + const actualResolution = candidate.evidenceResolution; + const expectedResolution = expectedEvidence.evidenceResolution; + return candidate.serviceKind === expectedEvidence.serviceKind + && candidate.serviceId === expectedEvidence.serviceId + && candidate.endpointUrl === expectedEvidence.endpointUrl + && candidate.legalIssuerId === expectedEvidence.legalIssuerId + && candidate.technicalProviderId === expectedEvidence.technicalProviderId + && sameOrderedStrings(candidate.conformsTo, expectedEvidence.conformsTo) + && sameOrderedStrings(candidate.jurisdictions, expectedEvidence.jurisdictions) + && sameOrderedStrings(candidate.evidenceTypeIds, expectedEvidence.evidenceTypeIds) + && candidate.matchedCapability.kind === expectedEvidence.matchedCapability.kind + && candidate.matchedCapability.id === expectedEvidence.matchedCapability.id + && actualResolution !== undefined + && actualResolution.requirementId === expectedResolution.requirementId + && actualResolution.jurisdiction === expectedResolution.jurisdiction + && actualResolution.mappingRevision === expectedResolution.mappingRevision + && actualResolution.evidenceTypeListId === expectedResolution.evidenceTypeListId + && sameOrderedStrings(actualResolution.evidenceTypeIds, expectedResolution.evidenceTypeIds) + && actualResolution.mappingId === expectedResolution.mappingId + && actualResolution.mappingAuthorityId === expectedResolution.mappingAuthorityId; +} + const client = new DiscoveryClient('https://discovery.example.invalid/'); const resolved = await client.resolveEvidenceTypes({ - requirementId: 'urn:example:requirement', - jurisdiction: 'urn:example:jurisdiction', + requirementId: expectedEvidence.evidenceResolution.requirementId, + jurisdiction: expectedEvidence.evidenceResolution.jurisdiction, }); -const context = selectEvidenceAlternative(resolved); // refuses zero or many alternatives +const context = selectEvidenceAlternative( + resolved, + expectedEvidence.evidenceResolution.evidenceTypeListId, +); // explicit choice; refuses an absent or duplicate alternative for (const evidenceTypeId of context.evidenceTypeIds) { const services = await client.searchEvidenceServices({ evidenceTypeId, + serviceIds: [expectedEvidence.serviceId], ...(context.jurisdiction ? { jurisdiction: context.jurisdiction } : {}), }); - const chosen = await adopterChooseRecord(services.items); // no catalog ranking is implied + // The application explicitly chooses a record. Discovery defines no rank. + const chosen = services.items.find( + (item) => item.serviceId === expectedEvidence.serviceId, + ); + if (!chosen) throw new Error('expected Evidence service is unavailable'); const selection = selectEvidenceService(services, { recordId: chosen.recordId, evidenceTypeId, resolution: context, }); - const checked = validateSelection(selection); // use after loading a persisted selection - appTrust.requireEvidence(checked); // local pins, never Discovery data + // Structural validation checks closed shape and capability binding only. + const structurallyValid = validateSelectionStructure(selection); + const accepted = acceptSelection(structurallyValid, acceptsExpectedEvidence); + + // Create credentials and the native client only after local acceptance. const evidence = new EvidenceClient({ - baseUrl: checked.endpointUrl, + baseUrl: accepted.endpointUrl, trustedJwks, revokedKeyIds, token, }); - if (!checked.evidenceResolution) throw new Error('missing Evidence resolution'); + const acceptedSelection = accepted.selection; + if (!acceptedSelection.evidenceResolution) throw new Error('missing Evidence resolution'); const prepared = evidence.prepare({ ...localEvidencePolicy, - requirement: checked.evidenceResolution.requirementId, - evidenceType: checked.matchedCapability.id, + requirement: acceptedSelection.evidenceResolution.requirementId, + evidenceType: acceptedSelection.matchedCapability.id, }); const verified = await evidence.requestAndVerify(prepared); } ``` -An Evidence alternative is an AND-list. The loop performs the search, explicit -choice, trust check, and native request for every `context.evidenceTypeIds` -member. +The sample pins a one-member Evidence alternative. An alternative may be an +AND-list; in that case, configure an expected policy for every +`context.evidenceTypeIds` member and perform the search, explicit choice, +acceptance, and native request for each member. The context supplies the resolved `requirementId` and selected Evidence Type; the native definition and local policy still supply purpose, audience, issuer/provider identity, configuration revision, selectors, and expected @@ -77,11 +147,67 @@ outputs. Relay follows the same boundary with `searchRelayServices` and `selectRelayService`. The selection retains both `semanticClassId` and -`operationFamilyId`; after `appTrust.requireRelay(selection)`, pass -`selection.endpointUrl` to `new RelayClient({ baseUrl, authorization })` and -use Relay's native metadata to choose the concrete resource and operation. -Discovery never invents Relay route arguments. - -Persist the plain selection object if useful, then call `validateSelection` -after loading it. Never treat its origin, endpoint, issuer, or capability -claims as trusted solely because Discovery returned them. +`operationFamilyId`. Its adopter callback should exact-pin `serviceKind`, +`serviceId`, `endpointUrl`, `operatorId`, `registryAuthorityId`, `conformsTo`, +`jurisdictions`, `matchedCapability`, and `relayCapabilityMatch`. Pass +`acceptSelection(selection, acceptsExpectedRelay).endpointUrl` to +`new RelayClient({ baseUrl, authorization })`, constructing `authorization` +only after acceptance. Use Relay's native metadata to choose the concrete +resource and operation. Discovery never invents Relay route arguments. + +## Persisting and renewing a selection + +Persist only the plain selection object. `AcceptedServiceSelection` is an +ephemeral handoff and must be recreated under current local policy before each +new native client or credential-bearing session. Loading a saved selection and +calling `validateSelectionStructure` proves only structural validity. It does +not prove that the catalog, mapping, endpoint, authority, or local policy is +current. + +Online renewal means re-resolving the requirement, re-searching, explicitly +reselecting the record, checking that its trust-relevant semantics are +unchanged, and applying local acceptance again: + +```js +async function renewEvidenceSelection(saved) { + const previous = validateSelectionStructure(saved); + if (!previous.evidenceResolution) throw new Error('missing Evidence resolution'); + + const resolved = await client.resolveEvidenceTypes({ + requirementId: previous.evidenceResolution.requirementId, + ...(previous.evidenceResolution.jurisdiction + ? { jurisdiction: previous.evidenceResolution.jurisdiction } + : {}), + }); + const currentContext = selectEvidenceAlternative( + resolved, + previous.evidenceResolution.evidenceTypeListId, + ); + const currentServices = await client.searchEvidenceServices({ + evidenceTypeId: previous.matchedCapability.id, + serviceIds: [previous.serviceId], + ...(currentContext.jurisdiction ? { jurisdiction: currentContext.jurisdiction } : {}), + }); + const current = selectEvidenceService(currentServices, { + recordId: previous.recordId, + evidenceTypeId: previous.matchedCapability.id, + resolution: currentContext, + }); + + // Refuses withdrawal or any trust-relevant semantic change. It never + // silently chooses a replacement service or Evidence alternative. + const renewed = renewUnchangedSelection(previous, current); + return acceptSelection(renewed, acceptsExpectedEvidence); +} +``` + +An application may deliberately use a saved selection while offline, but it is +then only structurally valid and subject to the application's own maximum-age +and currentness policy. Never treat origin, endpoint, issuer, provider, +authority, or capability claims as trusted solely because Discovery returned +them. + +This boundary preserves +[ADR-001](../../products/discovery/DECISIONS.md#adr-001-discovery-is-an-index-not-a-trust-or-invocation-layer): +Discovery remains an index and neither defines adopter trust policy nor proxies +native Evidence or Relay invocation. diff --git a/crates/registry-discovery-client-node/__test__/surface.test-d.ts b/crates/registry-discovery-client-node/__test__/surface.test-d.ts index 4ca639d6e..e8840354e 100644 --- a/crates/registry-discovery-client-node/__test__/surface.test-d.ts +++ b/crates/registry-discovery-client-node/__test__/surface.test-d.ts @@ -1,9 +1,13 @@ import { + AcceptedServiceSelection, DiscoveryClient, + acceptSelection, + renewUnchangedSelection, selectEvidenceAlternative, selectEvidenceService, selectRelayService, validateSelection, + validateSelectionStructure, type EvidenceServiceSelection, type RelayServiceSelection, type ServiceRecord, @@ -25,7 +29,20 @@ async function useDiscoveryClient(): Promise { resolution: context, }); expectType(selection.originContentDigest); + expectType(validateSelectionStructure(selection)); expectType(validateSelection(selection)); + const accepted = acceptSelection(selection, (candidate) => candidate.serviceKind === 'evidence'); + expectType>(accepted); + expectType(accepted.endpointUrl); + expectType(accepted.selection); + expectType(renewUnchangedSelection(selection, selection)); + + // @ts-expect-error Only acceptSelection can construct the accepted handoff. + const forged: AcceptedServiceSelection = { + endpointUrl: selection.endpointUrl, + selection, + }; + void forged; const relayResponse = await client.searchRelayServices({ semanticClassId: 'urn:example:business', diff --git a/crates/registry-discovery-client-node/__test__/surface.test.js b/crates/registry-discovery-client-node/__test__/surface.test.js index 2b8c550df..b075cbdd5 100644 --- a/crates/registry-discovery-client-node/__test__/surface.test.js +++ b/crates/registry-discovery-client-node/__test__/surface.test.js @@ -5,12 +5,16 @@ const crypto = require('node:crypto'); const http = require('node:http'); const test = require('node:test'); const { + AcceptedServiceSelection, DiscoveryClient, DiscoveryClientError, + acceptSelection, + renewUnchangedSelection, selectEvidenceAlternative, selectEvidenceService, selectRelayService, validateSelection, + validateSelectionStructure, } = require('../client'); function withDerivedBindingId(value) { @@ -30,6 +34,11 @@ function withDerivedBindingId(value) { }; } +function withoutEvidenceContext(value) { + const { evidenceResolution: _resolution, mappingRevision: _mapping, ...remaining } = value; + return remaining; +} + const digest = `sha256:${'1'.repeat(64)}`; const service = withDerivedBindingId({ recordId: 'record-a', @@ -39,6 +48,8 @@ const service = withDerivedBindingId({ description: 'Issues minimum-disclosure evidence', endpointUrl: 'https://provider.example/evidence', publisherId: 'urn:example:publisher', + legalIssuerId: 'urn:example:issuer', + technicalProviderId: 'urn:example:provider', jurisdictions: ['urn:example:jurisdiction'], conformsTo: ['urn:example:profile'], evidenceTypeIds: ['urn:example:evidence-type'], @@ -59,6 +70,73 @@ const relayService = withDerivedBindingId({ operationFamilyIds: ['urn:example:consultation-list'], }); +const expectedEvidence = Object.freeze({ + serviceKind: 'evidence', + serviceId: 'urn:example:service:a', + endpointUrl: 'https://provider.example/evidence', + legalIssuerId: 'urn:example:issuer', + technicalProviderId: 'urn:example:provider', + jurisdictions: ['urn:example:jurisdiction'], + conformsTo: ['urn:example:profile'], + evidenceTypeIds: ['urn:example:evidence-type'], + matchedCapability: { kind: 'evidence-type', id: 'urn:example:evidence-type' }, + evidenceResolution: { + requirementId: 'urn:example:requirement', + mappingRevision: digest, + evidenceTypeListId: 'urn:example:list', + evidenceTypeIds: ['urn:example:evidence-type'], + mappingId: 'urn:example:mapping', + mappingAuthorityId: 'urn:example:mapping-authority', + }, +}); + +function evidenceTrustProjection(candidate) { + return { + serviceKind: candidate.serviceKind, + serviceId: candidate.serviceId, + endpointUrl: candidate.endpointUrl, + legalIssuerId: candidate.legalIssuerId, + technicalProviderId: candidate.technicalProviderId, + jurisdictions: candidate.jurisdictions, + conformsTo: candidate.conformsTo, + evidenceTypeIds: candidate.evidenceTypeIds, + matchedCapability: candidate.matchedCapability, + evidenceResolution: candidate.evidenceResolution, + }; +} + +function acceptsExpectedEvidence(candidate) { + const resolution = candidate.evidenceResolution; + const expectedResolution = expectedEvidence.evidenceResolution; + return resolution !== undefined + && candidate.serviceKind === expectedEvidence.serviceKind + && candidate.serviceId === expectedEvidence.serviceId + && candidate.endpointUrl === expectedEvidence.endpointUrl + && candidate.legalIssuerId === expectedEvidence.legalIssuerId + && candidate.technicalProviderId === expectedEvidence.technicalProviderId + && candidate.jurisdictions.length === expectedEvidence.jurisdictions.length + && candidate.jurisdictions.every((value, index) => ( + value === expectedEvidence.jurisdictions[index] + )) + && candidate.conformsTo.length === expectedEvidence.conformsTo.length + && candidate.conformsTo.every((value, index) => value === expectedEvidence.conformsTo[index]) + && candidate.evidenceTypeIds.length === expectedEvidence.evidenceTypeIds.length + && candidate.evidenceTypeIds.every((value, index) => ( + value === expectedEvidence.evidenceTypeIds[index] + )) + && candidate.matchedCapability.kind === expectedEvidence.matchedCapability.kind + && candidate.matchedCapability.id === expectedEvidence.matchedCapability.id + && resolution.requirementId === expectedResolution.requirementId + && resolution.mappingRevision === expectedResolution.mappingRevision + && resolution.evidenceTypeListId === expectedResolution.evidenceTypeListId + && resolution.evidenceTypeIds.length === expectedResolution.evidenceTypeIds.length + && resolution.evidenceTypeIds.every((value, index) => ( + value === expectedResolution.evidenceTypeIds[index] + )) + && resolution.mappingId === expectedResolution.mappingId + && resolution.mappingAuthorityId === expectedResolution.mappingAuthorityId; +} + test('search, resolve, and inert exact selection use the Rust client', async () => { const server = http.createServer((request, response) => { response.setHeader('content-type', 'application/json'); @@ -109,6 +187,7 @@ test('search, resolve, and inert exact selection use the Rust client', async () }).matchedCapability, { kind: 'evidence-type', id: 'urn:example:evidence-type' }, ); + assert.equal(validateSelectionStructure(selection).recordId, 'record-a'); assert.equal(validateSelection(selection).recordId, 'record-a'); await new Promise((resolve) => server.close(resolve)); @@ -130,7 +209,188 @@ test('Relay selection retains the correlated semantic and operation match', () = semanticClassId: 'urn:example:registered-business', operationFamilyId: 'urn:example:consultation-list', }); - assert.equal(validateSelection(selection).serviceKind, 'relay'); + assert.equal(validateSelectionStructure(selection).serviceKind, 'relay'); +}); + +test('adopter acceptance is explicit and precedes credentials or native traffic', () => { + const resolution = { ...expectedEvidence.evidenceResolution }; + const selection = selectEvidenceService( + { catalogRevision: digest, items: [service] }, + { + recordId: service.recordId, + evidenceTypeId: 'urn:example:evidence-type', + resolution, + }, + ); + assert.deepEqual(evidenceTrustProjection(selection), expectedEvidence); + let tokenConstructions = 0; + let nativeCalls = 0; + const invoke = (candidate) => { + const accepted = acceptSelection(candidate, acceptsExpectedEvidence); + tokenConstructions += 1; + nativeCalls += 1; + return accepted; + }; + + const mutations = [ + (value) => withDerivedBindingId({ ...value, serviceId: 'urn:example:service:other' }), + (value) => withDerivedBindingId({ + ...value, + endpointUrl: 'https://other.example/evidence', + }), + (value) => ({ ...value, legalIssuerId: 'urn:example:issuer:other' }), + (value) => withDerivedBindingId({ ...value, conformsTo: ['urn:example:profile:other'] }), + (value) => ({ ...value, jurisdictions: ['urn:example:jurisdiction:other'] }), + (value) => ({ + ...value, + evidenceResolution: { + ...value.evidenceResolution, + mappingAuthorityId: 'urn:example:mapping-authority:other', + }, + }), + (value) => withoutEvidenceContext(value), + (value) => withDerivedBindingId({ + ...value, + evidenceTypeIds: ['urn:example:evidence-type:other'], + matchedCapability: { kind: 'evidence-type', id: 'urn:example:evidence-type:other' }, + evidenceResolution: { + ...value.evidenceResolution, + evidenceTypeIds: ['urn:example:evidence-type:other'], + }, + }), + ]; + for (const mutate of mutations) { + const changed = mutate(selection); + validateSelectionStructure(changed); + assert.throws( + () => invoke(changed), + (error) => error instanceof DiscoveryClientError + && error.kind === 'local_acceptance_refused', + ); + } + assert.equal(tokenConstructions, 0); + assert.equal(nativeCalls, 0); + + const accepted = invoke(selection); + assert.ok(accepted instanceof AcceptedServiceSelection); + assert.equal(accepted.endpointUrl, expectedEvidence.endpointUrl); + assert.equal(accepted.selection.recordId, selection.recordId); + assert.equal(tokenConstructions, 1); + assert.equal(nativeCalls, 1); +}); + +test('renewal refreshes provenance but never silently accepts semantic drift', () => { + const selection = selectEvidenceService( + { catalogRevision: digest, items: [service] }, + { + recordId: service.recordId, + evidenceTypeId: 'urn:example:evidence-type', + resolution: expectedEvidence.evidenceResolution, + }, + ); + const refreshedDigest = `sha256:${'2'.repeat(64)}`; + const current = { + ...selection, + originContentDigest: refreshedDigest, + originFetchedAt: '2026-08-20T00:00:00Z', + catalogRevision: refreshedDigest, + }; + assert.equal( + renewUnchangedSelection(selection, current).originFetchedAt, + '2026-08-20T00:00:00Z', + ); + + let tokenConstructions = 0; + let nativeCalls = 0; + const continueAfterRenewal = (previous, candidate) => { + const renewed = renewUnchangedSelection(previous, candidate); + tokenConstructions += 1; + nativeCalls += 1; + return renewed; + }; + + const changes = [ + { ...current, registryAuthorityId: 'urn:example:authority:other' }, + { ...current, jurisdictions: ['urn:example:jurisdiction:other'] }, + withDerivedBindingId({ + ...current, + endpointUrl: 'https://other.example/evidence', + }), + withDerivedBindingId({ + ...current, + conformsTo: ['urn:example:profile:other'], + }), + withDerivedBindingId({ + ...current, + evidenceTypeIds: ['urn:example:evidence-type:other'], + matchedCapability: { kind: 'evidence-type', id: 'urn:example:evidence-type:other' }, + evidenceResolution: { + ...current.evidenceResolution, + evidenceTypeIds: ['urn:example:evidence-type:other'], + }, + }), + { + ...current, + mappingRevision: refreshedDigest, + evidenceResolution: { + ...current.evidenceResolution, + mappingRevision: refreshedDigest, + }, + }, + withoutEvidenceContext(current), + ]; + for (const changed of changes) { + assert.throws( + () => continueAfterRenewal(selection, changed), + (error) => error instanceof DiscoveryClientError && error.kind === 'selection_changed', + ); + } + + const relayWithTwoOperations = withDerivedBindingId({ + ...relayService, + operationFamilyIds: [ + 'urn:example:consultation-list', + 'urn:example:consultation-search', + ], + }); + const relayPrevious = selectRelayService( + { catalogRevision: digest, items: [relayWithTwoOperations] }, + { + recordId: relayWithTwoOperations.recordId, + capabilityMatch: { + semanticClassId: 'urn:example:registered-business', + operationFamilyId: 'urn:example:consultation-list', + }, + }, + ); + const relayCurrent = selectRelayService( + { catalogRevision: refreshedDigest, items: [relayWithTwoOperations] }, + { + recordId: relayWithTwoOperations.recordId, + capabilityMatch: { + semanticClassId: 'urn:example:registered-business', + operationFamilyId: 'urn:example:consultation-search', + }, + }, + ); + assert.throws( + () => continueAfterRenewal(relayPrevious, relayCurrent), + (error) => error instanceof DiscoveryClientError && error.kind === 'selection_changed', + ); + assert.throws( + () => { + const reselected = selectEvidenceService( + { catalogRevision: refreshedDigest, items: [] }, + { recordId: selection.recordId, evidenceTypeId: 'urn:example:evidence-type' }, + ); + tokenConstructions += 1; + nativeCalls += 1; + return reselected; + }, + (error) => error instanceof DiscoveryClientError && error.kind === 'no_matching_service', + ); + assert.equal(tokenConstructions, 0); + assert.equal(nativeCalls, 0); }); test('a supported large response remains selectable', () => { @@ -186,7 +446,7 @@ test('binding failures expose a stable value-free kind', () => { test('object keys count against the bounded JSON bridge', () => { const oversizedKey = 'x'.repeat((16 * 1024 * 1024) + 1); assert.throws( - () => validateSelection({ [oversizedKey]: null }), + () => validateSelectionStructure({ [oversizedKey]: null }), (error) => error instanceof DiscoveryClientError && error.kind === 'query', ); }); diff --git a/crates/registry-discovery-client-node/client.d.ts b/crates/registry-discovery-client-node/client.d.ts index 3c3137413..17bca92f8 100644 --- a/crates/registry-discovery-client-node/client.d.ts +++ b/crates/registry-discovery-client-node/client.d.ts @@ -149,6 +149,8 @@ export type DiscoveryClientErrorKind = | 'no_matching_alternative' | 'ambiguous_alternative' | 'capability_mismatch' + | 'local_acceptance_refused' + | 'selection_changed' | 'transport' | 'problem' | 'protocol' @@ -202,4 +204,35 @@ export function selectRelayService( request: RelaySelectionRequest, ): RelayServiceSelection; +/** + * Validate closed shape and capability binding only. + * + * This does not prove origin authenticity, catalog currentness, mapping + * currency, authorization, or adopter trust. + */ +export function validateSelectionStructure(selection: T): T; + +/** @deprecated Use validateSelectionStructure. This operation is structural, not trust. */ export function validateSelection(selection: T): T; + +export class AcceptedServiceSelection { + private constructor(); + private readonly __acceptedServiceSelectionBrand: void; + readonly endpointUrl: string; + readonly selection: T; +} + +/** Apply a synchronous adopter-owned local policy after structural validation. */ +export function acceptSelection( + selection: T, + accepts: (selection: T) => boolean, +): AcceptedServiceSelection; + +/** + * Return a freshly reselected service only when its trust-relevant semantics + * are unchanged. The current selection must come from a new online lookup. + */ +export function renewUnchangedSelection( + previous: T, + current: T, +): T; diff --git a/crates/registry-discovery-client-node/client.js b/crates/registry-discovery-client-node/client.js index 81957c886..e6287a01c 100644 --- a/crates/registry-discovery-client-node/client.js +++ b/crates/registry-discovery-client-node/client.js @@ -8,6 +8,7 @@ const MAX_REQUEST_JSON_NODES = 100_000; // A valid 16 MiB result can contain far more collection nodes than a request. const MAX_RESPONSE_JSON_NODES = 3_000_000; const MAX_JSON_STRING_BYTES = 16 * 1024 * 1024; +const ACCEPTED_CONSTRUCTION = Symbol('accepted-service-selection'); class DiscoveryClientError extends Error { constructor(envelope) { @@ -52,6 +53,30 @@ function inputError(kind) { }); } +function localAcceptanceError() { + return new DiscoveryClientError({ + kind: 'local_acceptance_refused', + message: 'the relying application refused the advertised service', + }); +} + +class AcceptedServiceSelection { + #selection; + + constructor(construction, selection) { + if (construction !== ACCEPTED_CONSTRUCTION) throw inputError('query'); + this.#selection = selection; + } + + get endpointUrl() { + return this.#selection.endpointUrl; + } + + get selection() { + return responseValue(this.#selection); + } +} + function cloneJson(value, budget, depth) { if (depth > MAX_JSON_DEPTH) throw inputError('query'); budget.nodes += 1; @@ -261,20 +286,44 @@ function selectRelayService(response, request) { } } +function validateSelectionStructure(selection) { + try { + return native.validateSelectionStructure(requestValue(selection)); + } catch (error) { + throw normalize(error, 'query'); + } +} + function validateSelection(selection) { + return validateSelectionStructure(selection); +} + +function acceptSelection(selection, accepts) { + const checked = validateSelectionStructure(selection); + if (typeof accepts !== 'function') throw inputError('query'); + const accepted = accepts(responseValue(checked)); + if (accepted !== true) throw localAcceptanceError(); + return new AcceptedServiceSelection(ACCEPTED_CONSTRUCTION, checked); +} + +function renewUnchangedSelection(previous, current) { try { - return native.validateSelection(requestValue(selection)); + return native.renewUnchangedSelection(requestValue(previous), requestValue(current)); } catch (error) { throw normalize(error, 'query'); } } module.exports = { + AcceptedServiceSelection, DiscoveryClient, DiscoveryClientError, + acceptSelection, + renewUnchangedSelection, selectEvidenceAlternative, selectEvidenceService, selectExact, selectRelayService, validateSelection, + validateSelectionStructure, }; diff --git a/crates/registry-discovery-client-node/index.d.ts b/crates/registry-discovery-client-node/index.d.ts index c4955bc0a..9d512c693 100644 --- a/crates/registry-discovery-client-node/index.d.ts +++ b/crates/registry-discovery-client-node/index.d.ts @@ -20,6 +20,8 @@ export interface DiscoveryClientOptions { trustedRootCertificates?: Buffer } +export declare function renewUnchangedSelection(previous: any, current: any): any + export declare function selectEvidenceAlternative(response: any, evidenceTypeListId?: string | undefined | null): any export declare function selectEvidenceService(response: any, request: any): any @@ -29,3 +31,5 @@ export declare function selectExact(response: any, request: any): any export declare function selectRelayService(response: any, request: any): any export declare function validateSelection(selection: any): any + +export declare function validateSelectionStructure(selection: any): any diff --git a/crates/registry-discovery-client-node/index.js b/crates/registry-discovery-client-node/index.js index 7ce947715..ff3b58161 100644 --- a/crates/registry-discovery-client-node/index.js +++ b/crates/registry-discovery-client-node/index.js @@ -701,8 +701,10 @@ if (!nativeBinding) { module.exports = nativeBinding module.exports.DiscoveryClient = nativeBinding.DiscoveryClient +module.exports.renewUnchangedSelection = nativeBinding.renewUnchangedSelection module.exports.selectEvidenceAlternative = nativeBinding.selectEvidenceAlternative module.exports.selectEvidenceService = nativeBinding.selectEvidenceService module.exports.selectExact = nativeBinding.selectExact module.exports.selectRelayService = nativeBinding.selectRelayService module.exports.validateSelection = nativeBinding.validateSelection +module.exports.validateSelectionStructure = nativeBinding.validateSelectionStructure diff --git a/crates/registry-discovery-client-node/src/lib.rs b/crates/registry-discovery-client-node/src/lib.rs index d7890d740..cca6a5f5c 100644 --- a/crates/registry-discovery-client-node/src/lib.rs +++ b/crates/registry-discovery-client-node/src/lib.rs @@ -11,11 +11,12 @@ use napi::{ }; use napi_derive::napi; use registry_discovery_client::{ - validate_service_selection, DiscoveryClient as CoreClient, DiscoveryClientConfig, - DiscoveryClientError, DiscoveryProblem, EvidenceSelectionRequest, EvidenceServiceQuery, - EvidenceTypeResolveRequest, EvidenceTypeResolveResponse, EvidenceTypeResolveSelectionExt, - RelaySelectionRequest, RelayServiceQuery, SelectionRequest, ServiceFilters, - ServiceSearchResponse, ServiceSearchSelectionExt, ServiceSelection, + renew_unchanged_service_selection, validate_service_selection_structure, + DiscoveryClient as CoreClient, DiscoveryClientConfig, DiscoveryClientError, DiscoveryProblem, + EvidenceSelectionRequest, EvidenceServiceQuery, EvidenceTypeResolveRequest, + EvidenceTypeResolveResponse, EvidenceTypeResolveSelectionExt, RelaySelectionRequest, + RelayServiceQuery, SelectionRequest, ServiceFilters, ServiceSearchResponse, + ServiceSearchSelectionExt, ServiceSelection, }; use serde::{de::DeserializeOwned, Serialize}; use serde_json::{json, Value}; @@ -61,6 +62,14 @@ fn error(source: DiscoveryClientError) -> Error { "kind": "capability_mismatch", "message": "the selected advertised capability does not match the service" }), + DiscoveryClientError::LocalAcceptanceRefused => json!({ + "kind": "local_acceptance_refused", + "message": "the relying application refused the advertised service" + }), + DiscoveryClientError::SelectionChanged => json!({ + "kind": "selection_changed", + "message": "the current advertised service changed and requires new acceptance" + }), DiscoveryClientError::Transport { kind } => json!({ "kind": "transport", "transportKind": kind.kind(), @@ -143,12 +152,24 @@ pub fn select_relay_service(response: Value, request: Value) -> Result { } #[napi] -pub fn validate_selection(selection: Value) -> Result { +pub fn validate_selection_structure(selection: Value) -> Result { let selection: ServiceSelection = decode(selection)?; - validate_service_selection(&selection).map_err(error)?; + validate_service_selection_structure(&selection).map_err(error)?; encode(&selection) } +#[napi] +pub fn validate_selection(selection: Value) -> Result { + validate_selection_structure(selection) +} + +#[napi] +pub fn renew_unchanged_selection(previous: Value, current: Value) -> Result { + let previous: ServiceSelection = decode(previous)?; + let current: ServiceSelection = decode(current)?; + encode(&renew_unchanged_service_selection(&previous, ¤t).map_err(error)?) +} + #[napi] pub fn select_exact(response: Value, request: Value) -> Result { selection(response, request) diff --git a/crates/registry-discovery-client-py/README.md b/crates/registry-discovery-client-py/README.md index 467bab122..e83b2416b 100644 --- a/crates/registry-discovery-client-py/README.md +++ b/crates/registry-discovery-client-py/README.md @@ -26,12 +26,66 @@ caller values. ```python from registry_discovery_client import ( DiscoveryClient, + accept_selection, select_evidence_alternative, select_evidence_service, - validate_selection, + validate_selection_structure, ) from registry_evidence_client import EvidenceClient +# These values come from application-owned configuration or deployment +# ceremony. They are never copied from the Discovery response being checked. +evidence_pins = { + "serviceId": "urn:example:service:evidence", + "endpointUrl": "https://evidence.example.invalid/", + "publisherId": "urn:example:publisher", + "legalIssuerId": "urn:example:legal-issuer", + "technicalProviderId": "urn:example:technical-provider", + "jurisdictions": ["urn:example:jurisdiction"], + "conformsTo": ["urn:example:evidence-profile"], + "matchedCapability": { + "kind": "evidence-type", + "id": "urn:example:evidence-type", + }, + "resolution": { + "requirementId": "urn:example:requirement", + "jurisdiction": "urn:example:jurisdiction", + "mappingRevision": ( + "sha256:1111111111111111111111111111111111111111111111111111111111111111" + ), + "evidenceTypeListId": "urn:example:evidence-list", + "evidenceTypeIds": ["urn:example:evidence-type"], + "mappingId": "urn:example:mapping", + "mappingAuthorityId": "urn:example:mapping-authority", + }, + "originId": "approved-origin", + "originUrl": "https://publisher.example.invalid/catalog.jsonld", +} + + +def accepts_expected_evidence(candidate): + resolution = candidate.get("evidenceResolution") or {} + return ( + candidate["serviceKind"] == "evidence" + and candidate["serviceId"] == evidence_pins["serviceId"] + and candidate["endpointUrl"] == evidence_pins["endpointUrl"] + and candidate.get("publisherId") == evidence_pins["publisherId"] + and candidate.get("legalIssuerId") == evidence_pins["legalIssuerId"] + and candidate.get("technicalProviderId") + == evidence_pins["technicalProviderId"] + and candidate["jurisdictions"] == evidence_pins["jurisdictions"] + and candidate["conformsTo"] == evidence_pins["conformsTo"] + and candidate["matchedCapability"] == evidence_pins["matchedCapability"] + and { + key: resolution.get(key) + for key in evidence_pins["resolution"] + } + == evidence_pins["resolution"] + and candidate["originId"] == evidence_pins["originId"] + and candidate["originUrl"] == evidence_pins["originUrl"] + ) + + client = DiscoveryClient("https://discovery.example.invalid/") resolved = client.resolve_evidence_types({ "requirementId": "urn:example:requirement", @@ -43,21 +97,36 @@ for evidence_type_id in context["evidenceTypeIds"]: "evidenceTypeId": evidence_type_id, "jurisdiction": context.get("jurisdiction"), }) - chosen = adopter_choose_record(services["items"]) # no catalog ranking + # The adopter chooses explicitly. Discovery supplies no catalog ranking. + matches = [ + item for item in services["items"] + if item["serviceId"] == evidence_pins["serviceId"] + ] + if len(matches) != 1: + raise ValueError( + "the locally expected Evidence service is unavailable or ambiguous" + ) + chosen = matches[0] selection = select_evidence_service(services, { "recordId": chosen["recordId"], "evidenceTypeId": evidence_type_id, "resolution": context, }) - checked = validate_selection(selection) # use after loading persisted data - app_trust.require_evidence(checked) # local pins, never Discovery data + # Structural validation checks shape and capability binding. It does not + # establish origin authenticity, currentness, or trust. + checked = validate_selection_structure(selection) + accepted = accept_selection(checked, accepts_expected_evidence) + + # Credentials and the native client are created only from the ephemeral + # accepted handoff. The values below are application-owned configuration. evidence = EvidenceClient( - checked["endpointUrl"], + accepted.endpoint_url, trusted_jwks, revoked_key_ids, token, ) + checked = accepted.selection resolution = checked.get("evidenceResolution") if resolution is None: raise ValueError("missing Evidence resolution") @@ -77,8 +146,76 @@ native definition and local policy still supply the purpose, audience, issuer/provider identity, configuration revision, selectors, and expected outputs. +`validate_selection` remains a compatibility alias, but its behavior has +always been structural. New code should use `validate_selection_structure` so +the result cannot be mistaken for a trust decision. + +## Persisted selections and renewal + +Persist only the inert selection dictionary, never `AcceptedServiceSelection`. +Offline loading can establish structural validity, but cannot prove that the +catalog, mapping, endpoint, roles, or application policy are still current: + +```python +import json + +persisted = validate_selection_structure(json.loads(saved_selection_json)) +if application_selection_age_is_acceptable(persisted["originFetchedAt"]): + accepted = accept_selection(persisted, accepts_expected_evidence) + # Construct credentials and the native client only after this point. +``` + +The application owns the offline age limit. Discovery deliberately supplies no +universal time-to-live. + +For online renewal, resolve and search again, explicitly choose the same local +service, and build a fresh selection. `renew_unchanged_selection` accepts only +fetch-provenance and global catalog-revision changes: + +```python +from registry_discovery_client import renew_unchanged_selection + +previous_resolution = persisted["evidenceResolution"] +fresh_resolved = client.resolve_evidence_types({ + "requirementId": previous_resolution["requirementId"], + "jurisdiction": previous_resolution.get("jurisdiction"), +}) +fresh_context = select_evidence_alternative( + fresh_resolved, + previous_resolution["evidenceTypeListId"], +) +evidence_type_id = persisted["matchedCapability"]["id"] +fresh_services = client.search_evidence_services({ + "evidenceTypeId": evidence_type_id, + "jurisdiction": fresh_context.get("jurisdiction"), +}) +fresh_matches = [ + item for item in fresh_services["items"] + if item["serviceId"] == evidence_pins["serviceId"] +] +if len(fresh_matches) != 1: + raise ValueError( + "the previously selected service was withdrawn or is ambiguous" + ) +fresh = select_evidence_service(fresh_services, { + "recordId": fresh_matches[0]["recordId"], + "evidenceTypeId": evidence_type_id, + "resolution": fresh_context, +}) +renewed = renew_unchanged_selection(persisted, fresh) +accepted = accept_selection(renewed, accepts_expected_evidence) +``` + +A changed service identity, endpoint, issuer/provider, profile, jurisdiction, +capability, origin, or mapping context raises +`DiscoveryClientError(kind="selection_changed")`. A withdrawn record fails the +fresh selection. Both cases require explicit reselection and a new local +acceptance decision; renewal never switches to another service or Evidence +alternative automatically. + Relay follows the same boundary with `search_relay_services` and `select_relay_service`. The selection retains both the semantic class and -operation family. After local trust accepts it, pass `selection["endpointUrl"]` -to `registry_relay_client.RelayClient`, then use native Relay metadata to choose -the concrete resource and operation. Discovery never invents route arguments. +operation family. Apply exact local Relay pins with `accept_selection`, pass +only `accepted.endpoint_url` to `registry_relay_client.RelayClient`, then use +native Relay metadata to choose the concrete resource and operation. Discovery +never invents route arguments. diff --git a/crates/registry-discovery-client-py/python/registry_discovery_client/__init__.pyi b/crates/registry-discovery-client-py/python/registry_discovery_client/__init__.pyi index d3395e5d8..5365d6ed5 100644 --- a/crates/registry-discovery-client-py/python/registry_discovery_client/__init__.pyi +++ b/crates/registry-discovery-client-py/python/registry_discovery_client/__init__.pyi @@ -1,4 +1,4 @@ -from typing import Literal, TypedDict +from typing import Callable, Generic, Literal, TypeVar, TypedDict ServiceKind = Literal["evidence", "relay"] @@ -191,6 +191,13 @@ class RelayServiceSelection( relayCapabilityMatch: RelayCapabilityMatch ServiceSelection = CommonServiceSelection | EvidenceServiceSelection | RelayServiceSelection +_SelectionT = TypeVar("_SelectionT", bound=ServiceSelection) + +class AcceptedServiceSelection(Generic[_SelectionT]): + @property + def endpoint_url(self) -> str: ... + @property + def selection(self) -> _SelectionT: ... class DiscoveryClientError(Exception): """A stable, value-free Discovery client failure.""" @@ -202,6 +209,8 @@ class DiscoveryClientError(Exception): "no_matching_alternative", "ambiguous_alternative", "capability_mismatch", + "local_acceptance_refused", + "selection_changed", "transport", "problem", "protocol", @@ -254,4 +263,14 @@ def select_relay_service( response: ServiceSearchResponse, request: RelaySelectionRequest, ) -> RelayServiceSelection: ... -def validate_selection(selection: ServiceSelection) -> ServiceSelection: ... +def validate_selection_structure(selection: _SelectionT) -> _SelectionT: + """Validate closed shape and capability binding, not trust or currentness.""" + ... +def validate_selection(selection: _SelectionT) -> _SelectionT: + """Deprecated compatibility alias for validate_selection_structure.""" + ... +def accept_selection( + selection: _SelectionT, + accepts: Callable[[_SelectionT], bool], +) -> AcceptedServiceSelection[_SelectionT]: ... +def renew_unchanged_selection(previous: _SelectionT, current: _SelectionT) -> _SelectionT: ... diff --git a/crates/registry-discovery-client-py/src/lib.rs b/crates/registry-discovery-client-py/src/lib.rs index 265ac4104..bed5aaf20 100644 --- a/crates/registry-discovery-client-py/src/lib.rs +++ b/crates/registry-discovery-client-py/src/lib.rs @@ -4,11 +4,12 @@ use std::{collections::HashSet, time::Duration}; use discovery_client_sdk::{ - validate_service_selection, DiscoveryClient as CoreClient, DiscoveryClientConfig, - DiscoveryClientError, DiscoveryProblem, EvidenceSelectionRequest, EvidenceServiceQuery, - EvidenceTypeResolveRequest, EvidenceTypeResolveResponse, EvidenceTypeResolveSelectionExt, - RelaySelectionRequest, RelayServiceQuery, SelectionRequest, ServiceFilters, - ServiceSearchResponse, ServiceSearchSelectionExt, ServiceSelection, + renew_unchanged_service_selection, validate_service_selection_structure, + DiscoveryClient as CoreClient, DiscoveryClientConfig, DiscoveryClientError, DiscoveryProblem, + EvidenceSelectionRequest, EvidenceServiceQuery, EvidenceTypeResolveRequest, + EvidenceTypeResolveResponse, EvidenceTypeResolveSelectionExt, RelaySelectionRequest, + RelayServiceQuery, SelectionRequest, ServiceFilters, ServiceSearchResponse, + ServiceSearchSelectionExt, ServiceSelection, }; use pyo3::{ exceptions::{PyException, PyRuntimeError}, @@ -43,6 +44,8 @@ fn kind(error: &DiscoveryClientError) -> &'static str { DiscoveryClientError::NoMatchingAlternative => "no_matching_alternative", DiscoveryClientError::AmbiguousAlternative => "ambiguous_alternative", DiscoveryClientError::CapabilityMismatch => "capability_mismatch", + DiscoveryClientError::LocalAcceptanceRefused => "local_acceptance_refused", + DiscoveryClientError::SelectionChanged => "selection_changed", DiscoveryClientError::Transport { .. } => "transport", DiscoveryClientError::Problem { .. } => "problem", DiscoveryClientError::Protocol => "protocol", @@ -410,16 +413,89 @@ fn select_relay_service<'py>( } #[pyfunction] -fn validate_selection<'py>( +fn validate_selection_structure<'py>( py: Python<'py>, selection: &Bound<'_, PyAny>, ) -> PyResult> { let selection: ServiceSelection = python_response_to_rust(selection).map_err(|_| query_error(py))?; - validate_service_selection(&selection).map_err(|error| client_error(py, error))?; + validate_service_selection_structure(&selection).map_err(|error| client_error(py, error))?; rust_to_python(py, &selection) } +/// Deprecated compatibility alias for structural validation. +#[pyfunction] +fn validate_selection<'py>( + py: Python<'py>, + selection: &Bound<'_, PyAny>, +) -> PyResult> { + validate_selection_structure(py, selection) +} + +#[pyfunction] +fn renew_unchanged_selection<'py>( + py: Python<'py>, + previous: &Bound<'_, PyAny>, + current: &Bound<'_, PyAny>, +) -> PyResult> { + let previous: ServiceSelection = + python_response_to_rust(previous).map_err(|_| query_error(py))?; + let current: ServiceSelection = + python_response_to_rust(current).map_err(|_| query_error(py))?; + let renewed = renew_unchanged_service_selection(&previous, ¤t) + .map_err(|error| client_error(py, error))?; + rust_to_python(py, &renewed) +} + +#[pyclass( + name = "AcceptedServiceSelection", + module = "registry_discovery_client", + frozen, + generic +)] +struct AcceptedServiceSelection { + selection: ServiceSelection, +} + +#[pymethods] +impl AcceptedServiceSelection { + #[getter] + fn endpoint_url(&self) -> &str { + &self.selection.endpoint_url + } + + #[getter] + fn selection<'py>(&self, py: Python<'py>) -> PyResult> { + rust_to_python(py, &self.selection) + } +} + +#[pyfunction] +fn accept_selection( + py: Python<'_>, + selection: &Bound<'_, PyAny>, + accepts: &Bound<'_, PyAny>, +) -> PyResult { + let selection: ServiceSelection = + python_response_to_rust(selection).map_err(|_| query_error(py))?; + validate_service_selection_structure(&selection).map_err(|error| client_error(py, error))?; + if !accepts.is_callable() { + return Err(query_error(py)); + } + let candidate = rust_to_python(py, &selection)?; + let accepted = accepts.call1((candidate,))?; + if !accepted.is_exact_instance_of::() { + return Err(query_error(py)); + } + if !accepted.extract::()? { + return Err(client_error( + py, + DiscoveryClientError::LocalAcceptanceRefused, + )); + } + Ok(AcceptedServiceSelection { selection }) +} + #[pyclass(name = "DiscoveryClient", module = "registry_discovery_client")] struct DiscoveryClient { inner: CoreClient, @@ -585,11 +661,15 @@ impl DiscoveryClient { #[pymodule] pub fn registry_discovery_client(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_class::()?; + module.add_class::()?; module.add_function(wrap_pyfunction!(select_exact, module)?)?; module.add_function(wrap_pyfunction!(select_evidence_alternative, module)?)?; module.add_function(wrap_pyfunction!(select_evidence_service, module)?)?; module.add_function(wrap_pyfunction!(select_relay_service, module)?)?; + module.add_function(wrap_pyfunction!(validate_selection_structure, module)?)?; module.add_function(wrap_pyfunction!(validate_selection, module)?)?; + module.add_function(wrap_pyfunction!(renew_unchanged_selection, module)?)?; + module.add_function(wrap_pyfunction!(accept_selection, module)?)?; module.add( "DiscoveryClientError", module.py().get_type::(), diff --git a/crates/registry-discovery-client-py/tests/python/test_client.py b/crates/registry-discovery-client-py/tests/python/test_client.py index da7703e86..d46d96de5 100644 --- a/crates/registry-discovery-client-py/tests/python/test_client.py +++ b/crates/registry-discovery-client-py/tests/python/test_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import hashlib import http.server import json @@ -13,10 +14,13 @@ from registry_discovery_client import ( DiscoveryClient, DiscoveryClientError, + accept_selection, + renew_unchanged_selection, select_evidence_alternative, select_evidence_service, select_relay_service, validate_selection, + validate_selection_structure, ) @@ -44,6 +48,8 @@ def with_derived_binding_id(value: dict[str, object]) -> dict[str, object]: DIGEST = "sha256:" + "1" * 64 +NEXT_DIGEST = "sha256:" + "2" * 64 +NEWEST_DIGEST = "sha256:" + "3" * 64 SERVICE = with_derived_binding_id({ "recordId": "record-a", "serviceId": "urn:example:service:a", @@ -52,6 +58,8 @@ def with_derived_binding_id(value: dict[str, object]) -> dict[str, object]: "description": "Issues minimum-disclosure evidence", "endpointUrl": "https://provider.example/evidence", "publisherId": "urn:example:publisher", + "legalIssuerId": "urn:example:legal-issuer", + "technicalProviderId": "urn:example:technical-provider", "jurisdictions": ["urn:example:jurisdiction"], "conformsTo": ["urn:example:profile"], "evidenceTypeIds": ["urn:example:evidence-type"], @@ -73,6 +81,83 @@ def with_derived_binding_id(value: dict[str, object]) -> dict[str, object]: }) +def evidence_resolution() -> dict[str, object]: + return select_evidence_alternative({ + "requirementId": "urn:example:requirement", + "jurisdiction": "urn:example:jurisdiction", + "mappingRevision": DIGEST, + "alternatives": [{ + "evidenceTypeListId": "urn:example:list", + "evidenceTypeIds": ["urn:example:evidence-type"], + "mappingId": "urn:example:mapping", + "mappingAuthorityId": "urn:example:mapping-authority", + }], + }) + + +def evidence_selection(service: dict[str, object] | None = None) -> dict[str, object]: + selected_service = SERVICE if service is None else service + return select_evidence_service( + {"catalogRevision": DIGEST, "items": [selected_service]}, + { + "recordId": selected_service["recordId"], + "evidenceTypeId": "urn:example:evidence-type", + "resolution": evidence_resolution(), + }, + ) + + +def evidence_acceptance_subject(candidate: dict[str, object]) -> dict[str, object]: + """Project only the fields this adopter has independently pinned.""" + return { + "serviceKind": candidate["serviceKind"], + "serviceId": candidate["serviceId"], + "endpointUrl": candidate["endpointUrl"], + "publisherId": candidate.get("publisherId"), + "legalIssuerId": candidate.get("legalIssuerId"), + "technicalProviderId": candidate.get("technicalProviderId"), + "jurisdictions": candidate["jurisdictions"], + "conformsTo": candidate["conformsTo"], + "matchedCapability": candidate["matchedCapability"], + "evidenceResolution": candidate.get("evidenceResolution"), + "mappingRevision": candidate.get("mappingRevision"), + "originId": candidate["originId"], + "originUrl": candidate["originUrl"], + } + + +EVIDENCE_ACCEPTANCE_PINS = { + "serviceKind": "evidence", + "serviceId": "urn:example:service:a", + "endpointUrl": "https://provider.example/evidence", + "publisherId": "urn:example:publisher", + "legalIssuerId": "urn:example:legal-issuer", + "technicalProviderId": "urn:example:technical-provider", + "jurisdictions": ["urn:example:jurisdiction"], + "conformsTo": ["urn:example:profile"], + "matchedCapability": { + "kind": "evidence-type", + "id": "urn:example:evidence-type", + }, + "evidenceResolution": { + "requirementId": "urn:example:requirement", + "jurisdiction": "urn:example:jurisdiction", + "mappingRevision": DIGEST, + "evidenceTypeListId": "urn:example:list", + "evidenceTypeIds": ["urn:example:evidence-type"], + "mappingId": "urn:example:mapping", + "mappingAuthorityId": "urn:example:mapping-authority", + }, + "mappingRevision": DIGEST, + "originId": "origin-a", + "originUrl": "https://provider.example/catalog.jsonld", +} + + +def accepts_expected_evidence(candidate: dict[str, object]) -> bool: + return evidence_acceptance_subject(candidate) == EVIDENCE_ACCEPTANCE_PINS + + class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: if not self.path.startswith("/v1/services"): @@ -144,6 +229,234 @@ def test_search_resolve_and_inert_selection(self) -> None: self.assertEqual(json.loads(json.dumps(selection))["recordId"], "record-a") + def test_exact_local_acceptance_precedes_credentials_and_native_io(self) -> None: + selection = evidence_selection() + events: list[str] = [] + + def accept(candidate: dict[str, object]) -> bool: + events.append("local-acceptance") + return accepts_expected_evidence(candidate) + + accepted = accept_selection(selection, accept) + events.append("credential-construction") + events.append("native-io") + + self.assertEqual( + events, + ["local-acceptance", "credential-construction", "native-io"], + ) + self.assertEqual(accepted.endpoint_url, SERVICE["endpointUrl"]) + self.assertEqual(accepted.selection, selection) + + mutations: dict[str, dict[str, object]] = {} + + changed = copy.deepcopy(selection) + changed["endpointUrl"] = "https://attacker.example/evidence" + mutations["endpoint"] = with_derived_binding_id(changed) + + changed = copy.deepcopy(selection) + changed["serviceId"] = "urn:example:service:other" + mutations["service identity"] = with_derived_binding_id(changed) + + changed = copy.deepcopy(selection) + changed["legalIssuerId"] = "urn:example:legal-issuer:other" + mutations["legal issuer"] = changed + + changed = copy.deepcopy(selection) + changed["technicalProviderId"] = "urn:example:technical-provider:other" + mutations["technical provider"] = changed + + changed = copy.deepcopy(selection) + changed["conformsTo"] = ["urn:example:profile:other"] + mutations["profile"] = with_derived_binding_id(changed) + + changed = copy.deepcopy(selection) + changed["jurisdictions"] = ["urn:example:jurisdiction:other"] + changed["evidenceResolution"]["jurisdiction"] = ( + "urn:example:jurisdiction:other" + ) + mutations["jurisdiction"] = changed + + changed = copy.deepcopy(selection) + changed["evidenceTypeIds"] = ["urn:example:evidence-type:other"] + changed["matchedCapability"]["id"] = "urn:example:evidence-type:other" + changed["evidenceResolution"]["evidenceTypeIds"] = [ + "urn:example:evidence-type:other" + ] + mutations["capability"] = with_derived_binding_id(changed) + + changed = copy.deepcopy(selection) + changed["evidenceResolution"]["mappingAuthorityId"] = ( + "urn:example:mapping-authority:other" + ) + mutations["mapping context"] = changed + + changed = copy.deepcopy(selection) + changed.pop("evidenceResolution") + changed.pop("mappingRevision") + mutations["missing resolution context"] = changed + + for label, candidate in mutations.items(): + with self.subTest(label=label): + self.assertEqual(validate_selection_structure(candidate), candidate) + rejected_events: list[str] = [] + + def reject_unpinned(value: dict[str, object]) -> bool: + rejected_events.append("local-acceptance") + return accepts_expected_evidence(value) + + with self.assertRaises(DiscoveryClientError) as caught: + accepted_candidate = accept_selection(candidate, reject_unpinned) + rejected_events.append("credential-construction") + _ = accepted_candidate.endpoint_url + rejected_events.append("native-io") + self.assertEqual(caught.exception.kind, "local_acceptance_refused") + self.assertEqual(rejected_events, ["local-acceptance"]) + + def test_renewal_only_updates_provenance_for_the_same_accepted_subject(self) -> None: + previous = evidence_selection() + current = copy.deepcopy(previous) + current["originContentDigest"] = NEXT_DIGEST + current["originFetchedAt"] = "2026-08-25T00:00:00Z" + current["catalogRevision"] = NEWEST_DIGEST + + renewed = renew_unchanged_selection(previous, current) + self.assertEqual(renewed, current) + self.assertEqual(renewed["originContentDigest"], NEXT_DIGEST) + self.assertEqual(renewed["originFetchedAt"], "2026-08-25T00:00:00Z") + self.assertEqual(renewed["catalogRevision"], NEWEST_DIGEST) + self.assertEqual( + accept_selection(renewed, accepts_expected_evidence).endpoint_url, + SERVICE["endpointUrl"], + ) + + token_constructions = 0 + native_calls = 0 + + def continue_after_renewal( + baseline: dict[str, object], + candidate: dict[str, object], + ) -> dict[str, object]: + nonlocal token_constructions, native_calls + result = renew_unchanged_selection(baseline, candidate) + token_constructions += 1 + native_calls += 1 + return result + + changed_subjects: dict[str, dict[str, object]] = {} + + changed = copy.deepcopy(current) + changed["legalIssuerId"] = "urn:example:legal-issuer:other" + changed_subjects["issuer"] = changed + + changed = copy.deepcopy(current) + changed["jurisdictions"] = ["urn:example:jurisdiction:other"] + changed["evidenceResolution"]["jurisdiction"] = ( + "urn:example:jurisdiction:other" + ) + changed_subjects["jurisdiction"] = changed + + changed = copy.deepcopy(current) + changed["mappingRevision"] = NEXT_DIGEST + changed["evidenceResolution"]["mappingRevision"] = NEXT_DIGEST + changed_subjects["mapping revision"] = changed + + changed = copy.deepcopy(current) + changed["evidenceResolution"]["mappingAuthorityId"] = ( + "urn:example:mapping-authority:other" + ) + changed_subjects["mapping authority"] = changed + + changed = copy.deepcopy(current) + changed.pop("evidenceResolution") + changed.pop("mappingRevision") + changed_subjects["missing resolution context"] = changed + + changed = copy.deepcopy(current) + changed["endpointUrl"] = "https://provider.example/evidence-v2" + changed_subjects["endpoint"] = with_derived_binding_id(changed) + + changed = copy.deepcopy(current) + changed["conformsTo"] = ["urn:example:profile:other"] + changed_subjects["profile"] = with_derived_binding_id(changed) + + changed = copy.deepcopy(current) + changed["evidenceTypeIds"] = ["urn:example:evidence-type:other"] + changed["matchedCapability"]["id"] = "urn:example:evidence-type:other" + changed["evidenceResolution"]["evidenceTypeIds"] = [ + "urn:example:evidence-type:other" + ] + changed_subjects["capability"] = with_derived_binding_id(changed) + + for label, candidate in changed_subjects.items(): + with self.subTest(label=label), self.assertRaises( + DiscoveryClientError + ) as caught: + continue_after_renewal(previous, candidate) + self.assertEqual(caught.exception.kind, "selection_changed") + + relay_with_two_operations = with_derived_binding_id({ + **RELAY_SERVICE, + "operationFamilyIds": [ + "urn:example:consultation-list", + "urn:example:consultation-search", + ], + }) + relay_previous = select_relay_service( + {"catalogRevision": DIGEST, "items": [relay_with_two_operations]}, + { + "recordId": relay_with_two_operations["recordId"], + "capabilityMatch": { + "semanticClassId": "urn:example:registered-business", + "operationFamilyId": "urn:example:consultation-list", + }, + }, + ) + relay_current = select_relay_service( + { + "catalogRevision": NEWEST_DIGEST, + "items": [relay_with_two_operations], + }, + { + "recordId": relay_with_two_operations["recordId"], + "capabilityMatch": { + "semanticClassId": "urn:example:registered-business", + "operationFamilyId": "urn:example:consultation-search", + }, + }, + ) + with self.assertRaises(DiscoveryClientError) as caught: + continue_after_renewal(relay_previous, relay_current) + self.assertEqual(caught.exception.kind, "selection_changed") + + reselected = changed_subjects["issuer"] + new_pins = copy.deepcopy(EVIDENCE_ACCEPTANCE_PINS) + new_pins["legalIssuerId"] = "urn:example:legal-issuer:other" + explicitly_accepted = accept_selection( + reselected, + lambda candidate: evidence_acceptance_subject(candidate) == new_pins, + ) + self.assertEqual( + explicitly_accepted.selection["legalIssuerId"], + "urn:example:legal-issuer:other", + ) + + with self.assertRaises(DiscoveryClientError) as caught: + reselected = select_evidence_service( + {"catalogRevision": NEWEST_DIGEST, "items": []}, + { + "recordId": previous["recordId"], + "evidenceTypeId": "urn:example:evidence-type", + "resolution": evidence_resolution(), + }, + ) + token_constructions += 1 + native_calls += 1 + _ = reselected + self.assertEqual(caught.exception.kind, "no_matching_service") + self.assertEqual(token_constructions, 0) + self.assertEqual(native_calls, 0) + def test_configuration_failure_has_a_stable_kind(self) -> None: with self.assertRaises(DiscoveryClientError) as caught: DiscoveryClient("http://provider.example.invalid/") diff --git a/crates/registry-discovery-client-py/tests/python/test_drift.py b/crates/registry-discovery-client-py/tests/python/test_drift.py new file mode 100644 index 000000000..8497d0b21 --- /dev/null +++ b/crates/registry-discovery-client-py/tests/python/test_drift.py @@ -0,0 +1,218 @@ +"""Pin the Discovery Python runtime, stub, and package surface together.""" + +from __future__ import annotations + +import ast +import pathlib +import sys +import typing +import unittest + +TESTS = pathlib.Path(__file__).resolve().parent +CRATE = TESTS.parents[1] +PACKAGE = CRATE / "python" / "registry_discovery_client" +STUB = PACKAGE / "__init__.pyi" +sys.path.insert(0, str(TESTS)) + +import bootstrap # noqa: E402 + +bootstrap.ensure_built() + +import registry_discovery_client as discovery # noqa: E402 + + +RUNTIME_CLASS_NAMES = { + "AcceptedServiceSelection", + "DiscoveryClient", + "DiscoveryClientError", +} +RUNTIME_FUNCTION_NAMES = { + "accept_selection", + "renew_unchanged_selection", + "select_evidence_alternative", + "select_evidence_service", + "select_exact", + "select_relay_service", + "validate_selection", + "validate_selection_structure", +} +RUNTIME_NAMES = RUNTIME_CLASS_NAMES | RUNTIME_FUNCTION_NAMES +ERROR_ATTRIBUTES = {"kind", "status", "problem", "transport_kind"} +EXPECTED_PACKAGE_FILES = {"__init__.py", "__init__.pyi", "py.typed"} + +DIGEST = "sha256:" + "1" * 64 +NEXT_DIGEST = "sha256:" + "2" * 64 +RESPONSE = { + "catalogRevision": DIGEST, + "items": [ + { + "recordId": "record-a", + "bindingId": "urn:registrystack:discovery:binding:sha256:3a316636cd4b722c008a02dcf61633c7be64aa85bc9d3c20d932a0a2e8e06129", + "serviceId": "urn:example:service:a", + "serviceKind": "evidence", + "title": "Evidence service", + "description": "Issues minimum-disclosure evidence", + "endpointUrl": "https://provider.example/evidence", + "legalIssuerId": "urn:example:legal-issuer", + "technicalProviderId": "urn:example:technical-provider", + "jurisdictions": ["urn:example:jurisdiction"], + "conformsTo": ["urn:example:profile"], + "evidenceTypeIds": ["urn:example:evidence-type"], + "semanticClassIds": [], + "operationFamilyIds": [], + "originId": "origin-a", + "originUrl": "https://provider.example/catalog.jsonld", + "originContentDigest": DIGEST, + "originFetchedAt": "2026-08-15T00:00:00Z", + } + ], +} +REQUEST = { + "recordId": "record-a", + "matchedCapability": { + "kind": "evidence-type", + "id": "urn:example:evidence-type", + }, +} + + +def class_members(node: ast.ClassDef) -> set[str]: + result: set[str] = set() + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + result.add("__new__" if item.name == "__init__" else item.name) + elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + result.add(item.target.id) + return result + + +def live_class_members(cls: type) -> set[str]: + return set(vars(cls)) - {"__doc__", "__module__"} + + +class DriftTest(unittest.TestCase): + def setUp(self) -> None: + self.tree = ast.parse(STUB.read_text(encoding="utf-8")) + self.stub_classes = { + node.name: node + for node in self.tree.body + if isinstance(node, ast.ClassDef) + } + self.stub_functions = { + node.name: node + for node in self.tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + def test_runtime_and_stub_export_the_exact_function_and_class_surface(self) -> None: + live_names = {name for name in dir(discovery) if not name.startswith("_")} + self.assertEqual(live_names, RUNTIME_NAMES) + self.assertEqual( + set(self.stub_functions) & RUNTIME_NAMES, + RUNTIME_FUNCTION_NAMES, + ) + self.assertEqual( + set(self.stub_classes) & RUNTIME_NAMES, + RUNTIME_CLASS_NAMES, + ) + for name in RUNTIME_FUNCTION_NAMES: + with self.subTest(function=name): + self.assertTrue(callable(getattr(discovery, name))) + for name in RUNTIME_CLASS_NAMES: + with self.subTest(cls=name): + self.assertIsInstance(getattr(discovery, name), type) + + def test_client_methods_match_the_stub_in_both_directions(self) -> None: + self.assertEqual( + class_members(self.stub_classes["DiscoveryClient"]), + live_class_members(discovery.DiscoveryClient), + ) + + def test_accepted_selection_properties_match_and_are_returned_only_by_acceptance(self) -> None: + stub = self.stub_classes["AcceptedServiceSelection"] + self.assertEqual(class_members(stub), {"endpoint_url", "selection"}) + self.assertEqual( + live_class_members(discovery.AcceptedServiceSelection) + - {"__class_getitem__"}, + {"endpoint_url", "selection"}, + ) + accepted_type = discovery.AcceptedServiceSelection[dict[str, object]] + self.assertIs( + typing.get_origin(accepted_type), + discovery.AcceptedServiceSelection, + ) + self.assertEqual(typing.get_args(accepted_type), (dict[str, object],)) + for item in stub.body: + if isinstance(item, ast.FunctionDef): + self.assertTrue( + any( + isinstance(decorator, ast.Name) + and decorator.id == "property" + for decorator in item.decorator_list + ), + f"{item.name} must remain a read-only property", + ) + + selection = discovery.select_exact(RESPONSE, REQUEST) + accepted = discovery.accept_selection(selection, lambda candidate: candidate == selection) + self.assertIsInstance(accepted, discovery.AcceptedServiceSelection) + self.assertEqual(accepted.endpoint_url, selection["endpointUrl"]) + self.assertEqual(accepted.selection, selection) + + def test_structural_validation_alias_and_renewal_are_live_and_typed(self) -> None: + selection = discovery.select_exact(RESPONSE, REQUEST) + self.assertEqual( + discovery.validate_selection(selection), + discovery.validate_selection_structure(selection), + ) + legacy_docstring = ast.get_docstring(self.stub_functions["validate_selection"]) + self.assertIsNotNone(legacy_docstring) + self.assertIn("Deprecated", legacy_docstring) + self.assertIn("Deprecated", discovery.validate_selection.__doc__) + + current = { + **selection, + "catalogRevision": NEXT_DIGEST, + "originContentDigest": NEXT_DIGEST, + "originFetchedAt": "2026-08-25T00:00:00Z", + } + self.assertEqual( + discovery.renew_unchanged_selection(selection, current), + current, + ) + with self.assertRaises(discovery.DiscoveryClientError) as caught: + discovery.renew_unchanged_selection( + selection, + { + **current, + "legalIssuerId": "urn:example:legal-issuer:other", + }, + ) + self.assertEqual(caught.exception.kind, "selection_changed") + + def test_error_attributes_and_inheritance_are_pinned(self) -> None: + self.assertEqual( + class_members(self.stub_classes["DiscoveryClientError"]), + ERROR_ATTRIBUTES, + ) + self.assertTrue(issubclass(discovery.DiscoveryClientError, Exception)) + + def test_pep_561_marker_and_package_contents_are_pinned(self) -> None: + package_files = { + path.name for path in PACKAGE.iterdir() if path.is_file() + } + self.assertEqual(package_files, EXPECTED_PACKAGE_FILES) + self.assertEqual( + (PACKAGE / "py.typed").read_text(encoding="utf-8").strip(), + "", + "the complete typed package must not be marked partial", + ) + self.assertTrue((CRATE / "pyproject.toml").is_file()) + self.assertTrue((CRATE / "README.md").is_file()) + self.assertTrue((CRATE / "LICENSE").is_file()) + package_initializer = (PACKAGE / "__init__.py").read_text(encoding="utf-8") + self.assertIn("from .registry_discovery_client import *", package_initializer) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-discovery-client/src/error.rs b/crates/registry-discovery-client/src/error.rs index c32ccf84a..619911544 100644 --- a/crates/registry-discovery-client/src/error.rs +++ b/crates/registry-discovery-client/src/error.rs @@ -41,6 +41,10 @@ pub enum DiscoveryClientError { AmbiguousAlternative, #[error("the selected advertised capability does not match the service")] CapabilityMismatch, + #[error("the relying application refused the advertised service")] + LocalAcceptanceRefused, + #[error("the current advertised service changed and requires new acceptance")] + SelectionChanged, #[error("the Discovery exchange did not complete: {kind}")] Transport { kind: TransportKind }, #[error("Discovery returned {problem}: status {status}")] @@ -75,6 +79,8 @@ mod tests { DiscoveryClientError::NoMatchingAlternative, DiscoveryClientError::AmbiguousAlternative, DiscoveryClientError::CapabilityMismatch, + DiscoveryClientError::LocalAcceptanceRefused, + DiscoveryClientError::SelectionChanged, DiscoveryClientError::transport(TransportKind::Connect), DiscoveryClientError::Problem { status: 503, diff --git a/crates/registry-discovery-client/src/lib.rs b/crates/registry-discovery-client/src/lib.rs index a56d19f41..1741318a9 100644 --- a/crates/registry-discovery-client/src/lib.rs +++ b/crates/registry-discovery-client/src/lib.rs @@ -11,9 +11,11 @@ pub use registry_discovery::{ EvidenceTypeResolveRequest, EvidenceTypeResolveResponse, ResolvedAlternative, ServiceFilters, ServiceKind, ServiceRecord, ServiceSearchResponse, }; +#[allow(deprecated)] pub use selection::{ - validate_service_selection, EvidenceResolutionContext, EvidenceSelectionRequest, - EvidenceServiceSelection, EvidenceTypeResolveSelectionExt, MatchedCapability, - RelayCapabilityMatch, RelaySelectionRequest, RelayServiceSelection, SelectionRequest, - ServiceSearchSelectionExt, ServiceSelection, + accept_service_selection, renew_unchanged_service_selection, validate_service_selection, + validate_service_selection_structure, AcceptedServiceSelection, EvidenceResolutionContext, + EvidenceSelectionRequest, EvidenceServiceSelection, EvidenceTypeResolveSelectionExt, + MatchedCapability, RelayCapabilityMatch, RelaySelectionRequest, RelayServiceSelection, + SelectionRequest, ServiceSearchSelectionExt, ServiceSelection, }; diff --git a/crates/registry-discovery-client/src/selection.rs b/crates/registry-discovery-client/src/selection.rs index 40cc7593f..dcf18bdff 100644 --- a/crates/registry-discovery-client/src/selection.rs +++ b/crates/registry-discovery-client/src/selection.rs @@ -241,11 +241,34 @@ impl ServiceSelection { /// Parse the advertised native base URL after the application has applied /// its own trust policy. Calling this method is not a trust decision. pub fn advertised_base_url(&self) -> Result { - validate_service_selection(self)?; + validate_service_selection_structure(self)?; parsed_base_url(&self.endpoint_url) } } +/// An ephemeral handoff created only after adopter-owned local acceptance. +/// +/// This wrapper is deliberately not serializable. Persist the inert +/// [`ServiceSelection`] and apply current local policy again before native +/// credentials or input and output. +#[derive(Debug)] +pub struct AcceptedServiceSelection<'a> { + selection: &'a ServiceSelection, + base_url: Url, +} + +impl AcceptedServiceSelection<'_> { + #[must_use] + pub fn selection(&self) -> &ServiceSelection { + self.selection + } + + #[must_use] + pub fn base_url(&self) -> &Url { + &self.base_url + } +} + fn parsed_base_url(value: &str) -> Result { let url = Url::parse(value).map_err(|_| DiscoveryClientError::Protocol)?; registry_platform_httputil::client::ServiceBaseUrl::new(url.clone()) @@ -565,8 +588,12 @@ fn capability_matches(service: &ServiceRecord, capability: &MatchedCapability) - } } -/// Revalidate a persisted or foreign selection before applying local trust. -pub fn validate_service_selection( +/// Validate the closed shape and capability binding of a persisted or foreign +/// selection. +/// +/// This does not prove origin authenticity, catalog currentness, mapping +/// currency, authorization, or adopter trust. +pub fn validate_service_selection_structure( selection: &ServiceSelection, ) -> Result<(), DiscoveryClientError> { let record = ServiceRecord { @@ -667,6 +694,76 @@ pub fn validate_service_selection( Ok(()) } +/// Compatibility alias for the original structural-validation name. +#[deprecated( + note = "use validate_service_selection_structure; validation is structural, not trust" +)] +pub fn validate_service_selection( + selection: &ServiceSelection, +) -> Result<(), DiscoveryClientError> { + validate_service_selection_structure(selection) +} + +/// Apply adopter-owned local policy and create an ephemeral native handoff. +/// +/// The callback owns the trust decision. Discovery supplies no trust policy, +/// trust-store schema, credentials, or native client behavior. +pub fn accept_service_selection<'a>( + selection: &'a ServiceSelection, + accepts: impl FnOnce(&ServiceSelection) -> bool, +) -> Result, DiscoveryClientError> { + validate_service_selection_structure(selection)?; + if !accepts(selection) { + return Err(DiscoveryClientError::LocalAcceptanceRefused); + } + Ok(AcceptedServiceSelection { + selection, + base_url: parsed_base_url(&selection.endpoint_url)?, + }) +} + +/// Renew a selection only when a caller has freshly reselected the same +/// trust-relevant service semantics from an online Discovery lookup. +/// +/// Fetch provenance and the global catalog revision may advance without +/// changing this service. Every service, role, jurisdiction, capability, +/// mapping, or resolution change requires explicit new local acceptance. +pub fn renew_unchanged_service_selection( + previous: &ServiceSelection, + current: &ServiceSelection, +) -> Result { + validate_service_selection_structure(previous)?; + validate_service_selection_structure(current)?; + if !same_acceptance_subject(previous, current) { + return Err(DiscoveryClientError::SelectionChanged); + } + Ok(current.clone()) +} + +fn same_acceptance_subject(left: &ServiceSelection, right: &ServiceSelection) -> bool { + left.record_id == right.record_id + && left.binding_id == right.binding_id + && left.service_id == right.service_id + && left.service_kind == right.service_kind + && left.endpoint_url == right.endpoint_url + && left.publisher_id == right.publisher_id + && left.operator_id == right.operator_id + && left.registry_authority_id == right.registry_authority_id + && left.legal_issuer_id == right.legal_issuer_id + && left.technical_provider_id == right.technical_provider_id + && left.jurisdictions == right.jurisdictions + && left.conforms_to == right.conforms_to + && left.evidence_type_ids == right.evidence_type_ids + && left.semantic_class_ids == right.semantic_class_ids + && left.operation_family_ids == right.operation_family_ids + && left.matched_capability == right.matched_capability + && left.evidence_resolution == right.evidence_resolution + && left.relay_capability_match == right.relay_capability_match + && left.origin_id == right.origin_id + && left.origin_url == right.origin_url + && left.mapping_revision == right.mapping_revision +} + #[cfg(test)] mod tests { use registry_discovery::{ @@ -717,6 +814,19 @@ mod tests { .unwrap(); } + fn refresh_selection_binding_id(selection: &mut ServiceSelection) { + selection.binding_id = derive_binding_id( + &selection.service_id, + selection.service_kind, + &selection.endpoint_url, + &selection.conforms_to, + &selection.evidence_type_ids, + &selection.semantic_class_ids, + &selection.operation_family_ids, + ) + .unwrap(); + } + #[test] fn exact_selection_refuses_absence_ambiguity_and_capability_mismatch() { let record = service(); @@ -843,7 +953,8 @@ mod tests { selection.selection().evidence_resolution, Some(context.clone()) ); - validate_service_selection(selection.selection()).expect("the saved selection revalidates"); + validate_service_selection_structure(selection.selection()) + .expect("the saved selection revalidates structurally"); let mut persisted = selection.into_selection(); persisted @@ -852,7 +963,7 @@ mod tests { .expect("the selection retains its resolution") .jurisdiction = Some("urn:jurisdiction:other".into()); assert_eq!( - validate_service_selection(&persisted), + validate_service_selection_structure(&persisted), Err(DiscoveryClientError::Protocol) ); } @@ -909,7 +1020,7 @@ mod tests { .into_selection(); persisted.evidence_resolution = Some(oversized); assert_eq!( - validate_service_selection(&persisted), + validate_service_selection_structure(&persisted), Err(DiscoveryClientError::Protocol) ); } @@ -986,13 +1097,14 @@ mod tests { operation_family_id: Some("urn:operation:list".into()), }) ); - validate_service_selection(selection.selection()).expect("the Relay tuple revalidates"); + validate_service_selection_structure(selection.selection()) + .expect("the Relay tuple revalidates structurally"); let mut persisted = selection.into_selection(); persisted.matched_capability = MatchedCapability::SemanticClass("urn:semantic:person".into()); assert_eq!( - validate_service_selection(&persisted), + validate_service_selection_structure(&persisted), Err(DiscoveryClientError::Protocol) ); } @@ -1029,12 +1141,148 @@ mod tests { let mut drifted = selection.clone(); mutate(&mut drifted); assert_eq!( - validate_service_selection(&drifted), + validate_service_selection_structure(&drifted), Err(DiscoveryClientError::Protocol) ); } } + #[test] + fn structural_validation_does_not_turn_descriptive_metadata_into_binding_authority() { + let record = service(); + let response = ServiceSearchResponse { + catalog_revision: catalog_revision(std::slice::from_ref(&record)).unwrap(), + items: vec![record], + }; + let selection = response + .select_only(MatchedCapability::EvidenceType("urn:evidence".into())) + .expect("valid exact selection"); + + let mut descriptive_change = selection.clone(); + descriptive_change.publisher_id = Some("urn:publisher:other".into()); + descriptive_change.jurisdictions = vec!["urn:jurisdiction:other".into()]; + validate_service_selection_structure(&descriptive_change) + .expect("roles and jurisdictions remain outside capability binding"); + + let accepted = accept_service_selection(&selection, |candidate| candidate == &selection) + .expect("the exact local pin accepts"); + assert_eq!(accepted.selection(), &selection); + assert_eq!( + accepted.base_url().as_str(), + "https://provider.example/evidence" + ); + assert_eq!( + accept_service_selection(&descriptive_change, |candidate| candidate == &selection) + .map(|_| ()), + Err(DiscoveryClientError::LocalAcceptanceRefused) + ); + } + + #[test] + fn unchanged_renewal_refreshes_provenance_but_requires_new_acceptance_for_semantic_change() { + let record = service(); + let response = ServiceSearchResponse { + catalog_revision: catalog_revision(std::slice::from_ref(&record)).unwrap(), + items: vec![record], + }; + let previous = response + .select_only(MatchedCapability::EvidenceType("urn:evidence".into())) + .expect("valid exact selection"); + let mut current = previous.clone(); + current.origin_content_digest = format!("sha256:{}", "3".repeat(64)); + current.origin_fetched_at = "2026-08-20T00:00:00Z".into(); + current.catalog_revision = format!("sha256:{}", "4".repeat(64)); + + let renewed = renew_unchanged_service_selection(&previous, ¤t) + .expect("fresh provenance for unchanged semantics renews"); + assert_eq!(renewed.origin_fetched_at, "2026-08-20T00:00:00Z"); + assert_eq!(renewed.catalog_revision, current.catalog_revision); + + let semantic_changes: [fn(&mut ServiceSelection); 3] = [ + |selection| selection.legal_issuer_id = Some("urn:issuer:other".into()), + |selection| { + selection.conforms_to = vec!["urn:profile:other".into()]; + refresh_selection_binding_id(selection); + }, + |selection| { + selection.evidence_type_ids = vec!["urn:evidence:other".into()]; + selection.matched_capability = + MatchedCapability::EvidenceType("urn:evidence:other".into()); + refresh_selection_binding_id(selection); + }, + ]; + for mutate in semantic_changes { + let mut changed = current.clone(); + mutate(&mut changed); + validate_service_selection_structure(&changed) + .expect("the changed selection remains structurally valid"); + + let mut credentials_constructed = 0; + let mut native_calls = 0; + let result = renew_unchanged_service_selection(&previous, &changed).inspect(|_| { + credentials_constructed += 1; + native_calls += 1; + }); + assert_eq!(result, Err(DiscoveryClientError::SelectionChanged)); + assert_eq!(credentials_constructed, 0); + assert_eq!(native_calls, 0); + } + + let mut relay = service(); + relay.service_kind = ServiceKind::Relay; + relay.legal_issuer_id = None; + relay.technical_provider_id = None; + relay.registry_authority_id = Some("urn:registry-authority".into()); + relay.evidence_type_ids.clear(); + relay.semantic_class_ids = vec!["urn:semantic:business".into()]; + relay.operation_family_ids = + vec!["urn:operation:list".into(), "urn:operation:search".into()]; + refresh_binding_id(&mut relay); + let relay_response = ServiceSearchResponse { + catalog_revision: catalog_revision(std::slice::from_ref(&relay)).unwrap(), + items: vec![relay], + }; + let relay_previous = relay_response + .select_relay(RelaySelectionRequest::new( + "record-a", + RelayCapabilityMatch::for_semantic_class("urn:semantic:business") + .with_operation_family("urn:operation:list"), + )) + .expect("valid Relay tuple") + .into_selection(); + let relay_current = relay_response + .select_relay(RelaySelectionRequest::new( + "record-a", + RelayCapabilityMatch::for_semantic_class("urn:semantic:business") + .with_operation_family("urn:operation:search"), + )) + .expect("a changed Relay tuple remains structurally valid") + .into_selection(); + assert_eq!(relay_previous.binding_id, relay_current.binding_id); + assert_eq!( + relay_previous.matched_capability, + relay_current.matched_capability + ); + assert_eq!( + renew_unchanged_service_selection(&relay_previous, &relay_current), + Err(DiscoveryClientError::SelectionChanged) + ); + } + + #[test] + #[allow(deprecated)] + fn legacy_validation_name_remains_a_structural_compatibility_alias() { + let record = service(); + let response = ServiceSearchResponse { + catalog_revision: catalog_revision(std::slice::from_ref(&record)).unwrap(), + items: vec![record], + }; + let selection = response + .select_only(MatchedCapability::EvidenceType("urn:evidence".into())) + .expect("valid exact selection"); + validate_service_selection(&selection).expect("the compatibility alias remains available"); + } + #[test] fn select_only_refuses_an_unranked_zero_or_many_result_set() { let record = service(); diff --git a/crates/registry-discovery-client/tests/native_journey.rs b/crates/registry-discovery-client/tests/native_journey.rs index 415dbf5fd..58e793e36 100644 --- a/crates/registry-discovery-client/tests/native_journey.rs +++ b/crates/registry-discovery-client/tests/native_journey.rs @@ -21,10 +21,10 @@ use registry_discovery::{ ServiceKind, }; use registry_discovery_client::{ - validate_service_selection, DiscoveryClient, DiscoveryClientConfig, EvidenceResolutionContext, - EvidenceSelectionRequest, EvidenceTypeResolveSelectionExt, MatchedCapability, - RelayCapabilityMatch, RelaySelectionRequest, RelayServiceQuery, ServiceSearchSelectionExt, - ServiceSelection, + accept_service_selection, validate_service_selection_structure, DiscoveryClient, + DiscoveryClientConfig, EvidenceResolutionContext, EvidenceSelectionRequest, + EvidenceTypeResolveSelectionExt, MatchedCapability, RelayCapabilityMatch, + RelaySelectionRequest, RelayServiceQuery, ServiceSearchSelectionExt, ServiceSelection, }; use registry_discoveryctl::{build_project_at, BuildError}; use registry_evidence::config::EvidenceConfig; @@ -654,18 +654,11 @@ fn evidence_client_if_trusted( credentials: &CredentialFactory, trusted_jwks: JwksDocument, ) -> Option { - if !trust.accepts(selection) { - return None; - } + let accepted = + accept_service_selection(selection, |candidate| trust.accepts(candidate)).ok()?; let token = credentials.evidence(); - let config = EvidenceClientConfig::new( - selection - .advertised_base_url() - .expect("the trusted Evidence selection carries a valid native base URL"), - token, - trusted_jwks, - Vec::new(), - ); + let config = + EvidenceClientConfig::new(accepted.base_url().clone(), token, trusted_jwks, Vec::new()); Some(EvidenceClient::new(config).expect("the trusted Evidence client builds")) } @@ -674,16 +667,10 @@ fn relay_client_if_trusted( selection: &ServiceSelection, credentials: &CredentialFactory, ) -> Option { - if !trust.accepts(selection) { - return None; - } + let accepted = + accept_service_selection(selection, |candidate| trust.accepts(candidate)).ok()?; let token = credentials.relay(); - let config = RelayClientConfig::new( - selection - .advertised_base_url() - .expect("the trusted Relay selection carries a valid native base URL"), - ) - .with_token_provider(token); + let config = RelayClientConfig::new(accepted.base_url().clone()).with_token_provider(token); Some(RelayClient::new(config).expect("the trusted Relay client builds")) } @@ -868,10 +855,10 @@ async fn complete_evidence_and_relay_journeys_build_select_trust_and_invoke_nati ) = serde_json::from_slice(&saved).expect("saved selections reload without Discovery"); let evidence_selection = evidence_selection.into_selection(); let relay_selection = relay_selection.into_selection(); - validate_service_selection(&evidence_selection) - .expect("the persisted Evidence selection revalidates before trust"); - validate_service_selection(&relay_selection) - .expect("the persisted Relay selection revalidates before trust"); + validate_service_selection_structure(&evidence_selection) + .expect("the persisted Evidence selection revalidates structurally before trust"); + validate_service_selection_structure(&relay_selection) + .expect("the persisted Relay selection revalidates structurally before trust"); assert_eq!( evidence_selection.binding_id, provider.evidence_binding.binding_id From d68526d9a855a33db7f692e7edf7a7e1225ba7da Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 25 Aug 2026 21:07:08 +0700 Subject: [PATCH 2/6] test(discovery): bind consumer handoff contracts Require structural validation, explicit local acceptance, and unchanged-selection renewal in the Discovery completion and security traceability contracts. Execute the cross-SDK adopter journey as contract evidence. Refs #816 Refs #817 Refs #818 Signed-off-by: Jeremi Joslin --- .../contracts/definition-of-done.yaml | 2 +- .../contracts/security-invariant-matrix.yaml | 6 ++-- .../contracts/security-test-traceability.yaml | 6 ++++ .../scripts/test-adopter-tutorial.sh | 28 ++++++++++++++++++- .../scripts/test_contract_artifacts.py | 9 ++++++ .../scripts/validate_contract_artifacts.py | 8 ++++++ 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/products/discovery/contracts/definition-of-done.yaml b/products/discovery/contracts/definition-of-done.yaml index 897bee39d..b377b52b0 100644 --- a/products/discovery/contracts/definition-of-done.yaml +++ b/products/discovery/contracts/definition-of-done.yaml @@ -9,7 +9,7 @@ {"id": "discovery-dod-16-4-origin-build", "section": "16.4", "requirement": "The offline checked origin and mapping project feeds one bounded exact-target build that preserves provenance, origin isolation, and atomic, durably synced output replacement. Production builtAt is captured after all origin fetches complete. catalogRevision covers the normalized semantic service projection and excludes per-build originContentDigest, originFetchedAt, and builtAt values, so identical semantic inputs preserve record identities, semantic fields, and revisions while provenance timestamps may change. Every authored and compiled fixture satisfies its Draft 2020-12 schema and closed Rust parser.", "requiredEvidence": [{"path": "products/discovery/schemas/origins.schema.json"}, {"path": "products/discovery/schemas/evidence-mapping.schema.json"}, {"path": "products/discovery/schemas/runtime.schema.json"}, {"path": "products/discovery/schemas/index.schema.json"}, {"path": "products/discovery/fixtures/project/origins.yaml"}, {"path": "products/discovery/fixtures/project/discovery-index.json"}, {"path": "products/discovery/fixtures/schema-negative-corpus.json"}, {"path": "crates/registry-discoveryctl/tests/schema_contract.rs", "name": "every_positive_fixture_satisfies_draft_2020_12_and_the_closed_rust_parser"}, {"path": "crates/registry-discoveryctl/tests/schema_contract.rs", "name": "shared_negative_corpus_is_refused_by_both_schema_and_rust"}, {"path": "crates/registry-discoveryctl/src/project.rs", "name": "check_is_offline_and_accepts_an_unreachable_https_origin"}, {"path": "crates/registry-discoveryctl/tests/build.rs", "name": "build_fetches_each_origin_once_and_preserves_semantic_revisions"}, {"path": "crates/registry-discoveryctl/tests/build.rs", "name": "production_build_time_is_captured_after_origin_collection"}, {"path": "crates/registry-discoveryctl/tests/build.rs", "name": "failed_origin_fetch_leaves_the_previous_output_untouched"}, {"path": "crates/registry-discoveryctl/tests/build.rs", "name": "write_failure_preserves_previous_output_and_leaves_no_visible_temporary_file"}]}, {"id": "discovery-dod-16-5-evidence-resolver", "section": "16.5", "requirement": "Requirement resolution preserves exact jurisdiction, AND within a list, OR between alternatives, mapping provenance, empty success, and complete-or-refused bounds without provider resolution.", "requiredEvidence": [{"path": "crates/registry-discovery/src/query.rs", "name": "resolver_preserves_and_within_lists_or_across_alternatives_and_refuses_over_bound"}, {"path": "crates/registry-discovery/src/model.rs"}]}, {"id": "discovery-dod-16-6-runtime-query-api", "section": "16.6", "requirement": "Startup loads one strict immutable index and the runtime exposes only value-free probes, deterministic generated OpenAPI, exact bounded service search, and evidence-type resolution, with no metrics route. Runtime admission bounds request bodies before buffering, rejects malformed form encoding and request media parameters, and bounds synchronous query work.", "requiredEvidence": [{"path": "crates/registry-discovery/src/startup.rs", "name": "runtime_is_closed_and_contains_no_origin_mapping_trust_or_fetch_configuration"}, {"path": "crates/registry-discovery/src/server.rs", "name": "request_body_capacity_is_acquired_before_buffering_and_recovers"}, {"path": "crates/registry-discovery/src/server.rs", "name": "request_media_type_accepts_only_bare_or_utf8_json"}, {"path": "crates/registry-discovery/src/query.rs", "name": "malformed_percent_encoding_and_invalid_utf8_are_refused"}, {"path": "crates/registry-discovery/src/server.rs", "name": "real_trace_and_problem_output_exclude_request_canaries"}, {"path": "crates/registry-discovery/src/server.rs", "name": "real_router_exposes_only_the_fixed_read_only_surface"}, {"path": "crates/registry-discovery/src/openapi.rs", "name": "committed_openapi_is_the_deterministic_generator_output"}, {"path": "crates/registry-discovery/openapi.json"}]}, - {"id": "discovery-dod-16-7-client-trust-invocation", "section": "16.7", "requirement": "The client resolves, searches, and explicitly selects public metadata only; native product trust stays local and a Discovery selection cannot create native I/O or credentials.", "requiredEvidence": [{"path": "crates/registry-discovery-client/src/selection.rs", "name": "discovery_metadata_has_no_trust_or_native_io_capability"}, {"path": "crates/registry-discovery-client/src/client.rs", "name": "response_bytes_are_bounded"}]}, + {"id": "discovery-dod-16-7-client-trust-invocation", "section": "16.7", "requirement": "The Rust, Node, and Python clients resolve, search, and explicitly select public metadata only. Structural validation proves closed shape and capability binding, not trust or currentness. An ephemeral accepted handoff exists only after adopter-owned local acceptance; credentials and native product I/O occur only after that acceptance. Online renewal may refresh provenance only for unchanged trust-relevant service semantics, and every semantic change requires a new local acceptance decision.", "requiredEvidence": [{"path": "crates/registry-discovery-client/src/selection.rs", "name": "structural_validation_does_not_turn_descriptive_metadata_into_binding_authority"}, {"path": "crates/registry-discovery-client/src/selection.rs", "name": "unchanged_renewal_refreshes_provenance_but_requires_new_acceptance_for_semantic_change"}, {"path": "crates/registry-discovery-client/src/selection.rs", "name": "discovery_metadata_has_no_trust_or_native_io_capability"}, {"path": "crates/registry-discovery-client/tests/native_journey.rs", "name": "complete_evidence_and_relay_journeys_build_select_trust_and_invoke_natively"}, {"path": "crates/registry-discovery-client-node/__test__/surface.test.js"}, {"path": "crates/registry-discovery-client-py/tests/python/test_client.py", "name": "test_exact_local_acceptance_precedes_credentials_and_native_io"}, {"path": "crates/registry-discovery-client-py/tests/python/test_client.py", "name": "test_renewal_only_updates_provenance_for_the_same_accepted_subject"}, {"path": "products/discovery/scripts/test-adopter-tutorial.sh"}, {"path": "crates/registry-discovery-client/src/client.rs", "name": "response_bytes_are_bounded"}]}, {"id": "discovery-dod-16-8-adopter-maintenance-ux", "section": "16.8", "requirement": "Provider, catalog operator, and consumer/verifier adoption is documented and exercised from a clean source tree as a small public publication block, origins and mappings, offline check, explicit build, exact selection, native trust handoff, deployment, and restart loop.", "requiredEvidence": [{"path": "products/discovery/README.md"}, {"path": "products/discovery/ACCEPTANCE-JOURNEYS.md"}, {"path": "products/discovery/contracts/implementation-schedule.yaml"}, {"path": "docs/site/src/content/docs/tutorials/publish-and-consume-discovery-index.mdx"}, {"path": "products/discovery/scripts/test-adopter-tutorial.sh"}]}, {"id": "discovery-dod-16-9-acceptance-journeys", "section": "16.9", "requirement": "Evidence and Relay publication, build, exact selection, existing local trust, and direct native invocation are exercised as deterministic local journeys, including invalid origin and untrusted selection failures.", "requiredEvidence": [{"path": "products/discovery/ACCEPTANCE-JOURNEYS.md"}, {"path": "products/discovery/scripts/test-http.sh"}, {"path": "products/discovery/fixtures/descriptions/evidence.jsonld"}, {"path": "products/discovery/fixtures/descriptions/relay.jsonld"}]}, {"id": "discovery-dod-16-10-security-ci", "section": "16.10", "requirement": "Every named Discovery security threat has a refusal and executable traceability; profile, schema, standards, contract, publication, build, runtime, and client gates are selected and run without secrets or live network fixtures.", "requiredEvidence": [{"path": "products/discovery/contracts/security-invariant-matrix.yaml"}, {"path": "products/discovery/contracts/security-test-traceability.yaml"}, {"path": "products/discovery/scripts/check-contracts.sh"}, {"path": "products/discovery/scripts/test-http.sh"}, {"path": "crates/registry-discoveryctl/tests/schema_contract.rs", "name": "shared_negative_corpus_is_refused_by_both_schema_and_rust"}, {"path": "products/discovery/scripts/test_standards_oracle.py", "name": "test_shacl_oracle_rejects_missing_endpoint"}]}, diff --git a/products/discovery/contracts/security-invariant-matrix.yaml b/products/discovery/contracts/security-invariant-matrix.yaml index 32c2f1f25..180ed9918 100644 --- a/products/discovery/contracts/security-invariant-matrix.yaml +++ b/products/discovery/contracts/security-invariant-matrix.yaml @@ -60,9 +60,9 @@ { "id": "sec-discovery-not-trust", "status": "enforced", - "threat": "Catalog metadata becomes native trust configuration or causes credentials and provider I/O before local acceptance.", - "enforcementPoint": "Discovery client selection boundary and adopter-owned native trust configuration.", - "requiredNegativeBehavior": "Refuse local trust mismatch before credential construction or provider traffic.", + "threat": "Catalog metadata or structural validation becomes native trust configuration, a persisted selection bypasses current local acceptance, or renewal silently accepts changed service semantics and causes credentials or provider I/O.", + "enforcementPoint": "Discovery client structural validator, ephemeral AcceptedServiceSelection handoff after adopter-owned acceptance, and unchanged-semantic renewal comparison.", + "requiredNegativeBehavior": "Keep structural validation distinct from trust, refuse local trust mismatch before credential construction or provider traffic, and renew provenance only when trust-relevant service semantics are unchanged; otherwise require new acceptance.", "negativeTest": "discovery-selection-cannot-create-trust-or-native-io", "binding": {"path": "crates/registry-discovery-client/src/selection.rs", "name": "discovery_metadata_has_no_trust_or_native_io_capability"} }, diff --git a/products/discovery/contracts/security-test-traceability.yaml b/products/discovery/contracts/security-test-traceability.yaml index 0e3f50c3d..fb1688c82 100644 --- a/products/discovery/contracts/security-test-traceability.yaml +++ b/products/discovery/contracts/security-test-traceability.yaml @@ -93,6 +93,12 @@ {"path": "crates/registry-discovery-client/src/selection.rs", "name": "discovery_metadata_has_no_trust_or_native_io_capability"}, {"path": "crates/registry-discovery-client/src/selection.rs", "name": "exact_selection_refuses_binding_identity_drift"}, {"path": "crates/registry-discovery-client/src/selection.rs", "name": "persisted_selection_refuses_binding_identity_drift"}, + {"path": "crates/registry-discovery-client/src/selection.rs", "name": "structural_validation_does_not_turn_descriptive_metadata_into_binding_authority"}, + {"path": "crates/registry-discovery-client/src/selection.rs", "name": "unchanged_renewal_refreshes_provenance_but_requires_new_acceptance_for_semantic_change"}, + {"path": "crates/registry-discovery-client-node/__test__/surface.test.js", "name": "adopter acceptance is explicit and precedes credentials or native traffic"}, + {"path": "crates/registry-discovery-client-node/__test__/surface.test.js", "name": "renewal refreshes provenance but never silently accepts semantic drift"}, + {"path": "crates/registry-discovery-client-py/tests/python/test_client.py", "name": "test_exact_local_acceptance_precedes_credentials_and_native_io"}, + {"path": "crates/registry-discovery-client-py/tests/python/test_client.py", "name": "test_renewal_only_updates_provenance_for_the_same_accepted_subject"}, {"path": "crates/registry-discovery-client/tests/native_journey.rs", "name": "complete_evidence_and_relay_journeys_build_select_trust_and_invoke_natively"} ] }, diff --git a/products/discovery/scripts/test-adopter-tutorial.sh b/products/discovery/scripts/test-adopter-tutorial.sh index 2ff5d8fd5..32cab8dad 100755 --- a/products/discovery/scripts/test-adopter-tutorial.sh +++ b/products/discovery/scripts/test-adopter-tutorial.sh @@ -31,7 +31,7 @@ cleanup() { trap cleanup EXIT trap 'exit 130' HUP INT TERM -for tool in cargo curl python3; do +for tool in cargo curl node npm python3; do if ! command -v "$tool" >/dev/null 2>&1; then printf 'required tool not on PATH: %s\n' "$tool" >&2 exit 1 @@ -188,3 +188,29 @@ if ! ( fi printf '%s\n' '[handoff] adopter-owned Evidence trust accepted; native assertion verified' printf '%s\n' '[handoff] adopter-owned Relay trust accepted; native list response verified' + +node_log="$work_root/node-handoff.log" +if ! ( + cd "$repository/crates/registry-discovery-client-node" + npm ci --ignore-scripts --no-audit --no-fund + CARGO_BUILD_RUSTC_WRAPPER='' CARGO_INCREMENTAL=0 \ + CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ + npm run build:debug + npm test +) >"$node_log" 2>&1; then + printf '%s\n' 'the Node.js structural validation, acceptance, and renewal tests failed' >&2 + sed -n '1,200p' "$node_log" >&2 + exit 1 +fi + +python_log="$work_root/python-handoff.log" +if ! ( + cd "$repository/crates/registry-discovery-client-py" + CARGO_BUILD_RUSTC_WRAPPER='' CARGO_INCREMENTAL=0 \ + CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ + python3 -m unittest discover -s tests/python -v +) >"$python_log" 2>&1; then + printf '%s\n' 'the Python structural validation, acceptance, and renewal tests failed' >&2 + sed -n '1,200p' "$python_log" >&2 + exit 1 +fi diff --git a/products/discovery/scripts/test_contract_artifacts.py b/products/discovery/scripts/test_contract_artifacts.py index 70699eb49..f7632d254 100755 --- a/products/discovery/scripts/test_contract_artifacts.py +++ b/products/discovery/scripts/test_contract_artifacts.py @@ -41,6 +41,15 @@ def test_python_binding_without_discoverable_test_name_is_refused(self) -> None: without_test_name, "test_bound_refusal", "fixture.py" ) + def test_javascript_binding_without_discoverable_test_name_is_refused(self) -> None: + source = "test('bound refusal', () => {});\n" + VALIDATOR.require_executable_test(source, "bound refusal", "fixture.js") + without_test_name = source.replace("bound refusal", "other refusal") + with self.assertRaisesRegex(ValueError, "is not discoverable"): + VALIDATOR.require_executable_test( + without_test_name, "bound refusal", "fixture.js" + ) + if __name__ == "__main__": unittest.main() diff --git a/products/discovery/scripts/validate_contract_artifacts.py b/products/discovery/scripts/validate_contract_artifacts.py index 781feaaaa..8d5f6217b 100755 --- a/products/discovery/scripts/validate_contract_artifacts.py +++ b/products/discovery/scripts/validate_contract_artifacts.py @@ -59,6 +59,14 @@ def require_executable_test(source: str, name: str, binding: str) -> None: ) is None: fail(f"executable Python test binding is not discoverable: {binding}::{name}") return + if binding.endswith(".js"): + function = re.search( + rf"(?m)^[ \t]*test\([ \t]*(['\"]){re.escape(name)}\1[ \t]*,", + source, + ) + if function is None: + fail(f"executable JavaScript test binding is not discoverable: {binding}::{name}") + return function = re.compile( rf"(?m)(?P(?:^[ \t]*#\[[^\n]+\]\n)+)" rf"^[ \t]*(?:async[ \t]+)?fn[ \t]+{re.escape(name)}\b" From 0b10804bf1f440f671a5bfc76836dd8fdd416de0 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 25 Aug 2026 21:07:13 +0700 Subject: [PATCH 3/6] docs(discovery): explain the adopter trust handoff Document structural validation, adopter-owned acceptance, persisted inert selections, and online renewal across the Rust, Node.js, and Python client journeys. Refs #816 Refs #818 Signed-off-by: Jeremi Joslin --- docs/site/src/content/docs/changelog.mdx | 7 + .../explanation/discovery-as-an-index.mdx | 217 ++++++++++++++---- 2 files changed, 178 insertions(+), 46 deletions(-) diff --git a/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index 7c6b543de..f9b458582 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -18,6 +18,13 @@ relevant product pages on this site rather than duplicating release notes. ## Unreleased +- Clarified that Discovery selection validation checks closed structure and + capability binding, not trust or currentness, and documented the compatibility + aliases for existing Node.js, Python, and Rust adopters. +- Added the adopter-owned accepted-service handoff and explicit online renewal + workflow. Credentials and native Evidence or Relay input and output remain + outside Discovery and follow local acceptance. + ## 2026-08-20 Documentation prepared for the v0.23.0 beta-34 release: diff --git a/docs/site/src/content/docs/explanation/discovery-as-an-index.mdx b/docs/site/src/content/docs/explanation/discovery-as-an-index.mdx index 0e9b6216c..9031327fa 100644 --- a/docs/site/src/content/docs/explanation/discovery-as-an-index.mdx +++ b/docs/site/src/content/docs/explanation/discovery-as-an-index.mdx @@ -99,29 +99,94 @@ complete Evidence resolution context, Relay tuple, catalog revision, and origin `EvidenceTypeResolveSelectionExt`, `ServiceSearchSelectionExt::select_evidence()`, `ServiceSearchSelectionExt::select_relay()`, and the typed selection fields. */} -After selection, the application makes a local native trust decision and calls the advertised endpoint -directly with its Evidence Gateway or Registry Relay client. Discovery selection contains public -metadata only. It cannot carry a trust anchor, credential, request, or response into that native call. +After selection, the application validates the selection's closed structure and capability binding, +then applies a synchronous local acceptance policy. Structural validation does not authenticate the +origin, establish currentness, or trust the selected service. Successful local acceptance creates an +ephemeral accepted service that exposes the native endpoint. The application creates credentials and +performs native input and output only after that acceptance. {/* Evidence: `crates/registry-discovery-client/src/selection.rs`, - `discovery_metadata_has_no_trust_or_native_io_capability`; `products/discovery/DECISIONS.md`, - ADR-001. */} + `validate_service_selection_structure()`, `accept_service_selection()`, and + `AcceptedServiceSelection`; test `discovery_metadata_has_no_trust_or_native_io_capability`; + `crates/registry-discovery-client/tests/native_journey.rs`, test + `complete_evidence_and_relay_journeys_build_select_trust_and_invoke_natively`; + `products/discovery/DECISIONS.md`, ADR-001. */} ## The same client workflow in Rust, Node.js, and Python -The maintained clients expose the same three steps in each language: resolve an Evidence requirement -when needed, search with exact filters, then convert one exact record into a serializable selection. +The maintained clients expose the same boundary in each language: resolve an Evidence requirement +when needed, search with exact filters, convert one exact record into a serializable selection, +validate its structure, then apply synchronous adopter-owned acceptance. Node.js applications use `@registrystack/discovery-client`; Python applications use `registry-discovery-client`; Rust applications use `registry-discovery-client`. ```js const { DiscoveryClient, + acceptSelection, + renewUnchangedSelection, selectEvidenceAlternative, selectEvidenceService, - validateSelection, + validateSelectionStructure, } = require('@registrystack/discovery-client'); -const { EvidenceClient } = require('@registrystack/evidence-client'); + +const expectedEvidence = { + serviceKind: 'evidence', + serviceId: 'urn:example:service:evidence', + endpointUrl: 'https://evidence.example/', + legalIssuerId: 'urn:example:issuer', + technicalProviderId: 'urn:example:provider', + jurisdictions: ['urn:example:jurisdiction'], + conformsTo: ['urn:example:evidence-profile'], + evidenceTypeIds: ['urn:example:evidence-type'], + matchedCapability: { kind: 'evidence-type', id: 'urn:example:evidence-type' }, + evidenceResolution: { + requirementId: 'urn:example:requirement', + jurisdiction: 'urn:example:jurisdiction', + mappingRevision: `sha256:${'a'.repeat(64)}`, + evidenceTypeListId: 'urn:example:evidence-type-list', + evidenceTypeIds: ['urn:example:evidence-type'], + mappingId: 'urn:example:mapping', + mappingAuthorityId: 'urn:example:mapping-authority', + }, +}; + +function sameOrderedStrings(actual, expected) { + return Array.isArray(actual) + && actual.length === expected.length + && actual.every((value, index) => value === expected[index]); +} + +function acceptsExpectedEvidence(candidate) { + const actualResolution = candidate.evidenceResolution; + const expectedResolution = expectedEvidence.evidenceResolution; + return candidate.serviceKind === expectedEvidence.serviceKind + && candidate.serviceId === expectedEvidence.serviceId + && candidate.endpointUrl === expectedEvidence.endpointUrl + && candidate.legalIssuerId === expectedEvidence.legalIssuerId + && candidate.technicalProviderId === expectedEvidence.technicalProviderId + && sameOrderedStrings(candidate.jurisdictions, expectedEvidence.jurisdictions) + && sameOrderedStrings(candidate.conformsTo, expectedEvidence.conformsTo) + && sameOrderedStrings(candidate.evidenceTypeIds, expectedEvidence.evidenceTypeIds) + && candidate.matchedCapability.kind === expectedEvidence.matchedCapability.kind + && candidate.matchedCapability.id === expectedEvidence.matchedCapability.id + && actualResolution !== undefined + && actualResolution.requirementId === expectedResolution.requirementId + && actualResolution.jurisdiction === expectedResolution.jurisdiction + && actualResolution.mappingRevision === expectedResolution.mappingRevision + && actualResolution.evidenceTypeListId === expectedResolution.evidenceTypeListId + && sameOrderedStrings(actualResolution.evidenceTypeIds, expectedResolution.evidenceTypeIds) + && actualResolution.mappingId === expectedResolution.mappingId + && actualResolution.mappingAuthorityId === expectedResolution.mappingAuthorityId; +} + +function acceptFreshEvidence(previous, fresh) { + const checkedFresh = validateSelectionStructure(fresh); + const candidate = previous === undefined + ? checkedFresh + : renewUnchangedSelection(validateSelectionStructure(previous), checkedFresh); + return acceptSelection(candidate, acceptsExpectedEvidence); +} const discovery = new DiscoveryClient('https://discovery.example/'); const resolved = await discovery.resolveEvidenceTypes({ @@ -135,82 +200,142 @@ for (const evidenceTypeId of context.evidenceTypeIds) { evidenceTypeId, ...(context.jurisdiction ? { jurisdiction: context.jurisdiction } : {}), }); - const record = await adopterChooseRecord(results.items); + if (results.items.length !== 1) { + throw new Error('select one reviewed provider explicitly'); + } const selection = selectEvidenceService(results, { - recordId: record.recordId, + recordId: results.items[0].recordId, evidenceTypeId, resolution: context, }); - const checked = validateSelection(selection); - adopterTrust.requireEvidence(checked); - const evidence = new EvidenceClient({ baseUrl: checked.endpointUrl, ...nativeConfig }); - if (!checked.evidenceResolution) throw new Error('missing Evidence resolution'); - const prepared = evidence.prepare({ - ...localEvidencePolicy, - requirement: checked.evidenceResolution.requirementId, - evidenceType: checked.matchedCapability.id, - }); - const verified = await evidence.requestAndVerify(prepared); - for (const claim of verified.evidence.supportedValues) { - console.log(claim.providesValueFor, claim.value); - } + const accepted = acceptFreshEvidence(undefined, selection); + + // Construct the native Evidence client, credentials, and request only now, + // using accepted.endpointUrl and adopter-owned native configuration. + console.log(accepted.selection.matchedCapability.id, accepted.endpointUrl); } ``` ```python from registry_discovery_client import ( DiscoveryClient, + accept_selection, + renew_unchanged_selection, select_relay_service, - validate_selection, + validate_selection_structure, ) from registry_relay_client import RelayClient +EXPECTED_RELAY = { + "serviceKind": "relay", + "serviceId": "urn:example:service:relay", + "endpointUrl": "https://relay.example/", + "operatorId": "urn:example:operator", + "registryAuthorityId": "urn:example:registry-authority", + "jurisdictions": ["urn:example:jurisdiction"], + "conformsTo": ["urn:example:relay-profile"], + "semanticClassIds": ["urn:example:registered-business"], + "operationFamilyIds": ["urn:example:consultation-list"], + "relayCapabilityMatch": { + "semanticClassId": "urn:example:registered-business", + "operationFamilyId": "urn:example:consultation-list", + }, +} + +def relay_acceptance_subject(candidate): + return {key: candidate.get(key) for key in EXPECTED_RELAY} + +def accepts_expected_relay(candidate): + return relay_acceptance_subject(candidate) == EXPECTED_RELAY + +def accept_fresh_relay(previous, fresh): + checked_fresh = validate_selection_structure(fresh) + candidate = ( + checked_fresh + if previous is None + else renew_unchanged_selection( + validate_selection_structure(previous), checked_fresh + ) + ) + return accept_selection(candidate, accepts_expected_relay) + discovery = DiscoveryClient("https://discovery.example/") results = discovery.search_relay_services({ "semanticClassId": "urn:example:registered-business", "operationFamilyId": "urn:example:consultation-list", }) -record = adopter_choose_record(results["items"]) +if len(results["items"]) != 1: + raise RuntimeError("select one reviewed provider explicitly") selection = select_relay_service(results, { - "recordId": record["recordId"], + "recordId": results["items"][0]["recordId"], "capabilityMatch": { "semanticClassId": "urn:example:registered-business", "operationFamilyId": "urn:example:consultation-list", }, }) -checked = validate_selection(selection) -resource = adopter_trust.require_relay(checked) -relay = RelayClient(checked["endpointUrl"], authorization=native_authorization) -page = relay.list_records(resource, page_size=1) +accepted = accept_fresh_relay(None, selection) + +# For a protected surface, add native authorization only after acceptance. +relay = RelayClient(base_url=accepted.endpoint_url) +page = relay.list_records("businesses", page_size=1) if page["kind"] != "complete" or not page["value"]["items"]: raise RuntimeError("Relay returned no records") record = page["value"]["items"][0] print(record["recordIdentifier"], record["domainData"]) ``` -The chooser is application-owned because Discovery does not rank results. The libraries validate the -server response, complete Evidence alternative or Relay tuple, exact capability match, and any loaded -selection before returning the native base URL. They do not decide whether the selected origin, -issuer, operator, endpoint, or capability is trusted. Keep that decision in the application's native -Evidence or Relay trust configuration. Native definitions and local policy still supply Evidence -purpose, audience, issuer and provider identity, configuration revision, selectors, and expected -outputs. The JavaScript example reads values only from the payload that the native Evidence client -has verified. Native Relay metadata supplies the concrete resource and operation. In the Python -example, the adopter-owned trust mapping returns that reviewed resource identifier, and -`list_records` performs the native Relay request. The returned record remains a Relay response, not -Discovery metadata. +The application owns the choice because Discovery does not rank results. The structural validator +checks the closed response, complete Evidence alternative or Relay tuple, and capability binding. +It does not decide whether the selected origin, issuer, operator, endpoint, or capability is trusted +or current. The acceptance callback is synchronous, supplied by the application, and receives no +credential or native client. A successful callback creates the ephemeral accepted service used by +native client configuration. Native definitions and local policy still supply Evidence purpose, +audience, issuer and provider identity, configuration revision, selectors, expected outputs, and +Relay resource and operation identifiers. {/* Evidence: `crates/registry-discovery-client-node/src/lib.rs` and `crates/registry-discovery-client-py/src/lib.rs` are thin bindings over - `registry-discovery-client`. Their language-level loopback tests exercise typed search, complete - resolution context, correlated Relay selection, and persisted-selection validation. + `registry-discovery-client`. `crates/registry-discovery-client-node/client.js`, + `acceptSelection()` and `renewUnchangedSelection()`, and + `crates/registry-discovery-client-py/src/lib.rs`, `accept_selection()` and + `renew_unchanged_selection()`, expose the same acceptance and renewal boundary. + Their language-level tests exercise typed search, complete resolution context, correlated Relay + selection, structural validation, synchronous local acceptance, and unchanged renewal. `crates/registry-discovery-client/src/selection.rs`, test `discovery_metadata_has_no_trust_or_native_io_capability`, binds the shared trust boundary. - `crates/registry-evidence-client-node/src/lib.rs`, `EvidenceClient::request_and_verify`, returns - the verified Evidence payload used by the JavaScript example. `crates/registry-relay-client-py/src/lib.rs`, `RelayClient::list_records`, performs the bounded native exchange and returns the decoded record collection. */} +## A saved selection is not current trust + +Persist the plain selection when an application needs an offline handoff. The accepted wrapper is +ephemeral and is not a persistence format. Loading a selection and passing structural validation +proves only that the saved data still has the closed shape and capability binding. The saved data +remains inert until the application applies its current local acceptance policy. Neither loading nor +local acceptance establishes that the provider still advertises the service. + +Currentness requires an online renewal. For Evidence Gateway, the application re-resolves the +requirement and jurisdiction, explicitly chooses an alternative, re-searches every Evidence Type, +and explicitly reselects each provider. For Registry Relay, it re-searches the correlated semantic +class and operation family, then explicitly reselects the provider. Node.js then compares the old +and freshly selected values with `renewUnchangedSelection`; Python uses +`renew_unchanged_selection`; Rust uses `renew_unchanged_service_selection`. The comparison allows +new fetch provenance and a new global catalog revision for an otherwise unchanged service. It +refuses a changed identity, endpoint, role, jurisdiction, profile, capability, origin, mapping, or +resolution context. A refusal requires a new explicit local decision rather than automatic +acceptance. An unchanged renewal still passes through local acceptance before credentials or native +input and output. + +{/* Tier-C evidence: `crates/registry-discovery-client/src/selection.rs`, + `renew_unchanged_service_selection()` and `same_acceptance_subject()`, define the unchanged + comparison; tests `unchanged_renewal_refreshes_provenance_but_requires_new_acceptance_for_semantic_change` + and `structural_validation_does_not_turn_descriptive_metadata_into_binding_authority` cover the + comparison and local acceptance. `crates/registry-discovery-client-node/__test__/surface.test.js`, + tests `adopter acceptance is explicit and precedes credentials or native traffic` and + `renewal refreshes provenance but never silently accepts semantic drift`, cover the Node.js + surface. `crates/registry-discovery-client-py/tests/python/test_client.py`, test + `test_exact_local_acceptance_precedes_credentials_and_native_io`, covers the Python surface. */} + ## Why the split reduces maintenance The provider maintains one public description URL. The catalog operator maintains a small explicit From fe9cda34c97d356cdbd04e0be683e957f4149a80 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 25 Aug 2026 21:07:19 +0700 Subject: [PATCH 4/6] test(release): smoke discovery client handoffs Exercise validation, explicit acceptance, and unchanged-selection renewal through the installed Node.js package and Python wheel. Refs #817 Refs #818 Signed-off-by: Jeremi Joslin --- .../scripts/smoke-discovery-client-package.js | 83 ++++++++++++++++++- .../scripts/smoke-discovery-client-package.py | 79 ++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/release/scripts/smoke-discovery-client-package.js b/release/scripts/smoke-discovery-client-package.js index 2d8cd7fc9..67fad8858 100755 --- a/release/scripts/smoke-discovery-client-package.js +++ b/release/scripts/smoke-discovery-client-package.js @@ -2,9 +2,34 @@ 'use strict'; const assert = require('node:assert'); -const { DiscoveryClient, DiscoveryClientError, selectExact } = require('@registrystack/discovery-client'); +const discovery = require('@registrystack/discovery-client'); + +const { + AcceptedServiceSelection, + DiscoveryClient, + DiscoveryClientError, + acceptSelection, + renewUnchangedSelection, + selectExact, + validateSelection, + validateSelectionStructure, +} = discovery; + +for (const name of [ + 'AcceptedServiceSelection', + 'DiscoveryClient', + 'DiscoveryClientError', + 'acceptSelection', + 'renewUnchangedSelection', + 'selectExact', + 'validateSelection', + 'validateSelectionStructure', +]) { + assert.strictEqual(typeof discovery[name], 'function', `the package must export ${name}`); +} const digest = `sha256:${'1'.repeat(64)}`; +const nextDigest = `sha256:${'2'.repeat(64)}`; const response = { catalogRevision: digest, items: [{ @@ -15,6 +40,8 @@ const response = { title: 'Evidence service', description: 'Issues minimum-disclosure evidence', endpointUrl: 'https://provider.example/evidence', + legalIssuerId: 'urn:example:legal-issuer', + technicalProviderId: 'urn:example:technical-provider', jurisdictions: ['urn:example:jurisdiction'], conformsTo: ['urn:example:profile'], evidenceTypeIds: ['urn:example:evidence-type'], @@ -31,13 +58,65 @@ const request = { matchedCapability: { kind: 'evidence-type', id: 'urn:example:evidence-type' }, }; +function plainJson(value) { + return JSON.parse(JSON.stringify(value)); +} + // The reserved host makes the smoke fail closed if construction regresses into // unexpected network I/O. Exact selection itself remains local and inert. const client = new DiscoveryClient('https://discovery.invalid/'); const selection = client.selectExact(response, request); assert.strictEqual(selection.endpointUrl, response.items[0].endpointUrl); assert.deepStrictEqual(selectExact(response, request).matchedCapability, request.matchedCapability); -assert.strictEqual(JSON.parse(JSON.stringify(selection)).recordId, 'record-a'); +assert.strictEqual(plainJson(selection).recordId, 'record-a'); + +const structurallyValid = validateSelectionStructure(selection); +assert.deepStrictEqual( + validateSelection(selection), + structurallyValid, + 'the legacy validation name must remain a structural compatibility alias', +); + +let localAcceptanceCalls = 0; +const accepted = acceptSelection(structurallyValid, (candidate) => { + localAcceptanceCalls += 1; + return candidate.serviceKind === 'evidence' + && candidate.serviceId === 'urn:example:service:a' + && candidate.endpointUrl === 'https://provider.example/evidence' + && candidate.legalIssuerId === 'urn:example:legal-issuer' + && candidate.technicalProviderId === 'urn:example:technical-provider' + && candidate.jurisdictions.length === 1 + && candidate.jurisdictions[0] === 'urn:example:jurisdiction' + && candidate.conformsTo.length === 1 + && candidate.conformsTo[0] === 'urn:example:profile' + && candidate.matchedCapability.kind === 'evidence-type' + && candidate.matchedCapability.id === 'urn:example:evidence-type'; +}); +assert.ok(accepted instanceof AcceptedServiceSelection); +assert.strictEqual(accepted.endpointUrl, response.items[0].endpointUrl); +assert.deepStrictEqual(plainJson(accepted.selection), plainJson(structurallyValid)); +assert.strictEqual(localAcceptanceCalls, 1); + +const current = { + ...selection, + catalogRevision: nextDigest, + originContentDigest: nextDigest, + originFetchedAt: '2026-08-25T00:00:00Z', +}; +assert.deepStrictEqual( + plainJson(renewUnchangedSelection(selection, current)), + plainJson(current), + 'fresh provenance may renew an otherwise unchanged selection', +); +assert.throws( + () => renewUnchangedSelection(selection, { + ...current, + legalIssuerId: 'urn:example:legal-issuer:other', + }), + (error) => error instanceof DiscoveryClientError && error.kind === 'selection_changed', + 'trust-relevant changes must require explicit new acceptance', +); + assert.throws( () => new DiscoveryClient('http://discovery.invalid/'), (error) => error instanceof DiscoveryClientError && error.kind === 'configuration', diff --git a/release/scripts/smoke-discovery-client-package.py b/release/scripts/smoke-discovery-client-package.py index c9c6db52b..979c1edde 100755 --- a/release/scripts/smoke-discovery-client-package.py +++ b/release/scripts/smoke-discovery-client-package.py @@ -7,6 +7,7 @@ DIGEST = "sha256:" + "1" * 64 +NEXT_DIGEST = "sha256:" + "2" * 64 RESPONSE = { "catalogRevision": DIGEST, "items": [ @@ -18,6 +19,8 @@ "title": "Evidence service", "description": "Issues minimum-disclosure evidence", "endpointUrl": "https://provider.example/evidence", + "legalIssuerId": "urn:example:legal-issuer", + "technicalProviderId": "urn:example:technical-provider", "jurisdictions": ["urn:example:jurisdiction"], "conformsTo": ["urn:example:profile"], "evidenceTypeIds": ["urn:example:evidence-type"], @@ -40,6 +43,19 @@ def main() -> None: + for name in { + "AcceptedServiceSelection", + "DiscoveryClient", + "DiscoveryClientError", + "accept_selection", + "renew_unchanged_selection", + "select_exact", + "validate_selection", + "validate_selection_structure", + }: + if not callable(getattr(client_module, name, None)): + raise SystemExit(f"the package must export {name}") + # This reserved host makes the smoke fail closed if construction regresses # into network I/O. Exact selection is local and returns inert metadata. client = client_module.DiscoveryClient("https://discovery.invalid/") @@ -50,6 +66,69 @@ def main() -> None: raise SystemExit("the standalone selector changed") if json.loads(json.dumps(selection))["originContentDigest"] != DIGEST: raise SystemExit("the selection did not remain serializable") + + structurally_valid = client_module.validate_selection_structure(selection) + if client_module.validate_selection(selection) != structurally_valid: + raise SystemExit( + "the legacy validation name is not a structural compatibility alias" + ) + + local_acceptance_calls = 0 + + def accepts_expected_service(candidate: dict[str, object]) -> bool: + nonlocal local_acceptance_calls + local_acceptance_calls += 1 + return ( + candidate["serviceKind"] == "evidence" + and candidate["serviceId"] == "urn:example:service:a" + and candidate["endpointUrl"] == "https://provider.example/evidence" + and candidate["legalIssuerId"] == "urn:example:legal-issuer" + and candidate["technicalProviderId"] + == "urn:example:technical-provider" + and candidate["jurisdictions"] == ["urn:example:jurisdiction"] + and candidate["conformsTo"] == ["urn:example:profile"] + and candidate["matchedCapability"] + == { + "kind": "evidence-type", + "id": "urn:example:evidence-type", + } + ) + + accepted = client_module.accept_selection( + structurally_valid, + accepts_expected_service, + ) + if not isinstance(accepted, client_module.AcceptedServiceSelection): + raise SystemExit("explicit local acceptance returned the wrong type") + if accepted.endpoint_url != RESPONSE["items"][0]["endpointUrl"]: + raise SystemExit("explicit local acceptance returned the wrong endpoint") + if accepted.selection != structurally_valid: + raise SystemExit("explicit local acceptance changed the selection") + if local_acceptance_calls != 1: + raise SystemExit("explicit local acceptance did not run exactly once") + + current = { + **selection, + "catalogRevision": NEXT_DIGEST, + "originContentDigest": NEXT_DIGEST, + "originFetchedAt": "2026-08-25T00:00:00Z", + } + if client_module.renew_unchanged_selection(selection, current) != current: + raise SystemExit("fresh provenance did not renew an unchanged selection") + try: + client_module.renew_unchanged_selection( + selection, + { + **current, + "legalIssuerId": "urn:example:legal-issuer:other", + }, + ) + except client_module.DiscoveryClientError as error: + if error.kind != "selection_changed": + raise SystemExit(f"unexpected renewal error kind {error.kind!r}") from error + else: + raise SystemExit("trust-relevant selection drift must require new acceptance") + try: client_module.DiscoveryClient("http://discovery.invalid/") except client_module.DiscoveryClientError as error: From d227d3e2f6be9cbdbdb487fbe6fbdc497e4b1cab Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 25 Aug 2026 21:07:25 +0700 Subject: [PATCH 5/6] ci(discovery): bind consumer boundary gates Select the Discovery client packages, adopter contract journey, documentation, and installed-package smokes together, with the pinned Node.js toolchain required by the contract job. Refs #818 Signed-off-by: Jeremi Joslin --- .github/scripts/test_ci_changes.py | 18 ++++++++++++++++++ .github/workflows/ci.yml | 7 +++++++ 2 files changed, 25 insertions(+) diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 88d01e493..ae31eacd7 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -851,6 +851,24 @@ def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: self.assertIn("\n - relay-client-contracts\n", rust_result) self.assertNotIn("\n - notary-contracts\n", rust_result) + def test_discovery_contracts_pins_node_for_adopter_binding_tests(self) -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + discovery_job = workflow.split("\n discovery-contracts:\n", 1)[1].split( + "\n relay-v2-contracts:\n", 1 + )[0] + + self.assertIn( + "uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + discovery_job, + ) + self.assertIn("node-version: 22.12.0", discovery_job) + self.assertIn("cache: npm", discovery_job) + self.assertIn( + "cache-dependency-path: " + "crates/registry-discovery-client-node/package-lock.json", + discovery_job, + ) + def test_archive_content_is_immutable_during_routine_docs_changes(self) -> None: current_content = classify( self.workspace, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 497aec28f..4f5b09721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -509,6 +509,13 @@ jobs: persist-credentials: false submodules: false + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: crates/registry-discovery-client-node/package-lock.json + - name: Cache Cargo registry uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2 with: From 570e2f379763a2d34fcf46fd62f0f1bfadc7e400 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 25 Aug 2026 21:09:42 +0700 Subject: [PATCH 6/6] test(release): pin Python accepted-selection typing Require the installed Python wheel to support runtime subscription of the generic accepted handoff. Refs #817 Signed-off-by: Jeremi Joslin --- release/scripts/smoke-discovery-client-package.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/release/scripts/smoke-discovery-client-package.py b/release/scripts/smoke-discovery-client-package.py index 979c1edde..0a4e02029 100755 --- a/release/scripts/smoke-discovery-client-package.py +++ b/release/scripts/smoke-discovery-client-package.py @@ -2,6 +2,7 @@ """Offline smoke for an installed Registry Discovery Python client wheel.""" import json +import typing import registry_discovery_client as client_module @@ -106,6 +107,9 @@ def accepts_expected_service(candidate: dict[str, object]) -> bool: raise SystemExit("explicit local acceptance changed the selection") if local_acceptance_calls != 1: raise SystemExit("explicit local acceptance did not run exactly once") + accepted_type = client_module.AcceptedServiceSelection[dict[str, object]] + if typing.get_origin(accepted_type) is not client_module.AcceptedServiceSelection: + raise SystemExit("the accepted handoff is not subscriptable at runtime") current = { **selection,