DppCredentialSubject carries product_categories: Vec<String>, and core will enforce it — verify_credential_claims_with_trust takes a required_product_category and applies the same "empty means unscoped, populated must contain" rule it applies to sectors.
The engine passes None. So a credential scoped to one product category grants its full audience over every product in a sector it covers.
Why it was left unenforced
This is a deliberate non-implementation, not an oversight, and it should not be "fixed" by wiring the obvious thing.
There is no defined mapping between the credential's free-string product_categories and the typed dpp_domain::ProductCategory a passport carries. ProductCategory is a #[non_exhaustive] enum with rename_all = "snake_case" plus an Other(String) variant, so:
- unit variants serialise as
"ev_battery", "apparel", "smartphone"
Other(s) serialises as {"other": s} — an object, not a string
There is no round-trippable &str form today. Enforcing scope would mean inventing a correspondence and then relying on it as a security control: get it wrong in the permissive direction and the check silently passes everything; wrong in the restrictive direction and lawful holders are denied. Neither failure is visible without a spec to test against.
Sector scope is enforced (VerifiedCredential::audience_for_sector), and sector is the dispatch key that selects schema and plugin. Product category is a data attribute within a sector, so the unenforced axis is the narrower one.
What closing it needs
- A core decision on the canonical wire form of a product category — most likely a
ProductCategory::as_wire_str()/from_wire_str() pair with Other(s) mapping to s itself, and a documented rule for collisions between an Other string and a future unit variant. This is the actual blocker and it belongs in dpp-core.
- Thread it through
VerifiedCredential::audience_for_sector → something like audience_for(sector, product_category), passing required_product_category to core.
- Decide the same question sector scope already answered: out-of-scope is a downgrade to
Audience::Public, not a denial. A credential that does not cover this product is valid, it simply unlocks nothing here — and denying would make the route a probe for a passport's category.
- Tests mirroring the sector ones in
crates/dpp-vault/src/middleware/credential.rs: in-scope grants, out-of-scope downgrades, empty list stays unscoped, and an Other(_) category round-trips.
Where it is recorded
Named in the module docs at crates/dpp-vault/src/handlers/audience_read.rs and on VerifiedCredential::audience_for_sector, both of which record it as deliberately not built.
Introduced alongside #63.
DppCredentialSubjectcarriesproduct_categories: Vec<String>, and core will enforce it —verify_credential_claims_with_trusttakes arequired_product_categoryand applies the same "empty means unscoped, populated must contain" rule it applies to sectors.The engine passes
None. So a credential scoped to one product category grants its full audience over every product in a sector it covers.Why it was left unenforced
This is a deliberate non-implementation, not an oversight, and it should not be "fixed" by wiring the obvious thing.
There is no defined mapping between the credential's free-string
product_categoriesand the typeddpp_domain::ProductCategorya passport carries.ProductCategoryis a#[non_exhaustive]enum withrename_all = "snake_case"plus anOther(String)variant, so:"ev_battery","apparel","smartphone"Other(s)serialises as{"other": s}— an object, not a stringThere is no round-trippable
&strform today. Enforcing scope would mean inventing a correspondence and then relying on it as a security control: get it wrong in the permissive direction and the check silently passes everything; wrong in the restrictive direction and lawful holders are denied. Neither failure is visible without a spec to test against.Sector scope is enforced (
VerifiedCredential::audience_for_sector), and sector is the dispatch key that selects schema and plugin. Product category is a data attribute within a sector, so the unenforced axis is the narrower one.What closing it needs
ProductCategory::as_wire_str()/from_wire_str()pair withOther(s)mapping tositself, and a documented rule for collisions between anOtherstring and a future unit variant. This is the actual blocker and it belongs indpp-core.VerifiedCredential::audience_for_sector→ something likeaudience_for(sector, product_category), passingrequired_product_categoryto core.Audience::Public, not a denial. A credential that does not cover this product is valid, it simply unlocks nothing here — and denying would make the route a probe for a passport's category.crates/dpp-vault/src/middleware/credential.rs: in-scope grants, out-of-scope downgrades, empty list stays unscoped, and anOther(_)category round-trips.Where it is recorded
Named in the module docs at
crates/dpp-vault/src/handlers/audience_read.rsand onVerifiedCredential::audience_for_sector, both of which record it as deliberately not built.Introduced alongside #63.