What I ran into
The Node sample server declares UCP_VERSION 2026-04-08 (config.ts:15). At that pin, source/discovery/profile_schema.json $defs/base requires ucp and separately declares signing_keys as a top level sibling of ucp. That schema defines no keys field anywhere, nested or otherwise.
The server publishes signing_keys[] correctly at the top level, but also mirrors the same key into a nested ucp.keys[] that has no basis in the schema at any pin. Its own verifier, extractKeys() in signature.ts, reads only that nested field:
const ucp = "ucp" in doc ? doc["ucp"] : doc;
const value = (ucp as Record<string, unknown>)["keys"];
Every real profile document carries ucp (the schema requires it), so this reader always takes the "ucp" in doc branch and looks inside ucp for keys. It can never see a top level sibling field on any real document, whether that field is named signing_keys (the pin this server itself declares) or keys (the name ucp#566 gives it starting 2026-08-25). Concretely: a peer that publishes only the schema correct top level signing_keys[], with nothing extra, fails key discovery against this very server. The reference cannot verify a conformant counterpart at its own declared version.
Observed against main (00333a8)
Fresh clone, documented setup (npm ci && npm run build && npm test in rest/nodejs, mirroring .github/workflows/nodejs.yml). A direct call against the exported extractKeys confirms the read side:
extractKeys({ ucp: { version: "2026-04-08" }, signing_keys: [{ kid: "k" }] })
// => [] -- the schema correct shape returns nothing
extractKeys({ ucp: { keys: [{ kid: "k" }] } })
// => [{ kid: "k" }] -- only the nonstandard nested shape is read
The booted server confirms the write side matches (curl /.well-known/ucp): signing_keys[] at the top level, keys[] mirrored under ucp, both carrying the same webhook JWK.
Expected
signing_keys[] (the 2026-04-08 pin this server declares) published at the top level, and read from the top level. No field the schema does not define should be published, and the verifier should trust the field the server itself declares as canonical.
Where it comes from
This traces to two of our own merged PRs. #162 (request signature verification) introduced extractKeys reading only a nested keys[], justified in its comment as pre-adopting the rename: "keys[] is the canonical RFC 7517 JWK Set field per ucp#566, which removed the earlier signing_keys[]". ucp#566 does rename the field, but only for 2026-08-25 and later, and it places keys as a top level sibling of ucp, never nested inside it, so the pre-adoption got both the timing (this server never moved its declared version) and the location (nested rather than top level) wrong at once. #179 (Node webhook signing) then added the top level signing_keys[] publication and, to satisfy the #162 reader, the nested ucp.keys[] mirror. The Python reference reached the identical defect by the same two steps: #122 introduced _extract_keys reading only ucp.get("keys") with a near identical comment, and #169 added the matching publication plus mirror. This issue and its Node PR are scoped to the Node server only; the Python twin needs a companion fix.
Why CI did not catch it
Every test that exercises key discovery constructs its own profile document rather than reading the real served output through both the write and read paths at once. test/signing.test.ts and test/signature.test.ts built fixtures shaped { ucp: { keys: [...] } }, matching the actual (wrong) expectation of extractKeys rather than the schema. webhook_signing.test.ts asserted both signing_keys[] and ucp.keys[] were present without checking that the reader on this same server could resolve the schema correct one alone. No test ever fed the served top level signing_keys[] output back into extractKeys with nothing else present.
Two more discovery profile issues, same file, same PR
Fixing the same discovery.ts response surfaced two more corrections small enough to ship alongside the keys fix rather than as separate PRs.
extends is typed too narrowly. capability.json $defs/base declares extends as oneOf [reverse_domain_name, array<reverse_domain_name> (minItems 1)] at both 2026-04-08 and 2026-08-25 ("Use array for multi-parent extensions"), but the DiscoveryCapability type in discovery.ts declares extends?: string. Every entry that carries extends today has a single parent, so this has not broken anything yet, but the type would reject a real multi-parent extension the schema explicitly allows.
Three declared capabilities do not exist anywhere in the spec. dev.ucp.shopping.refund, .return, and .dispute are declared in the discovery.ts capability catalog with schema URLs (.../schemas/shopping/refund.json etc.), but no such file exists under source/schemas/shopping/ at 2026-04-08 or 2026-08-25 (checked both trees: the full list at either pin is buyer_consent, cart, catalog_lookup, catalog_search, checkout, discount, fulfillment, order, plus payment and ap2_mandate at 04-08 / permalink at 08-25; no refund, return, or dispute file at either). No route in src/api/ implements them. The Python reference discovery_profile.json (both upstream, unmodified, and our own 2026-08-25 golden reference server) never declared them either. A capability catalog entry with no schema behind it and no implementation is not a real capability; it should be removed.
What I ran into
The Node sample server declares UCP_VERSION 2026-04-08 (config.ts:15). At that pin, source/discovery/profile_schema.json $defs/base requires
ucpand separately declaressigning_keysas a top level sibling ofucp. That schema defines nokeysfield anywhere, nested or otherwise.The server publishes
signing_keys[]correctly at the top level, but also mirrors the same key into a nesteducp.keys[]that has no basis in the schema at any pin. Its own verifier, extractKeys() in signature.ts, reads only that nested field:Every real profile document carries
ucp(the schema requires it), so this reader always takes the"ucp" in docbranch and looks insideucpforkeys. It can never see a top level sibling field on any real document, whether that field is namedsigning_keys(the pin this server itself declares) orkeys(the name ucp#566 gives it starting 2026-08-25). Concretely: a peer that publishes only the schema correct top levelsigning_keys[], with nothing extra, fails key discovery against this very server. The reference cannot verify a conformant counterpart at its own declared version.Observed against main (00333a8)
Fresh clone, documented setup (
npm ci && npm run build && npm testin rest/nodejs, mirroring .github/workflows/nodejs.yml). A direct call against the exported extractKeys confirms the read side:The booted server confirms the write side matches (curl /.well-known/ucp):
signing_keys[]at the top level,keys[]mirrored underucp, both carrying the same webhook JWK.Expected
signing_keys[] (the 2026-04-08 pin this server declares) published at the top level, and read from the top level. No field the schema does not define should be published, and the verifier should trust the field the server itself declares as canonical.
Where it comes from
This traces to two of our own merged PRs. #162 (request signature verification) introduced extractKeys reading only a nested keys[], justified in its comment as pre-adopting the rename: "keys[] is the canonical RFC 7517 JWK Set field per ucp#566, which removed the earlier signing_keys[]". ucp#566 does rename the field, but only for 2026-08-25 and later, and it places keys as a top level sibling of ucp, never nested inside it, so the pre-adoption got both the timing (this server never moved its declared version) and the location (nested rather than top level) wrong at once. #179 (Node webhook signing) then added the top level signing_keys[] publication and, to satisfy the #162 reader, the nested ucp.keys[] mirror. The Python reference reached the identical defect by the same two steps: #122 introduced _extract_keys reading only ucp.get("keys") with a near identical comment, and #169 added the matching publication plus mirror. This issue and its Node PR are scoped to the Node server only; the Python twin needs a companion fix.
Why CI did not catch it
Every test that exercises key discovery constructs its own profile document rather than reading the real served output through both the write and read paths at once. test/signing.test.ts and test/signature.test.ts built fixtures shaped
{ ucp: { keys: [...] } }, matching the actual (wrong) expectation of extractKeys rather than the schema. webhook_signing.test.ts asserted bothsigning_keys[]anducp.keys[]were present without checking that the reader on this same server could resolve the schema correct one alone. No test ever fed the served top levelsigning_keys[]output back into extractKeys with nothing else present.Two more discovery profile issues, same file, same PR
Fixing the same discovery.ts response surfaced two more corrections small enough to ship alongside the keys fix rather than as separate PRs.
extends is typed too narrowly. capability.json $defs/base declares
extendsasoneOf [reverse_domain_name, array<reverse_domain_name> (minItems 1)]at both 2026-04-08 and 2026-08-25 ("Use array for multi-parent extensions"), but the DiscoveryCapability type in discovery.ts declaresextends?: string. Every entry that carries extends today has a single parent, so this has not broken anything yet, but the type would reject a real multi-parent extension the schema explicitly allows.Three declared capabilities do not exist anywhere in the spec. dev.ucp.shopping.refund, .return, and .dispute are declared in the discovery.ts capability catalog with schema URLs (
.../schemas/shopping/refund.jsonetc.), but no such file exists under source/schemas/shopping/ at 2026-04-08 or 2026-08-25 (checked both trees: the full list at either pin is buyer_consent, cart, catalog_lookup, catalog_search, checkout, discount, fulfillment, order, plus payment and ap2_mandate at 04-08 / permalink at 08-25; no refund, return, or dispute file at either). No route in src/api/ implements them. The Python reference discovery_profile.json (both upstream, unmodified, and our own 2026-08-25 golden reference server) never declared them either. A capability catalog entry with no schema behind it and no implementation is not a real capability; it should be removed.