rhi

Coordinated trade for connected markets
git clone https://radroots.dev/git/rhi.git
Log | Files | Refs | README | LICENSE

commit 7978b532627281c711a0d599106078d20f1b54c5
parent ef53ce07d5eaac77f388b48d9ec73e7a0922de8b
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 22:49:09 +0000

runtime(rhi): define injected adapter boundary

Diffstat:
MAGENTS.md | 11+++++++++++
MCargo.lock | 1+
MCargo.toml | 1+
MREADME | 29+++++++++++++++++++++++++++++
Mcontracts/api_baselines/rhi.txt | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/services_hardening/runtime_adapters.v1.json | 46++++++++++++++++++++++++++++++++++++++++++++++
Mradroots.service.source-lock.v2.toml | 2+-
Msrc/lib.rs | 12++++++++++++
Asrc/runtime_adapters.rs | 768+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/build_policy.rs | 7+++++++
Mtests/package_boundary.rs | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
11 files changed, 1064 insertions(+), 4 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -213,6 +213,17 @@ evidence sources, and failpoints. Supervise and join every authoritative task; panic, error, or unexpected successful return from a critical task must coordinate shutdown and produce a nonzero process result. +- Compose those dependencies only through the sealed runtime-adapter boundary. + Wall UTC and process-local monotonic observations remain distinct; jitter is + bounded whole-millisecond full jitter derived only from injected entropy. + The transport-neutral `radroots_transport` source, subscription, and sink + traits are the sole generic event I/O SPI. Credential access must precede + independently verified encrypted-identity access, with no fallback or + generation. The adapter set owns one private shared `TaskSupervisor` and + exposes no task handle or concrete transport handle. +- Library code must not install signals, create a runtime, install logging, + call process exit, or detach an authoritative task. Those process authorities + remain exclusively with the final binary checkpoint. - On startup, reclaim expired reconciliation/publication leases, resume durable retry schedules with injected bounded jitter, retain unknown submissions, finalize already-proven outcomes idempotently, and scan all authoritative diff --git a/Cargo.lock b/Cargo.lock @@ -1838,6 +1838,7 @@ dependencies = [ "radroots_service_sqlite", "radroots_storage", "radroots_trade", + "radroots_transport", "rustix", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml @@ -51,6 +51,7 @@ radroots_runtime_paths = { git = "https://github.com/radrootslabs/lib", rev = "7 radroots_service_host = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha", default-features = false } +radroots_transport = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha", default-features = false, features = ["std"] } radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } radroots_trade = { git = "https://github.com/radrootslabs/lib", rev = "7d7b454b4c9ed86569671993bd03ca868b676665", version = "=0.1.0-alpha" } diff --git a/README b/README @@ -12,6 +12,11 @@ runtime-path, service-SQLite, storage, event, and trade values that appear in signatures are deliberate governed contract types; raw SQLx, Serde, transport, filesystem, and task authority never crosses this boundary. +The shared clock and entropy traits and their source-free error values are also +deliberate governed injection contracts. RHI operations normalize their +failures into stable RHI classifications; concrete system adapters remain +private implementation details. + Child modules cannot bypass the reviewed root surface: ```compile_fail @@ -28,6 +33,30 @@ RHI publishes its service kind-0 Profile only through the sealed `RadrootsAuthoredProfile` replacement snapshot. It does not retain a generic kind-0 event-authoring path. +## Injected runtime adapters + +`RhiRuntimeAdapters` is the sealed composition boundary for all runtime inputs +that must remain replaceable in deterministic tests. It owns distinct injected +whole-second wall UTC, process-local monotonic time, and entropy sources; a +transport-neutral bounded event source, live subscriber, and publication sink; +ordered read-existing credential and independently verified encrypted-identity +access; and one private shared join-owning task supervisor. + +Full jitter is measured only in whole milliseconds, is sampled from injected +entropy with rejection-sampled multiply-high mapping, is always in the +inclusive configured range, and is capped at the exact v1 maximum of 3,600,000 +milliseconds. Sampling fails closed after sixteen rejected entropy draws, so a +bad adapter cannot hang scheduling. Jitter is never derived from wall-clock +nanoseconds. Event-authored time remains untrusted input and cannot substitute +for an injected observation time or a monotonic deadline. + +Constructing the adapter set performs no clock read, entropy read, identity or +credential access, DNS lookup, network operation, or task spawn. Concrete +transport handles and the task supervisor remain sealed. The library installs +no signal handler, Tokio runtime, logger, or process-exit policy; the final +binary checkpoint owns those authorities. The exact machine contract is +[`runtime_adapters.v1.json`](contracts/services_hardening/runtime_adapters.v1.json). + ## Hardened v1 configuration contract The target service configuration is frozen by diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt @@ -1,6 +1,12 @@ pub mod rhi +pub use rhi::EntropyError +pub use rhi::EntropySource pub use rhi::INSTANCE_ID_MAX_BYTES pub use rhi::InstanceId +pub use rhi::MonotonicClock +pub use rhi::MonotonicClockError +pub use rhi::MonotonicDeadline +pub use rhi::MonotonicTime pub use rhi::RadrootsHostEnvironment pub use rhi::RadrootsPathProfile pub use rhi::RadrootsPathResolver @@ -9,6 +15,9 @@ pub use rhi::RadrootsServiceInstanceArtifacts pub use rhi::RuntimeContext pub use rhi::RuntimeContextSource pub use rhi::ServiceId +pub use rhi::UnixTimeSeconds +pub use rhi::WallClock +pub use rhi::WallClockError pub enum rhi::RhiBootstrapProfileV1 pub rhi::RhiBootstrapProfileV1::Interactive pub rhi::RhiBootstrapProfileV1::RepoLocal @@ -122,6 +131,15 @@ pub enum rhi::RhiReconciliationCommandV1 pub rhi::RhiReconciliationCommandV1::Jobs pub rhi::RhiReconciliationCommandV1::Refresh pub rhi::RhiReconciliationCommandV1::Status +pub enum rhi::RhiRuntimeAdapterErrorKind +pub rhi::RhiRuntimeAdapterErrorKind::CredentialAccess +pub rhi::RhiRuntimeAdapterErrorKind::EntropyUnavailable +pub rhi::RhiRuntimeAdapterErrorKind::IdentityAccess +pub rhi::RhiRuntimeAdapterErrorKind::InvalidJitterBound +pub rhi::RhiRuntimeAdapterErrorKind::MonotonicDeadlineInvalid +pub rhi::RhiRuntimeAdapterErrorKind::WallClockUnavailable +impl rhi::RhiRuntimeAdapterErrorKind +pub const fn rhi::RhiRuntimeAdapterErrorKind::code(self) -> &'static str pub enum rhi::RhiRuntimeContextErrorKind pub rhi::RhiRuntimeContextErrorKind::InvalidBootstrapBinding pub rhi::RhiRuntimeContextErrorKind::InvalidServiceIdentity @@ -224,6 +242,12 @@ impl rhi::TradeAgreementAttestationErrorKind pub const fn rhi::TradeAgreementAttestationErrorKind::code(self) -> &'static str impl core::fmt::Display for rhi::TradeAgreementAttestationErrorKind pub fn rhi::TradeAgreementAttestationErrorKind::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::CanonicalRhiCredentialAccess +impl rhi::RhiCredentialAccess for rhi::CanonicalRhiCredentialAccess +pub fn rhi::CanonicalRhiCredentialAccess::resolve_existing(&self, &rhi::RhiRuntimeContext, &rhi::RhiIdentityEnvelopeBinding) -> core::result::Result<rhi::RhiWrappingCredential, rhi::RhiCredentialResolutionError> +pub struct rhi::CanonicalRhiIdentityAccess +impl rhi::RhiIdentityAccess for rhi::CanonicalRhiIdentityAccess +pub fn rhi::CanonicalRhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> pub struct rhi::NostrEventAdapter<'a> impl<'a> rhi::NostrEventAdapter<'a> pub fn rhi::NostrEventAdapter<'a>::new(&'a nostr::event::Event) -> Self @@ -341,6 +365,13 @@ impl rhi::RhiExpectedPublicIdentity pub fn rhi::RhiExpectedPublicIdentity::as_hex(&self) -> &str impl core::fmt::Debug for rhi::RhiExpectedPublicIdentity pub fn rhi::RhiExpectedPublicIdentity::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiIdentityCredentialAdapters +impl rhi::RhiIdentityCredentialAdapters +pub fn rhi::RhiIdentityCredentialAdapters::canonical() -> Self +pub fn rhi::RhiIdentityCredentialAdapters::new(alloc::sync::Arc<dyn rhi::RhiCredentialAccess>, alloc::sync::Arc<dyn rhi::RhiIdentityAccess>) -> Self +pub fn rhi::RhiIdentityCredentialAdapters::open_existing(&self, &rhi::RhiRuntimeContext, &rhi::RhiIdentityEnvelopeBinding) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiRuntimeAdapterError> +impl core::fmt::Debug for rhi::RhiIdentityCredentialAdapters +pub fn rhi::RhiIdentityCredentialAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiIdentityEnvelopeBinding impl rhi::RhiIdentityEnvelopeBinding pub const fn rhi::RhiIdentityEnvelopeBinding::expected_identity(&self) -> &rhi::RhiExpectedPublicIdentity @@ -349,6 +380,14 @@ pub const fn rhi::RhiIdentityEnvelopeBinding::kind(&self) -> rhi::RhiIdentityPro pub const fn rhi::RhiIdentityEnvelopeBinding::role(&self) -> rhi::RhiIdentityRole impl core::fmt::Debug for rhi::RhiIdentityEnvelopeBinding pub fn rhi::RhiIdentityEnvelopeBinding::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiJitterBoundMilliseconds(_) +impl rhi::RhiJitterBoundMilliseconds +pub const fn rhi::RhiJitterBoundMilliseconds::get(self) -> u64 +pub const fn rhi::RhiJitterBoundMilliseconds::new(u64) -> core::result::Result<Self, rhi::RhiRuntimeAdapterError> +pub struct rhi::RhiJitterMilliseconds(_) +impl rhi::RhiJitterMilliseconds +pub const fn rhi::RhiJitterMilliseconds::duration(self) -> core::time::Duration +pub const fn rhi::RhiJitterMilliseconds::get(self) -> u64 pub struct rhi::RhiMutationRepository<'host> impl rhi::RhiMutationRepository<'_> pub const fn rhi::RhiMutationRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor @@ -408,6 +447,23 @@ pub const fn rhi::RhiReportRepository<'_>::descriptor(&self) -> rhi::RhiStateRep pub const fn rhi::RhiReportRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind impl core::fmt::Debug for rhi::RhiReportRepository<'_> pub fn rhi::RhiReportRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiRuntimeAdapterError +impl rhi::RhiRuntimeAdapterError +pub const fn rhi::RhiRuntimeAdapterError::code(self) -> &'static str +pub const fn rhi::RhiRuntimeAdapterError::kind(self) -> rhi::RhiRuntimeAdapterErrorKind +impl core::error::Error for rhi::RhiRuntimeAdapterError +impl core::fmt::Debug for rhi::RhiRuntimeAdapterError +pub fn rhi::RhiRuntimeAdapterError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::fmt::Display for rhi::RhiRuntimeAdapterError +pub fn rhi::RhiRuntimeAdapterError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiRuntimeAdapters +impl rhi::RhiRuntimeAdapters +pub const fn rhi::RhiRuntimeAdapters::identity_credential(&self) -> &rhi::RhiIdentityCredentialAdapters +pub fn rhi::RhiRuntimeAdapters::new(rhi::RhiTimeEntropyAdapters, rhi::RhiTransportAdapters, rhi::RhiIdentityCredentialAdapters) -> Self +pub fn rhi::RhiRuntimeAdapters::supervised_task_count(&self) -> usize +pub const fn rhi::RhiRuntimeAdapters::time_entropy(&self) -> &rhi::RhiTimeEntropyAdapters +impl core::fmt::Debug for rhi::RhiRuntimeAdapters +pub fn rhi::RhiRuntimeAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiRuntimeContext impl rhi::RhiRuntimeContext pub const fn rhi::RhiRuntimeContext::artifacts(&self) -> &radroots_runtime_paths::conventions::RadrootsServiceInstanceArtifacts @@ -561,6 +617,21 @@ pub const fn rhi::RhiSupersessionRepository<'_>::descriptor(&self) -> rhi::RhiSt pub const fn rhi::RhiSupersessionRepository<'_>::kind(&self) -> rhi::RhiStateRepositoryKind impl core::fmt::Debug for rhi::RhiSupersessionRepository<'_> pub fn rhi::RhiSupersessionRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiTimeEntropyAdapters +impl rhi::RhiTimeEntropyAdapters +pub fn rhi::RhiTimeEntropyAdapters::deadline_after(&self, core::time::Duration) -> core::result::Result<radroots_service_host::time::MonotonicDeadline, rhi::RhiRuntimeAdapterError> +pub fn rhi::RhiTimeEntropyAdapters::new<W, M, E>(W, M, E) -> Self where W: radroots_service_host::time::WallClock + 'static, M: radroots_service_host::time::MonotonicClock + 'static, E: radroots_service_host::entropy::EntropySource + 'static +pub fn rhi::RhiTimeEntropyAdapters::now_monotonic(&self) -> radroots_service_host::time::MonotonicTime +pub fn rhi::RhiTimeEntropyAdapters::now_utc(&self) -> core::result::Result<radroots_service_host::time::UnixTimeSeconds, rhi::RhiRuntimeAdapterError> +pub fn rhi::RhiTimeEntropyAdapters::sample_full_jitter(&self, rhi::RhiJitterBoundMilliseconds) -> core::result::Result<rhi::RhiJitterMilliseconds, rhi::RhiRuntimeAdapterError> +pub fn rhi::RhiTimeEntropyAdapters::system() -> Self +impl core::fmt::Debug for rhi::RhiTimeEntropyAdapters +pub fn rhi::RhiTimeEntropyAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +pub struct rhi::RhiTransportAdapters +impl rhi::RhiTransportAdapters +pub fn rhi::RhiTransportAdapters::new(alloc::sync::Arc<dyn radroots_transport::source::EventSource>, alloc::sync::Arc<dyn radroots_transport::source::EventSubscriber>, alloc::sync::Arc<dyn radroots_transport::sink::EventSink>) -> Self +impl core::fmt::Debug for rhi::RhiTransportAdapters +pub fn rhi::RhiTransportAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct rhi::RhiVerifiedStateBackup impl rhi::RhiVerifiedStateBackup pub const fn rhi::RhiVerifiedStateBackup::database_metadata(&self) -> &radroots_service_sqlite::metadata::ServiceDatabaseMetadata @@ -630,6 +701,9 @@ pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32 pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize pub const rhi::RHI_MIGRATION_CATALOG_SHA256: [u8; 32] pub const rhi::RHI_PROVIDER_CONTRACT_VERSION: u32 +pub const rhi::RHI_RUNTIME_ADAPTER_CONTRACT_VERSION: u32 +pub const rhi::RHI_RUNTIME_JITTER_MAX_ENTROPY_DRAWS: usize +pub const rhi::RHI_RUNTIME_JITTER_MAX_MILLISECONDS: u64 pub const rhi::RHI_STATE_APPLICATION_ID: u32 pub const rhi::RHI_STATE_REPOSITORY_CONTRACT_VERSION: u32 pub const rhi::RHI_STATE_REPOSITORY_COUNT: usize @@ -640,6 +714,14 @@ pub const rhi::RHI_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32] pub const rhi::RHI_STATUS_CONTRACT_VERSION: u32 pub const rhi::RHI_WRAPPING_CREDENTIAL_ARTIFACT_BYTES: usize pub const rhi::RHI_WRAPPING_CREDENTIAL_CONTRACT_VERSION: u32 +pub trait rhi::RhiCredentialAccess: core::marker::Send + core::marker::Sync +pub fn rhi::RhiCredentialAccess::resolve_existing(&self, &rhi::RhiRuntimeContext, &rhi::RhiIdentityEnvelopeBinding) -> core::result::Result<rhi::RhiWrappingCredential, rhi::RhiCredentialResolutionError> +impl rhi::RhiCredentialAccess for rhi::CanonicalRhiCredentialAccess +pub fn rhi::CanonicalRhiCredentialAccess::resolve_existing(&self, &rhi::RhiRuntimeContext, &rhi::RhiIdentityEnvelopeBinding) -> core::result::Result<rhi::RhiWrappingCredential, rhi::RhiCredentialResolutionError> +pub trait rhi::RhiIdentityAccess: core::marker::Send + core::marker::Sync +pub fn rhi::RhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> +impl rhi::RhiIdentityAccess for rhi::CanonicalRhiIdentityAccess +pub fn rhi::CanonicalRhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError> pub fn rhi::attest_projection_claim(&radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1, &radroots_event::id::MutationId, &rhi::TradeAgreementAttestationPolicy) -> core::result::Result<rhi::TradeAgreementAttestationReportV1, rhi::TradeAgreementAttestationError> pub async fn rhi::finalize_rhi_state_restore(rhi::RhiStagedStateRestore) -> core::result::Result<(), rhi::RhiStateMaintenanceError> pub async fn rhi::initialize_rhi_state(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), rhi::RhiStateHostError> diff --git a/contracts/services_hardening/runtime_adapters.v1.json b/contracts/services_hardening/runtime_adapters.v1.json @@ -0,0 +1,46 @@ +{ + "schema": "radroots.rhi.runtime-adapters", + "schema_version": 1, + "contract_version": 1, + "time_entropy": { + "wall_time": "injected_whole_second_utc", + "monotonic_time": "injected_process_local_domain", + "entropy": "injected_complete_fill_or_error", + "event_authored_time": "untrusted_input" + }, + "jitter": { + "algorithm": "rejection_sampled_multiply_high_full_jitter", + "unit": "milliseconds", + "inclusive_minimum": 0, + "inclusive_maximum": 3600000, + "maximum_entropy_draws": 16, + "wall_clock_derived": false + }, + "transport": { + "contract": "radroots_transport", + "evidence_fetch": "EventSource", + "evidence_subscription": "EventSubscriber", + "publication": "EventSink", + "construction_performs_io": false, + "concrete_handles_exposed": false + }, + "identity": { + "order": ["credential", "encrypted_identity"], + "credential": "read_existing_canonical_instance_artifact", + "encrypted_identity": "read_existing_and_independently_verify", + "fallback": false, + "generation": false + }, + "tasks": { + "supervisor": "radroots_service_host::TaskSupervisor", + "join_owned": true, + "handles_exposed": false + }, + "library_exclusions": [ + "signal_installation", + "runtime_creation", + "logging_installation", + "process_exit", + "detached_tasks" + ] +} diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml @@ -7,7 +7,7 @@ architecture = "radroots.crates.release.v2" workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4" version = "0.1.0-alpha" source_archive_sha256 = "b425371c134be96cce46b37f7035d6212f1efe8cff50bef366631ba5632991b0" -cargo_lock_sha256 = "acf51e7848c64d0361b5d00f5035edc93daa6d3b3f73256ba3718c6b4f498db9" +cargo_lock_sha256 = "f519a64d8093610f9536a0911ddac85412e2f6aef2724cb09b5af5b32ab9654b" rust_version = "1.97.1" host_feature_profile = "service-host" diff --git a/src/lib.rs b/src/lib.rs @@ -8,6 +8,7 @@ mod config_v1; mod features; mod identity_credential; mod identity_envelope; +mod runtime_adapters; mod runtime_context; mod state_catalog; mod state_host; @@ -55,6 +56,17 @@ pub use radroots_runtime_paths::{ RadrootsPathResolver, RadrootsPlatform, RadrootsServiceInstanceArtifacts, RuntimeContext, RuntimeContextSource, ServiceId, }; +pub use radroots_service_host::{ + EntropyError, EntropySource, MonotonicClock, MonotonicClockError, MonotonicDeadline, + MonotonicTime, UnixTimeSeconds, WallClock, WallClockError, +}; +pub use runtime_adapters::{ + CanonicalRhiCredentialAccess, CanonicalRhiIdentityAccess, RHI_RUNTIME_ADAPTER_CONTRACT_VERSION, + RHI_RUNTIME_JITTER_MAX_ENTROPY_DRAWS, RHI_RUNTIME_JITTER_MAX_MILLISECONDS, RhiCredentialAccess, + RhiIdentityAccess, RhiIdentityCredentialAdapters, RhiJitterBoundMilliseconds, + RhiJitterMilliseconds, RhiRuntimeAdapterError, RhiRuntimeAdapterErrorKind, RhiRuntimeAdapters, + RhiTimeEntropyAdapters, RhiTransportAdapters, +}; pub use runtime_context::{ RhiRuntimeContext, RhiRuntimeContextError, RhiRuntimeContextErrorKind, resolve_rhi_runtime_context, diff --git a/src/runtime_adapters.rs b/src/runtime_adapters.rs @@ -0,0 +1,768 @@ +//! Injected, bounded runtime capability composition. + +use core::{fmt, time::Duration}; +use std::{error::Error, sync::Arc}; + +use radroots_service_host::{ + EntropySource, MonotonicClock, MonotonicDeadline, MonotonicTime, SystemEntropy, + SystemMonotonicClock, SystemWallClock, TaskSupervisor, UnixTimeSeconds, WallClock, +}; +use radroots_transport::{EventSink, EventSource, EventSubscriber}; + +use crate::{ + RhiCredentialResolutionError, RhiDecryptedIdentity, RhiEncryptedIdentityEnvelopeError, + RhiIdentityEnvelopeBinding, RhiRuntimeContext, RhiWrappingCredential, + open_rhi_encrypted_identity, resolve_rhi_wrapping_credential, +}; + +#[cfg(test)] +const RUNTIME_ADAPTER_CONTRACT: &str = + include_str!("../contracts/services_hardening/runtime_adapters.v1.json"); + +/// Exact version of the RHI runtime-adapter contract. +pub const RHI_RUNTIME_ADAPTER_CONTRACT_VERSION: u32 = 1; + +/// Largest full-jitter ceiling admitted by the RHI v1 configuration contract. +pub const RHI_RUNTIME_JITTER_MAX_MILLISECONDS: u64 = 3_600_000; + +/// Maximum entropy draws allowed for one exact unbiased full-jitter sample. +pub const RHI_RUNTIME_JITTER_MAX_ENTROPY_DRAWS: usize = 16; + +/// Stable source-free runtime-adapter failure classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RhiRuntimeAdapterErrorKind { + InvalidJitterBound, + EntropyUnavailable, + WallClockUnavailable, + MonotonicDeadlineInvalid, + CredentialAccess, + IdentityAccess, +} + +impl RhiRuntimeAdapterErrorKind { + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::InvalidJitterBound => "runtime_jitter_bound_invalid", + Self::EntropyUnavailable => "runtime_entropy_unavailable", + Self::WallClockUnavailable => "runtime_wall_clock_unavailable", + Self::MonotonicDeadlineInvalid => "runtime_monotonic_deadline_invalid", + Self::CredentialAccess => "runtime_credential_access_failed", + Self::IdentityAccess => "runtime_identity_access_failed", + } + } + + const fn message(self) -> &'static str { + match self { + Self::InvalidJitterBound => "RHI jitter bound is invalid", + Self::EntropyUnavailable => "RHI entropy source is unavailable", + Self::WallClockUnavailable => "RHI wall clock is unavailable", + Self::MonotonicDeadlineInvalid => "RHI monotonic deadline is invalid", + Self::CredentialAccess => "RHI credential access failed", + Self::IdentityAccess => "RHI identity access failed", + } + } +} + +/// One redacted source-free runtime-adapter failure. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RhiRuntimeAdapterError { + kind: RhiRuntimeAdapterErrorKind, +} + +impl RhiRuntimeAdapterError { + const fn new(kind: RhiRuntimeAdapterErrorKind) -> Self { + Self { kind } + } + + /// Returns the stable failure kind. + #[must_use] + pub const fn kind(self) -> RhiRuntimeAdapterErrorKind { + self.kind + } + + /// Returns the stable machine-facing safe code. + #[must_use] + pub const fn code(self) -> &'static str { + self.kind.code() + } +} + +impl fmt::Debug for RhiRuntimeAdapterError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiRuntimeAdapterError") + .field("kind", &self.kind) + .finish() + } +} + +impl fmt::Display for RhiRuntimeAdapterError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.kind.message()) + } +} + +impl Error for RhiRuntimeAdapterError {} + +/// Validated inclusive maximum for one full-jitter sample, in whole milliseconds. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RhiJitterBoundMilliseconds(u64); + +impl RhiJitterBoundMilliseconds { + /// Validates a whole-millisecond bound against the complete RHI v1 ceiling. + pub const fn new(milliseconds: u64) -> Result<Self, RhiRuntimeAdapterError> { + if milliseconds > RHI_RUNTIME_JITTER_MAX_MILLISECONDS { + Err(RhiRuntimeAdapterError::new( + RhiRuntimeAdapterErrorKind::InvalidJitterBound, + )) + } else { + Ok(Self(milliseconds)) + } + } + + /// Returns the inclusive maximum in whole milliseconds. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// One injected full-jitter result, in whole milliseconds. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RhiJitterMilliseconds(u64); + +impl RhiJitterMilliseconds { + /// Returns the sampled value. + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + /// Returns the sampled value as a duration. + #[must_use] + pub const fn duration(self) -> Duration { + Duration::from_millis(self.0) + } +} + +/// Injected wall-time, monotonic-time, and entropy capabilities. +pub struct RhiTimeEntropyAdapters { + wall: Arc<dyn WallClock>, + monotonic: Arc<dyn MonotonicClock>, + entropy: Arc<dyn EntropySource>, +} + +impl RhiTimeEntropyAdapters { + /// Owns injected adapters without reading a clock or entropy source. + pub fn new<W, M, E>(wall: W, monotonic: M, entropy: E) -> Self + where + W: WallClock + 'static, + M: MonotonicClock + 'static, + E: EntropySource + 'static, + { + Self { + wall: Arc::new(wall), + monotonic: Arc::new(monotonic), + entropy: Arc::new(entropy), + } + } + + /// Constructs the production adapters without reading any value yet. + #[must_use] + pub fn system() -> Self { + Self::new(SystemWallClock, SystemMonotonicClock::new(), SystemEntropy) + } + + /// Reads one explicit whole-second UTC observation. + pub fn now_utc(&self) -> Result<UnixTimeSeconds, RhiRuntimeAdapterError> { + self.wall.now_utc().map_err(|_| { + RhiRuntimeAdapterError::new(RhiRuntimeAdapterErrorKind::WallClockUnavailable) + }) + } + + /// Reads one observation from the injected process-local monotonic domain. + #[must_use] + pub fn now_monotonic(&self) -> MonotonicTime { + self.monotonic.now_monotonic() + } + + /// Computes a deadline in the injected monotonic domain without wrapping. + pub fn deadline_after( + &self, + duration: Duration, + ) -> Result<MonotonicDeadline, RhiRuntimeAdapterError> { + self.monotonic.deadline_after(duration).map_err(|_| { + RhiRuntimeAdapterError::new(RhiRuntimeAdapterErrorKind::MonotonicDeadlineInvalid) + }) + } + + /// Samples unbiased full jitter in the inclusive range `0..=maximum`. + /// + /// Rejection sampling is capped so an adversarial injected entropy source + /// cannot keep one scheduler decision pending indefinitely. + pub fn sample_full_jitter( + &self, + maximum: RhiJitterBoundMilliseconds, + ) -> Result<RhiJitterMilliseconds, RhiRuntimeAdapterError> { + let range = maximum.get() + 1; + let rejection_threshold = range.wrapping_neg() % range; + for _ in 0..RHI_RUNTIME_JITTER_MAX_ENTROPY_DRAWS { + let mut bytes = [0_u8; 8]; + self.entropy.fill_bytes(&mut bytes).map_err(|_| { + RhiRuntimeAdapterError::new(RhiRuntimeAdapterErrorKind::EntropyUnavailable) + })?; + let product = u128::from(u64::from_be_bytes(bytes)) * u128::from(range); + let low = u64::try_from(product & u128::from(u64::MAX)) + .expect("masked multiply-high remainder fits u64"); + if low >= rejection_threshold { + let sampled = u64::try_from(product >> u64::BITS) + .expect("multiply-high full-jitter result fits the admitted u64 bound"); + return Ok(RhiJitterMilliseconds(sampled)); + } + } + Err(RhiRuntimeAdapterError::new( + RhiRuntimeAdapterErrorKind::EntropyUnavailable, + )) + } +} + +impl fmt::Debug for RhiTimeEntropyAdapters { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiTimeEntropyAdapters([injected])") + } +} + +/// Transport-neutral capabilities for bounded evidence fetch, live subscription, and publication. +/// +/// Construction performs no network, DNS, or TLS operation. The capabilities +/// remain sealed inside RHI so concrete transports and detachable I/O handles +/// do not become public runtime authority. +pub struct RhiTransportAdapters { + _evidence_source: Arc<dyn EventSource>, + _evidence_subscriber: Arc<dyn EventSubscriber>, + _publication_sink: Arc<dyn EventSink>, +} + +impl RhiTransportAdapters { + /// Binds the complete transport-neutral capability inventory without I/O. + #[must_use] + pub fn new( + evidence_source: Arc<dyn EventSource>, + evidence_subscriber: Arc<dyn EventSubscriber>, + publication_sink: Arc<dyn EventSink>, + ) -> Self { + Self { + _evidence_source: evidence_source, + _evidence_subscriber: evidence_subscriber, + _publication_sink: publication_sink, + } + } +} + +impl fmt::Debug for RhiTransportAdapters { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiTransportAdapters([sealed])") + } +} + +/// Injected read-existing-only wrapping-credential access. +pub trait RhiCredentialAccess: Send + Sync { + /// Resolves the configured credential for the exact runtime and identity binding. + fn resolve_existing( + &self, + runtime: &RhiRuntimeContext, + binding: &RhiIdentityEnvelopeBinding, + ) -> Result<RhiWrappingCredential, RhiCredentialResolutionError>; +} + +/// Injected read-existing-only encrypted-identity access. +pub trait RhiIdentityAccess: Send + Sync { + /// Opens and independently verifies the exact configured encrypted identity. + fn open_existing( + &self, + binding: &RhiIdentityEnvelopeBinding, + credential: &RhiWrappingCredential, + ) -> Result<RhiDecryptedIdentity, RhiEncryptedIdentityEnvelopeError>; +} + +/// Canonical credential resolver backed by the governed instance artifact boundary. +#[derive(Clone, Copy, Debug, Default)] +pub struct CanonicalRhiCredentialAccess; + +impl RhiCredentialAccess for CanonicalRhiCredentialAccess { + fn resolve_existing( + &self, + runtime: &RhiRuntimeContext, + binding: &RhiIdentityEnvelopeBinding, + ) -> Result<RhiWrappingCredential, RhiCredentialResolutionError> { + resolve_rhi_wrapping_credential(runtime, binding) + } +} + +/// Canonical encrypted-identity opener backed by the governed envelope boundary. +#[derive(Clone, Copy, Debug, Default)] +pub struct CanonicalRhiIdentityAccess; + +impl RhiIdentityAccess for CanonicalRhiIdentityAccess { + fn open_existing( + &self, + binding: &RhiIdentityEnvelopeBinding, + credential: &RhiWrappingCredential, + ) -> Result<RhiDecryptedIdentity, RhiEncryptedIdentityEnvelopeError> { + open_rhi_encrypted_identity(binding, credential) + } +} + +/// Ordered credential-then-identity access with no fallback or ambient selector. +pub struct RhiIdentityCredentialAdapters { + credential: Arc<dyn RhiCredentialAccess>, + identity: Arc<dyn RhiIdentityAccess>, +} + +impl RhiIdentityCredentialAdapters { + /// Owns injected accessors without reading a credential or identity. + #[must_use] + pub fn new( + credential: Arc<dyn RhiCredentialAccess>, + identity: Arc<dyn RhiIdentityAccess>, + ) -> Self { + Self { + credential, + identity, + } + } + + /// Constructs the canonical read-existing-only accessors without performing I/O. + #[must_use] + pub fn canonical() -> Self { + Self::new( + Arc::new(CanonicalRhiCredentialAccess), + Arc::new(CanonicalRhiIdentityAccess), + ) + } + + /// Resolves the credential first, then opens and verifies the identity. + pub fn open_existing( + &self, + runtime: &RhiRuntimeContext, + binding: &RhiIdentityEnvelopeBinding, + ) -> Result<RhiDecryptedIdentity, RhiRuntimeAdapterError> { + let credential = self + .credential + .resolve_existing(runtime, binding) + .map_err(|_| { + RhiRuntimeAdapterError::new(RhiRuntimeAdapterErrorKind::CredentialAccess) + })?; + self.identity + .open_existing(binding, &credential) + .map_err(|_| RhiRuntimeAdapterError::new(RhiRuntimeAdapterErrorKind::IdentityAccess)) + } +} + +impl fmt::Debug for RhiIdentityCredentialAdapters { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RhiIdentityCredentialAdapters([sealed])") + } +} + +/// Complete injected RHI foundation adapters with privately join-owned tasks. +/// +/// This value creates no runtime, installs no signal or logger, performs no +/// transport or identity I/O, and exposes no task handle or supervisor. +#[must_use = "runtime adapters retain join-owned task authority"] +pub struct RhiRuntimeAdapters { + time_entropy: RhiTimeEntropyAdapters, + _transport: RhiTransportAdapters, + identity_credential: RhiIdentityCredentialAdapters, + supervisor: TaskSupervisor, +} + +impl RhiRuntimeAdapters { + /// Composes already-constructed injected capabilities without invoking them. + pub fn new( + time_entropy: RhiTimeEntropyAdapters, + transport: RhiTransportAdapters, + identity_credential: RhiIdentityCredentialAdapters, + ) -> Self { + Self { + time_entropy, + _transport: transport, + identity_credential, + supervisor: TaskSupervisor::new(), + } + } + + /// Returns the injected time and entropy boundary. + #[must_use] + pub const fn time_entropy(&self) -> &RhiTimeEntropyAdapters { + &self.time_entropy + } + + /// Returns the ordered identity and credential boundary. + #[must_use] + pub const fn identity_credential(&self) -> &RhiIdentityCredentialAdapters { + &self.identity_credential + } + + /// Returns the number of join-owned tasks currently registered. + #[must_use] + pub fn supervised_task_count(&self) -> usize { + self.supervisor.task_count() + } + + #[cfg(test)] + pub(crate) fn supervisor_mut(&mut self) -> &mut TaskSupervisor { + &mut self.supervisor + } +} + +impl fmt::Debug for RhiRuntimeAdapters { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RhiRuntimeAdapters") + .field("time_entropy", &"[injected]") + .field("transport", &"[sealed]") + .field("identity_credential", &"[sealed]") + .field("supervised_task_count", &self.supervised_task_count()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use core::{ + future::ready, + sync::atomic::{AtomicUsize, Ordering}, + }; + + use radroots_service_host::{ + EntropyError, HostError, MonotonicClockError, TaskClassification, TaskMetadata, TaskName, + WallClockError, + }; + use radroots_transport::{ + BoxFuture, DeliveryReceipt, DeliveryRequest, EventSubscription, FetchPage, FetchRequest, + SinkFailure, SinkStatus, SourceStatus, SubscriptionRequest, + }; + + use super::*; + + #[derive(Clone, Copy)] + struct FixedWall(Result<UnixTimeSeconds, WallClockError>); + + impl WallClock for FixedWall { + fn now_utc(&self) -> Result<UnixTimeSeconds, WallClockError> { + self.0 + } + } + + #[derive(Clone, Copy)] + struct FixedMonotonic(MonotonicTime); + + impl MonotonicClock for FixedMonotonic { + fn now_monotonic(&self) -> MonotonicTime { + self.0 + } + } + + #[derive(Clone, Copy)] + struct FixedEntropy(Result<u64, EntropyError>); + + impl EntropySource for FixedEntropy { + fn fill_bytes(&self, destination: &mut [u8]) -> Result<(), EntropyError> { + let value = self.0?; + destination.copy_from_slice(&value.to_be_bytes()); + Ok(()) + } + } + + struct NoIoTransport; + + impl EventSource for NoIoTransport { + fn status(&self) -> BoxFuture<'_, Result<SourceStatus, radroots_transport::Error>> { + Box::pin(ready(Err(radroots_transport::Error::UnsupportedOperation))) + } + + fn fetch( + &self, + _request: FetchRequest, + ) -> BoxFuture<'_, Result<FetchPage, radroots_transport::Error>> { + Box::pin(ready(Err(radroots_transport::Error::UnsupportedOperation))) + } + } + + impl EventSubscriber for NoIoTransport { + fn subscribe( + &self, + _request: SubscriptionRequest, + ) -> BoxFuture<'_, Result<Box<dyn EventSubscription>, radroots_transport::Error>> { + Box::pin(ready(Err(radroots_transport::Error::UnsupportedOperation))) + } + } + + impl EventSink for NoIoTransport { + fn status(&self) -> BoxFuture<'_, Result<SinkStatus, radroots_transport::Error>> { + Box::pin(ready(Err(radroots_transport::Error::UnsupportedOperation))) + } + + fn deliver( + &self, + request: DeliveryRequest, + ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { + Box::pin(ready(Err(SinkFailure::invalid_contract(&request)))) + } + } + + #[test] + fn runtime_adapter_contract_is_exact_and_defers_process_authority() { + let contract: serde_json::Value = + serde_json::from_str(RUNTIME_ADAPTER_CONTRACT).expect("runtime adapter contract"); + assert_eq!( + contract, + serde_json::json!({ + "schema": "radroots.rhi.runtime-adapters", + "schema_version": 1, + "contract_version": RHI_RUNTIME_ADAPTER_CONTRACT_VERSION, + "time_entropy": { + "wall_time": "injected_whole_second_utc", + "monotonic_time": "injected_process_local_domain", + "entropy": "injected_complete_fill_or_error", + "event_authored_time": "untrusted_input" + }, + "jitter": { + "algorithm": "rejection_sampled_multiply_high_full_jitter", + "unit": "milliseconds", + "inclusive_minimum": 0, + "inclusive_maximum": RHI_RUNTIME_JITTER_MAX_MILLISECONDS, + "maximum_entropy_draws": RHI_RUNTIME_JITTER_MAX_ENTROPY_DRAWS, + "wall_clock_derived": false + }, + "transport": { + "contract": "radroots_transport", + "evidence_fetch": "EventSource", + "evidence_subscription": "EventSubscriber", + "publication": "EventSink", + "construction_performs_io": false, + "concrete_handles_exposed": false + }, + "identity": { + "order": ["credential", "encrypted_identity"], + "credential": "read_existing_canonical_instance_artifact", + "encrypted_identity": "read_existing_and_independently_verify", + "fallback": false, + "generation": false + }, + "tasks": { + "supervisor": "radroots_service_host::TaskSupervisor", + "join_owned": true, + "handles_exposed": false + }, + "library_exclusions": [ + "signal_installation", + "runtime_creation", + "logging_installation", + "process_exit", + "detached_tasks" + ] + }) + ); + } + + #[test] + fn injected_time_deadline_and_full_jitter_are_exactly_bounded() { + let now = MonotonicTime::from_duration_since_origin(Duration::from_millis(40)); + let minimum = RhiTimeEntropyAdapters::new( + FixedWall(Ok(UnixTimeSeconds::new(1_000))), + FixedMonotonic(now), + FixedEntropy(Ok(1)), + ); + assert_eq!(minimum.now_utc().expect("wall").get(), 1_000); + assert_eq!(minimum.now_monotonic(), now); + assert_eq!( + minimum + .deadline_after(Duration::from_millis(2)) + .expect("deadline") + .time() + .duration_since_origin(), + Duration::from_millis(42) + ); + let maximum = RhiJitterBoundMilliseconds::new(RHI_RUNTIME_JITTER_MAX_MILLISECONDS) + .expect("maximum bound"); + assert_eq!( + minimum.sample_full_jitter(maximum).expect("minimum").get(), + 0 + ); + + let upper = RhiTimeEntropyAdapters::new( + FixedWall(Ok(UnixTimeSeconds::new(1))), + FixedMonotonic(now), + FixedEntropy(Ok(u64::MAX)), + ); + assert_eq!( + upper.sample_full_jitter(maximum).expect("maximum").get(), + maximum.get() + ); + assert_eq!( + upper + .sample_full_jitter(RhiJitterBoundMilliseconds::new(0).expect("zero")) + .expect("zero sample") + .duration(), + Duration::ZERO + ); + assert_eq!( + RhiJitterBoundMilliseconds::new(RHI_RUNTIME_JITTER_MAX_MILLISECONDS + 1) + .expect_err("above maximum") + .kind(), + RhiRuntimeAdapterErrorKind::InvalidJitterBound + ); + + let rejected = RhiTimeEntropyAdapters::new( + FixedWall(Ok(UnixTimeSeconds::new(1))), + FixedMonotonic(now), + FixedEntropy(Ok(0)), + ); + assert_eq!( + rejected + .sample_full_jitter(RhiJitterBoundMilliseconds::new(2).expect("bound")) + .expect_err("bounded rejection") + .kind(), + RhiRuntimeAdapterErrorKind::EntropyUnavailable + ); + } + + #[test] + fn injected_failures_and_deadline_overflow_are_stable_and_source_free() { + let maximum_time = MonotonicTime::from_duration_since_origin(Duration::MAX); + let adapters = RhiTimeEntropyAdapters::new( + FixedWall(Err(WallClockError::BeforeUnixEpoch)), + FixedMonotonic(maximum_time), + FixedEntropy(Err(EntropyError::Unavailable)), + ); + let cases = [ + ( + adapters.now_utc().expect_err("wall").kind(), + RhiRuntimeAdapterErrorKind::WallClockUnavailable, + ), + ( + adapters + .deadline_after(Duration::from_millis(1)) + .expect_err("deadline") + .kind(), + RhiRuntimeAdapterErrorKind::MonotonicDeadlineInvalid, + ), + ( + adapters + .sample_full_jitter(RhiJitterBoundMilliseconds::new(1).expect("bound")) + .expect_err("entropy") + .kind(), + RhiRuntimeAdapterErrorKind::EntropyUnavailable, + ), + ]; + for (actual, expected) in cases { + assert_eq!(actual, expected); + let error = RhiRuntimeAdapterError::new(actual); + assert!(Error::source(&error).is_none()); + assert!(!format!("{error:?} {error}").contains("secret")); + } + assert_eq!( + maximum_time.checked_deadline_after(Duration::from_millis(1)), + Err(MonotonicClockError::DeadlineOverflow) + ); + } + + #[tokio::test] + async fn adapter_set_is_inert_until_invoked_and_owns_joined_tasks() { + let transport = Arc::new(NoIoTransport); + let transports = RhiTransportAdapters::new(transport.clone(), transport.clone(), transport); + let mut adapters = RhiRuntimeAdapters::new( + RhiTimeEntropyAdapters::new( + FixedWall(Ok(UnixTimeSeconds::new(1))), + FixedMonotonic(MonotonicTime::from_duration_since_origin(Duration::ZERO)), + FixedEntropy(Ok(1)), + ), + transports, + RhiIdentityCredentialAdapters::canonical(), + ); + assert_eq!(adapters.supervised_task_count(), 0); + let calls = Arc::new(AtomicUsize::new(0)); + let task_calls = Arc::clone(&calls); + adapters + .supervisor_mut() + .spawn( + TaskMetadata::new( + TaskName::new("adapter_contract_test").expect("task name"), + TaskClassification::OneShot, + None, + ) + .expect("metadata"), + move |_cancel| async move { + task_calls.fetch_add(1, Ordering::Relaxed); + Ok::<(), HostError>(()) + }, + ) + .expect("register"); + assert_eq!(adapters.supervised_task_count(), 1); + assert_eq!( + adapters + .supervisor_mut() + .supervise() + .await + .expect("joined") + .len(), + 1 + ); + assert_eq!(calls.load(Ordering::Relaxed), 1); + assert_eq!(adapters.supervised_task_count(), 0); + assert_eq!( + format!("{adapters:?}"), + "RhiRuntimeAdapters { time_entropy: \"[injected]\", transport: \"[sealed]\", identity_credential: \"[sealed]\", supervised_task_count: 0 }" + ); + } + + #[test] + fn all_error_codes_messages_and_debug_are_stable() { + let cases = [ + ( + RhiRuntimeAdapterErrorKind::InvalidJitterBound, + "runtime_jitter_bound_invalid", + "RHI jitter bound is invalid", + ), + ( + RhiRuntimeAdapterErrorKind::EntropyUnavailable, + "runtime_entropy_unavailable", + "RHI entropy source is unavailable", + ), + ( + RhiRuntimeAdapterErrorKind::WallClockUnavailable, + "runtime_wall_clock_unavailable", + "RHI wall clock is unavailable", + ), + ( + RhiRuntimeAdapterErrorKind::MonotonicDeadlineInvalid, + "runtime_monotonic_deadline_invalid", + "RHI monotonic deadline is invalid", + ), + ( + RhiRuntimeAdapterErrorKind::CredentialAccess, + "runtime_credential_access_failed", + "RHI credential access failed", + ), + ( + RhiRuntimeAdapterErrorKind::IdentityAccess, + "runtime_identity_access_failed", + "RHI identity access failed", + ), + ]; + for (kind, code, message) in cases { + let error = RhiRuntimeAdapterError::new(kind); + assert_eq!(error.code(), code); + assert_eq!(error.to_string(), message); + assert_eq!( + format!("{error:?}"), + format!("RhiRuntimeAdapterError {{ kind: {kind:?} }}") + ); + assert!(Error::source(&error).is_none()); + } + } +} diff --git a/tests/build_policy.rs b/tests/build_policy.rs @@ -69,6 +69,13 @@ fn shared_storage_generation_type_is_exactly_source_locked() { } #[test] +fn shared_transport_spi_is_exactly_source_locked_without_serde() { + assert!(MANIFEST.contains( + "radroots_transport = { git = \"https://github.com/radrootslabs/lib\", rev = \"7d7b454b4c9ed86569671993bd03ca868b676665\", version = \"=0.1.0-alpha\", default-features = false, features = [\"std\"] }" + )); +} + +#[test] fn source_lock_binds_the_current_cargo_lock() { let digest = lower_hex(&Sha256::digest(include_bytes!("../Cargo.lock"))); assert!(SOURCE_LOCK.starts_with( diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs @@ -7,6 +7,9 @@ const ROOT: &str = include_str!("../src/lib.rs"); const ADAPTERS: &str = include_str!("../src/adapters/mod.rs"); const NOSTR_ADAPTERS: &str = include_str!("../src/adapters/nostr/mod.rs"); const FEATURES: &str = include_str!("../src/features/mod.rs"); +const RUNTIME_ADAPTERS: &str = include_str!("../src/runtime_adapters.rs"); +const RUNTIME_ADAPTER_CONTRACT: &str = + include_str!("../contracts/services_hardening/runtime_adapters.v1.json"); const PUBLIC_API: &str = include_str!("../contracts/api_baselines/rhi.txt"); const SOURCES: &[&str] = &[ include_str!("../src/adapters/nostr/event.rs"), @@ -16,6 +19,7 @@ const SOURCES: &[&str] = &[ include_str!("../src/identity_credential.rs"), include_str!("../src/identity_envelope.rs"), include_str!("../src/runtime_context.rs"), + include_str!("../src/runtime_adapters.rs"), include_str!("../src/state_catalog.rs"), include_str!("../src/state_host.rs"), include_str!("../src/state_maintenance.rs"), @@ -44,20 +48,26 @@ fn package_identity_is_standalone_and_non_publishable() { } #[test] -fn shared_host_implementations_do_not_escape_the_public_api() { +fn shared_runtime_contracts_are_curated_without_exposing_implementation_authority() { for forbidden in [ - "pub use radroots_service_host", "pub use radroots_service_sqlite", "pub mod service_host", "pub mod service_sqlite", "sqlx::Pool", "sqlx::SqliteConnection", + "SystemEntropy", + "SystemMonotonicClock", + "SystemWallClock", + "TaskSupervisor", + "CancellationToken", ] { assert!( !ROOT.contains(forbidden), "RHI public root exposes private host implementation {forbidden}" ); } + assert!(!PUBLIC_API.contains("radroots_service_host::HostError")); + assert!(!PUBLIC_API.contains("radroots_service_host::TaskSupervisor")); } #[test] @@ -70,6 +80,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "identity_credential", "identity_envelope", "runtime_context", + "runtime_adapters", "state_catalog", "state_host", "state_maintenance", @@ -97,6 +108,17 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { "rhi_schema_catalog", "validate_rhi_state_catalogs", "RhiStateCatalogError", + "RhiRuntimeAdapters", + "RhiTimeEntropyAdapters", + "RhiTransportAdapters", + "RhiCredentialAccess", + "RhiIdentityAccess", + "WallClock", + "MonotonicClock", + "EntropySource", + "EntropyError", + "WallClockError", + "MonotonicClockError", ] { assert!( ROOT.contains(required), @@ -113,6 +135,7 @@ fn state_catalog_module_is_private_and_root_api_is_curated() { assert!(PUBLIC_API.contains("pub struct rhi::TradeAgreementAttestationError")); assert!(!PUBLIC_API.contains("rhi::adapters::")); assert!(!PUBLIC_API.contains("rhi::features::")); + assert!(!PUBLIC_API.contains("rhi::runtime_adapters::")); } #[test] @@ -147,7 +170,74 @@ fn public_errors_are_crate_owned_redacted_and_source_free() { .lines() .filter(|line| line.starts_with("pub struct rhi::") && line.ends_with("Error")) .count(); - assert_eq!(public_error_count, 10); + assert_eq!(public_error_count, 11); +} + +#[test] +fn runtime_adapter_boundary_is_exact_bounded_and_process_neutral() { + let contract: serde_json::Value = + serde_json::from_str(RUNTIME_ADAPTER_CONTRACT).expect("runtime adapter contract"); + assert_eq!(contract["schema"], "radroots.rhi.runtime-adapters"); + assert_eq!(contract["schema_version"], 1); + assert_eq!(contract["contract_version"], 1); + assert_eq!(contract["jitter"]["inclusive_maximum"], 3_600_000); + assert_eq!(contract["jitter"]["maximum_entropy_draws"], 16); + assert_eq!(contract["jitter"]["wall_clock_derived"], false); + assert_eq!(contract["transport"]["evidence_fetch"], "EventSource"); + assert_eq!( + contract["transport"]["evidence_subscription"], + "EventSubscriber" + ); + assert_eq!(contract["transport"]["publication"], "EventSink"); + assert_eq!( + contract["identity"]["order"], + serde_json::json!(["credential", "encrypted_identity"]) + ); + assert_eq!(contract["identity"]["fallback"], false); + assert_eq!(contract["identity"]["generation"], false); + assert_eq!(contract["tasks"]["join_owned"], true); + assert_eq!(contract["tasks"]["handles_exposed"], false); + + for required in [ + "Arc<dyn WallClock>", + "Arc<dyn MonotonicClock>", + "Arc<dyn EntropySource>", + "Arc<dyn EventSource>", + "Arc<dyn EventSubscriber>", + "Arc<dyn EventSink>", + "TaskSupervisor", + "RHI_RUNTIME_JITTER_MAX_MILLISECONDS: u64 = 3_600_000", + "RHI_RUNTIME_JITTER_MAX_ENTROPY_DRAWS: usize = 16", + "u128::from(u64::from_be_bytes(bytes)) * u128::from(range)", + "low >= rejection_threshold", + "resolve_rhi_wrapping_credential(runtime, binding)", + "open_rhi_encrypted_identity(binding, credential)", + ] { + assert!( + RUNTIME_ADAPTERS.contains(required), + "runtime adapter boundary is missing {required}" + ); + } + for forbidden in [ + "tokio::runtime::Runtime", + "tokio::runtime::Builder", + "tokio::signal", + "signal_hook", + "tracing_subscriber", + "std::process::exit", + "tokio::spawn", + "std::thread::spawn", + "SystemTime::now", + "subsec_nanos", + "rand::", + ] { + assert!( + !RUNTIME_ADAPTERS.contains(forbidden), + "runtime adapter boundary contains forbidden authority {forbidden}" + ); + } + assert!(MANIFEST.contains("radroots_transport =")); + assert!(MANIFEST.contains("default-features = false, features = [\"std\"]")); } #[test] @@ -156,8 +246,19 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "## Public API boundary", "one curated crate-root API", "public errors use RHI-owned stable classifications", + "shared clock and entropy traits and their source-free error values", + "failures into stable RHI classifications", "```compile_fail", "[RHI API baseline](contracts/api_baselines/rhi.txt)", + "## Injected runtime adapters", + "whole-second wall UTC", + "process-local monotonic time", + "exact v1 maximum of 3,600,000", + "fails closed after sixteen rejected entropy draws", + "never derived from wall-clock", + "Constructing the adapter set performs no clock read", + "no signal handler, Tokio runtime, logger, or process-exit policy", + "[`runtime_adapters.v1.json`](contracts/services_hardening/runtime_adapters.v1.json)", ] { assert!(README.contains(required), "README is missing {required}"); } @@ -166,6 +267,8 @@ fn readme_freezes_the_root_only_boundary_and_exact_baseline() { "contracts/api_baselines/rhi.txt", "Public errors must use RHI-owned stable classifications", "no raw dependency-owned source chain", + "Compose those dependencies only through the sealed runtime-adapter boundary", + "exposes no task handle or concrete transport handle", ] { assert!(AGENTS.contains(required), "AGENTS is missing {required}"); }