UCP v2026-08-25 Breaks Quietly: 4 Changes That Still Pass Validation
The new Universal Commerce Protocol release renamed fields your profile is still allowed to contain. Your JSON stays valid, your validator stays green, and agents stop reading your keys. Here is what actually changed and how to migrate.
On August 25, 2026, the Universal Commerce Protocol shipped v2026-08-25. It is the first
new spec version in four and a half months, and it is a big one: a multi-vertical refactor
laying groundwork for Food and Lodging, grocery and location capabilities, 3D Secure via a
new Actions primitive, split payments and installment terms, a loyalty extension, and Web
Bot Auth interop.
The release notes carry a clearly labelled "Breaking Changes" section. Good.
The problem is that the most dangerous changes in it are not the ones that break loudly. They are the ones where your profile still parses, still validates against a naive checker, and agents quietly stop working.
I know, because our own validator got this exactly wrong. More on that below.
Why this release breaks quietly
Look at source/schemas/profile.json at tag v2026-08-25. The base definition ends like this:
{
"type": "object",
"required": ["ucp"],
"properties": {
"ucp": { "$ref": "ucp.json#/$defs/base" },
"keys": {
"type": "array",
"items": { "$ref": "#/$defs/jwk_public_key" }
}
},
"additionalProperties": true
}
Two things matter here.
required is ["ucp"] and nothing else. And additionalProperties is true.
That combination means a profile carrying removed fields is not invalid. It parses. It
validates. It just contains members that nothing reads any more. When a spec renames a field
under additionalProperties: true, the old name does not become an error. It becomes
decoration.
That is the failure mode to hunt for in this release. Not "my profile is rejected" but "my profile is accepted and ignored".
Silent breaker 1: signing_keys became keys
This is the big one, and it is the cleanest example of the pattern.
Before v2026-08-25, a profile published its signing keys in a root-level signing_keys
array. As of this release, signing_keys is gone from profile.json entirely and keys
is the sole canonical field. The schema is blunt about it:
When a profile publishes signing keys, they MUST appear here; this is where every UCP verifier reads them.
So:
// Before - v2026-04-08
{
"ucp": { "version": "2026-04-08", "...": "..." },
"signing_keys": [
{ "kty": "EC", "kid": "key-1", "crv": "P-256", "x": "...", "y": "..." }
]
}
// After - v2026-08-25
{
"ucp": { "version": "2026-08-25", "...": "..." },
"keys": [
{ "kty": "EC", "kid": "key-1", "crv": "P-256", "x": "...", "y": "..." }
]
}
The array contents do not change. It is the same JWK Set under a different name.
Now put that next to additionalProperties: true. If you bump your version to
2026-08-25 and leave signing_keys where it is, your profile is still schema-valid.
Nothing rejects it. But no verifier looks at signing_keys any more, so signature
verification fails against a profile in which nothing looks wrong. You will be debugging
your signing code, not your field names.
The fix is a rename. The hard part is knowing you need it.
We got this wrong in the opposite direction
Worth being straight about this, because it is the same trap from the other side.
Our validator hard-required signing_keys and had no concept of keys at all. So when the
spec moved, we started emitting a hard error on profiles that were correctly migrated. A
merchant who did the right thing got told their profile was broken.
It got worse. Our pipeline skips rules and network validation whenever structural validation reports an error. So the bogus error did not just add noise, it suppressed every real check behind it. One false error, and no actual analysis.
We shipped the fix on August 29. Both spellings are now accepted, keys is treated as
canonical from 2026-08-25, and a leftover signing_keys on a new-version profile raises a
warning that tells you it will be ignored rather than an error claiming it is invalid.
The lesson generalises past our bug: a validator that hard-codes a field name is a validator that will eventually fail correct input. If you have written any internal UCP checking, go look at how it resolves keys today.
Silent breaker 2: fulfillment config lost its allows_ prefix and changed shape
The file was renamed from merchant_fulfillment_config.json to
business_fulfillment_config.json, and two properties were renamed. But this is not just a
rename, and that is what makes it dangerous. multi_destination also changed type.
// Before - v2026-04-08: a map of method -> boolean
"allows_multi_destination": {
"shipping": true,
"pickup": false
}
// After - v2026-08-25: an array of objects, listing = permitting
"multi_destination": [
{ "method": "shipping" }
]
Now trace what happens if you skip this migration. The old schema set
additionalProperties: false, so a stray key there used to be a hard error. The new
business_fulfillment_config does not, so allows_multi_destination is now simply an
unrecognised member that nothing reads. And the new field's own rule is explicit:
Listing a method permits it; an omitted method does not.
So your untouched config does not fail. It resolves to an empty permission set. You silently stop allowing split shipping across addresses, and nothing anywhere says so. That is a capability you believe you advertise quietly turning off.
method_combinations kept its array-of-arrays shape, but its enum restriction to
["shipping", "pickup"] was removed in favour of an open vocabulary with those as
well-known values. That one is a loosening, so it will not break you.
While you are in these files: fulfillment_option.description was upgraded from a flat
string to a structured object (it now $refs common/types/description.json). That one
does change type on a required-ish field, so it is more likely to fail loudly, which
honestly makes it the safer of the two.
Silent breaker 3: buyer consent went from booleans to a reverse-DNS map
This one changes shape rather than just names.
// Before - v2026-04-08: four fixed booleans
"consent": {
"analytics": true,
"marketing": false,
"preferences": true,
"sale_of_data": false
}
In v2026-08-25, consent is a dynamic map keyed by reverse-DNS identifiers, where each
purpose is an object requiring granted, source, and description:
// After - v2026-08-25
"consent": {
"dev.ucp.consent.marketing": {
"granted": false,
"source": "platform",
"description": "Promotional communications across all channels",
"segments": {
"dev.ucp.consent.marketing.email": {
"granted": true,
"source": "platform",
"description": "Email marketing only"
}
}
}
}
The source field is the interesting addition: it distinguishes a business default
("business") from an explicit buyer decision captured by the platform ("platform"). If
you are handling consent for compliance reasons, that distinction is probably one you have
been reconstructing by hand. Now it is in the protocol. Segments let you scope a decision to
a channel without inventing your own convention, and UCP predefines
dev.ucp.consent.marketing.email and .sms.
Silent breaker 4: payment extensions moved namespace
Payment extensions migrated out of the shopping vertical:
dev.ucp.shopping.split_paymentsbecomesdev.ucp.common.payment.split_payments- the same move applies to
payment_termsandap2_mandates
Related, and easy to miss: shared primitive schemas (amount.json, price.json,
line_item.json) moved under common/types/, which changes their $id URLs. If you
pin, cache, or fetch those schemas by URL anywhere, those references are now stale.
This is the change most likely to bite tooling rather than merchants. Anything that walks capability extension chains by prefix will read migrated extensions as orphaned.
The migration checklist
- Rename
signing_keystokeysat the profile root. Delete the old key. Do not keep both - two sources of truth for signing keys drifting apart is precisely the failure this change was made to eliminate. - Move to
business_fulfillment_config.json, drop theallows_prefix, and convertmulti_destinationfrom a method-keyed boolean map to an array of{ "method": "..." }objects. Do not just rename it - an unconverted map reads as "nothing permitted". - Convert
fulfillment_option.descriptionfrom a flat string to the structuredcommon/types/description.jsonobject. - Restructure buyer consent into the reverse-DNS map with
granted/source/description. - Repoint payment extensions to
dev.ucp.common.payment.*. - Update any pinned
$idURLs foramount,price, andline_itemtocommon/types/. - Bump
versionto2026-08-25last, after the above. The version field is the part agents read to decide how to interpret everything else.
Then re-validate. And validate with something that actually knows about 2026-08-25 - a
checker still targeting an older version will happily tell you a half-migrated profile is
fine.
The broader point
Date-based protocol versions make it easy to think of a spec bump as a number you increment.
This release is a good argument against that. The changes that will cost you time are not in
the "Breaking Changes" heading you read once. They are in the gap between "my JSON is valid"
and "an agent can actually transact with me", and additionalProperties: true is exactly
where that gap lives.
If you maintain UCP tooling, the practical takeaway is narrower: stop hard-coding field names, and make your validator report which layout it detected rather than silently assuming one.
UCP is an open standard co-developed by Google and Shopify. UCPtools is an independent
community tool and is not affiliated with either company. Every schema detail above was read
from the published specification at tag v2026-08-25 in the
Universal-Commerce-Protocol/ucp
repository, not from the release notes.
You can check a profile against v2026-08-25 with the free validator at
ucptools.dev, or run it in CI with the
UCP validate GitHub Action.
