RFC: Core-resolved product manifests - #610
Conversation
The core reads a product's root manifest from dotNS and answers whether one product grants another a scope, so `manifest_grants_scope` returns a real answer instead of always refusing. Hosts neither fetch manifests nor decide what a grant covers. Resolution derives the node, finds the resolver through the dotNS registry and reads the `manifest` text record, reusing the gateway helpers and the pinned-block transport the identity lookup already drives. That transport moves out of identity.rs into runtime/dotns_lookup.rs, since both readers need it. A resolved manifest is cached in CoreStorage and honoured for one day. That lifetime is a revocation bound: a record edit carries no signal, so a withdrawn grant takes effect when the manifest is next read. Every reason a grant cannot be established answers the same — unresolvable name, no manifest, failed fetch, narrower scope — so a cross-product call cannot probe which products exist, and an unreachable chain withdraws grants rather than assuming them.
e732261 to
482afe2
Compare
A product id carries the TLD it was minted on, but DOTNS_TLDS spans every network a host can be pointed at. Folding the whole dotted identifier hashed `dim2.dot` as such against a `.paseo` deployment, landing on a node no registry holds: the resolver read answered zero and the grant was denied as if the product had published nothing. Discard the carried suffix and re-derive under the TLD the network reports, reusing the `network_tld` and `tld_node` helpers `label_available` already resolves names through, so the previewnet fallback for registries without a `tld()` view is inherited rather than restated. Node derivation and grant matching now agree by construction: both reduce an identifier through `bare_product_label`, where before only the grant lookup did. Splits the registry lookup into the protocol registry and one component read, since the TLD read needs the protocol registry alongside `get(registry)`. Resolution through the node's own resolver is unchanged: an unredirected resolver is an unpublished product, as the RFC specifies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@lore-bot-app review |
|
Reading the diff and checking what the record says. Back in a few minutes. |
There was a problem hiding this comment.
Findings gathered. Writing the review now.
TL;DR
Implements core-side dotNS manifest resolution so trustedProducts grants actually admit cross-product storage reads and context use, with a one-day cache in CoreStorage. 2 blocking, 4 minor.
Summary
The diff fills the manifest_grants_scope seam: it normalizes the target id, reads a CachedManifest from a new CoreStorageKey::ProductManifest slot, refetches from Asset Hub via a shared pinned-block DotnsLookup when the entry is older than 24h, parses the JSON with a lenient RootManifest model, and answers the grant question. The dotNS transport is extracted out of identity.rs into runtime/dotns_lookup.rs, PairingHost::new installs the Asset Hub genesis hash into RuntimeServices, and a companion RFC documents the design. Wire schema hash and TS golden move with the new key variant.
What the record says
- The
trustedProductskey shape is settled: bare<product_id>labels, lowercase, no TLD, and a suffixed key is inert. The in-repo RFCdocs/rfcs/product-manifest.md:139and the scoped-grants RFC (#477) both say so. The parser follows this. - The feature issue (#608) and the RFC issue (#611) list "grants never override user denials" as a requirement. The diff explicitly defers that check with a doc comment. Consistent with the record only because no cross-product prompt exists yet.
- The motivating divergence (Android prompts, core refuses) is #373. This diff is the core half of the fix.
- Controller discovery was made two-path on purpose in PR #564: the gateway may still store a
RootGatewayDispatcher, andprotocolRegistry()reverts there, soTARGET()is the fallback. re-gius called the dispatcher hop transitional (review comment), but I found nothing in the record saying every chain has repointed, anddotns_gateway.rs:606still says both are in service. - The repo already treats subnames as distinct products:
has_trusted_remote_permissionsstrips only the last label, andpermissions.rs:1278tests thatapp.peopl.dotis notpeopl. That is the precedent the new label stripping breaks (see Concerns). - Local-dev grants for undeployed products are tracked separately in #524; this diff does not address them, which is fine.
- The TLD-collision hazard for product ids across networks is live in #619. The re-derivation under the network TLD here is the right direction for that class of bug.
Concerns
-
Blocking:
bare_product_labelsplits on the first dot, not the last.rust/crates/truapi-server/src/host_logic/product_manifest.rs:90.normalize_product_identifieraccepts multi-label ids such asapp.peopl.dotorsub.dim2.paseo(truapi-platform/src/lib.rs:275only checks the last label). Two consequences. On the caller side, a product running aswallet.evil.dotbecomes bare labelwallet, so any manifest granting"wallet"admits it. On the target side,fetch_root_manifestforsub.wallet.dothashessub.<tld>(runtime/product_manifest.rs:52), so the owner ofsub.<tld>can grant access tosub.wallet.dot's storage. Usersplit_onceand reject or pass through multi-label ids explicitly. The only test covers single-label ids. -
Blocking:
protocol_registry()drops the dispatcher fallback thatdiscover_pop_controllerhas.rust/crates/truapi-server/src/runtime/product_manifest.rs:86. It readsDispatcherAddressand callsprotocolRegistry()on it, erroring on revert. On a chain whose gateway still stores aRootGatewayDispatcher, that view reverts, the fetch errors, and every grant is refused on that network with only awarn!. The shared helper atdotns_gateway.rs:623already handles both shapes; call it and then readprotocolRegistry()from the controller asresolve_labelsdoes atdotns_gateway.rs:689. -
No negative cache and no overall budget on the request path.
rust/crates/truapi-server/src/runtime.rs:653onwards.Ok(None)andErrresults are not cached, so every cross-product call against a product with no manifest performs a full resolution: one follow open plus six to seven dry-run views, each with a 10s ceiling and no whole-lookup budget likeidentity.rs:30. A product loopingreadStorage(foreign)makes the host open a chainHead follow per call, and a single refusal can take over a minute. Cache the negative result for a short window and wrap the fetch in a budget. -
Cache key is not network-scoped.
rust/crates/truapi-platform/src/lib.rs:1294. The manifest fordim2.dotdiffers per Asset Hub (the diff tests exactly this atruntime/product_manifest.rs:171), but the slot is keyed by product id alone. A host that switches Asset Hub with the same core storage honours the other network's grants for up to a day. Include the genesis hash in the key or the value. -
TTL check trusts a future
fetched_at_secs.rust/crates/truapi-server/src/runtime.rs:648.saturating_subyields 0 for an entry stamped in the future, so a clock that moved backwards keeps a stale grant valid until wall time passes the stamp plus a day. The RFC calls this lifetime a security parameter. Also rejectfetched_at_secs > now. -
Doc regressions.
services.rs:143glues the oldpermission_status_hostdoc ontoinstall_asset_hub_genesis_hash, leavingpermission_status_hostat line 161 undocumented.runtime.rs:637linksMANIFEST_TTL, which does not exist.identity.rs:144still linksOPERATION_TIMEOUT, which moved. CLAUDE.md requires a doc on every pub item.
Questions for the author
- The signing-host role never installs the Asset Hub hash:
SigningHostConfighas no such field (truapi-platform/src/lib.rs:85) andSigningHostRuntime::with_chat_platformathost_core.rs:569never callsinstall_asset_hub_genesis_hash. Products hosted by Polkadot Mobile in-app or bytruapi-host signing-hostwill therefore refuse every grant. Is that intended for this PR, and if so should the RFC say so rather than "every host"? - Has every deployment repointed
DispatcherAddressto the controller since PR #564, or does the dispatcher path still need to work? RootManifest::parseaccepts a document missingdisplayName,descriptionandicon. The manifest RFC says schema failure means "do not partially trust the result". Is honouring grants from a schema-invalid manifest a deliberate loosening?- Should
network_tld,tld_nodeandTLD_WITHOUT_VIEWbepub(crate)instead ofpub?host_logic::dotns_gatewayis a public module, so this widens the crate's API for one internal consumer.
Next: fix concern 1 first. Change split_once to rsplit_once in bare_product_label and add a test for sub.dim2.paseo.
🤖 Reviewed by Lore (Parity knowledge base) · 50 agent turns · 337.3s · knowledge as of 2026-09-07
| /// Strips the TLD from a normalized product identifier, yielding the bare label | ||
| /// a `trustedProducts` key is written with. | ||
| /// | ||
| /// A localhost development identifier has no TLD and is returned unchanged. |
There was a problem hiding this comment.
Blocking: bare_product_label splits on the first dot, not the last. rust/crates/truapi-server/src/host_logic/product_manifest.rs:90. normalize_product_identifier accepts multi-label ids such as app.peopl.dot or sub.dim2.paseo (truapi-platform/src/lib.rs:275 only checks the last label). Two consequences. On the caller side, a product running as wallet.evil.dot becomes bare label wallet, so any manifest granting "wallet" admits it. On the target side, fetch_root_manifest for sub.wallet.dot hashes sub.<tld> (runtime/product_manifest.rs:52), so the owner of sub.<tld> can grant access to sub.wallet.dot's storage. Use rsplit_once and reject or pass through multi-label ids explicitly. The only test covers single-label ids.
| } | ||
| Ok(Some(manifest)) | ||
| } | ||
|
|
There was a problem hiding this comment.
Blocking: protocol_registry() drops the dispatcher fallback that discover_pop_controller has. rust/crates/truapi-server/src/runtime/product_manifest.rs:86. It reads DispatcherAddress and calls protocolRegistry() on it, erroring on revert. On a chain whose gateway still stores a RootGatewayDispatcher, that view reverts, the fetch errors, and every grant is refused on that network with only a warn!. The shared helper at dotns_gateway.rs:623 already handles both shapes; call it and then read protocolRegistry() from the controller as resolve_labels does at dotns_gateway.rs:689.
| return Some(cached.json); | ||
| } | ||
|
|
||
| let genesis_hash = self.services.asset_hub_chain_genesis_hash()?; |
There was a problem hiding this comment.
No negative cache and no overall budget on the request path. rust/crates/truapi-server/src/runtime.rs:653 onwards. Ok(None) and Err results are not cached, so every cross-product call against a product with no manifest performs a full resolution: one follow open plus six to seven dry-run views, each with a 10s ceiling and no whole-lookup budget like identity.rs:30. A product looping readStorage(foreign) makes the host open a chainHead follow per call, and a single refusal can take over a minute. Cache the negative result for a short window and wrap the fetch in a budget.
| /// The value carries the manifest JSON alongside the time it was read. The | ||
| /// core honours it for a bounded lifetime, which is what makes a revoked | ||
| /// trust grant eventually take effect. | ||
| #[codec(index = 12)] |
There was a problem hiding this comment.
Cache key is not network-scoped. rust/crates/truapi-platform/src/lib.rs:1294. The manifest for dim2.dot differs per Asset Hub (the diff tests exactly this at runtime/product_manifest.rs:171), but the slot is keyed by product id alone. A host that switches Asset Hub with the same core storage honours the other network's grants for up to a day. Include the genesis hash in the key or the value.
| let now = unix_time_secs()?; | ||
| if let Ok(Some(bytes)) = self.platform.read_core_storage(key.clone()).await | ||
| && let Ok(cached) = CachedManifest::decode(&mut bytes.as_slice()) | ||
| && now.saturating_sub(cached.fetched_at_secs) < MANIFEST_TTL_SECS |
There was a problem hiding this comment.
TTL check trusts a future fetched_at_secs. rust/crates/truapi-server/src/runtime.rs:648. saturating_sub yields 0 for an entry stamped in the future, so a clock that moved backwards keeps a stale grant valid until wall time passes the stamp plus a day. The RFC calls this lifetime a security parameter. Also reject fetched_at_secs > now.
| @@ -136,6 +141,23 @@ impl RuntimeServices { | |||
| } | |||
|
|
|||
| /// The host's live OS permission-status adapter, when one is installed. | |||
There was a problem hiding this comment.
Doc regressions. services.rs:143 glues the old permission_status_host doc onto install_asset_hub_genesis_hash, leaving permission_status_host at line 161 undocumented. runtime.rs:637 links MANIFEST_TTL, which does not exist. identity.rs:144 still links OPERATION_TIMEOUT, which moved. CLAUDE.md requires a doc on every pub item.
The core resolves each product's root manifest from dotNS and answers whether one
product grants another a scope. Hosts neither fetch manifests nor decide what a
grant covers, so a grant means the same thing wherever a product runs.
Resolution derives the node under the TLD the network reports, finds the resolver
through the dotNS registry and reads the
manifesttext record, reusing thegateway helpers and the pinned-block transport the identity lookup drives. That
transport lives in
runtime/dotns_lookup.rs, shared by both readers.A product id carries the TLD it was minted on, and
DOTNS_TLDSspans everynetwork a host can be pointed at, so the suffix it arrives with is discarded
rather than hashed:
dim2.dotresolves against a.paseodeployment as the namethat deployment actually holds, instead of landing on a node no registry has a
record for. Derivation goes through
network_tldandtld_node, the samehelpers
label_availableresolves names through, so the fallback for registrieswithout a
tld()view is inherited rather than restated. Node derivation andgrant matching now reduce an identifier the same way, through
bare_product_label, so atrustedProductskey and the node it names agree byconstruction.
A resolved manifest is cached in
CoreStorageand honoured for one day. Thelifetime is a revocation bound rather than a performance knob: a record edit
carries no signal, so a withdrawn grant takes effect when the manifest is next
read.
Every reason a grant cannot be established answers the same — unresolvable name,
no manifest, failed fetch, narrower scope — so a cross-product call cannot probe
which products exist. An unreachable chain withdraws grants rather than assuming
them.
Based on #454, which carries the adjudication seam this fills.
Verification
truapi-servertests, whole workspace greencargo +nightly fmt --checkand workspace clippy cleanCoreStorageKey::ProductManifestvariant and the wire schema hashProtocolRegistry.tldNode()is0x096b43…, andbrowse.paseois0x185056…as the dotNS SDK derives it
A user's own denial is not consulted yet — nothing prompts for cross-product
access, so there is no stored denial to read. The check belongs with the prompt
that creates one.