Skip to content

RFC: Core-resolved product manifests - #610

Open
filvecchiato wants to merge 2 commits into
rfc/granted-scopesfrom
feat/core-manifest-resolution
Open

RFC: Core-resolved product manifests#610
filvecchiato wants to merge 2 commits into
rfc/granted-scopesfrom
feat/core-manifest-resolution

Conversation

@filvecchiato

@filvecchiato filvecchiato commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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 manifest text record, reusing the
gateway 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_TLDS spans every
network a host can be pointed at, so the suffix it arrives with is discarded
rather than hashed: dim2.dot resolves against a .paseo deployment as the name
that deployment actually holds, instead of landing on a node no registry has a
record for. Derivation goes through network_tld and tld_node, the same
helpers label_available resolves names through, so the fallback for registries
without a tld() view is inherited rather than restated. Node derivation and
grant matching now reduce an identifier the same way, through
bare_product_label, so a trustedProducts key and the node it names agree by
construction.

A resolved manifest is cached in CoreStorage and honoured for one day. The
lifetime 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

  • 819 truapi-server tests, whole workspace green
  • cargo +nightly fmt --check and workspace clippy clean
  • codegen regenerated and diffed: the only movement is the new
    CoreStorageKey::ProductManifest variant and the wire schema hash
  • the derivation test pins values read off paseo-v2 rather than recomputed here:
    ProtocolRegistry.tldNode() is 0x096b43…, and browse.paseo is 0x185056…
    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.

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.
@filvecchiato
filvecchiato force-pushed the feat/core-manifest-resolution branch from e732261 to 482afe2 Compare September 4, 2026 16:11
@filvecchiato filvecchiato linked an issue Sep 4, 2026 that may be closed by this pull request
8 tasks
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>
@filvecchiato

Copy link
Copy Markdown
Collaborator Author

@lore-bot-app review

@lore-bot-app

lore-bot-app Bot commented Sep 7, 2026

Copy link
Copy Markdown

Reading the diff and checking what the record says. Back in a few minutes.

@lore-bot-app lore-bot-app Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 trustedProducts key shape is settled: bare <product_id> labels, lowercase, no TLD, and a suffixed key is inert. The in-repo RFC docs/rfcs/product-manifest.md:139 and 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, and protocolRegistry() reverts there, so TARGET() is the fallback. re-gius called the dispatcher hop transitional (review comment), but I found nothing in the record saying every chain has repointed, and dotns_gateway.rs:606 still says both are in service.
  • The repo already treats subnames as distinct products: has_trusted_remote_permissions strips only the last label, and permissions.rs:1278 tests that app.peopl.dot is not peopl. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Questions for the author

  • The signing-host role never installs the Asset Hub hash: SigningHostConfig has no such field (truapi-platform/src/lib.rs:85) and SigningHostRuntime::with_chat_platform at host_core.rs:569 never calls install_asset_hub_genesis_hash. Products hosted by Polkadot Mobile in-app or by truapi-host signing-host will 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 DispatcherAddress to the controller since PR #564, or does the dispatcher path still need to work?
  • RootManifest::parse accepts a document missing displayName, description and icon. 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_node and TLD_WITHOUT_VIEW be pub(crate) instead of pub? host_logic::dotns_gateway is 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RFC: Core-resolved product manifests

1 participant