From 841b88340556121dff22b94218e314cfcbd57ae3 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 20 Aug 2026 15:49:48 +0100 Subject: [PATCH 1/5] feat(server): allocate allowances from HostAdmin without a forged frame RFC-0028. HostAdmin::allocate_allowances reaches ProductAuthority::allocate_resources directly and does not raise the product-facing ResourceAllocation confirmation review, since no product is asking. Hosts previously had to synthesize a ProtocolMessage through a short-lived product endpoint and suppress the resulting prompt with an out-of-band token keyed by (product_id, resource), which could match and silently approve a concurrent real product request. Status and invalidate are specified in the RFC but not implemented here: their semantics differ between the pairing and signing roles and need agreement first. --- docs/rfcs/0028-host-allowance-admin.md | 271 +++++++++++++++++++++ docs/rfcs/_index.md | 1 + rust/crates/truapi-server/README.md | 9 + rust/crates/truapi-server/src/host_core.rs | 45 ++++ rust/crates/truapi-server/src/lib.rs | 4 +- rust/crates/truapi-server/src/runtime.rs | 113 +++++++++ 6 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 docs/rfcs/0028-host-allowance-admin.md diff --git a/docs/rfcs/0028-host-allowance-admin.md b/docs/rfcs/0028-host-allowance-admin.md new file mode 100644 index 000000000..9eab7a2eb --- /dev/null +++ b/docs/rfcs/0028-host-allowance-admin.md @@ -0,0 +1,271 @@ +# RFC-0028: Host allowance administration + +| | | +| --------------- | -------------------------------------------------------------------------------------------------------- | +| **RFC Number** | 28 | +| **Start Date** | 2026-08-20 | +| **Description** | A non-secret host-facing surface on `HostAdmin` for administering product allowances, with an explicit host origin so host-initiated work is distinguishable from a product request | +| **Authors** | Filippo Vecchiato | + +## Summary + +`HostAdmin` gains a non-secret allowance surface — allocate, status, and +invalidate — scoped to a product and a resource kind, with an explicit +`HostAllowanceOrigin` so the runtime can tell host-initiated allowance work from +a product's request. The wire protocol does not change and no new generated +method appears. The underlying operations already exist inside `truapi-server`; +today they are reachable only through the product dispatcher, which is what +forces hosts to synthesize product traffic and suppress the resulting consent +prompt to administer their own allowances. + +## Motivation + +[RFC-0010](0010-allowance.md) settled that products never manage slot tables, and +stated the consequence in its requirements: "allowance is entirely the Host's +concern". The product-facing half of that landed as +`host_request_resource_allocation`. The host-facing half did not. `HostAdmin` +(`rust/crates/truapi-server/src/host_core.rs`) exposes exactly +`disconnect_session`, `permission_authorization_status`, +`permission_authorization_statuses` and `set_permission_authorization_status`, +plus `get_session_chat_identity_key` and `get_device_encryption_key` through its +`CoreAdmin` impl. There is no allowance operation and no allowance status. + +A host still has to administer allowances outside a product request: warm them at +startup so a product's first call does not stall, re-check them when returning to +the foreground, and re-acquire after a rejection. With no admin entry point, the +only route to the authoritative implementation is to impersonate a product. + +### What the workaround costs + +`brevity-dozer` does exactly that, and documents why. +`SigningHost::request_product_allowances` builds a `ProtocolMessage` around +`HostRequestResourceAllocationRequest::V1`, feeds it to a short-lived +`product_runtime` over a capturing frame sink, disposes the endpoint, then decodes +the single response frame and correlates the request id by hand. Its own doc +comment states the reason: "TrUAPI exposes that implementation only through its +generated product dispatcher, so this adapter creates a short-lived product +endpoint". The same pattern is reused for `register_personhood_ring_vrf_keys`, so +it is becoming load-bearing rather than incidental. + +Three costs follow, and the third is the one that matters. + +1. **A parallel type system.** `brevity-core/src/allowance_admin.rs` is 869 lines + of `AllowanceResourceKind`, `AllowanceLifecycle`, `AllowanceTarget`, + `AllowanceStatus`, `AllowanceAdminError`, an `AllowanceAdminBackend` execution + seam and an observer stream — a lifecycle model every host needs and each one + will otherwise rebuild. Its module doc calls itself "the temporary + Brevity-side override". + +2. **Capabilities that exist but are sealed.** That backend records what it + cannot do: "inspect/invalidate remain a non-secret process-lifetime read model + because the pinned `HostAdmin` exposes neither a chain status probe nor cache + eviction". Upstream those primitives are already written — + `evict_bulletin_allowance_key`, `clear_statement_store_allowance_keys`, + `clear_bulletin_allowance_keys`, `remove_allowance_key` and the `cached_*` + readers in `runtime/pairing_host.rs` and `runtime/allowances.rs` — but they are + `pub(super)`. A host reimplements a weaker version of code that already exists. + +3. **Consent has to be bypassed out of band.** `ResourceAllocation::request` + (`runtime.rs`) gates on + `platform.confirm_user_action(UserConfirmationReview::ResourceAllocation(..))` + before calling the authority. That gate is right for a product and wrong for + the host, which would be prompting itself about its own maintenance. Because a + forged frame is indistinguishable from a real one, the host cannot say "this + one is mine" — so it arms a token instead. `HostAllowanceAutoConfirm` keys a + counter by `(product_id, resource_tag)`, and the host's consent delegate + silently returns approval when the token matches + (`brevity-viewmodel/src/ux.rs`). + + That registry is carefully scoped — one token, single-resource matches only, + RAII-withdrawn when the dispatch ends — but the matching is heuristic by + construction. A genuine product-initiated request for the same product and the + same single resource, arriving while a host dispatch is armed, consumes the + token and is approved without asking the user. Nothing outside the runtime can + close that window, because the runtime is the only party that knows which call + it originated. + +That third cost is the argument for this RFC. The rest is duplication; this is a +consent decision made by pattern-matching because the API offers no way to state +it. + +## Stakeholders + +- **Host developers** — gain a supported surface for startup warm-up, foreground + re-check and recovery, and can delete their forged-frame adapters and + auto-confirm registries. +- **Product developers** — unaffected. `host_request_resource_allocation` and its + confirmation review are unchanged. +- **Account Holder developers** — unaffected; the authority operation invoked is + the one the product path already invokes. + +## Explanation + +### `HostAdmin` methods + +```rust +impl HostAdmin { + /// Allocate product-scoped resources on the host's own initiative. + pub async fn allocate_allowances( + &self, + resources: Vec, + origin: HostAllowanceOrigin, + ) -> Result, v01::GenericError>; + + /// Non-secret lifecycle for one resource kind. + pub async fn allowance_status( + &self, + resource: HostAllowanceResource, + ) -> Result; + + /// Drop cached allowance key material for one resource kind, so the next + /// use re-derives or re-requests it. + pub async fn invalidate_allowance( + &self, + resource: HostAllowanceResource, + ) -> Result<(), v01::GenericError>; +} +``` + +`HostAdmin` is already product-scoped and holds both collaborators it needs — +`authority: Arc` and `product_runtime: Arc` +— so reaching the work needs no new plumbing. + +### Origin + +```rust +pub enum HostAllowanceOrigin { + StartupReadiness, + ForegroundRenewal, + Recovery, +} +``` + +`origin` is recorded in tracing and passed through to the host. It exists so the +runtime and the host can distinguish lifecycle moments without inventing a +product identity, and so a prompting policy can be added later without changing +any signature. The names mirror brevity's `HostAllowanceReason`, which lets that +module become a thin adapter rather than a rewrite. + +### Consent + +The admin path does not raise `UserConfirmationReview::ResourceAllocation`. That +review's contract is "a product asked for this, approve or decline", and no +product is asking. `ResourceAllocation::request` keeps it unchanged for products. + +This deliberately moves the prompting decision into host code. That is the point: +a host that wants to ask the user about a first-ever grant can, and one doing +routine maintenance does not have to manufacture a bypass. It is also strictly +safer than the status quo, where the forged path already auto-approves — just +invisibly, and with a matching window that can catch a real product request. + +### Status and invalidate + +Both resolve against the existing per-role machinery: + +- **Status** reads the `cached_*` accessors for cache and persisted-store + presence, and can escalate to a chain probe through the already-public + `truapi_server::statement_allowance` (`scan_collections`, `allocated_in`, + `fetch_bulletin_allowance`). Cache-only is the cheap default. +- **Invalidate** surfaces `evict_bulletin_allowance_key`, the two + `clear_*_allowance_keys` methods, and `allowances::remove_allowance_key`. + +The caches live on the concrete runtimes rather than behind the trait, so both +need seams on `ProductAuthority`, implemented for `PairingHost` and `SigningHost`. +That trait already carries `refresh_bulletin_allowance_key`, so allowance +lifecycle is established as its concern and these are consistent additions. + +The two roles mean different things by "status", and this is the part that needs +agreement before implementation: + +- `PairingHost` holds keys obtained from the Account Holder over SSO, cached in + memory and in `CoreStorage`. Presence is well defined, and eviction is exactly + the `pub(super)` primitives above. +- `SigningHost` *is* the Account Holder. It provisions on demand through + `sso_responder::allocate_*_allowance` with `OnExistingAllowancePolicy::Ignore`, + so a key is always derivable and "cached presence" is not the right question. A + meaningful status here is an on-chain slot probe, and a meaningful invalidate + may be a no-op. + +`AllowanceLifecycle` is status-only — `Active`, `Absent`, `Expired`, +`Unavailable { code }` — mirroring the model `allowance_admin.rs` arrived at +independently. `HostAllowanceResource` covers `StatementStore` and `Bulletin`, +the two the runtime administers. + +**No type in this surface carries key material, statement bytes, or a chain +payload.** That is the discipline `allowance_admin.rs` holds itself to, and it is +what makes the surface safe to expose over UniFFI. + +### Native surface + +The methods get UniFFI exposure next to the renewal surface +[#308](https://github.com/paritytech/host-rust-core/pull/308) already ships +(`renew_statement_allowances`, `start_statement_allowance_renewal` in +`native.rs`). Renewal reaching the FFI while allocation and status stay Rust-only +is the asymmetry this closes: today a Swift or Kotlin host can keep an allowance +alive but cannot obtain one or ask about it. + +## Implementation status + +`allocate_allowances` and `HostAllowanceOrigin` are implemented in this change: +`ProductRuntimeHost::allocate_resources_for_host` calls +`ProductAuthority::allocate_resources` directly, with a unique correlation id per +call because the SSO channel matches responses on it. Tests cover the +no-session rejection, request-id uniqueness, and — asserting the product path +raises exactly one review first, so the check cannot pass vacuously — that the +host path raises none. + +`allowance_status`, `invalidate_allowance` and the native bindings are not in +this change. Status and invalidate need the per-role semantics above settled +first, and the native surface is a separately CI-gated step +(`make uniffi && ios/truapi-host/scripts/sync-bindings.sh`, with committed +bindings). Landing allocation alone already removes both the forged frame and the +auto-confirm registry, which is the security-relevant half. + +## Drawbacks + +- **`HostAdmin` grows a third concern.** It is currently a small + session-and-permissions handle. A separate `AllowanceAdmin` reachable from + `HostAdmin` would keep it narrow, at the cost of one more type to discover. + Both are source-compatible for callers going through `HostAdmin`. +- **Two paths to allocation.** Products go through the dispatcher with consent; + hosts go direct. That is intended, but it puts the consent question in host + code, where a careless host could allocate without ever asking. The + counter-argument is that this is already true today, less visibly. +- **Status semantics differ per resource.** "Active" for Statement Store means a + slot is allocated in the current period; for Bulletin it means allocated and + inside expiry-plus-grace. One enum spanning two lifetimes invites misreading; + the honest mitigation is documentation on each variant. + +## Alternatives + +- **A host-origin flag on the wire.** Rejected: it puts a host-only concept in + the product protocol, and a product could then claim host origin. +- **Treat `statement_allowance` as the answer.** It is already public and + `truapi-host-cli` administers allowances entirely through it + (`register_pairing_allowances`, talking to chain over its own `RpcClient`). But + those are free functions over a subxt client with no product scoping, no + session awareness and no lifecycle model — usable from a Rust binary, not from + a Swift host, and not an admin API. It is the right layer underneath this one, + not a replacement. +- **Leave it downstream.** Viable while brevity-dozer is the only host that needs + it. It stops being viable at the second host, and it leaves the consent + matching window in place permanently. +- **Bless the dispatcher for host use.** The current workaround, promoted to a + supported API. Rejected: it preserves the consent ambiguity, which is the + problem worth solving. + +## Unresolved Questions + +- What does `allowance_status` mean on `SigningHost`, and is a chain probe in + scope for the first cut or is cache-presence-plus-`Unavailable` acceptable? +- Should a host-origin allocation ever prompt? A first grant for a product the + user has never seen is a plausible exception to "don't ask about maintenance". +- Should `allowance_status` expose the period boundary so hosts can schedule, or + does #308's renewal scheduler already own scheduling? +- Does brevity's `AllowanceTarget::session_id` belong upstream? It exists because + paired storage operations need a session id while a direct signing host does + not; `HostAdmin` is already session-scoped, which may make it redundant. +- PGAS: brevity models it as a third resource kind, but RFC-0010 treats + smart-contract allowance as an anonymous per-user claim rather than a slot + table, and the runtime administers no PGAS state. Out of scope here — should it + stay out? diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index a72a0cd1e..32ebe9ce5 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -26,3 +26,4 @@ created: 2026-03-13 | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | +| 0028 | [Host allowance administration](0028-host-allowance-admin.md) | draft | Filippo Vecchiato | — | diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 388997266..0135a1971 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -166,6 +166,15 @@ PermissionsService CoreStorageKey::PermissionAuthorization { product_id, request } ``` +`HostAdmin::allocate_allowances(resources, origin)` allocates product-scoped +resources on the host's own initiative — startup warm-up, foreground re-check, +recovery after a rejection. It reaches the same authority operation as the +product-facing `ResourceAllocation` call but does not raise that call's +confirmation review, which exists to put a *product's* request to the user. A +host that wants to prompt for its own maintenance work decides that itself, and +`HostAllowanceOrigin` says which lifecycle moment asked. See +[RFC-0028](../../../docs/rfcs/0028-host-allowance-admin.md). + The embedder builds a role handle, `PairingHostRuntime::new(...)` or `SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for each product connection. Role-specific operations live only on the matching handle: diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 34c729942..4c906361a 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -677,6 +677,31 @@ fn ring_vrf_admin_error( } } +/// Why the host, rather than a product, asked for an allowance operation. +/// +/// Carried through to tracing and available to host policy: a first-ever grant +/// may warrant a prompt where routine maintenance does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostAllowanceOrigin { + /// Warming allowances so a product's first call does not stall. + StartupReadiness, + /// Re-checking allowances as the host returns to the foreground. + ForegroundRenewal, + /// Re-acquiring an allowance after a failure or rejection. + Recovery, +} + +impl HostAllowanceOrigin { + /// Stable lowercase tag for tracing and correlation ids. + pub fn as_str(self) -> &'static str { + match self { + Self::StartupReadiness => "startup-readiness", + Self::ForegroundRenewal => "foreground-renewal", + Self::Recovery => "recovery", + } + } +} + /// Product-scoped administration handle for host UI. /// /// Host UI should use this when it needs to inspect or update core-owned state @@ -736,6 +761,26 @@ impl HostAdmin { .await } + /// Allocate product-scoped resources on the host's own initiative. + /// + /// The product-facing `ResourceAllocation` confirmation review is not + /// raised: it exists to put a product's request to the user, and no + /// product is asking. A host that wants to prompt for its own maintenance + /// work decides that itself, using `origin` to tell the cases apart. + /// + /// Returns one outcome per requested resource, in request order. No key + /// material crosses this boundary. + #[instrument(skip_all, fields(runtime.method = "host_admin.allocate_allowances"))] + pub async fn allocate_allowances( + &self, + resources: Vec, + origin: HostAllowanceOrigin, + ) -> Result, v01::GenericError> { + self.product_runtime + .allocate_resources_for_host(resources, origin) + .await + } + /// Update a stored permission authorization status. #[instrument(skip_all, fields(runtime.method = "host_admin.set_permission_authorization_status"))] pub async fn set_permission_authorization_status( diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index d9ba7b79b..e03ec7e0e 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -47,8 +47,8 @@ pub mod native_renderer; pub mod wasm; pub use host_core::{ - FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeControl, - ProductRuntimeError, SigningHostRuntime, + FrameSink, HostAdmin, HostAllowanceOrigin, PairingHostRuntime, ProductRuntime, + ProductRuntimeControl, ProductRuntimeError, SigningHostRuntime, }; pub use host_logic::session::{ ExternalPairedSession, SsoSessionInfo, decode_persisted_session, encode_external_paired_session, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index a56627db7..56e4d328f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -41,6 +41,9 @@ use std::time::Instant; use web_time::Instant; use crate::chain_runtime::RuntimeFailure; +use core::sync::atomic::{AtomicU64, Ordering}; + +use crate::host_core::HostAllowanceOrigin; use crate::host_logic::bulletin::preimage_key; use crate::host_logic::dotns::{NavigateDecision, external_host, parse_navigate}; use crate::host_logic::features::{chain_info, feature_supported, supported_chains}; @@ -605,6 +608,52 @@ impl ProductRuntimeHost { service.set_authorization_status(&request, status).await } + /// Allocate product-scoped resources on the host's own initiative. + /// + /// Reaches the same authority operation as [`ResourceAllocation::request`] + /// without raising [`UserConfirmationReview::ResourceAllocation`]: that + /// review asks the user to approve a *product's* request, and there is no + /// product asking here. Hosts that want to prompt for a host-initiated + /// allocation own that decision, and `origin` records which lifecycle + /// moment asked so a host policy can branch on it. + #[instrument( + skip_all, + fields(runtime.method = "resource_allocation.host_request", origin = ?origin) + )] + pub(crate) async fn allocate_resources_for_host( + &self, + resources: Vec, + origin: HostAllowanceOrigin, + ) -> Result, v01::GenericError> { + let Some(session) = self.authority.current_session() else { + return Err(v01::GenericError { + reason: "No active session".to_string(), + }); + }; + let mut cx = CallContext::with_request_id(self.host_allowance_request_id(origin)); + cx.set_timeout(RESOURCE_ALLOCATION_REMOTE_AUTHORITY_RESPONSE_TIMEOUT); + let request = v01::HostRequestResourceAllocationRequest { resources }; + remote_authority_call( + &cx, + self.authority + .allocate_resources(&cx, &session, self.product_id(), request), + ) + .await + .map(|response| response.outcomes) + .map_err(|err| v01::GenericError { + reason: err.to_string(), + }) + } + + /// Correlation id for a host-initiated allowance request. Unique per call + /// because the SSO channel matches responses on it, so two concurrent + /// host allocations must not share one. + fn host_allowance_request_id(&self, origin: HostAllowanceOrigin) -> String { + static NEXT_HOST_ALLOWANCE_REQUEST: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT_HOST_ALLOWANCE_REQUEST.fetch_add(1, Ordering::Relaxed); + format!("host-allowance-{}-{sequence}", origin.as_str()) + } + #[instrument(skip_all, fields(runtime.method = "permissions.remote_authorization"))] async fn remote_permission_authorization( &self, @@ -5517,6 +5566,70 @@ mod tests { } } + #[test] + fn host_allowance_allocation_rejects_without_session() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let err = futures::executor::block_on(host.allocate_resources_for_host( + vec![v01::AllocatableResource::StatementStoreAllowance], + HostAllowanceOrigin::StartupReadiness, + )) + .unwrap_err(); + assert_eq!(err.reason, "No active session"); + } + + /// A product request on this platform raises the review and fails closed. + /// The host path must not consult it at all. Asserting the product path + /// first keeps the "no review" half from passing vacuously. + #[test] + fn host_allowance_allocation_raises_no_confirmation_review() { + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: false, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + install_pairing_session(&host, session_info()); + let reviews = || { + platform + .resource_allocation_reviews + .lock() + .expect("resource allocation review list mutex poisoned") + .len() + }; + + let cx = CallContext::default(); + let declined = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )); + assert!(declined.is_err(), "product path should fail closed here"); + assert_eq!(reviews(), 1, "product path must raise exactly one review"); + + let outcome = futures::executor::block_on(host.allocate_resources_for_host( + vec![v01::AllocatableResource::StatementStoreAllowance], + HostAllowanceOrigin::ForegroundRenewal, + )); + assert_eq!( + reviews(), + 1, + "host-initiated allocation must not raise the product confirmation review" + ); + // Whatever the stub authority answers, it must not be the decline the + // product path produces from the same platform. + if let Err(err) = outcome { + assert_ne!(err.reason, "User rejected resource allocation"); + } + } + + #[test] + fn host_allowance_request_ids_are_unique_per_call() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let first = host.host_allowance_request_id(HostAllowanceOrigin::Recovery); + let second = host.host_allowance_request_id(HostAllowanceOrigin::Recovery); + assert_ne!(first, second); + assert!(first.starts_with("host-allowance-recovery-"), "{first}"); + } + #[test] fn resource_allocation_rejects_when_user_declines() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); From a510b55e7bc766c77090a198c98152a43153abc7 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 20 Aug 2026 15:51:09 +0100 Subject: [PATCH 2/5] docs(rfc): link RFC 0028 to its PR --- docs/rfcs/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 32ebe9ce5..7a1e864a1 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -26,4 +26,4 @@ created: 2026-03-13 | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | -| 0028 | [Host allowance administration](0028-host-allowance-admin.md) | draft | Filippo Vecchiato | — | +| 0028 | [Host allowance administration](0028-host-allowance-admin.md) | draft | Filippo Vecchiato | [#467](https://github.com/paritytech/host-rust-core/pull/467) | From 64a2e01aa947b9649b14bef46684a11a50927b4d Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 20 Aug 2026 16:13:40 +0100 Subject: [PATCH 3/5] docs: file the allowance-admin proposal under features, not rfcs The check-rfc gate requires every docs/rfcs/ change to carry a matching rust/crates/truapi/ change. This surface is host-side and adds no product-facing protocol method, and the crate invariants keep host-side runtime types out of truapi, so it cannot satisfy that gate. --- docs/features/_index.md | 1 + .../host-allowance-admin.md} | 20 +++++++++++++++---- docs/rfcs/_index.md | 1 - rust/crates/truapi-server/README.md | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) rename docs/{rfcs/0028-host-allowance-admin.md => features/host-allowance-admin.md} (95%) diff --git a/docs/features/_index.md b/docs/features/_index.md index ae6bce350..09a0fe2c6 100644 --- a/docs/features/_index.md +++ b/docs/features/_index.md @@ -10,3 +10,4 @@ created: 2026-03-13 | Title | Status | Author | PR | | ----- | ------ | ------ | --- | +| [Host allowance administration on `HostAdmin`](host-allowance-admin.md) | draft | @filippovecchiato | — | diff --git a/docs/rfcs/0028-host-allowance-admin.md b/docs/features/host-allowance-admin.md similarity index 95% rename from docs/rfcs/0028-host-allowance-admin.md rename to docs/features/host-allowance-admin.md index 9eab7a2eb..2e86dccb1 100644 --- a/docs/rfcs/0028-host-allowance-admin.md +++ b/docs/features/host-allowance-admin.md @@ -1,12 +1,24 @@ -# RFC-0028: Host allowance administration +--- +title: "Host allowance administration" +type: feature +status: draft +author: "@filippovecchiato" +created: 2026-08-20 +--- + +# Feature — Host allowance administration on `HostAdmin` | | | | --------------- | -------------------------------------------------------------------------------------------------------- | -| **RFC Number** | 28 | | **Start Date** | 2026-08-20 | | **Description** | A non-secret host-facing surface on `HostAdmin` for administering product allowances, with an explicit host origin so host-initiated work is distinguishable from a product request | | **Authors** | Filippo Vecchiato | +This is a feature proposal rather than a numbered RFC: it adds no product-facing +protocol method and changes nothing in `rust/crates/truapi/`. The surface is +host-side, which the crate invariants place in `truapi-platform` and +`truapi-server`. + ## Summary `HostAdmin` gains a non-secret allowance surface — allocate, status, and @@ -20,7 +32,7 @@ prompt to administer their own allowances. ## Motivation -[RFC-0010](0010-allowance.md) settled that products never manage slot tables, and +[RFC-0010](../rfcs/0010-allowance.md) settled that products never manage slot tables, and stated the consequence in its requirements: "allowance is entirely the Host's concern". The product-facing half of that landed as `host_request_resource_allocation`. The host-facing half did not. `HostAdmin` @@ -84,7 +96,7 @@ Three costs follow, and the third is the one that matters. close that window, because the runtime is the only party that knows which call it originated. -That third cost is the argument for this RFC. The rest is duplication; this is a +That third cost is the argument for this proposal. The rest is duplication; this is a consent decision made by pattern-matching because the API offers no way to state it. diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 7a1e864a1..a72a0cd1e 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -26,4 +26,3 @@ created: 2026-03-13 | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | -| 0028 | [Host allowance administration](0028-host-allowance-admin.md) | draft | Filippo Vecchiato | [#467](https://github.com/paritytech/host-rust-core/pull/467) | diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 0135a1971..aa7373f46 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -173,7 +173,7 @@ product-facing `ResourceAllocation` call but does not raise that call's confirmation review, which exists to put a *product's* request to the user. A host that wants to prompt for its own maintenance work decides that itself, and `HostAllowanceOrigin` says which lifecycle moment asked. See -[RFC-0028](../../../docs/rfcs/0028-host-allowance-admin.md). +[the host allowance administration proposal](../../../docs/features/host-allowance-admin.md). The embedder builds a role handle, `PairingHostRuntime::new(...)` or `SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for From 160363cf61f70ee5bd5ba5eb691a3888915c6bda Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 20 Aug 2026 16:18:05 +0100 Subject: [PATCH 4/5] docs(rfc): file host allowance administration as RFC 0028 --- docs/features/_index.md | 1 - .../0028-host-allowance-admin.md} | 22 ++++++------------- docs/rfcs/_index.md | 1 + rust/crates/truapi-server/README.md | 2 +- 4 files changed, 9 insertions(+), 17 deletions(-) rename docs/{features/host-allowance-admin.md => rfcs/0028-host-allowance-admin.md} (95%) diff --git a/docs/features/_index.md b/docs/features/_index.md index 09a0fe2c6..ae6bce350 100644 --- a/docs/features/_index.md +++ b/docs/features/_index.md @@ -10,4 +10,3 @@ created: 2026-03-13 | Title | Status | Author | PR | | ----- | ------ | ------ | --- | -| [Host allowance administration on `HostAdmin`](host-allowance-admin.md) | draft | @filippovecchiato | — | diff --git a/docs/features/host-allowance-admin.md b/docs/rfcs/0028-host-allowance-admin.md similarity index 95% rename from docs/features/host-allowance-admin.md rename to docs/rfcs/0028-host-allowance-admin.md index 2e86dccb1..1dffdbfc3 100644 --- a/docs/features/host-allowance-admin.md +++ b/docs/rfcs/0028-host-allowance-admin.md @@ -1,23 +1,15 @@ ---- -title: "Host allowance administration" -type: feature -status: draft -author: "@filippovecchiato" -created: 2026-08-20 ---- - -# Feature — Host allowance administration on `HostAdmin` +# RFC-0028: Host allowance administration | | | | --------------- | -------------------------------------------------------------------------------------------------------- | +| **RFC Number** | 28 | | **Start Date** | 2026-08-20 | | **Description** | A non-secret host-facing surface on `HostAdmin` for administering product allowances, with an explicit host origin so host-initiated work is distinguishable from a product request | | **Authors** | Filippo Vecchiato | -This is a feature proposal rather than a numbered RFC: it adds no product-facing -protocol method and changes nothing in `rust/crates/truapi/`. The surface is -host-side, which the crate invariants place in `truapi-platform` and -`truapi-server`. +This RFC changes no product-facing protocol method and nothing in +`rust/crates/truapi/`: the surface is host-side, which the crate invariants place +in `truapi-platform` and `truapi-server`. ## Summary @@ -32,7 +24,7 @@ prompt to administer their own allowances. ## Motivation -[RFC-0010](../rfcs/0010-allowance.md) settled that products never manage slot tables, and +[RFC-0010](0010-allowance.md) settled that products never manage slot tables, and stated the consequence in its requirements: "allowance is entirely the Host's concern". The product-facing half of that landed as `host_request_resource_allocation`. The host-facing half did not. `HostAdmin` @@ -96,7 +88,7 @@ Three costs follow, and the third is the one that matters. close that window, because the runtime is the only party that knows which call it originated. -That third cost is the argument for this proposal. The rest is duplication; this is a +That third cost is the argument for this RFC. The rest is duplication; this is a consent decision made by pattern-matching because the API offers no way to state it. diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index a72a0cd1e..7a1e864a1 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -26,3 +26,4 @@ created: 2026-03-13 | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | +| 0028 | [Host allowance administration](0028-host-allowance-admin.md) | draft | Filippo Vecchiato | [#467](https://github.com/paritytech/host-rust-core/pull/467) | diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index aa7373f46..0135a1971 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -173,7 +173,7 @@ product-facing `ResourceAllocation` call but does not raise that call's confirmation review, which exists to put a *product's* request to the user. A host that wants to prompt for its own maintenance work decides that itself, and `HostAllowanceOrigin` says which lifecycle moment asked. See -[the host allowance administration proposal](../../../docs/features/host-allowance-admin.md). +[RFC-0028](../../../docs/rfcs/0028-host-allowance-admin.md). The embedder builds a role handle, `PairingHostRuntime::new(...)` or `SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for From 3760c8222e865ef21936385ae5846bdad3602118 Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Thu, 20 Aug 2026 16:42:54 +0100 Subject: [PATCH 5/5] docs: drop the allowance-admin design doc The HostAdmin change stands on its own; the crate README documents it. --- docs/rfcs/0028-host-allowance-admin.md | 275 ------------------------- docs/rfcs/_index.md | 1 - rust/crates/truapi-server/README.md | 3 +- 3 files changed, 1 insertion(+), 278 deletions(-) delete mode 100644 docs/rfcs/0028-host-allowance-admin.md diff --git a/docs/rfcs/0028-host-allowance-admin.md b/docs/rfcs/0028-host-allowance-admin.md deleted file mode 100644 index 1dffdbfc3..000000000 --- a/docs/rfcs/0028-host-allowance-admin.md +++ /dev/null @@ -1,275 +0,0 @@ -# RFC-0028: Host allowance administration - -| | | -| --------------- | -------------------------------------------------------------------------------------------------------- | -| **RFC Number** | 28 | -| **Start Date** | 2026-08-20 | -| **Description** | A non-secret host-facing surface on `HostAdmin` for administering product allowances, with an explicit host origin so host-initiated work is distinguishable from a product request | -| **Authors** | Filippo Vecchiato | - -This RFC changes no product-facing protocol method and nothing in -`rust/crates/truapi/`: the surface is host-side, which the crate invariants place -in `truapi-platform` and `truapi-server`. - -## Summary - -`HostAdmin` gains a non-secret allowance surface — allocate, status, and -invalidate — scoped to a product and a resource kind, with an explicit -`HostAllowanceOrigin` so the runtime can tell host-initiated allowance work from -a product's request. The wire protocol does not change and no new generated -method appears. The underlying operations already exist inside `truapi-server`; -today they are reachable only through the product dispatcher, which is what -forces hosts to synthesize product traffic and suppress the resulting consent -prompt to administer their own allowances. - -## Motivation - -[RFC-0010](0010-allowance.md) settled that products never manage slot tables, and -stated the consequence in its requirements: "allowance is entirely the Host's -concern". The product-facing half of that landed as -`host_request_resource_allocation`. The host-facing half did not. `HostAdmin` -(`rust/crates/truapi-server/src/host_core.rs`) exposes exactly -`disconnect_session`, `permission_authorization_status`, -`permission_authorization_statuses` and `set_permission_authorization_status`, -plus `get_session_chat_identity_key` and `get_device_encryption_key` through its -`CoreAdmin` impl. There is no allowance operation and no allowance status. - -A host still has to administer allowances outside a product request: warm them at -startup so a product's first call does not stall, re-check them when returning to -the foreground, and re-acquire after a rejection. With no admin entry point, the -only route to the authoritative implementation is to impersonate a product. - -### What the workaround costs - -`brevity-dozer` does exactly that, and documents why. -`SigningHost::request_product_allowances` builds a `ProtocolMessage` around -`HostRequestResourceAllocationRequest::V1`, feeds it to a short-lived -`product_runtime` over a capturing frame sink, disposes the endpoint, then decodes -the single response frame and correlates the request id by hand. Its own doc -comment states the reason: "TrUAPI exposes that implementation only through its -generated product dispatcher, so this adapter creates a short-lived product -endpoint". The same pattern is reused for `register_personhood_ring_vrf_keys`, so -it is becoming load-bearing rather than incidental. - -Three costs follow, and the third is the one that matters. - -1. **A parallel type system.** `brevity-core/src/allowance_admin.rs` is 869 lines - of `AllowanceResourceKind`, `AllowanceLifecycle`, `AllowanceTarget`, - `AllowanceStatus`, `AllowanceAdminError`, an `AllowanceAdminBackend` execution - seam and an observer stream — a lifecycle model every host needs and each one - will otherwise rebuild. Its module doc calls itself "the temporary - Brevity-side override". - -2. **Capabilities that exist but are sealed.** That backend records what it - cannot do: "inspect/invalidate remain a non-secret process-lifetime read model - because the pinned `HostAdmin` exposes neither a chain status probe nor cache - eviction". Upstream those primitives are already written — - `evict_bulletin_allowance_key`, `clear_statement_store_allowance_keys`, - `clear_bulletin_allowance_keys`, `remove_allowance_key` and the `cached_*` - readers in `runtime/pairing_host.rs` and `runtime/allowances.rs` — but they are - `pub(super)`. A host reimplements a weaker version of code that already exists. - -3. **Consent has to be bypassed out of band.** `ResourceAllocation::request` - (`runtime.rs`) gates on - `platform.confirm_user_action(UserConfirmationReview::ResourceAllocation(..))` - before calling the authority. That gate is right for a product and wrong for - the host, which would be prompting itself about its own maintenance. Because a - forged frame is indistinguishable from a real one, the host cannot say "this - one is mine" — so it arms a token instead. `HostAllowanceAutoConfirm` keys a - counter by `(product_id, resource_tag)`, and the host's consent delegate - silently returns approval when the token matches - (`brevity-viewmodel/src/ux.rs`). - - That registry is carefully scoped — one token, single-resource matches only, - RAII-withdrawn when the dispatch ends — but the matching is heuristic by - construction. A genuine product-initiated request for the same product and the - same single resource, arriving while a host dispatch is armed, consumes the - token and is approved without asking the user. Nothing outside the runtime can - close that window, because the runtime is the only party that knows which call - it originated. - -That third cost is the argument for this RFC. The rest is duplication; this is a -consent decision made by pattern-matching because the API offers no way to state -it. - -## Stakeholders - -- **Host developers** — gain a supported surface for startup warm-up, foreground - re-check and recovery, and can delete their forged-frame adapters and - auto-confirm registries. -- **Product developers** — unaffected. `host_request_resource_allocation` and its - confirmation review are unchanged. -- **Account Holder developers** — unaffected; the authority operation invoked is - the one the product path already invokes. - -## Explanation - -### `HostAdmin` methods - -```rust -impl HostAdmin { - /// Allocate product-scoped resources on the host's own initiative. - pub async fn allocate_allowances( - &self, - resources: Vec, - origin: HostAllowanceOrigin, - ) -> Result, v01::GenericError>; - - /// Non-secret lifecycle for one resource kind. - pub async fn allowance_status( - &self, - resource: HostAllowanceResource, - ) -> Result; - - /// Drop cached allowance key material for one resource kind, so the next - /// use re-derives or re-requests it. - pub async fn invalidate_allowance( - &self, - resource: HostAllowanceResource, - ) -> Result<(), v01::GenericError>; -} -``` - -`HostAdmin` is already product-scoped and holds both collaborators it needs — -`authority: Arc` and `product_runtime: Arc` -— so reaching the work needs no new plumbing. - -### Origin - -```rust -pub enum HostAllowanceOrigin { - StartupReadiness, - ForegroundRenewal, - Recovery, -} -``` - -`origin` is recorded in tracing and passed through to the host. It exists so the -runtime and the host can distinguish lifecycle moments without inventing a -product identity, and so a prompting policy can be added later without changing -any signature. The names mirror brevity's `HostAllowanceReason`, which lets that -module become a thin adapter rather than a rewrite. - -### Consent - -The admin path does not raise `UserConfirmationReview::ResourceAllocation`. That -review's contract is "a product asked for this, approve or decline", and no -product is asking. `ResourceAllocation::request` keeps it unchanged for products. - -This deliberately moves the prompting decision into host code. That is the point: -a host that wants to ask the user about a first-ever grant can, and one doing -routine maintenance does not have to manufacture a bypass. It is also strictly -safer than the status quo, where the forged path already auto-approves — just -invisibly, and with a matching window that can catch a real product request. - -### Status and invalidate - -Both resolve against the existing per-role machinery: - -- **Status** reads the `cached_*` accessors for cache and persisted-store - presence, and can escalate to a chain probe through the already-public - `truapi_server::statement_allowance` (`scan_collections`, `allocated_in`, - `fetch_bulletin_allowance`). Cache-only is the cheap default. -- **Invalidate** surfaces `evict_bulletin_allowance_key`, the two - `clear_*_allowance_keys` methods, and `allowances::remove_allowance_key`. - -The caches live on the concrete runtimes rather than behind the trait, so both -need seams on `ProductAuthority`, implemented for `PairingHost` and `SigningHost`. -That trait already carries `refresh_bulletin_allowance_key`, so allowance -lifecycle is established as its concern and these are consistent additions. - -The two roles mean different things by "status", and this is the part that needs -agreement before implementation: - -- `PairingHost` holds keys obtained from the Account Holder over SSO, cached in - memory and in `CoreStorage`. Presence is well defined, and eviction is exactly - the `pub(super)` primitives above. -- `SigningHost` *is* the Account Holder. It provisions on demand through - `sso_responder::allocate_*_allowance` with `OnExistingAllowancePolicy::Ignore`, - so a key is always derivable and "cached presence" is not the right question. A - meaningful status here is an on-chain slot probe, and a meaningful invalidate - may be a no-op. - -`AllowanceLifecycle` is status-only — `Active`, `Absent`, `Expired`, -`Unavailable { code }` — mirroring the model `allowance_admin.rs` arrived at -independently. `HostAllowanceResource` covers `StatementStore` and `Bulletin`, -the two the runtime administers. - -**No type in this surface carries key material, statement bytes, or a chain -payload.** That is the discipline `allowance_admin.rs` holds itself to, and it is -what makes the surface safe to expose over UniFFI. - -### Native surface - -The methods get UniFFI exposure next to the renewal surface -[#308](https://github.com/paritytech/host-rust-core/pull/308) already ships -(`renew_statement_allowances`, `start_statement_allowance_renewal` in -`native.rs`). Renewal reaching the FFI while allocation and status stay Rust-only -is the asymmetry this closes: today a Swift or Kotlin host can keep an allowance -alive but cannot obtain one or ask about it. - -## Implementation status - -`allocate_allowances` and `HostAllowanceOrigin` are implemented in this change: -`ProductRuntimeHost::allocate_resources_for_host` calls -`ProductAuthority::allocate_resources` directly, with a unique correlation id per -call because the SSO channel matches responses on it. Tests cover the -no-session rejection, request-id uniqueness, and — asserting the product path -raises exactly one review first, so the check cannot pass vacuously — that the -host path raises none. - -`allowance_status`, `invalidate_allowance` and the native bindings are not in -this change. Status and invalidate need the per-role semantics above settled -first, and the native surface is a separately CI-gated step -(`make uniffi && ios/truapi-host/scripts/sync-bindings.sh`, with committed -bindings). Landing allocation alone already removes both the forged frame and the -auto-confirm registry, which is the security-relevant half. - -## Drawbacks - -- **`HostAdmin` grows a third concern.** It is currently a small - session-and-permissions handle. A separate `AllowanceAdmin` reachable from - `HostAdmin` would keep it narrow, at the cost of one more type to discover. - Both are source-compatible for callers going through `HostAdmin`. -- **Two paths to allocation.** Products go through the dispatcher with consent; - hosts go direct. That is intended, but it puts the consent question in host - code, where a careless host could allocate without ever asking. The - counter-argument is that this is already true today, less visibly. -- **Status semantics differ per resource.** "Active" for Statement Store means a - slot is allocated in the current period; for Bulletin it means allocated and - inside expiry-plus-grace. One enum spanning two lifetimes invites misreading; - the honest mitigation is documentation on each variant. - -## Alternatives - -- **A host-origin flag on the wire.** Rejected: it puts a host-only concept in - the product protocol, and a product could then claim host origin. -- **Treat `statement_allowance` as the answer.** It is already public and - `truapi-host-cli` administers allowances entirely through it - (`register_pairing_allowances`, talking to chain over its own `RpcClient`). But - those are free functions over a subxt client with no product scoping, no - session awareness and no lifecycle model — usable from a Rust binary, not from - a Swift host, and not an admin API. It is the right layer underneath this one, - not a replacement. -- **Leave it downstream.** Viable while brevity-dozer is the only host that needs - it. It stops being viable at the second host, and it leaves the consent - matching window in place permanently. -- **Bless the dispatcher for host use.** The current workaround, promoted to a - supported API. Rejected: it preserves the consent ambiguity, which is the - problem worth solving. - -## Unresolved Questions - -- What does `allowance_status` mean on `SigningHost`, and is a chain probe in - scope for the first cut or is cache-presence-plus-`Unavailable` acceptable? -- Should a host-origin allocation ever prompt? A first grant for a product the - user has never seen is a plausible exception to "don't ask about maintenance". -- Should `allowance_status` expose the period boundary so hosts can schedule, or - does #308's renewal scheduler already own scheduling? -- Does brevity's `AllowanceTarget::session_id` belong upstream? It exists because - paired storage operations need a session id while a direct signing host does - not; `HostAdmin` is already session-scoped, which may make it redundant. -- PGAS: brevity models it as a third resource kind, but RFC-0010 treats - smart-contract allowance as an anonymous per-user claim rather than a slot - table, and the runtime administers no PGAS state. Out of scope here — should it - stay out? diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 7a1e864a1..a72a0cd1e 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -26,4 +26,3 @@ created: 2026-03-13 | 0022 | [Account key derivations](0022-account-derivations.md) | draft | Valentin Sergeev | — | | 0023 | [sr25519 VRF signing for product accounts](0023-account-sign-vrf.md) | draft | Valentin Sergeev | — | | 0026 | [Host chain discovery and name resolution](0026-supported-chains.md) | draft | Valentin Fernandez | [#354](https://github.com/paritytech/host-rust-core/pull/354) | -| 0028 | [Host allowance administration](0028-host-allowance-admin.md) | draft | Filippo Vecchiato | [#467](https://github.com/paritytech/host-rust-core/pull/467) | diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 0135a1971..b72da9b03 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -172,8 +172,7 @@ recovery after a rejection. It reaches the same authority operation as the product-facing `ResourceAllocation` call but does not raise that call's confirmation review, which exists to put a *product's* request to the user. A host that wants to prompt for its own maintenance work decides that itself, and -`HostAllowanceOrigin` says which lifecycle moment asked. See -[RFC-0028](../../../docs/rfcs/0028-host-allowance-admin.md). +`HostAllowanceOrigin` says which lifecycle moment asked. The embedder builds a role handle, `PairingHostRuntime::new(...)` or `SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for