Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions rust/crates/truapi-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ 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.

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:
Expand Down
45 changes: 45 additions & 0 deletions rust/crates/truapi-server/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please scope this claim to the local runtime. On the pairing-host role
resource_allocation_response on the paired wallet raises
UserConfirmationReview::ResourceAllocation with calling_product_id set before
allocating, so the user is prompted for something no product asked for and the
call waits up to 300 seconds. Only the signing-host role is silent. The README
paragraph needs the same qualifier, and
host_allowance_allocation_raises_no_confirmation_review would be more accurate
as ..._raises_no_local_confirmation_review.

///
/// 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<v01::AllocatableResource>,
origin: HostAllowanceOrigin,
) -> Result<Vec<v01::AllocationOutcome>, 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(
Expand Down
4 changes: 2 additions & 2 deletions rust/crates/truapi-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
113 changes: 113 additions & 0 deletions rust/crates/truapi-server/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<v01::AllocatableResource>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please refuse AllocatableResource::AutoSigning here. On a signing host this
path reaches grant_auto_signing with no confirmation, and that grant is what
lets sign_vrf skip its per-call prompt, so a host-initiated call can silently
let a product sign as the user. An early return when resources contains
AutoSigning is enough.

origin: HostAllowanceOrigin,
) -> Result<Vec<v01::AllocationOutcome>, 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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Increase is the wrong policy for StartupReadiness and ForegroundRenewal. It
sets reuse_existing = false, the flag that lets the slot scan notice an
allowance already exists, so a foreground re-check claims another slot and
submits another extrinsic every time. The live People chain declares 20 plus 10
slots per person per 24 hours, pooled across all their products, with eviction
off. RFC-0010 says send Ignore unless you are scaling up a cached allocation.
Please add a policy argument to ProductAuthority::allocate_resources and derive
it from origin, keeping Increase for Recovery and for the product path.

)
.await
.map(|response| response.outcomes)
.map_err(|err| v01::GenericError {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unavailable, NotSupported and Unknown all render as just their inner reason, so after to_string() a host cant tell them apart. For the Recovery origin thats the retry or dont-retry decision.

I know AuthorityError is pub(crate) so this isnt a small change, and the rest of HostAdmin returns GenericError too. Worth saying in the body what a host is meant to do with a failure?

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I dont believe the SSO channel matches responses on this id? It looks like remote_allocate_resources generates its own message_id from sso_message_id(), and submit_remote_message registers on that one.

The uniqueness still seems worth keeping since this id shows up in the cancel error text. Can we fix the reason in the comment?

/// 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,
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The if let Err here is a no-op when the outcome is Ok, so nothing is asserted on success. I replaced the whole body of allocate_resources_for_host after the session check with Ok(Vec::new()) and all three tests still passed, so this one cant tell
"reached the authority without a review" from "did nothing". Can we assert the shape we expect unconditionally?

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());
Expand Down