From 0132a85b0fb5b708c751c0fce3250ef2607a801d Mon Sep 17 00:00:00 2001 From: mfw78 Date: Mon, 17 Aug 2026 04:16:57 +0000 Subject: [PATCH] feat(runtime)!: cut guest-to-guest calling and restore the single-store model Remove [component].provides and the engine.toml [implements] table with their verification, authorization and prepass machinery: enforce_provides, enforce_implements, the interface claim ledger, and the six refusals they raised. The six labels leave the pinned error_kind set, a deliberate operator-contract change the closed-set test now enforces in reduced form. Keep InterfaceId and InterfaceTrack for the plugin registry, less matches_export, and record why in the module rustdoc. Move the operator artifact pin from the [implements] row to [[modules]].digest, verified on the exact bytes handed to the compiler beside the author's [component].digest. DigestPin::Operator now names [[modules]].digest in engine.toml, and its wording test changes with it. Supersede ADR-0021 with ADR-0022, which re-grounds one-component-one-Store on store-scoped enforcement. Amend ADR-0017, ADR-0020 and ADR-0016 in place, and repoint the operator handbook and the packaging guide at [[modules]].digest. BREAKING CHANGE: a manifest carrying [component].provides or an engine.toml carrying [implements] now refuses at parse as an unknown key. Closes #253 Closes #254 Closes #255 Closes #256 Closes #257 AI Assistance: Claude Fable 5 used for the implementation. --- README.md | 4 +- crates/nexum-runtime/Cargo.toml | 2 +- crates/nexum-runtime/examples/embed.rs | 1 + crates/nexum-runtime/src/builder.rs | 60 +-- crates/nexum-runtime/src/digest.rs | 6 +- .../nexum-runtime/src/engine_config/error.rs | 30 +- .../src/engine_config/implements.rs | 70 --- crates/nexum-runtime/src/engine_config/mod.rs | 171 ++++--- crates/nexum-runtime/src/interface_id.rs | 67 +-- .../src/manifest/capabilities.rs | 1 - crates/nexum-runtime/src/manifest/error.rs | 6 - crates/nexum-runtime/src/manifest/load.rs | 46 +- crates/nexum-runtime/src/manifest/types.rs | 22 +- crates/nexum-runtime/src/refusal.rs | 6 - .../nexum-runtime/src/supervisor/artifact.rs | 7 +- crates/nexum-runtime/src/supervisor/load.rs | 169 +------ crates/nexum-runtime/src/supervisor/mod.rs | 8 +- .../nexum-runtime/src/supervisor/prepass.rs | 44 -- .../src/supervisor/tests/digest.rs | 128 +++++ .../nexum-runtime/src/supervisor/tests/mod.rs | 4 +- .../src/supervisor/tests/provides.rs | 446 ------------------ .../nexum-runtime/src/test_utils/manifest.rs | 11 - .../nexum-runtime/src/test_utils/scenario.rs | 45 +- .../testdata/provides/component.wat | 90 ---- docs/02-modules-events-packaging.md | 14 +- docs/adr/0016-component-vocabulary.md | 4 +- docs/adr/0017-capabilities-and-services.md | 11 +- docs/adr/0018-one-operator-policy-surface.md | 4 + docs/adr/0020-retire-component-kind.md | 7 + docs/adr/0021-provides-and-implements.md | 7 +- docs/adr/0022-cut-guest-to-guest-calling.md | 91 ++++ docs/design/capability-and-service-model.md | 1 + docs/production.md | 10 +- 33 files changed, 401 insertions(+), 1192 deletions(-) delete mode 100644 crates/nexum-runtime/src/engine_config/implements.rs delete mode 100644 crates/nexum-runtime/src/supervisor/tests/provides.rs delete mode 100644 crates/nexum-runtime/testdata/provides/component.wat create mode 100644 docs/adr/0022-cut-guest-to-guest-calling.md diff --git a/README.md b/README.md index 614dd85e..5f91e20d 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,9 @@ The example module declares no subscriptions, so `just run` needs no `engine.tom A manifest may pin its artifact with `digest = "sha256:<64 hex chars>"` in `[component]` (one `sha256sum` of the `.wasm`). A present pin is strictly verified against the loaded bytes before compilation; a mismatch or a malformed pin refuses the boot. An absent pin loads with a warning that logs the computed digest; set `require_component_digest = true` under `[engine]` in `engine.toml` to make an absent pin a boot error. -The warning is silent when an `engine.toml` `[implements]` row pins the same artifact, because the bytes are verified against that pin instead. +An operator may pin the same artifact independently with `digest` on its `[[modules]]` entry in `engine.toml`; both pins are verified against the loaded bytes, and the warning is silent when the operator pin covers the artifact. The default sibling `component.toml` lives in the same trust domain as the artifact, so an author-side pin closes accidental drift only. -Against a compromised artifact store, supply an operator-owned manifest from outside the artifact directory via the `manifest` key on `[[modules]]`, combined with `require_component_digest = true`. +Against a compromised artifact store, set `[[modules]].digest`, which lives in trusted config; an operator-owned manifest outside the artifact directory via the `manifest` key on `[[modules]]` combined with `require_component_digest = true` closes the same gap. ## Licence diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 9ffdfcf6..da837837 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -28,7 +28,7 @@ derive_more.workspace = true # `strum::IntoStaticStr`: the snake_case variant name is the tracing # `source` field (`LogSource`) and the boot-refusal `error_kind` label. strum.workspace = true -# Full-semver `provides` versions and their compatibility tracks. +# Full-semver interface ids and their compatibility tracks. semver.workspace = true # No `rt`: spawning belongs to `nexum-tasks`. Omitting it enforces nothing, # since `nexum-tasks` unifies `rt` back in; the `clippy.toml` ban does. diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index 58f0b058..40bd05ed 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -21,6 +21,7 @@ async fn main() -> anyhow::Result<()> { id: "example".to_owned(), path: "target/wasm32-wasip2/release/example.wasm".into(), manifest: Some("modules/example/component.toml".into()), + digest: None, }], ..EngineConfig::default() }; diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 2c7d4ca7..ac9bb723 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -247,25 +247,15 @@ impl AssembledRuntime { configured component, so it gets the [policy] defaults" ); } - if !engine_cfg.implements.is_empty() { - warn!( - "ignoring engine.toml [implements] rows: the override is not a \ - configured component, so no binding can authorize it" - ); - } // The override is not any configured component, and its file // stem is not an operator-written id, so no [policy.component] // row may bind to it (ADR-0018); the stem is display-only. - // [implements] binds on the same id column, so it is cleared - // for the same reason: a `provides` claimant refuses here. let policy = PolicySection { component: Default::default(), ..engine_cfg.policy.clone() }; - let implements = crate::engine_config::ImplementsSection::default(); let env = supervisor::BootEnv { policy: &policy, - implements: &implements, ..supervisor::BootEnv::from_config(engine_cfg) }; let id = wasm @@ -276,6 +266,9 @@ impl AssembledRuntime { id, path: wasm, manifest, + // No [[modules]] entry describes the override, so no + // operator pin can apply to it. + digest: None, }; Supervisor::boot_single( &engine, @@ -1137,6 +1130,7 @@ mod tests { id: stem.clone(), path: dir.path().join("unrelated.wasm"), manifest: None, + digest: None, }); config.policy.component.insert( stem, @@ -1160,52 +1154,6 @@ mod tests { handle.wait().await.expect("clean shutdown"); } - /// As the policy-row rule above, for `[implements]`: the override's - /// file stem is author-controlled, so a row keyed to it must not - /// authorize the override's `provides` claim. The claimant refuses as - /// unbound rather than silently loading (ADR-0018). - #[tokio::test] - async fn a_module_source_override_never_binds_an_implements_row() { - let dir = tempfile::tempdir().expect("tempdir"); - let wasm = dir.path().join("claimant.wasm"); - std::fs::write(&wasm, b"never read: the refusal precedes the read").expect("write wasm"); - let manifest = TestManifest::new("claimant") - .provides("acme:pool/quoter@2.0.0") - .write_to(dir.path()); - let stem = "claimant".to_owned(); - - let mut config = EngineConfig::default(); - config.engine.state_dir = dir.path().join("state"); - config.modules.push(ModuleEntry { - id: stem.clone(), - path: dir.path().join("unrelated.wasm"), - manifest: None, - }); - config.implements.insert( - crate::interface_id::InterfaceTrack::parse("acme:pool/quoter@2").expect("valid track"), - crate::engine_config::Implementer { - component: stem, - digest: None, - }, - ); - - let err = RuntimeBuilder::new(&config) - .with_types::() - .with_module_source(Some(wasm), Some(manifest)) - .with_components(ComponentsBuilder::new( - ProviderPoolBuilder, - LocalStoreBuilder, - )) - .launch() - .await - .err() - .expect("an override claimant must refuse as unbound"); - Refusal::from(err).variant::(|e| { - matches!(e, crate::supervisor::LoadRefusal::ImplementerUnbound { bound, .. } - if bound == "nothing") - }); - } - /// Every module failing `init` aborts launch instead of idling. #[tokio::test] async fn launch_bails_when_all_modules_fail_init() { diff --git a/crates/nexum-runtime/src/digest.rs b/crates/nexum-runtime/src/digest.rs index a57a0443..5ed85cc3 100644 --- a/crates/nexum-runtime/src/digest.rs +++ b/crates/nexum-runtime/src/digest.rs @@ -92,8 +92,8 @@ pub enum DigestParseError { /// send the operator to edit the wrong one. #[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] pub enum DigestPin { - /// `[implements]..digest`, in the trusted `engine.toml`. - #[display("[implements] digest in engine.toml")] + /// `[[modules]].digest`, in the trusted `engine.toml`. + #[display("[[modules]].digest in engine.toml")] Operator, /// `[component].digest`, in the author-supplied manifest. #[display("[component].digest in the manifest")] @@ -228,6 +228,6 @@ mod tests { // wording must not serve both. assert!(msg.contains("[component].digest in the manifest"), "{msg}"); let msg = message(DigestPin::Operator); - assert!(msg.contains("[implements] digest in engine.toml"), "{msg}"); + assert!(msg.contains("[[modules]].digest in engine.toml"), "{msg}"); } } diff --git a/crates/nexum-runtime/src/engine_config/error.rs b/crates/nexum-runtime/src/engine_config/error.rs index 585456ba..d5e22c31 100644 --- a/crates/nexum-runtime/src/engine_config/error.rs +++ b/crates/nexum-runtime/src/engine_config/error.rs @@ -73,33 +73,11 @@ pub enum EngineConfigError { /// The entry as written. entry: String, }, - /// Refused rather than dropped: an ignored row loses an authorization - /// and its claimant then refuses at load with the wrong message. - #[error( - "engine config: [implements] key {key:?} is not an interface track \ - (namespace:package/interface@major, or @0.minor below 1.0)" - )] - InvalidInterfaceTrack { - /// The key as written. - key: String, - }, - /// A row binding to nothing is a typo, and an unapplied binding fails - /// closed at load with a message that points away from the typo. - #[error( - "engine config: [implements].{interface:?} names component {id:?}, \ - which matches no [[modules]].id" - )] - UnknownImplementsComponent { - /// The row's key as written. - interface: String, - /// The dangling component value. + /// A `[[modules]].digest` that is not a digest. + #[error("engine config: [[modules]] entry {id:?} digest {value:?}: {source}")] + InvalidModuleDigest { + /// The entry's operator-written id. id: String, - }, - /// Refused at load, as the `[component].digest` grammar is. - #[error("engine config: [implements].{interface:?} digest {value:?}: {source}")] - InvalidImplementerDigest { - /// The row's key as written. - interface: String, /// The digest as written. value: String, /// Why the digest refused. diff --git a/crates/nexum-runtime/src/engine_config/implements.rs b/crates/nexum-runtime/src/engine_config/implements.rs deleted file mode 100644 index 6971c760..00000000 --- a/crates/nexum-runtime/src/engine_config/implements.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! `[implements]`: the operator's binding of an interface track to its -//! authorized implementer. - -use std::collections::{BTreeMap, HashSet}; - -use serde::Deserialize; - -use super::error::EngineConfigError; -use crate::digest::ContentDigest; -use crate::interface_id::InterfaceTrack; - -/// `[implements]` resolved at load, keyed on the compatibility track. A -/// `provides` claim with no row here does not load: type-level truth is -/// not authorization (ADR-0001). -pub type ImplementsSection = BTreeMap; - -/// One `[implements]` row. -#[derive(Debug, Clone)] -pub struct Implementer { - /// The authorized `[[modules]].id`. The author-supplied - /// `[component].name` never binds (ADR-0001). - pub component: String, - /// Pin of the implementer's artifact; an unpinned row refuses at load. - pub digest: Option, -} - -/// Raw `[implements]` row; validated by [`resolve_implements`]. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct RawImplementer { - pub(super) component: String, - #[serde(default)] - pub(super) digest: Option, -} - -pub(super) fn resolve_implements( - raw: BTreeMap, - ids: &HashSet<&str>, -) -> Result { - let mut implements = ImplementsSection::new(); - for (key, row) in raw { - let Ok(track) = InterfaceTrack::parse(&key) else { - return Err(EngineConfigError::InvalidInterfaceTrack { key }); - }; - if !ids.contains(row.component.as_str()) { - return Err(EngineConfigError::UnknownImplementsComponent { - interface: key, - id: row.component, - }); - } - let digest = match row.digest { - Some(value) => Some(value.parse::().map_err(|source| { - EngineConfigError::InvalidImplementerDigest { - interface: key, - value, - source, - } - })?), - None => None, - }; - implements.insert( - track, - Implementer { - component: row.component, - digest, - }, - ); - } - Ok(implements) -} diff --git a/crates/nexum-runtime/src/engine_config/mod.rs b/crates/nexum-runtime/src/engine_config/mod.rs index 5c4d6322..ef57aaf6 100644 --- a/crates/nexum-runtime/src/engine_config/mod.rs +++ b/crates/nexum-runtime/src/engine_config/mod.rs @@ -7,14 +7,12 @@ mod chain; mod error; -mod implements; mod limits; mod load; mod policy; pub use chain::{ChainConfig, RpcEndpoint, RpcEndpointError, RpcTransport}; pub use error::{EngineConfigError, EnvVarError}; -pub use implements::{Implementer, ImplementsSection}; pub use limits::{ ChainLimitsSection, DispatchLimitsSection, HttpLimitsSection, LogLimitsSection, LogRetentionLimits, ModuleLimits, OutboundHttpLimits, PoisonLimitsSection, @@ -23,15 +21,15 @@ pub use limits::{ pub use load::load_or_default; pub use policy::{ComponentPolicy, EffectivePolicy, PolicyCeilings, PolicySection, TotalPolicy}; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use std::num::{NonZeroU64, NonZeroUsize}; use std::path::PathBuf; use alloy_chains::Chain; use serde::Deserialize; +use crate::digest::ContentDigest; use chain::{RawChainConfig, resolve_chains}; -use implements::{RawImplementer, resolve_implements}; use policy::{RawPolicySection, resolve_policy}; /// A literal as non-zero; a zero fails the build. @@ -62,9 +60,6 @@ pub struct EngineConfig { pub limits: ResolvedModuleLimits, /// Operator resource and egress policy. pub policy: PolicySection, - /// `[implements]` bindings, keyed on the interface's compatibility - /// track. The sole authorization of a `provides` claim. - pub implements: ImplementsSection, /// Per-chain RPC config keyed by EIP-155 chain id. Numeric /// (`[chains.11155111]`) and named (`[chains.sepolia]`) keys both /// validate via `Chain`'s `FromStr` after the TOML parse. @@ -91,13 +86,11 @@ struct RawEngineConfig { #[serde(default)] policy: RawPolicySection, #[serde(default)] - implements: BTreeMap, - #[serde(default)] chains: HashMap, #[serde(default)] extensions: HashMap, #[serde(default)] - modules: Vec, + modules: Vec, } impl TryFrom for EngineConfig { @@ -124,24 +117,41 @@ impl TryFrom for EngineConfig { } } let policy = resolve_policy(raw.policy, &ids)?; - let implements = resolve_implements(raw.implements, &ids)?; + let modules = raw + .modules + .into_iter() + .map(ModuleEntry::try_from) + .collect::>()?; Ok(Self { engine: raw.engine, limits: raw.limits.try_into()?, policy, - implements, chains, extensions: raw.extensions, - modules: raw.modules, + modules, defaulted: false, }) } } -/// One `[[modules]]` table. `manifest` defaults to a sibling -/// `component.toml`. +/// Raw `[[modules]]` table; the digest stays as written until the +/// [`ModuleEntry`] conversion validates it. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] +struct RawModuleEntry { + id: String, + path: std::path::PathBuf, + #[serde(default)] + manifest: Option, + #[serde(default)] + digest: Option, +} + +/// One `[[modules]]` table. `manifest` defaults to a sibling +/// `component.toml`. Deserialization goes through the raw shape, so a +/// standalone parse validates the digest exactly as the config load does. +#[derive(Debug, Deserialize)] +#[serde(try_from = "RawModuleEntry")] pub struct ModuleEntry { /// Operator-written identity; the `[policy.component.]` key. The /// author-supplied `[component].name` never binds policy (ADR-0001). @@ -149,8 +159,34 @@ pub struct ModuleEntry { /// Path to the compiled `.wasm` component. pub path: std::path::PathBuf, /// Path to the module's `component.toml`. Defaults to `/component.toml`. - #[serde(default)] pub manifest: Option, + /// The operator's pin on this entry's artifact, verified against the + /// exact bytes handed to the compiler. Independent of the author's + /// `[component].digest`: both are verified when present. + pub digest: Option, +} + +impl TryFrom for ModuleEntry { + type Error = EngineConfigError; + + fn try_from(raw: RawModuleEntry) -> Result { + let digest = match raw.digest { + Some(value) => Some(value.parse::().map_err(|source| { + EngineConfigError::InvalidModuleDigest { + id: raw.id.clone(), + value, + source, + } + })?), + None => None, + }; + Ok(Self { + id: raw.id, + path: raw.path, + manifest: raw.manifest, + digest, + }) + } } /// `[engine]`: settings that apply to the process, not to one module. @@ -405,9 +441,11 @@ request_timeout_secs = 0 "[limits]\nevent_deadline_secs = 30\n", ), ( - "key in an implements row", + // ADR-0022 cut `[implements]`; a stale table refuses at + // parse as an unknown key rather than binding nothing. + "retired [implements] table", "[[modules]]\nid = \"m\"\npath = \"m.wasm\"\n\ - [implements.\"a:b/c@1\"]\ncomponent = \"m\"\ndigets = \"sha256:00\"\n", + [implements.\"a:b/c@1\"]\ncomponent = \"m\"\n", ), ] { let err = toml::from_str::(toml) @@ -445,10 +483,6 @@ max_memory_bytes = 4294967296 max_memory_bytes = 1073741824 http_allow = ["api.cow.fi"] -[implements."nexum:wallet/signer@2"] -component = "m" -digest = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - [chains.1] rpc_url = "https://example.test" @@ -458,13 +492,14 @@ anything = "goes here, the engine never reads it" [[modules]] id = "m" path = "m.wasm" +digest = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" "#, ) .expect("every documented section parses under the guard"); assert_eq!(cfg.policy.ceilings.max_fuel_per_dispatch.get(), 7); assert_eq!(cfg.modules.len(), 1); assert_eq!(cfg.modules[0].id, "m"); - assert_eq!(cfg.implements.len(), 1); + assert!(cfg.modules[0].digest.is_some()); assert!( cfg.extensions.contains_key("acme"), "an extension table stays opaque and unguarded", @@ -546,84 +581,48 @@ max_state_bytes = 2_048 } #[test] - fn an_implements_row_resolves_to_a_typed_binding() { + fn a_modules_digest_parses_to_a_typed_pin() { let pin = format!("sha256:{}", "ab".repeat(32)); let cfg: EngineConfig = toml::from_str(&format!( - "[implements.\"nexum:wallet/signer@2\"]\ncomponent = \"m\"\ndigest = \"{pin}\"\n\ - [implements.\"acme:venues/registry@0.3\"]\ncomponent = \"m\"\n\ - [[modules]]\nid = \"m\"\npath = \"m.wasm\"\n", + "[[modules]]\nid = \"m\"\npath = \"m.wasm\"\ndigest = \"{pin}\"\n\ + [[modules]]\nid = \"n\"\npath = \"n.wasm\"\n", )) - .expect("[implements] rows parse"); - let track = crate::interface_id::InterfaceTrack::parse("nexum:wallet/signer@2") - .expect("valid track"); - let row = cfg.implements.get(&track).expect("pinned row"); - assert_eq!(row.component, "m"); - assert_eq!(row.digest.expect("pin parsed").to_string(), pin); - let track = crate::interface_id::InterfaceTrack::parse("acme:venues/registry@0.3") - .expect("valid track"); - let row = cfg.implements.get(&track).expect("unpinned row"); + .expect("[[modules]] digests parse"); + let pinned = &cfg.modules[0]; + assert_eq!(pinned.digest.expect("pin parsed").to_string(), pin); assert!( - row.digest.is_none(), - "an absent digest survives parse; load refuses it as implementer_unpinned", + cfg.modules[1].digest.is_none(), + "an absent operator pin is the permitted default", ); } - /// The key must be the compatibility track: a full version would let - /// two in-track claimants both match distinct rows, defeating the - /// prepass duplicate-claim gate. + /// `ModuleEntry` is public API a downstream config can embed, so the + /// standalone `Deserialize` impl must survive the raw-shape split, + /// and it must validate the digest exactly as the config load does. #[test] - fn an_implements_key_that_is_not_a_track_refuses() { - for bad in [ - "nexum:wallet/signer@2.0.0", - "signer@2", - "nexum:wallet/signer", - ] { - let toml = format!( - "[implements.\"{bad}\"]\ncomponent = \"m\"\n\ - [[modules]]\nid = \"m\"\npath = \"m.wasm\"\n", - ); - let raw = toml::from_str::(&toml) - .expect("the raw parse only decides the TOML is well formed"); - let err = EngineConfig::try_from(raw).expect_err("a non-track key must not validate"); - assert!( - matches!(err, EngineConfigError::InvalidInterfaceTrack { ref key } if key == bad), - "{err:?}", - ); - // The public `Deserialize` path runs the same conversion. - let err = toml::from_str::(&toml) - .expect_err("the derived Deserialize must refuse too"); - assert!(err.to_string().contains(bad), "{err}"); - } - } - - #[test] - fn an_implements_row_matching_no_module_id_refuses() { - const DANGLING: &str = "[implements.\"nexum:wallet/signer@2\"]\ncomponent = \"wallet\"\n\ - [[modules]]\nid = \"tracker\"\npath = \"t.wasm\"\n"; - let raw = toml::from_str::(DANGLING) - .expect("the raw parse only decides the TOML is well formed"); - let err = EngineConfig::try_from(raw).expect_err("a dangling row must not validate"); - assert!( - matches!(err, EngineConfigError::UnknownImplementsComponent { ref interface, ref id } - if interface == "nexum:wallet/signer@2" && id == "wallet"), - "{err:?}", - ); - let err = toml::from_str::(DANGLING) - .expect_err("the derived Deserialize must refuse too"); - assert!(err.to_string().contains("wallet"), "{err}"); + fn a_module_entry_deserializes_standalone() { + let pin = format!("sha256:{}", "ab".repeat(32)); + let entry: ModuleEntry = toml::from_str(&format!( + "id = \"m\"\npath = \"m.wasm\"\ndigest = \"{pin}\"\n" + )) + .expect("a standalone entry parses"); + assert_eq!(entry.id, "m"); + assert_eq!(entry.digest.expect("pin parsed").to_string(), pin); + let err = + toml::from_str::("id = \"m\"\npath = \"m.wasm\"\ndigest = \"bad\"\n") + .expect_err("a malformed pin must refuse standalone too"); + assert!(err.to_string().contains("bad"), "{err}"); } #[test] - fn a_malformed_implementer_digest_refuses() { - const BAD: &str = "[implements.\"nexum:wallet/signer@2\"]\n\ - component = \"m\"\ndigest = \"notahash\"\n\ - [[modules]]\nid = \"m\"\npath = \"m.wasm\"\n"; + fn a_malformed_modules_digest_refuses() { + const BAD: &str = "[[modules]]\nid = \"m\"\npath = \"m.wasm\"\ndigest = \"notahash\"\n"; let raw = toml::from_str::(BAD) .expect("the raw parse only decides the TOML is well formed"); let err = EngineConfig::try_from(raw).expect_err("a malformed pin must not validate"); assert!( - matches!(err, EngineConfigError::InvalidImplementerDigest { ref interface, ref value, .. } - if interface == "nexum:wallet/signer@2" && value == "notahash"), + matches!(err, EngineConfigError::InvalidModuleDigest { ref id, ref value, .. } + if id == "m" && value == "notahash"), "{err:?}", ); let err = toml::from_str::(BAD) diff --git a/crates/nexum-runtime/src/interface_id.rs b/crates/nexum-runtime/src/interface_id.rs index 19a38492..d8757fcf 100644 --- a/crates/nexum-runtime/src/interface_id.rs +++ b/crates/nexum-runtime/src/interface_id.rs @@ -1,5 +1,9 @@ -//! Interface identity: the `[component].provides` claim and the -//! `[implements]` key. +//! Interface identity: a full interface id and its compatibility track. +//! +//! Uncalled since ADR-0022 cut guest-to-guest calling, and kept on purpose: +//! a plugin registry selects slot candidates before reading artifact bytes, +//! and [`InterfaceTrack`] decides whether an update needs fresh user +//! consent. Not an orphan. use derive_more::Display; use thiserror::Error; @@ -42,26 +46,7 @@ impl InterfaceId { }) } - /// True when `export` names the same interface at a version that - /// satisfies the claim: the same [track](Self::track) and no older - /// than the claimed version. An unversioned export never satisfies a - /// claim, which always carries a full version. - pub fn matches_export(&self, export: &str) -> bool { - let Some((name, version)) = export.split_once('@') else { - return false; - }; - if name != self.name { - return false; - } - let Ok(version) = semver::Version::parse(version) else { - return false; - }; - track_suffix(&version) == track_suffix(&self.version) && version >= self.version - } - - /// The compatibility track this id belongs to. `[implements]` and the - /// prepass duplicate-claim ledger both key on it, through this one - /// derivation, so the two sites cannot drift. + /// The one derivation, so two sites keyed on a track cannot drift. pub fn track(&self) -> InterfaceTrack { InterfaceTrack(format!("{}@{}", self.name, track_suffix(&self.version))) } @@ -102,8 +87,8 @@ fn is_kebab_word(word: &str) -> bool { } /// The key is not a track: full semver, leading zeros, and a bare `@0` -/// are all refused, because a loosely spelt authorization row silently -/// binds nothing. +/// are all refused, because a loosely spelt track key silently matches +/// nothing. #[derive(Debug, Error)] #[error( "{0:?} is not an interface track \ @@ -112,15 +97,14 @@ fn is_kebab_word(word: &str) -> bool { pub struct InvalidInterfaceTrack(pub String); /// An interface's compatibility track, e.g. `nexum:wallet/signer@2`. -/// A compatible provider release stays inside its track, so an -/// `[implements]` row keyed on one survives it; the digest pins the exact -/// artifact. Under 0.1 nothing is compatible, so the track is the whole -/// version and every release is an operator edit. +/// A compatible release stays inside its track, so a selection keyed on +/// one survives it. Under 0.1 nothing is compatible, so the track is the +/// whole version and every release leaves the track. #[derive(Clone, Debug, Display, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct InterfaceTrack(String); impl InterfaceTrack { - /// Validate an `[implements]` key; the grammar is strict, as a pin's is. + /// Strict: a loose spelling matches nothing. pub fn parse(key: &str) -> Result { let refuse = || InvalidInterfaceTrack(key.to_owned()); let Some((name, suffix)) = key.split_once('@') else { @@ -219,31 +203,6 @@ mod tests { assert_ne!(id("a:b/c@0.0.1").track(), id("a:b/c@0.0.9").track()); } - #[test] - fn an_export_satisfies_the_claim_only_in_track_and_no_older() { - let claim = id("nexum:wallet/signer@2.1.0"); - assert!(claim.matches_export("nexum:wallet/signer@2.1.0")); - assert!(claim.matches_export("nexum:wallet/signer@2.2.0")); - // An older in-track export does not honour the claimed surface. - assert!(!claim.matches_export("nexum:wallet/signer@2.0.0")); - // The wrong track is the fail-open the operator cannot see. - assert!(!claim.matches_export("nexum:wallet/signer@1.0.0")); - assert!(!claim.matches_export("nexum:wallet/signer@3.0.0")); - assert!(!claim.matches_export("nexum:wallet/signer")); - assert!(!claim.matches_export("nexum:wallet/other@2.1.0")); - assert!(!claim.matches_export("init")); - } - - /// Cargo reads `^0.0.1` as `=0.0.1`, so a newer patch under 0.1 is a - /// different interface, not a compatible one. - #[test] - fn a_newer_patch_under_zero_one_does_not_satisfy_the_claim() { - let claim = id("acme:pool/quoter@0.0.1"); - assert!(claim.matches_export("acme:pool/quoter@0.0.1")); - assert!(!claim.matches_export("acme:pool/quoter@0.0.9")); - assert!(!claim.matches_export("acme:pool/quoter@0.1.0")); - } - #[test] fn track_parse_accepts_only_the_canonical_spelling() { for good in ["nexum:wallet/signer@2", "a:b/c@0.3", "a:b/c@0.0.7"] { diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 1f5ec826..21867815 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -248,7 +248,6 @@ mod tests { LoadedManifest { name: test_module_id(), component_digest: None, - provides: None, resources: ResourceSection::default(), dependencies: required .iter() diff --git a/crates/nexum-runtime/src/manifest/error.rs b/crates/nexum-runtime/src/manifest/error.rs index c7f3ab2c..acbd049e 100644 --- a/crates/nexum-runtime/src/manifest/error.rs +++ b/crates/nexum-runtime/src/manifest/error.rs @@ -58,12 +58,6 @@ pub enum ParseError { #[source] source: crate::digest::DigestParseError, }, - #[error("manifest: [component].provides {value:?} is not an interface id: {source}")] - InvalidInterfaceId { - value: String, - #[source] - source: crate::interface_id::InvalidInterfaceId, - }, /// A `[[subscription]]` table without a string `kind`. #[error("manifest: [[subscription]] table {index} must declare a string `kind`")] MissingSubscriptionKind { diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 75d587c5..699d00e1 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -747,47 +747,13 @@ max_fuel_per_dispatch = 100000 assert_eq!(digest.to_string(), pin); } + /// The property ADR-0020 relied on for `kind`, now holding for the + /// `provides` field ADR-0022 retired: a stale manifest refuses at + /// parse rather than silently ignoring the claim. #[test] - fn load_parses_a_valid_provides_claim_and_round_trips() { - let loaded = load_inline(&digest_manifest("provides = \"nexum:wallet/signer@2.0.0\"")) - .expect("valid provides loads"); - let claim = loaded.provides.expect("provides parsed"); - assert_eq!(claim.to_string(), "nexum:wallet/signer@2.0.0"); - } - - #[test] - fn load_defaults_an_absent_provides_to_none() { - let loaded = load_inline(&digest_manifest("")).expect("absent provides loads"); - assert!(loaded.provides.is_none()); - } - - #[test] - fn load_rejects_a_malformed_provides_claim() { - for (label, bad) in [ - ("truncated version", "nexum:wallet/signer@2.0"), - ("bare major", "nexum:wallet/signer@2"), - ("no version", "nexum:wallet/signer"), - ("no package", "signer@2.0.0"), - ("no namespace", "wallet/signer@2.0.0"), - ("no interface", "nexum:wallet@2.0.0"), - ("empty", ""), - ] { - let err = - load_inline(&digest_manifest(&format!("provides = \"{bad}\""))).expect_err(label); - assert!( - matches!(err, ParseError::InvalidInterfaceId { ref value, .. } if value == bad), - "{label}: {err:?}", - ); - } - } - - /// The property ADR-0020 relied on for `kind`: a stale engine reading - /// a `provides` manifest refuses at parse rather than silently - /// ignoring the claim; here its neighbour, a misspelt `provides`. - #[test] - fn load_rejects_a_misspelt_provides_key() { - let err = load_inline(&digest_manifest("provdes = \"nexum:wallet/signer@2.0.0\"")) - .expect_err("an unknown [component] key must refuse"); + fn load_rejects_a_retired_provides_key() { + let err = load_inline(&digest_manifest("provides = \"nexum:wallet/signer@2.0.0\"")) + .expect_err("the retired [component].provides key must refuse"); assert!( matches!(&err, ParseError::Toml(e) if e.to_string().contains("unknown")), "{err:?}", diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index 53fc9116..c8172547 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -201,11 +201,6 @@ pub(crate) struct ComponentSection { /// bytes before compile. #[serde(default)] pub digest: Option, - /// The interface this component exports, as a full interface id. A - /// claim, not a fact: load verifies it against the real exports, and - /// only an operator `[implements]` row authorizes it (ADR-0001). - #[serde(default)] - pub provides: Option, /// Per-component resource requests; each unset field inherits the /// component's `[policy]` ceiling and never widens it. #[serde(default)] @@ -233,8 +228,8 @@ pub struct ResourceSection { pub max_state_bytes: Option, } -/// `[dependencies]`: each key names a host capability or a service, and -/// its table carries the attributes that qualify it. +/// `[dependencies]`: each key names a host capability, and its table +/// carries the attributes that qualify it. pub type DependencySection = BTreeMap; /// One dependency's attributes. `deny_unknown_fields` so a misspelled @@ -256,8 +251,6 @@ pub struct LoadedManifest { pub name: crate::module_id::ModuleId, /// `[component].digest` parsed to its typed digest. pub component_digest: Option, - /// `[component].provides` parsed to its typed interface id. - pub provides: Option, /// `[component.resources]` overrides. pub resources: ResourceSection, /// `[dependencies]`; presence is validated, an absent table refuses. @@ -295,16 +288,6 @@ impl TryFrom for LoadedManifest { value: manifest.component.digest.clone().unwrap_or_default(), source, })?; - let provides = manifest - .component - .provides - .as_deref() - .map(crate::interface_id::InterfaceId::parse) - .transpose() - .map_err(|source| ParseError::InvalidInterfaceId { - value: manifest.component.provides.clone().unwrap_or_default(), - source, - })?; let subscriptions = manifest .subscriptions .into_iter() @@ -331,7 +314,6 @@ impl TryFrom for LoadedManifest { Ok(Self { name, component_digest, - provides, resources: manifest.component.resources, dependencies, http_allowlist, diff --git a/crates/nexum-runtime/src/refusal.rs b/crates/nexum-runtime/src/refusal.rs index dec3531f..f5b111f8 100644 --- a/crates/nexum-runtime/src/refusal.rs +++ b/crates/nexum-runtime/src/refusal.rs @@ -169,7 +169,6 @@ mod tests { const PINNED_LABELS: &[&str] = &[ // BootRefusal, with `manifest` split into the ParseError classes. "namespace_claimed", - "interface_claimed", "manifest_not_found", "manifest_missing", "unconfigured_chain_defaulted", @@ -185,7 +184,6 @@ mod tests { "missing_capabilities", "misplaced_dependency_attribute", "invalid_component_digest", - "invalid_interface_id", "missing_subscription_kind", "invalid_subscription", "invalid_chain_log_address", @@ -200,10 +198,6 @@ mod tests { "digest_unpinned", "capability_not_permitted", "chain_subscription_not_permitted", - "provides_not_exported", - "implementer_unbound", - "implementer_unpinned", - "implementer_not_claiming", // LaunchRefusal, less the wait-time `event_loop_gone`, which is // raised after a successful boot and never counted. "nothing_to_run", diff --git a/crates/nexum-runtime/src/supervisor/artifact.rs b/crates/nexum-runtime/src/supervisor/artifact.rs index 78badc8a..6f41f55f 100644 --- a/crates/nexum-runtime/src/supervisor/artifact.rs +++ b/crates/nexum-runtime/src/supervisor/artifact.rs @@ -14,11 +14,11 @@ use super::load::LoadRefusal; use crate::digest::{ContentDigest, DigestMismatch, DigestPin}; use crate::refusal::Refusal; -/// Digest expectations for one artifact. The operator's `[implements]` +/// Digest expectations for one artifact. The operator's `[[modules]]` /// pin and the author's `[component].digest` are independent: both are /// verified when present, so a disagreement between them refuses. pub(super) struct DigestPolicy<'a> { - /// The `[implements]` row's pin; checked first, so a disagreement + /// The `[[modules]].digest` pin; checked first, so a disagreement /// reports the operator's expectation. pub(super) operator: Option<&'a ContentDigest>, /// The manifest's `[component].digest` pin. @@ -30,7 +30,6 @@ pub(super) struct DigestPolicy<'a> { #[cfg(test)] impl<'a> DigestPolicy<'a> { - /// The author-pin-only policy, for tests without an `[implements]` row. pub(super) fn author(declared: Option<&'a ContentDigest>, require: bool) -> Self { Self { operator: None, @@ -59,7 +58,7 @@ pub(super) fn read_verified_component( } .into()); } - debug!(component = %path.display(), digest = %actual, "operator [implements] pin verified"); + debug!(component = %path.display(), digest = %actual, "operator [[modules]].digest pin verified"); } match pins.author { // A mismatch stays a typed arm of the refusal: callers match on it. diff --git a/crates/nexum-runtime/src/supervisor/load.rs b/crates/nexum-runtime/src/supervisor/load.rs index b63431f4..b0ce301e 100644 --- a/crates/nexum-runtime/src/supervisor/load.rs +++ b/crates/nexum-runtime/src/supervisor/load.rs @@ -9,7 +9,6 @@ use anyhow::{Context, Error, Result}; use strum::{IntoStaticStr, VariantNames}; use thiserror::Error as ThisError; use tracing::{info, warn}; -use wasmtime::component::types::ComponentItem; use wasmtime::component::{Component, Linker}; use super::admission::{ @@ -24,11 +23,10 @@ use super::{BootEnv, Shared}; use crate::bindings::nexum::host::types::Fault; use crate::bindings::{Config, EventModule}; use crate::digest::ContentDigest; -use crate::engine_config::{ImplementsSection, ModuleEntry}; +use crate::engine_config::ModuleEntry; use crate::host::component::RuntimeTypes; use crate::host::logs::RunId; use crate::host::state::HostState; -use crate::interface_id::{InterfaceId, InterfaceTrack}; use crate::manifest::{self, CapabilityRegistry, LoadedManifest, Subscription}; use crate::module_id::ModuleId; use crate::refusal::{Refusal, RefusalContext as _}; @@ -120,68 +118,6 @@ pub enum LoadRefusal { /// The permitted set, for the fix. permitted: String, }, - /// The `provides` claim is author-supplied (ADR-0001); a component - /// that does not export it must not enter the tree as its implementer. - #[error( - "component {id} ({}) claims provides = {claimed} but exports no \ - satisfying interface instance; interface exports: {exported}", - path.display() - )] - ProvidesNotExported { - /// The entry's operator-written id. - id: String, - /// The artifact whose exports were walked. - path: PathBuf, - /// The claimed interface id. - claimed: String, - /// The interface-instance exports found, so a version near-miss - /// is legible; `none` when there are none. - exported: String, - }, - /// A genuine export is still not authorization: binding an - /// implementer is an operator act, written in `[implements]` - /// (ADR-0001), and there is no permissive default. - #[error( - "component {id} provides {interface} but [implements].\"{interface}\" \ - authorizes: {bound}; bind the interface to this entry's [[modules]].id \ - in engine.toml" - )] - ImplementerUnbound { - /// The entry's operator-written id. - id: String, - /// The claim's compatibility track, as the `[implements]` key. - interface: String, - /// What the row authorizes today: another id, or `nothing`. - bound: String, - }, - /// The binding names an id, not bytes; only the digest fixes the - /// artifact, so an unpinned implementer does not load. - #[error( - "component {id} is bound to {interface} without a digest; \ - pin the artifact's sha256 in [implements].\"{interface}\"" - )] - ImplementerUnpinned { - /// The entry's operator-written id. - id: String, - /// The claim's compatibility track, as the `[implements]` key. - interface: String, - }, - /// The row's digest is the only operator-written pin on the artifact. - /// Were an unmatched row inert, deleting one line of the untrusted - /// manifest would delete the operator's pin with it. - #[error( - "[implements].\"{interface}\" authorizes component {id}, whose manifest \ - provides {claimed}; the row pins no artifact as written, so drop it or \ - restore the claim" - )] - ImplementerNotClaiming { - /// The entry's operator-written id. - id: String, - /// The row's key, which the entry does not claim. - interface: String, - /// The entry's claim as a track, or `nothing`. - claimed: String, - }, } /// Restarts reuse the cache, so the boot-time digest holds for every run. @@ -330,89 +266,6 @@ fn enforce_policy_capabilities( Ok(()) } -/// A `provides` claim loads only for the entry `[implements]` binds and -/// pins; the returned pin is verified on the exact bytes the compiler -/// receives. `None` claim is the common case and needs no row. -/// -/// The sweep over every row runs first, and it is what stops the author -/// from switching the operator's pin off: a row naming this entry must be -/// matched by the entry's claim, so deleting the manifest's `provides` -/// line refuses instead of dropping the operator's digest with it. -fn enforce_implements<'a>( - entry: &ModuleEntry, - claim: Option<&InterfaceId>, - implements: &'a ImplementsSection, -) -> Result, LoadRefusal> { - let claimed = claim.map(InterfaceId::track); - for (row_track, row) in implements { - if row.component == entry.id && Some(row_track) != claimed.as_ref() { - return Err(LoadRefusal::ImplementerNotClaiming { - id: entry.id.clone(), - interface: row_track.to_string(), - claimed: claimed - .as_ref() - .map_or_else(|| "nothing".to_owned(), InterfaceTrack::to_string), - }); - } - } - let Some(track) = claimed else { - return Ok(None); - }; - let row = implements.get(&track); - // A row alone is a presence test; the binding is the id comparison. - let Some(row) = row.filter(|row| row.component == entry.id) else { - return Err(LoadRefusal::ImplementerUnbound { - id: entry.id.clone(), - interface: track.to_string(), - bound: row.map_or_else(|| "nothing".to_owned(), |row| row.component.clone()), - }); - }; - let Some(digest) = row.digest.as_ref() else { - return Err(LoadRefusal::ImplementerUnpinned { - id: entry.id.clone(), - interface: track.to_string(), - }); - }; - Ok(Some(digest)) -} - -/// The claim is satisfied only by an interface-instance export: a bare -/// func under a matching name must not pass, and `synthesize` gives every -/// module `init` and `on-event` funcs. -/// -/// The match is nominal: name, kind and version, never the interface's -/// surface, so an empty instance under the claimed name passes. Nothing -/// in the engine holds the interface's WIT to compare against until WIT -/// distribution lands with the consumer side (#205), which is also when a -/// caller could first be misled by the gap. -pub(super) fn enforce_provides<'a>( - id: &str, - path: &Path, - claim: &InterfaceId, - exports: impl Iterator, -) -> Result<(), LoadRefusal> { - let mut instance_exports = Vec::new(); - for (name, item) in exports { - if !matches!(item, ComponentItem::ComponentInstance(_)) { - continue; - } - if claim.matches_export(name) { - return Ok(()); - } - instance_exports.push(name.to_owned()); - } - Err(LoadRefusal::ProvidesNotExported { - id: id.to_owned(), - path: path.to_path_buf(), - claimed: claim.to_string(), - exported: if instance_exports.is_empty() { - "none".to_owned() - } else { - instance_exports.join(", ") - }, - }) -} - /// A failed `init` loads the module dead; the dispatcher skips it. pub(super) async fn module( shared: &Shared, @@ -425,7 +278,6 @@ pub(super) async fn module( let BootEnv { limits: limits_cfg, policy, - implements, require_component_digest, .. } = *env; @@ -433,13 +285,10 @@ pub(super) async fn module( let effective = policy.for_component(&entry.id); enforce_policy_capabilities(&entry.id, &loaded_manifest, effective.capabilities) .with_refusal_context(|| format!("install refused for {}", entry.path.display()))?; - // Before any artifact byte is read: authorization precedes verification. - let operator_pin = enforce_implements(entry, loaded_manifest.provides.as_ref(), implements) - .with_refusal_context(|| format!("install refused for {}", entry.path.display()))?; let registry = capability_registry(&shared.extensions); let sections = &loaded_manifest.extensions; let pins = DigestPolicy { - operator: operator_pin, + operator: entry.digest.as_ref(), author: loaded_manifest.component_digest.as_ref(), require_author: require_component_digest, }; @@ -461,20 +310,6 @@ pub(super) async fn module( Ok(()) }, )?; - // Post-compile, pre-instantiation, exactly as the import walk above: - // a false claim never reaches `instantiate` or `init`. - if let Some(claim) = &loaded_manifest.provides { - enforce_provides( - &entry.id, - &entry.path, - claim, - component - .component_type() - .exports(&shared.engine) - .map(|(name, export)| (name, export.ty)), - ) - .with_refusal_context(|| format!("install refused for {}", entry.path.display()))?; - } let ResolvedLimits { fuel, diff --git a/crates/nexum-runtime/src/supervisor/mod.rs b/crates/nexum-runtime/src/supervisor/mod.rs index 6bc15bdb..93f8d565 100644 --- a/crates/nexum-runtime/src/supervisor/mod.rs +++ b/crates/nexum-runtime/src/supervisor/mod.rs @@ -22,9 +22,7 @@ use tracing::info; use wasmtime::Engine; use wasmtime::component::Linker; -use crate::engine_config::{ - EngineConfig, ImplementsSection, ModuleEntry, PolicySection, ResolvedModuleLimits, -}; +use crate::engine_config::{EngineConfig, ModuleEntry, PolicySection, ResolvedModuleLimits}; use crate::host::component::{Components, RuntimeTypes}; use crate::host::extension::Extension; use crate::host::state::HostState; @@ -53,9 +51,6 @@ pub struct BootEnv<'a> { pub policy: &'a PolicySection, /// Chains with an `engine.toml` entry; a subscription elsewhere refuses. pub configured_chains: ConfiguredChains, - /// The operator's `[implements]` bindings; a `provides` claimant - /// outside them refuses. - pub implements: &'a ImplementsSection, /// Refuse a component whose manifest declares no digest. pub require_component_digest: bool, } @@ -67,7 +62,6 @@ impl<'a> BootEnv<'a> { limits: &cfg.limits, policy: &cfg.policy, configured_chains: ConfiguredChains::from_config(cfg), - implements: &cfg.implements, require_component_digest: cfg.engine.require_component_digest, } } diff --git a/crates/nexum-runtime/src/supervisor/prepass.rs b/crates/nexum-runtime/src/supervisor/prepass.rs index 02e860dd..9671d69d 100644 --- a/crates/nexum-runtime/src/supervisor/prepass.rs +++ b/crates/nexum-runtime/src/supervisor/prepass.rs @@ -11,7 +11,6 @@ use tracing::{info, warn}; use super::store::{ResolvedLimits, resolve_module_limits}; use crate::engine_config::{EngineConfig, PolicySection}; -use crate::interface_id::{InterfaceId, InterfaceTrack}; use crate::manifest::{self, CapabilityRegistry, LoadedManifest, ParseError, Subscription}; use crate::module_id::ModuleId; use crate::refusal::{Refusal, RefusalContext as _}; @@ -39,24 +38,6 @@ pub enum BootRefusal { /// The second claimant's path. path: PathBuf, }, - /// A second claimant would make the `[implements]` binding ambiguous, - /// so it refuses before either artifact compiles. Keyed on the - /// compatibility track, as `[implements]` is, so two full versions of - /// one track still collide. - #[error( - "interface {interface} is claimed twice: {} and {}; \ - one [implements] row authorizes one implementer", - held.display(), - path.display() - )] - InterfaceClaimed { - /// The claimed compatibility track. - interface: InterfaceTrack, - /// The holding entry's path. - held: PathBuf, - /// The second claimant's path. - path: PathBuf, - }, /// The manifest failed to load or validate; the wrapped class is the /// counter label. #[error(transparent)] @@ -160,27 +141,6 @@ pub(super) fn claim_namespace( Ok(()) } -/// Claimed interface tracks, each with the claiming entry's path. -pub(super) type InterfaceLedger = BTreeMap; - -/// Claim the interface's track for `path`, refusing a second claimant. -pub(super) fn claim_interface( - ledger: &mut InterfaceLedger, - claim: &InterfaceId, - path: &Path, -) -> Result<(), BootRefusal> { - let track = claim.track(); - if let Some(held_path) = ledger.get(&track) { - return Err(BootRefusal::InterfaceClaimed { - interface: track, - held: held_path.clone(), - path: path.to_path_buf(), - }); - } - ledger.insert(track, path.to_path_buf()); - Ok(()) -} - /// `[component].name`. pub(super) fn manifest_namespace(loaded: &LoadedManifest) -> ModuleId { loaded.name.clone() @@ -327,7 +287,6 @@ pub(super) fn run( registry: &CapabilityRegistry, ) -> Result, Refusal> { let mut ledger = NamespaceLedger::new(); - let mut interfaces = InterfaceLedger::new(); let configured_chains = ConfiguredChains::from_config(engine_cfg); let mut manifests = Vec::new(); for entry in &engine_cfg.modules { @@ -335,9 +294,6 @@ pub(super) fn run( .with_refusal_context(|| format!("load module {}", entry.path.display()))?; let namespace = manifest_namespace(&loaded); claim_namespace(&mut ledger, namespace.as_str(), &entry.path)?; - if let Some(claim) = &loaded.provides { - claim_interface(&mut interfaces, claim, &entry.path)?; - } enforce_subscriptions(namespace.as_str(), &loaded, &configured_chains) .with_refusal_context(|| format!("load module {}", entry.path.display()))?; let limits = resolve_module_limits( diff --git a/crates/nexum-runtime/src/supervisor/tests/digest.rs b/crates/nexum-runtime/src/supervisor/tests/digest.rs index 132b12cc..a37d11fa 100644 --- a/crates/nexum-runtime/src/supervisor/tests/digest.rs +++ b/crates/nexum-runtime/src/supervisor/tests/digest.rs @@ -39,6 +39,31 @@ fn read_verified_component_rejects_a_mismatched_digest() { .lacks("compile"); } +#[test] +fn read_verified_component_rejects_a_mismatched_operator_pin() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("tampered.wasm"); + std::fs::write(&path, b"not the pinned bytes").expect("write artifact"); + + let engine = test_wasmtime_engine(); + let declared = wrong_digest(); + let pins = DigestPolicy { + operator: Some(&declared), + author: None, + require_author: false, + }; + let err = read_verified_component(&engine, &path, pins) + .err() + .expect("a mismatched operator pin must refuse the component"); + Refusal::from(err) + .variant::(|e| { + e.pin == crate::digest::DigestPin::Operator && e.declared == declared + }) + // Operator wording pin: the fix is in engine.toml, not the manifest. + .names("[[modules]].digest in engine.toml") + .lacks("compile"); +} + #[test] fn read_verified_component_requires_a_digest_when_the_flag_is_set() { let dir = tempfile::tempdir().expect("tempdir"); @@ -54,6 +79,27 @@ fn read_verified_component_requires_a_digest_when_the_flag_is_set() { .lacks("compile"); } +#[test] +fn read_verified_component_requires_an_author_pin_despite_an_operator_pin() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("operator-pinned.wasm"); + std::fs::write(&path, b"operator pinned bytes").expect("write artifact"); + + let engine = test_wasmtime_engine(); + let matching = ContentDigest::of_bytes(b"operator pinned bytes"); + let pins = DigestPolicy { + operator: Some(&matching), + author: None, + require_author: true, + }; + let err = read_verified_component(&engine, &path, pins) + .err() + .expect("a matching operator pin must not satisfy the author-pin flag"); + Refusal::from(err) + .variant::(|e| matches!(e, LoadRefusal::DigestUnpinned { .. })) + .lacks("compile"); +} + #[test] fn read_verified_component_verifies_the_committed_pinned_fixture() { let (wat, manifest) = pinned_fixture(); @@ -184,6 +230,70 @@ async fn e2e_boot_single_accepts_a_matching_pinned_digest() { assert_eq!(supervisor.alive_count(), 1); } +#[tokio::test] +async fn boot_refuses_a_mismatched_operator_pin_before_compile() { + let scenario = BootScenario::new(); + let wasm = scenario.dir().join("module.wasm"); + std::fs::write(&wasm, b"drifted artifact bytes").expect("write artifact"); + scenario + .module( + Entry::new(TestManifest::new("pinned-wrong")) + .wasm(wasm) + .digest(wrong_digest()), + ) + .expect_refusal() + .await + .variant::(|e| { + e.pin == crate::digest::DigestPin::Operator + && e.declared == wrong_digest() + && e.actual == ContentDigest::of_bytes(b"drifted artifact bytes") + }) + .names("[[modules]].digest in engine.toml") + .lacks("compile"); +} + +/// Both pins present and disagreeing: at most one matches the bytes, so +/// the artifact refuses; the operator's expectation is reported first. +#[tokio::test] +async fn disagreeing_operator_and_author_pins_refuse() { + let scenario = BootScenario::new(); + let wasm = scenario.dir().join("torn.wasm"); + std::fs::write(&wasm, b"torn pin bytes").expect("write artifact"); + let actual = ContentDigest::of_bytes(b"torn pin bytes"); + scenario + .module( + Entry::new(TestManifest::new("torn-pins").component_digest(actual.to_string())) + .wasm(wasm) + .digest(wrong_digest()), + ) + .expect_refusal() + .await + .variant::(|e| { + e.pin == crate::digest::DigestPin::Operator + && e.declared == wrong_digest() + && e.actual == actual + }) + .lacks("compile"); +} + +#[tokio::test] +async fn e2e_boot_accepts_a_matching_operator_pin() { + let Some(wasm) = example_wasm_or_skip() else { + return; + }; + let digest = ContentDigest::of_bytes(&std::fs::read(&wasm).expect("read example wasm")); + let booted = BootScenario::new() + .module( + Entry::new(TestManifest::new("example").cap("logging")) + .wasm(wasm) + .digest(digest), + ) + .boot() + .await + .expect("a matching operator pin boots"); + assert_eq!(booted.supervisor.alive_count(), 1); +} + #[tokio::test] async fn boot_requires_a_module_digest_when_the_engine_flag_is_set() { let scenario = BootScenario::new().require_digest(); @@ -196,3 +306,21 @@ async fn boot_requires_a_module_digest_when_the_engine_flag_is_set() { .variant::(|e| matches!(e, LoadRefusal::DigestUnpinned { .. })) .lacks("compile"); } + +#[tokio::test] +async fn boot_requires_a_manifest_pin_despite_a_matching_operator_pin() { + let scenario = BootScenario::new().require_digest(); + let wasm = scenario.dir().join("module.wasm"); + std::fs::write(&wasm, b"operator pinned bytes").expect("write artifact"); + let matching = ContentDigest::of_bytes(b"operator pinned bytes"); + scenario + .module( + Entry::new(TestManifest::new("operator-pinned")) + .wasm(wasm) + .digest(matching), + ) + .expect_refusal() + .await + .variant::(|e| matches!(e, LoadRefusal::DigestUnpinned { .. })) + .lacks("compile"); +} diff --git a/crates/nexum-runtime/src/supervisor/tests/mod.rs b/crates/nexum-runtime/src/supervisor/tests/mod.rs index f26274e2..f5ad8e8f 100644 --- a/crates/nexum-runtime/src/supervisor/tests/mod.rs +++ b/crates/nexum-runtime/src/supervisor/tests/mod.rs @@ -8,7 +8,6 @@ mod dispatch; mod e2e; mod ledger; mod lifecycle; -mod provides; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -108,15 +107,14 @@ async fn try_boot_single( id: "single".to_owned(), path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), + digest: None, }; let limits = ResolvedModuleLimits::default(); let policy = PolicySection::default(); - let implements = crate::engine_config::ImplementsSection::default(); let env = BootEnv { limits: &limits, policy: &policy, configured_chains: test_chains(), - implements: &implements, require_component_digest: require_digest, }; let result = Supervisor::boot_single( diff --git a/crates/nexum-runtime/src/supervisor/tests/provides.rs b/crates/nexum-runtime/src/supervisor/tests/provides.rs deleted file mode 100644 index f9ec1b00..00000000 --- a/crates/nexum-runtime/src/supervisor/tests/provides.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! The `provides` claim: export verification, the `[implements]` -//! binding, the operator pin, and the prepass duplicate-claim gate. - -use wasmtime::component::types::ComponentItem; - -use super::*; -use crate::digest::DigestPin; -use crate::interface_id::InterfaceId; -use crate::supervisor::load::enforce_provides; - -/// The committed fixture that exports an interface instance alongside the -/// event-module surface. -fn provider_fixture() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/provides/component.wat") -} - -fn fixture_digest(path: &Path) -> ContentDigest { - ContentDigest::of_bytes(&std::fs::read(path).expect("read fixture")) -} - -/// The empty component from the pinned fixture: compiles, exports nothing. -fn empty_component_fixture() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/pinned/component.wat") -} - -fn claim(value: &str) -> InterfaceId { - InterfaceId::parse(value).expect("valid claim") -} - -/// Type-level exports of a compiled component, as `enforce_provides` sees -/// them. -fn exports_of( - engine: &wasmtime::Engine, - component: &wasmtime::component::Component, -) -> Vec<(String, ComponentItem)> { - component - .component_type() - .exports(engine) - .map(|(name, export)| (name.to_owned(), export.ty)) - .collect() -} - -fn compile(engine: &wasmtime::Engine, path: &Path) -> wasmtime::component::Component { - let (component, _digest) = - read_verified_component(engine, path, DigestPolicy::author(None, false)) - .expect("fixture compiles"); - component -} - -#[test] -fn a_satisfying_interface_instance_export_passes_the_walk() { - let engine = test_wasmtime_engine(); - let component = compile(&engine, &provider_fixture()); - let exports = exports_of(&engine, &component); - // In track and no older than the claim. - for ok in [ - "nexum:fixture/provider@1.0.0", - "nexum:fixture/provider@1.2.3", - ] { - enforce_provides( - "m0", - &provider_fixture(), - &claim(ok), - exports.iter().map(|(n, item)| (n.as_str(), item.clone())), - ) - .expect("the export satisfies the claim"); - } -} - -#[test] -fn a_wrong_track_or_newer_claim_is_refused_naming_the_near_miss() { - let engine = test_wasmtime_engine(); - let component = compile(&engine, &provider_fixture()); - let exports = exports_of(&engine, &component); - // The export is @1.2.3: another track, and a claim above it, both lie. - for bad in [ - "nexum:fixture/provider@2.0.0", - "nexum:fixture/provider@1.3.0", - ] { - let err = enforce_provides( - "m0", - &provider_fixture(), - &claim(bad), - exports.iter().map(|(n, item)| (n.as_str(), item.clone())), - ) - .expect_err("an unsatisfied claim must refuse"); - assert!( - matches!(&err, LoadRefusal::ProvidesNotExported { id, claimed, exported, .. } - if id == "m0" && claimed == bad - && exported.contains("nexum:fixture/provider@1.2.3")), - "{err}", - ); - } -} - -/// A component may export a bare func under an interface-shaped name; -/// only an instance export satisfies a claim. -#[test] -fn a_func_export_under_the_claimed_name_does_not_satisfy_the_claim() { - const FUNC_ONLY: &str = r#" -(component - (core module $m (func (export "f") (result i32) (i32.const 0))) - (core instance $i (instantiate $m)) - (alias core export $i "f" (core func $fc)) - (func $f (result u32) (canon lift (core func $fc))) - (export "acme:iface/thing@1.0.0" (func $f)) -) -"#; - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("func-only.wat"); - std::fs::write(&path, FUNC_ONLY).expect("write fixture"); - let engine = test_wasmtime_engine(); - let component = compile(&engine, &path); - let exports = exports_of(&engine, &component); - let err = enforce_provides( - "m0", - &path, - &claim("acme:iface/thing@1.0.0"), - exports.iter().map(|(n, item)| (n.as_str(), item.clone())), - ) - .expect_err("a bare func must not satisfy an interface claim"); - assert!( - matches!(&err, LoadRefusal::ProvidesNotExported { exported, .. } if exported == "none"), - "{err}", - ); -} - -#[tokio::test] -async fn two_claimants_of_one_interface_refuse_in_prepass_naming_both_paths() { - let scenario = BootScenario::new(); - let (first, second) = ( - scenario.dir().join("first-claimant.wasm"), - scenario.dir().join("second-claimant.wasm"), - ); - // Two full versions on one track; neither artifact exists, so the - // refusal provably precedes any read or compile. - scenario - .module( - Entry::new( - TestManifest::new("claimant-a") - .cap("logging") - .provides("acme:pool/quoter@2.0.0"), - ) - .wasm(first), - ) - .module( - Entry::new( - TestManifest::new("claimant-b") - .cap("logging") - .provides("acme:pool/quoter@2.1.0"), - ) - .wasm(second), - ) - .expect_refusal() - .await - .variant::(|e| { - matches!(e, BootRefusal::InterfaceClaimed { interface, held, path } - if interface.as_str() == "acme:pool/quoter@2" - && held.ends_with("first-claimant.wasm") - && path.ends_with("second-claimant.wasm")) - }) - .names("first-claimant.wasm") - .names("second-claimant.wasm") - .lacks("compile"); -} - -#[tokio::test] -async fn an_implementer_absent_from_implements_does_not_load() { - let scenario = BootScenario::new(); - let wasm = scenario.dir().join("claimant.wasm"); - scenario - .module( - Entry::new( - TestManifest::new("claimant") - .cap("logging") - .provides("acme:pool/quoter@2.0.0"), - ) - .wasm(wasm), - ) - .expect_refusal() - .await - .variant::(|e| { - matches!(e, LoadRefusal::ImplementerUnbound { id, interface, bound } - if id == "m0" && interface == "acme:pool/quoter@2" && bound == "nothing") - }) - // The artifact does not exist: the refusal precedes any read. - .lacks("read component"); -} - -#[tokio::test] -async fn an_implementer_bound_to_another_id_does_not_load() { - let scenario = BootScenario::new(); - let wasm = scenario.dir().join("claimant.wasm"); - scenario - .implement("acme:pool/quoter@2", "the-authorized-one", None) - .module( - Entry::new( - TestManifest::new("claimant") - .cap("logging") - .provides("acme:pool/quoter@2.0.0"), - ) - .wasm(wasm), - ) - .expect_refusal() - .await - .variant::(|e| { - matches!(e, LoadRefusal::ImplementerUnbound { id, bound, .. } - if id == "m0" && bound == "the-authorized-one") - }) - .names("the-authorized-one"); -} - -#[tokio::test] -async fn an_implementer_without_an_operator_digest_does_not_load() { - let scenario = BootScenario::new(); - let wasm = scenario.dir().join("claimant.wasm"); - scenario - .implement("acme:pool/quoter@2", "m0", None) - .module( - Entry::new( - TestManifest::new("claimant") - .cap("logging") - .provides("acme:pool/quoter@2.0.0"), - ) - .wasm(wasm), - ) - .expect_refusal() - .await - .variant::(|e| { - matches!(e, LoadRefusal::ImplementerUnpinned { id, interface } - if id == "m0" && interface == "acme:pool/quoter@2") - }) - .lacks("read component"); -} - -/// `boot_single` never runs the prepass, so the binding gate must hold on -/// its path too. -#[tokio::test] -async fn boot_single_refuses_an_unbound_provides_claimant() { - let dir = tempfile::tempdir().expect("tempdir"); - let wasm = dir.path().join("claimant.wasm"); - let manifest = TestManifest::new("claimant") - .provides("acme:pool/quoter@2.0.0") - .write_to(dir.path()); - - let (_store, result) = try_boot_single(&wasm, Some(&manifest), false, None).await; - Refusal::from(result.err().expect("an unbound claimant must refuse")) - .variant::(|e| matches!(e, LoadRefusal::ImplementerUnbound { .. })) - .lacks("read component"); -} - -#[tokio::test] -async fn a_claim_the_component_does_not_export_is_refused_naming_the_claim() { - let fixture = empty_component_fixture(); - let scenario = BootScenario::new().implement( - "nexum:fixture/provider@1", - "m0", - Some(fixture_digest(&fixture)), - ); - scenario - .module( - Entry::new( - TestManifest::new("false-claimant").provides("nexum:fixture/provider@1.0.0"), - ) - .wasm(fixture), - ) - .expect_refusal() - .await - .variant::(|e| { - matches!(e, LoadRefusal::ProvidesNotExported { id, claimed, exported, .. } - if id == "m0" - && claimed == "nexum:fixture/provider@1.0.0" - && exported == "none") - }) - .names("nexum:fixture/provider@1.0.0"); -} - -/// The operator's row carries the only operator-written pin on the -/// artifact. Dropping one line of the untrusted manifest must not drop -/// that pin with it, so a row naming an entry that claims nothing -/// refuses rather than going inert. -#[tokio::test] -async fn dropping_the_claim_does_not_disarm_the_operator_pin() { - let fixture = provider_fixture(); - let stale: ContentDigest = format!("sha256:{}", "4".repeat(64)) - .parse() - .expect("valid non-matching pin"); - BootScenario::new() - // The operator reviewed and pinned m0's artifact. - .implement("nexum:fixture/provider@1", "m0", Some(stale)) - // The author ships other bytes and deletes `provides`. - .module(Entry::new(TestManifest::new("silent-claimant")).wasm(fixture)) - .expect_refusal() - .await - .variant::(|e| { - matches!(e, LoadRefusal::ImplementerNotClaiming { id, interface, claimed } - if id == "m0" - && interface == "nexum:fixture/provider@1" - && claimed == "nothing") - }) - .lacks("read component"); -} - -/// The same hole through a claim on another interface: the row must -/// match the entry's own claim, not merely coexist with one. -#[tokio::test] -async fn a_row_the_entrys_claim_does_not_match_refuses() { - let fixture = provider_fixture(); - BootScenario::new() - .implement("acme:pool/quoter@2", "m0", Some(fixture_digest(&fixture))) - .implement( - "nexum:fixture/provider@1", - "m0", - Some(fixture_digest(&fixture)), - ) - .module( - Entry::new( - TestManifest::new("one-claim-two-rows").provides("nexum:fixture/provider@1.0.0"), - ) - .wasm(fixture), - ) - .expect_refusal() - .await - .variant::(|e| { - matches!(e, LoadRefusal::ImplementerNotClaiming { interface, claimed, .. } - if interface == "acme:pool/quoter@2" - && claimed == "nexum:fixture/provider@1") - }); -} - -/// The whole positive path: the name types nothing, the claim is -/// verified against the real exports, and the operator's binding plus -/// pin authorize the load. -#[tokio::test] -async fn a_component_whose_name_differs_from_its_interface_id_still_loads() { - let fixture = provider_fixture(); - let booted = BootScenario::new() - .implement( - "nexum:fixture/provider@1", - "m0", - Some(fixture_digest(&fixture)), - ) - .module( - Entry::new( - TestManifest::new("a-name-unlike-the-interface") - .provides("nexum:fixture/provider@1.0.0"), - ) - .wasm(fixture), - ) - .boot() - .await - .expect("a bound, pinned, true claim boots"); - assert_eq!(booted.supervisor.module_count(), 1); - assert_eq!(booted.supervisor.alive_count(), 1); -} - -#[tokio::test] -async fn a_mismatched_operator_pin_refuses_before_compile() { - let fixture = provider_fixture(); - let wrong: ContentDigest = format!("sha256:{}", "2".repeat(64)) - .parse() - .expect("valid non-matching pin"); - BootScenario::new() - .implement("nexum:fixture/provider@1", "m0", Some(wrong)) - .module( - Entry::new(TestManifest::new("pinned-wrong").provides("nexum:fixture/provider@1.0.0")) - .wasm(fixture), - ) - .expect_refusal() - .await - .variant::(|e| e.pin == DigestPin::Operator && e.declared == wrong) - // The operator edits engine.toml, not the author's manifest. - .names("[implements] digest in engine.toml") - .lacks("compile"); -} - -/// Both pins present and disagreeing: at most one matches the bytes, so -/// the artifact refuses; the operator's expectation is reported first. -#[tokio::test] -async fn disagreeing_operator_and_author_pins_refuse() { - let fixture = provider_fixture(); - let actual = fixture_digest(&fixture); - let wrong: ContentDigest = format!("sha256:{}", "3".repeat(64)) - .parse() - .expect("valid non-matching pin"); - BootScenario::new() - .implement("nexum:fixture/provider@1", "m0", Some(wrong)) - .module( - Entry::new( - TestManifest::new("torn-pins") - .component_digest(actual.to_string()) - .provides("nexum:fixture/provider@1.0.0"), - ) - .wasm(fixture), - ) - .expect_refusal() - .await - .variant::(|e| { - e.pin == DigestPin::Operator && e.declared == wrong && e.actual == actual - }) - .lacks("compile"); -} - -/// A single claimant passes the prepass ledger; the ledger only refuses -/// the second claimant of one track. -#[test] -fn claim_interface_holds_distinct_tracks_apart() { - let mut ledger = super::prepass::InterfaceLedger::new(); - super::prepass::claim_interface(&mut ledger, &claim("a:b/c@1.0.0"), Path::new("a.wasm")) - .expect("first claim"); - super::prepass::claim_interface(&mut ledger, &claim("a:b/c@2.0.0"), Path::new("b.wasm")) - .expect("another track is another claim"); - let err = - super::prepass::claim_interface(&mut ledger, &claim("a:b/c@2.9.9"), Path::new("c.wasm")) - .expect_err("one track, one implementer"); - assert!( - matches!(&err, BootRefusal::InterfaceClaimed { interface, held, path } - if interface.as_str() == "a:b/c@2" - && held.as_path() == Path::new("b.wasm") - && path.as_path() == Path::new("c.wasm")), - "the refusal names both claimants: {err}", - ); -} - -/// `BootScenario` bypasses the raw-config conversion, so this pins the -/// serde path an operator actually takes: `[implements]` parses and its -/// claimant boots. -#[test] -fn an_implements_row_parses_from_engine_toml_to_the_typed_section() { - let toml = r#" -[implements."nexum:fixture/provider@1"] -component = "m0" -digest = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - -[[modules]] -id = "m0" -path = "m0.wasm" -"#; - let cfg: crate::engine_config::EngineConfig = - toml::from_str(toml).expect("[implements] parses"); - let track = crate::interface_id::InterfaceTrack::parse("nexum:fixture/provider@1") - .expect("valid track"); - let row = cfg.implements.get(&track).expect("row resolved"); - assert_eq!(row.component, "m0"); - assert!(row.digest.is_some()); -} diff --git a/crates/nexum-runtime/src/test_utils/manifest.rs b/crates/nexum-runtime/src/test_utils/manifest.rs index 23397455..37aa8dda 100644 --- a/crates/nexum-runtime/src/test_utils/manifest.rs +++ b/crates/nexum-runtime/src/test_utils/manifest.rs @@ -46,7 +46,6 @@ pub fn manifest(name: impl Into) -> TestManifest { pub struct TestManifest { name: String, component: Option, - provides: Option, caps: Vec, http_allow: Vec, config: Vec<(String, String)>, @@ -59,7 +58,6 @@ impl TestManifest { Self { name: name.into(), component: None, - provides: None, caps: Vec::new(), http_allow: Vec::new(), config: Vec::new(), @@ -73,12 +71,6 @@ impl TestManifest { self } - /// Set `[component].provides` to an interface-id claim. - pub fn provides(mut self, interface: impl Into) -> Self { - self.provides = Some(interface.into()); - self - } - /// Append a `[dependencies]` key; the table is emitted even when empty. pub fn cap(mut self, cap: impl Into) -> Self { self.caps.push(cap.into()); @@ -154,9 +146,6 @@ impl TestManifest { if let Some(digest) = &self.component { component.insert("digest".into(), digest.clone().into()); } - if let Some(provides) = &self.provides { - component.insert("provides".into(), provides.clone().into()); - } // Each dependency is a table, so an attribute belongs to the thing // it qualifies. `hosts` implies the http dependency. diff --git a/crates/nexum-runtime/src/test_utils/scenario.rs b/crates/nexum-runtime/src/test_utils/scenario.rs index 8db1618d..76170e8b 100644 --- a/crates/nexum-runtime/src/test_utils/scenario.rs +++ b/crates/nexum-runtime/src/test_utils/scenario.rs @@ -11,16 +11,12 @@ use tempfile::TempDir; use super::manifest::{ManifestSource, TestManifest}; use super::{in_memory_logs, test_chain_configs}; use crate::digest::ContentDigest; -use crate::engine_config::{ - ChainConfig, EngineConfig, Implementer, ImplementsSection, ModuleEntry, ModuleLimits, - PolicySection, -}; +use crate::engine_config::{ChainConfig, EngineConfig, ModuleEntry, ModuleLimits, PolicySection}; use crate::host::component::{Components, RuntimeTypes}; use crate::host::extension::{Extension, attach_wall_clock}; use crate::host::local_store_redb::LocalStore; use crate::host::logs::{LogPipeline, LogRecord}; use crate::host::provider_pool::ProviderPool; -use crate::interface_id::InterfaceTrack; use crate::preset::CoreRuntime; use crate::supervisor::{Supervisor, WasiClockOverride, build_linker}; use crate::test_utils::wasm::test_wasmtime_engine; @@ -30,6 +26,7 @@ pub struct Entry { id: Option, wasm: Option, manifest: ManifestSource, + digest: Option, } impl Entry { @@ -39,6 +36,7 @@ impl Entry { id: None, wasm: None, manifest: manifest.into(), + digest: None, } } @@ -53,6 +51,12 @@ impl Entry { self.wasm = Some(wasm.into()); self } + + /// The operator's `[[modules]].digest` pin for this entry's artifact. + pub fn digest(mut self, digest: ContentDigest) -> Self { + self.digest = Some(digest); + self + } } impl From for Entry { @@ -80,7 +84,6 @@ pub struct BootScenario { extensions: Vec>>, limits: ModuleLimits, policy: PolicySection, - implements: ImplementsSection, chains: HashMap, wasm: Option, modules: Vec, @@ -118,7 +121,6 @@ impl BootScenario { extensions: Vec::new(), limits: ModuleLimits::default(), policy: PolicySection::default(), - implements: ImplementsSection::default(), chains: test_chain_configs(), wasm: None, modules: Vec::new(), @@ -157,24 +159,6 @@ impl BootScenario { self } - /// Add one `[implements]` row binding `track` to the entry id - /// `component`, with an optional artifact pin. - pub fn implement( - mut self, - track: &str, - component: impl Into, - digest: Option, - ) -> Self { - self.implements.insert( - InterfaceTrack::parse(track).expect("scenario interface track"), - Implementer { - component: component.into(), - digest, - }, - ); - self - } - /// Operator-permitted destinations, as `[limits.http].permit_destinations`. /// A test serving over loopback needs this: the address rules refuse /// loopback by default and a module allowlist cannot widen them. @@ -260,6 +244,7 @@ impl BootScenario { entry.id.unwrap_or_else(|| format!("m{i}")), entry.wasm.unwrap_or_else(|| default_wasm.clone()), entry.manifest.resolve(&at), + entry.digest, ) }; @@ -269,7 +254,6 @@ impl BootScenario { .try_into() .expect("scenario [limits] must carry no zero"), policy: self.policy, - implements: self.implements, chains: self.chains, ..Default::default() }; @@ -277,8 +261,13 @@ impl BootScenario { config.engine.require_component_digest = self.require_digest; config.defaulted = self.defaulted; for (i, entry) in self.modules.into_iter().enumerate() { - let (id, path, manifest) = resolve(i, entry); - config.modules.push(ModuleEntry { id, path, manifest }); + let (id, path, manifest, digest) = resolve(i, entry); + config.modules.push(ModuleEntry { + id, + path, + manifest, + digest, + }); } ( config, diff --git a/crates/nexum-runtime/testdata/provides/component.wat b/crates/nexum-runtime/testdata/provides/component.wat deleted file mode 100644 index 1cb3e153..00000000 --- a/crates/nexum-runtime/testdata/provides/component.wat +++ /dev/null @@ -1,90 +0,0 @@ -;; A component satisfying the event-module world surface (init and -;; on-event return `ok`) that also exports an interface instance, which -;; no in-tree guest can: world synthesis emits func exports only. -;; Byte-stable fixture; tests hash it at runtime for their pins. -;; -;; The named types are structural copies of nexum:host/types; runtime -;; type equality does not care where a type was declared. Each is -;; exported because an exported func may refer to named types only. -(component - (core module $impl - (memory (export "mem") 1) - (global $next (mut i32) (i32.const 64)) - (func (export "cabi_realloc") (param i32 i32 i32 i32) (result i32) - (local $ptr i32) - (local.set $ptr - (i32.and (i32.add (global.get $next) (i32.const 7)) (i32.const -8))) - (global.set $next (i32.add (local.get $ptr) (local.get 3))) - (local.get $ptr)) - ;; Each export returns a pointer to a zeroed result<_, fault>: - ;; discriminant 0 is `ok`. - (func (export "init") (param i32 i32) (result i32) - (i32.const 8)) - (func (export "on-event") (param i32 i64 i64 i32 i32 i64) (result i32) - (i32.const 8))) - (core instance $i (instantiate $impl)) - (alias core export $i "mem" (core memory $mem)) - (alias core export $i "cabi_realloc" (core func $realloc)) - (alias core export $i "init" (core func $init-core)) - (alias core export $i "on-event" (core func $on-event-core)) - - (type $rate-limit' (record (field "retry-after-ms" (option u64)))) - (export $rate-limit "rate-limit" (type $rate-limit')) - (type $fault' (variant - (case "unsupported" string) - (case "unavailable" string) - (case "denied" string) - (case "rate-limited" $rate-limit) - (case "timeout") - (case "invalid-input" string) - (case "internal" string))) - (export $fault "fault" (type $fault')) - (type $block' (record - (field "chain-id" u64) - (field "number" u64) - (field "hash" (list u8)) - (field "timestamp" u64))) - (export $block "block" (type $block')) - (type $chain-log' (record - (field "address" (list u8)) - (field "topics" (list (list u8))) - (field "data" (list u8)) - (field "block-hash" (option (list u8))) - (field "block-number" (option u64)) - (field "block-timestamp" (option u64)) - (field "transaction-hash" (option (list u8))) - (field "transaction-index" (option u64)) - (field "log-index" (option u64)) - (field "removed" bool))) - (export $chain-log "chain-log" (type $chain-log')) - (type $chain-logs' (record - (field "chain-id" u64) - (field "logs" (list $chain-log)))) - (export $chain-logs "chain-logs" (type $chain-logs')) - (type $tick' (record (field "fired-at" u64))) - (export $tick "tick" (type $tick')) - (type $custom-event' (record - (field "kind" string) - (field "payload" (list u8)))) - (export $custom-event "custom-event" (type $custom-event')) - (type $event' (variant - (case "block" $block) - (case "chain-logs" $chain-logs) - (case "tick" $tick) - (case "custom" $custom-event))) - (export $event "event" (type $event')) - (type $config (list (tuple string string))) - - (func $init (param "config" $config) (result (result (error $fault))) - (canon lift (core func $init-core) (memory $mem) (realloc $realloc))) - (func $on-event (param "event" $event) (result (result (error $fault))) - (canon lift (core func $on-event-core) (memory $mem) (realloc $realloc))) - (export "init" (func $init)) - (export "on-event" (func $on-event)) - - ;; The interface-instance export a provides claim is verified against. - ;; It is empty because the check is nominal: the engine holds no WIT for - ;; the interface to compare a surface against until #205. - (instance $iface) - (export "nexum:fixture/provider@1.2.3" (instance $iface)) -) diff --git a/docs/02-modules-events-packaging.md b/docs/02-modules-events-packaging.md index 89448ad0..d2a09b4a 100644 --- a/docs/02-modules-events-packaging.md +++ b/docs/02-modules-events-packaging.md @@ -20,11 +20,6 @@ version = "0.3.0" # Optional content pin: one sha256sum of the compiled .wasm. digest = "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" -# Optional interface claim: the full id of one interface this component -# exports. The engine verifies the claim against the compiled exports, -# and only an [implements] row in engine.toml authorizes the load. -provides = "acme:pool/quoter@2.0.0" - # Per-component resource requests. Each field narrows the engine [policy] # ceiling and never widens it. [component.resources] @@ -67,21 +62,16 @@ Key design points: - **`digest` is a verification pin, not a locator.** The engine hashes the bytes it read and compares them to the pin before it compiles them (`crates/nexum-runtime/src/supervisor/artifact.rs`). The pin is optional: an absent pin loads with a warning, unless `require_component_digest = true` under `[engine]` in `engine.toml` makes it a boot error. - The warning is silent when an `[implements]` row already pins the same artifact, because the bytes are verified either way. + The operator can pin the same artifact independently with `digest` on its `[[modules]]` entry in `engine.toml`, and the warning is silent when that pin covers the artifact, because the bytes are verified either way. A refusal names which of the two pins the bytes disagree with, so the operator knows which file to edit. - **`[[subscription]]` blocks are declarative.** A component does not set up its own subscriptions imperatively. The runtime loads each component and runs its `init` first, then derives the subscription plan from the booted supervisor and opens the event sources. `call_init` runs during load in `crates/nexum-runtime/src/supervisor/load.rs`, and `subscription_plan` reads the already-booted supervisor in `crates/nexum-runtime/src/supervisor/subscriptions.rs`. - **`[dependencies]` drives what the runtime links.** - Each key names a core host capability or another component's service, and its table carries the attributes that qualify it. + Each key names a host capability, and its table carries the attributes that qualify it. A component that declares `http` imports `wasi:http/outgoing-handler`, the SDK's `http::fetch` helper wraps it, and the host checks every outgoing request against the `hosts` list on the `http` dependency. See `modules/examples/http-probe` for a complete example. -- **`provides` is a claim, not authorization.** - The engine walks the compiled component's exports before instantiation and refuses a claim no interface-instance export satisfies. - A verified claim still does not load on its own: the operator binds the interface's compatibility track to one `[[modules]].id` in the `engine.toml` `[implements]` table, with a digest pin for the artifact, and an unbound or unpinned implementer refuses at boot. - A row whose component makes no matching claim refuses too, so deleting `provides` cannot disarm the operator's pin. - See [ADR-0021](adr/0021-provides-and-implements.md). - **The `[dependencies]` table is mandatory.** A manifest with no table at all is refused; an empty table is valid and grants nothing. `hosts` qualifies the `http` dependency and nothing else, and it is refused anywhere else rather than silently dropped. diff --git a/docs/adr/0016-component-vocabulary.md b/docs/adr/0016-component-vocabulary.md index 143ccb6f..0ba18bd4 100644 --- a/docs/adr/0016-component-vocabulary.md +++ b/docs/adr/0016-component-vocabulary.md @@ -1,5 +1,5 @@ --- -status: superseded in part by 0017-capabilities-and-services.md, 0019-modules-react-to-triggers.md, and 0020-retire-component-kind.md +status: superseded in part by 0017-capabilities-and-services.md, 0019-modules-react-to-triggers.md, 0020-retire-component-kind.md, and 0022-cut-guest-to-guest-calling.md --- # A component declares a kind and its dependencies @@ -7,6 +7,7 @@ status: superseded in part by 0017-capabilities-and-services.md, 0019-modules-re > Amendment: this record was edited after acceptance. > [ADR-0020](0020-retire-component-kind.md) retired the `[component].kind` field: a component states its identity and its dependencies, and nothing classifies the component itself. > The word `kind` in the title is superseded, and the `kind` rule and examples in the Decision and the two Consequences that turn on the kind carry marks in place. +> [ADR-0022](0022-cut-guest-to-guest-calling.md) cut guest-to-guest calling: the Decision clause that a dependency names another component's service is retired, and it carries a mark in place. ## Context @@ -62,6 +63,7 @@ kind = "service" ``` A dependency names a host capability or another component's service. +Superseded by [ADR-0022](0022-cut-guest-to-guest-calling.md): the service half is retired, and a dependency names a host capability. The engine resolves the name against the core capability table first, then the registered services. The two sets may not collide, which world synthesis already enforces. diff --git a/docs/adr/0017-capabilities-and-services.md b/docs/adr/0017-capabilities-and-services.md index 898c4e17..c4f5a0b7 100644 --- a/docs/adr/0017-capabilities-and-services.md +++ b/docs/adr/0017-capabilities-and-services.md @@ -8,8 +8,11 @@ supersedes: 0016-component-vocabulary.md (in part) > Amendment: this record was edited after acceptance. > [ADR-0020](0020-retire-component-kind.md) retired the `[component].kind` field. > The Context sentence below, "a component declares a kind and its dependencies", now stands only for the dependencies half. -> The three concepts this record settles are unchanged. -> [ADR-0021](0021-provides-and-implements.md) fixes the shapes the Decision below leaves open: the `provides` grammar, the track-keyed `[implements]` row with its digest, and the refusals each gap raises. +> [ADR-0021](0021-provides-and-implements.md) fixed the shapes the Decision below leaves open, and [ADR-0022](0022-cut-guest-to-guest-calling.md) then cut guest-to-guest calling entirely: `provides`, the `[implements]` table, and the service edge below are gone from the tree. +> The Service concept below is therefore vocabulary without machinery, kept for the record. +> The deletion under "What this deletes" (#233) was argued partly on a replacement ADR-0022 removed; it now stands on its independent merits, marked in place below. +> A host-calls-guest seam is expected to return for the plugin engine; adding one is not re-litigating that deletion, because the defects that justified it were in the deleted path itself, not in host-calls-guest as such. +> The `wac` alternative under "Alternatives rejected" carries a mark in place: its union-of-imports ground is superseded, and the rejection stands on ADR-0022's store-scoped grounds. ## Context @@ -64,6 +67,8 @@ The operator already records it, in the `[implements]` binding. So `ServiceKind`, `ServiceInstance`, `HostService`, `Extension::provider`, `Extension::service`, `ServiceKinds` and the parallel admission path they drive are removed. An extension that needs a guest component to implement its capability declares that interface and lets the operator bind a service to it, through the same mechanism every other service edge uses. +Superseded by [ADR-0022](0022-cut-guest-to-guest-calling.md): the service-edge replacement in the sentence above no longer exists. +The removal stands regardless, on grounds this record also states and that were independently real: the deleted path was an unused parallel admission path with a second registry, and #204 reported a shadowing defect in it. The one thing the deleted path can do that a service edge cannot is carry a resource handle, because a trampoline marshals plain data between two stores. The `nexum:host` WIT is deliberately resource-free, so nothing is lost. @@ -95,3 +100,5 @@ One table cannot make that difference legible, and the engine must not treat the Rejected previously and still rejected. One component is one `Store`, and the memory ceiling, fuel, local-store namespace, restart window and capability grant all hang off that. Composed, a plugin with no chain grant is indistinguishable from a module that has one. +Superseded ground, marked by [ADR-0022](0022-cut-guest-to-guest-calling.md): the indistinguishability sentence above rests on composed imports becoming the union of the parts, which is true of `wac` but no longer inherent to the Component Model since `implements` and `external-id` merged. +The rejection stands, on the store-scoped grounds in ADR-0022's "One component is one `Store`, re-grounded": resource limits, fuel and epoch deadlines, and host-function caller identity are per-`Store` in wasmtime, under any composition mode. diff --git a/docs/adr/0018-one-operator-policy-surface.md b/docs/adr/0018-one-operator-policy-surface.md index d69e82e1..0d1fb149 100644 --- a/docs/adr/0018-one-operator-policy-surface.md +++ b/docs/adr/0018-one-operator-policy-surface.md @@ -9,6 +9,8 @@ status: accepted > `[limits.watch]` and `[limits.quota]` were retired and the per-dispatch deadline moved to `[limits.dispatch].deadline_secs`; the Decision text below carries the current names. > [ADR-0019](0019-modules-react-to-triggers.md) retired the export name `on-event`, and the Capabilities text below carries the decided name, `on-trigger`. > The WIT rename lands in a later code issue, so the export in the tree may still read `on-event`. +> [ADR-0022](0022-cut-guest-to-guest-calling.md) landed the digest-pin dial from the Context list as `digest` on the `[[modules]]` entry it pins, not as a `[policy.component]` row; the pin binds one artifact to one entry, so it lives on the entry. +> ADR-0022 also cut the service load path, so the `[[services]]` Consequence below carries a mark in place. ## Context @@ -74,7 +76,9 @@ The gate admits a host only when it matches both name lists, so neither file can - This is one breaking config change: every `[[modules]]` entry needs an `id`, and the three `[limits]` scalars move to `[policy]`. `deny_unknown_fields` makes a second change a second hard boot failure, so the surface lands whole. - The dial issues consume this surface instead of adding their own sections. + Amended by [ADR-0022](0022-cut-guest-to-guest-calling.md): the digest-pin dial landed as `[[modules]].digest`, on the entry it pins, and the other dials stay on this surface. - `[[services]]` entries gain an `id` and join `[policy.component]` when the service load path lands. + Superseded by [ADR-0022](0022-cut-guest-to-guest-calling.md): the service load path is cut and a `[[services]]` table refuses at parse, so no such entry will land. - Every load-time refusal goes through the validated `TryFrom` conversion with a typed error, never through a serde string. ## Alternatives rejected diff --git a/docs/adr/0020-retire-component-kind.md b/docs/adr/0020-retire-component-kind.md index 55916d69..239664dc 100644 --- a/docs/adr/0020-retire-component-kind.md +++ b/docs/adr/0020-retire-component-kind.md @@ -6,6 +6,11 @@ amends: 0017-capabilities-and-services.md, 0019-modules-react-to-triggers.md # A component declares no kind +> Amendment: this record was edited after acceptance. +> [ADR-0022](0022-cut-guest-to-guest-calling.md) removed `provides`, so the Context argument that `provides` would restate what the engine verifies is gone with it. +> The retirement stands on the independent ground this record also states: nothing in the runtime branched on the parsed kind. +> The affected sentences carry marks in place. + ## Context [ADR-0016](0016-component-vocabulary.md) gave the manifest a `[component].kind` field spelled `module` or `service`. @@ -17,6 +22,7 @@ Nothing in the runtime branches on the parsed kind. `ComponentKind` was parsed, validated, and stored, and its only readers were the tests that asserted the round trip. No manifest in the tree carries the field. `provides` (#207) will restate, per interface, what the engine already verifies, so `kind` would be a second author-written statement of a fact the engine derives. +Superseded by [ADR-0022](0022-cut-guest-to-guest-calling.md): `provides` was removed, so the sentence above no longer supports this retirement; the no-reader ground above does. A fact the engine derives must not also be author-declared, because two sources can disagree and each disagreement forces a precedence rule, a refusal, or a silent winner. ## Decision @@ -74,3 +80,4 @@ Its header now marks both as retired, so the #207 plan must not reintroduce the `nexum-world` reads `component.toml` untyped, so a stale manifest still compiles its wasm and refuses only when the engine loads it. - #207 adds `provides` and the verification of it. This record clears the field ahead of that work and adds nothing in its place. + Superseded by [ADR-0022](0022-cut-guest-to-guest-calling.md): the #207 work shipped and was then cut; the cleared field stays cleared. diff --git a/docs/adr/0021-provides-and-implements.md b/docs/adr/0021-provides-and-implements.md index 1ee8dbd4..30d92c35 100644 --- a/docs/adr/0021-provides-and-implements.md +++ b/docs/adr/0021-provides-and-implements.md @@ -1,10 +1,15 @@ --- -status: accepted +status: superseded by 0022-cut-guest-to-guest-calling.md amends: 0017-capabilities-and-services.md --- # A provides claim is verified by the engine and authorized by the operator +> Amendment: this record was superseded after acceptance. +> [ADR-0022](0022-cut-guest-to-guest-calling.md) cut guest-to-guest calling: `[component].provides`, the `[implements]` table, and every refusal below are removed from the tree. +> The whole Decision is the pair, so no clause survives in part. +> `InterfaceId` and `InterfaceTrack` survive for the plugin registry, and the operator digest pin moves to `[[modules]].digest`. + ## Context [ADR-0017](0017-capabilities-and-services.md) settled that a service is a versioned WIT interface an untrusted guest exports, that the author claims it with `provides`, and that the engine verifies the claim against the component's real exports. diff --git a/docs/adr/0022-cut-guest-to-guest-calling.md b/docs/adr/0022-cut-guest-to-guest-calling.md new file mode 100644 index 00000000..3673a82f --- /dev/null +++ b/docs/adr/0022-cut-guest-to-guest-calling.md @@ -0,0 +1,91 @@ +--- +status: accepted +supersedes: 0021-provides-and-implements.md, 0016-component-vocabulary.md (in part) +amends: 0017-capabilities-and-services.md, 0018-one-operator-policy-surface.md, 0020-retire-component-kind.md +--- + +# Guest-to-guest calling is cut and the single-store model stands + +## Context + +[ADR-0021](0021-provides-and-implements.md) shipped `[component].provides` verification and `[implements]` authorization for a service edge no artifact ever took (#253). +No component the toolchain builds can satisfy a `provides` claim: `synthesize` in `crates/nexum-world` emits one world with func exports only, `export init` and `export on-event`, never an interface instance, and `enforce_provides` accepted only an interface-instance export. +Zero in-tree manifests declared `provides` and zero configs declared `[implements]`. + +The runtime serves two products, a daemon runtime and a wallet plugin engine, and in both the caller is host Rust. +A decoder plugin feeding a risk plugin is cascading host-to-guest over a host-defined interchange type, not a guest-to-guest call. + +## Decision + +Guest-to-guest calling is removed from the runtime (#254). + +Deleted: + +- `[component].provides` from the manifest, raw and parsed, and `ParseError::InvalidInterfaceId`. +- The `engine.toml` `[implements]` table: `Implementer`, `resolve_implements`, the `implements` field on `EngineConfig`, and the `EngineConfigError` variants `InvalidInterfaceTrack`, `UnknownImplementsComponent` and `InvalidImplementerDigest`. +- `enforce_provides` and `enforce_implements` in the supervisor load path, with `LoadRefusal::{ProvidesNotExported, ImplementerUnbound, ImplementerUnpinned, ImplementerNotClaiming}`. +- The prepass duplicate-claim gate: `InterfaceLedger`, `claim_interface` and `BootRefusal::InterfaceClaimed`. +- `InterfaceId::matches_export`, whose only caller was `enforce_provides`. +- The `--wasm` override's `[implements]` clearing and `BootEnv.implements`. +- The `provides` test area and its component fixture. + +Kept, deliberately: + +- `InterfaceId` and `InterfaceTrack` in `crates/nexum-runtime/src/interface_id.rs`, less `matches_export`. + A plugin registry must select the candidates for a slot before it reads any artifact bytes, and `InterfaceTrack`'s leading-zero rule decides whether a registry update stays inside the installed track (auto-installable) or leaves it and needs fresh user consent. + The module rustdoc records this so the next reader does not delete it as an orphan. +- The digest machinery, strengthened as below. + +## The operator pin moves to `[[modules]].digest` (#255) + +`Implementer.digest` was the only operator-written artifact pin in trusted config, and it dies with `[implements]`. +The pin therefore moves: `ModuleEntry` gains `digest: Option`, parsed under the same strict grammar as the manifest pin. +`DigestPolicy` is unchanged in shape: `operator`, `author` and `require_author`, two independent pins, both verified against the exact bytes handed to the compiler, the operator's expectation reported first on a disagreement. +`DigestPin::Operator`'s `Display` retargets from the `[implements]` digest to `[[modules]].digest in engine.toml`; the test that pins that wording changes with it. + +This is a net security gain. +The old pin was reachable only for a `provides` claimant, which no artifact could be, so it was unreachable in practice. +The new pin is available for every module the operator configures. + +## One component is one `Store`, re-grounded + +ADR-0017 rejected `wac`-style composition on the argument that composed imports become the union of the parts. +That is true of `wac` but no longer inherent to the Component Model since `implements` and `external-id` merged, so the record must not rest on it. + +The argument that holds is store-scoped enforcement: + +- `ResourceLimiter` is store-level, and `memory_growing` receives no instance identity, so two components in one `Store` share one memory ceiling and neither refusal nor attribution can name the grower. +- Fuel and epoch deadlines are `Store` methods, so one component can drain the budget the operator set for another. +- `LinkerInstance::func_new` closures carry no caller identity, so a host capability granted to one component in a shared store is callable by its neighbour. + +None of that is fixable by any composition mode, because the enforcement seams are per-`Store` by wasmtime's design. + +Conceded: WASI Virt's attenuation is structural and does hold against a malicious guest, because the attenuated import is absent from the composed graph rather than wrapped. +It covers import-shaped capabilities only, it is compile-time and per-graph, and it touches no resource or lifecycle control, so it does not answer the store-scoped list above. +The concession does not weaken the decision; it bounds it. + +Rejected: same-`Store`, multiple separately-instantiated components, the option between full composition and one-component-one-store. +It has the same fuel, memory and poison problem, because all three hang off the `Store`. + +## Records settled (#256) + +- ADR-0021 is superseded in full by this record; its whole Decision is the `provides` and `[implements]` pair. +- ADR-0017 is amended. + Its deletion of the extension-installed component path (#233) was argued partly on the replacement this record removes, and now stands on its independent merits: an unused parallel admission path, a second registry, and the shadowing defect #204 reported. + A host-calls-guest seam is expected to return for the plugin engine, and adding one is not re-litigating that deletion. + Its `wac` alternative carries a mark in place: the union-of-imports ground is superseded, and the rejection stands on the store-scoped grounds above. +- ADR-0018 is amended in its Consequences: the digest-pin dial lands as `[[modules]].digest` on the entry it pins rather than under `[policy.component]`, and the clause that `[[services]]` entries join the policy surface is retired with the service load path. +- ADR-0020 is amended in its Context: the retirement of `[component].kind` stands on the ground that nothing branched on the parsed kind, not on `provides` restating anything. +- ADR-0016's clause that a dependency names another component's service is retired; a dependency names a host capability. + +## Consequences + +- Six labels leave the closed `error_kind` set: `invalid_interface_id`, `interface_claimed`, `provides_not_exported`, `implementer_unbound`, `implementer_unpinned`, `implementer_not_claiming`. + That set is an operator contract, and this is a deliberate contract change the pinned-set test now enforces in its reduced form. +- `EngineConfig` carries `deny_unknown_fields`, so a stale `[implements]` table refuses at parse as a TOML unknown-key error an operator cannot distinguish from a typo. + The message names the table. + A `RetiredKey` refusal pointing at `[[modules]].digest`, the pin's new home, exists for the retired `[limits]` scalars and was considered here. + It is not added because zero configs ever declared `[implements]`: no buildable component could satisfy a `provides` claim, so the shim would guide no deployment. + The operator handbook names the pin's new home instead. +- A stale `[component].provides` line refuses the same way, from the manifest side. +- The operator handbook and the packaging guide drop `[implements]` and repoint the digest guidance at `[[modules]].digest`. diff --git a/docs/design/capability-and-service-model.md b/docs/design/capability-and-service-model.md index eb12f729..7b450436 100644 --- a/docs/design/capability-and-service-model.md +++ b/docs/design/capability-and-service-model.md @@ -9,6 +9,7 @@ status: superseded by ADR-0017; historical > [ADR-0020](../adr/0020-retire-component-kind.md) retired the manifest `kind` field after this document was written. > The "`kind` is required" rule and the `kind = "backend"` spelling below belong to the superseded argument, not to the current manifest, and must not be reintroduced by the `provides` work. > `wit/nexum-host/query-module.wit`, cited in the migration below, was deleted with it; git history preserves its shape. +> [ADR-0022](../adr/0022-cut-guest-to-guest-calling.md) then cut guest-to-guest calling: `provides`, `[implements]`, and the service edge below are no longer in the tree, and the operator digest pin lives on `[[modules]].digest`. > [ADR-0021](../adr/0021-provides-and-implements.md) fixed the `[implements]` row shape after this document was written. > A row is a table with `component` and `digest`, not the bare `key = "id"` string the `engine.toml` sketch below shows, and the sketch's shape now fails the config parse. > The track spelling below is also incomplete: below 0.1 the track is the full version, because every `0.0.z` release is a distinct interface. diff --git a/docs/production.md b/docs/production.md index 11dd0394..d1d3d577 100644 --- a/docs/production.md +++ b/docs/production.md @@ -10,10 +10,10 @@ A downstream composition root that registers extensions runs the same way, under - Every component `.wasm` artifact present on a path the service user can read. - An `engine.toml` with `state_dir` on a persistent path (never `/tmp`), `log_level = "info"`, `[engine.metrics] enabled = true` with `bind_addr = "127.0.0.1:9100"`, one `[chains.]` per subscribed chain with a paid RPC URL, and one `[[modules]]` per module, each with an operator-written `id`. - `require_component_digest = true` under `[engine]`, with every manifest carrying a `[component].digest` pin. -- One `[implements.":/@"]` row per component whose manifest carries a `provides` claim, with `component` set to that entry's `[[modules]].id` and `digest` set to the artifact's sha256. - A claimant with no row refuses with `implementer_unbound`, and a row without a digest refuses with `implementer_unpinned`. - A row whose component claims no matching interface refuses with `implementer_not_claiming`, which is what stops an edit to the untrusted manifest from disarming this pin. - The track is the major version at or above 1.0 (`@2`), `0.minor` below it (`@0.3`), and the full version below 0.1 (`@0.0.7`), which is semver's own compatibility rule. +- A `digest` on each `[[modules]]` entry, set to the artifact's sha256. + This is the operator's own pin, in trusted config: the default sibling manifest lives in the same trust domain as the artifact, so its `[component].digest` does not hold against a compromised artifact store. + An operator-owned manifest outside the artifact directory, named by the `manifest` key on the entry and combined with `require_component_digest = true`, closes the same gap; `[[modules]].digest` is the direct form and needs no separate manifest path. + Both pins are verified against the exact bytes handed to the compiler, and a mismatch refuses the boot naming which pin failed and the file it is in. - The `state_dir` exists and is writable by the service user. - A Prometheus instance scraping `/metrics` (section 6) with the alert rules in section 7. - A log aggregator ingesting the engine's JSON stdout (section 5). @@ -270,7 +270,7 @@ journalctl -u nexum -f --output=json \ | jq 'select(.MESSAGE | fromjson? | .fields.module == "twap-monitor")' ``` -Recover a poisoned component: fix the underlying bug, rebuild the artifact, update the `[component].digest` pin (and the `[implements]` digest, if the component provides an interface), then `sudo systemctl restart nexum`. +Recover a poisoned component: fix the underlying bug, rebuild the artifact, update the `[component].digest` pin and the entry's `[[modules]].digest` pin, then `sudo systemctl restart nexum`. A `digest_mismatch` refusal names the pin that failed and the file it is in, so it says which of the two to edit. The failure ring is in memory and clears at boot. The engine reads `[[modules]]` at boot only, and it detects no artifact change while running, so adding, changing, or removing a component means editing `engine.toml` and restarting.