Skip to content

fix: enforce capability extends constraints on the discovery projection - #63

Merged
damaz91 merged 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix/discovery-capability-extends-constraint
Sep 7, 2026
Merged

fix: enforce capability extends constraints on the discovery projection#63
damaz91 merged 1 commit into
Universal-Commerce-Protocol:mainfrom
vishkaty:fix/discovery-capability-extends-constraint

Conversation

@vishkaty

@vishkaty vishkaty commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #62

What

CapabilityDiscoverySchema.extends (the discovery profile projection of
capability.json) now enforces the same constraint CapabilityResponseSchema.extends
already enforces since #55: a string matching the reverse domain pattern
^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$, or a non empty array of such strings.

Before this change it accepted any string and rejected every array, including
conformant ones.

Root cause

capability.json declares extends once, on $defs/base, as a oneOf of a
constrained string or a constrained array. Two things in this repo consume that
oneOf:

  • capabilityResponse, in writeCompatibilityDiscoverySchemas(), is derived from
    the source through buildEntityResponseSchema(), which flattens $defs/response_schema
    and runs each property through toCompatLeaf(). That path keeps the union shape, so
    quicktype emits a z.union(...) for it, and the injector fix: enforce capability extends constraints #55 extended
    (describeStringArrayUnionConstraint) can find and reattach the pattern and
    minItems.
  • capabilityDiscovery, in the same function, is a hand authored object for
    $defs/platform_schema (the discovery profile shape). Its extends property was a
    bare { type: "string" } literal, never wired to the source oneOf. quicktype had
    no union to render, so there was nothing for the injector to recover, regardless of
    fix: enforce capability extends constraints #55.

The two projections exist because platform_schema and response_schema differ in
required fields (platform requires spec/schema; response does not, and also has no
name), so they cannot share one output type. #55 closed the gap on the response
side; this PR closes the same gap on the discovery side, using the property the fixed
side already draws from:

const capabilityExtends =
  capabilitySchema.$defs.base.allOf[1].properties.extends;
// ...
extends: rewriteDiscoveryRefs(clone(capabilityExtends)),

This mirrors how the adjacent schema, spec, and version properties in the same
object are already cloned from the source schemas rather than hand typed, so the fix
follows the shape of an existing helper in the file, rewriteItemRefForDiscovery,
though it does add a small one of its own.

Testing

All commands run on the exact branch, from a clean clone of
Universal-Commerce-Protocol/js-sdk at dbd0dbec22b699fa6e8171f09c72f7ccf01b0b64:

  • npm run generate against a checkout of the pinned release/2026-08-25 spec:
    regenerated src/spec_generated.ts. Ran twice; byte identical both times (the
    repo model-drift CI job diffs this file against a fresh regeneration, so this
    PR ships that check already passing).
  • npm run pretest && npm test: 147 pass, 0 fail (144 pre-existing plus 3 new;
    one of the three, the empty array case, already passed before the fix for an
    unrelated reason and is kept as a named regression guard).
  • npm run build:noEmit: clean.
  • npm run build: clean (cjs, esm, types).
  • Kill test: reverting the projection change with the tests kept turns exactly the
    two new constraint tests red; restoring returns the suite to 147 passing.
  • Pinned pre-commit run on the changed files: every hook passes.

Sweep of the defect class

Swept the projection script for the same class, a hand authored literal shadowing
a constrained source declaration (the constrained leaves elsewhere in the
discovery objects are already cloned from source):

Literal Verdict
extends Fixed, this PR
name Not applicable: capability.json has no name property; the registry keys capabilities via propertyNames against reverse_domain_name.json, so the discovery name field has no source counterpart to derive
signingKey Now in scope, and not addressed here: at release/2026-08-25 profile.json declares $defs/jwk_public_key and base.properties.keys, so this literal does shadow a real source declaration. It was out of scope when this PR was written against the 2026-04-08 pin, where no such schema shipped. Left for a separate change rather than widened here
two ap2_mandate fallbacks, in writeCompatibilityAp2Schema() Not applicable: dead code against the pinned spec; both referenced $defs exist (ap2_with_checkout_mandate, and the projected checkout def), so the fallbacks never engage

Coexistence

#61 has merged, and the rebase onto it was not trivial. The projection change is
unchanged in substance, but regenerating on top of #61 failed outright: the
shared extends node carries a ref written relative to schemas/, which does not
resolve from the discovery/ output directory. The node is now passed through a
rewrite that repoints its refs the way the response projection already repoints
its item ref, which keeps the pattern and minItems attached rather than relying
on the constraint injector, whose index is keyed by the containing object
property set and so does not carry from the response object to this one.

@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Aug 30, 2026
@carolinerg1 carolinerg1 added status:under-review and removed status:needs-triage Signal that the PR is ready for human triage labels Aug 31, 2026
@carolinerg1

Copy link
Copy Markdown
Contributor

Hi @vishkaty, now that PR #61 has been merged into main, could you please rebase this PR onto main and regenerate src/spec_generated.ts. Thank you!

@vishkaty
vishkaty force-pushed the fix/discovery-capability-extends-constraint branch 2 times, most recently from 6686e14 to b00608c Compare September 3, 2026 18:40
@vishkaty

vishkaty commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main and regenerated, thank you for the nudge.

Worth flagging that the regeneration did not go through unchanged. This PR
clones the shared extends declaration from capability.json, and that node
carries a ref written relative to schemas/, which does not resolve from the
projected discovery/ directory, so generation failed with "Could not fetch
schema". The node is now passed through a rewrite that repoints its refs the
way the response projection already repoints its item ref. Flattening it
through toCompatLeaf instead resolves the ref but drops the pattern and the
non empty array bound, because the constraint would then have to be reattached
by the injector, whose index is keyed by the containing object property set,
and this object differs from the response one by a single property name.

Re-measured on the pushed head: CapabilityDiscoverySchema.extends emits the
reverse domain pattern and the non empty array bound, matching
CapabilityResponseSchema, and the pattern is byte identical to
common/types/reverse_domain_name.json at release/2026-08-25. npm test 122
passing, tsc clean, npm run build clean, pre-commit clean, and regeneration is
byte identical to the committed file across two runs. Reverting the projection
change with the tests kept turns exactly two tests red.

Checked against the running reference as well: the Python flower shop serves
dev.ucp.shopping.discount with extends as an array of two capability names,
which the schema on main rejects with "Expected string, received array" and
this branch accepts. The Node reference serves only the string form and is
unaffected either way.

There is a second force push on this branch. The first one carried a stale
comment describing an earlier approach I had discarded, which contradicted the
code beside it. That amend is comment only and the generated file is unchanged.

I have also corrected the PR body, which still quoted the 2026-04-08 pattern,
the pre-rebase base commit and the pre-rebase test count, and carried a sweep
verdict for signingKey that stopped being true at the 2026-08-25 pin.

Fixes Universal-Commerce-Protocol#62

CapabilityDiscoverySchema.extends now enforces the same constraint
CapabilityResponseSchema.extends already enforces since Universal-Commerce-Protocol#55: a string matching
the reverse domain pattern that common/types/reverse_domain_name.json declares,
or a non empty array of such strings. Before this change it accepted any string
and rejected every array, including conformant ones.

capability.json declares extends once, on $defs/base, and both the discovery
projection and the response projection inherit it through allOf. The discovery
side was hand authored with a bare { type: "string" } instead, so the two
disagreed about the same declaration.

Rebased onto main after Universal-Commerce-Protocol#61 and regenerated. The clone of the shared node
carries refs written relative to schemas/, which do not resolve from the
discovery/ output directory, so it is passed through a rewrite that repoints
them the way the response projection already repoints its item ref. Keeping the
node otherwise intact matters: the pattern and minItems stay attached, so
quicktype emits the real constraint rather than a bare union that would then
depend on the injector matching it by property set, which it does not, because
the discovery and response objects differ in one property name.
@vishkaty
vishkaty force-pushed the fix/discovery-capability-extends-constraint branch from b00608c to 2010aa2 Compare September 4, 2026 14:46
@damaz91
damaz91 merged commit 99572ca into Universal-Commerce-Protocol:main Sep 7, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CapabilityDiscoverySchema.extends accepts any string and rejects a conformant array (the #55 twin)

5 participants