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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ toml = "1.1"
zip = { version = "8", default-features = false, features = ["deflate"] }
libsql = { version = "0.9", default-features = false, features = ["core", "replication", "remote", "tls"] }
ironclaw_host_api = { path = "crates/ironclaw_host_api", version = "0.1.0" }
ironclaw_memory = { path = "crates/ironclaw_memory", version = "0.1.0" }
ironclaw_host_ingress = { path = "crates/ironclaw_host_ingress", version = "0.1.0" }
ironclaw_runtime_policy = { path = "crates/ironclaw_runtime_policy", version = "0.1.0" }
ironclaw_common = { path = "crates/ironclaw_common" }
Expand Down
123 changes: 123 additions & 0 deletions crates/ironclaw_architecture/tests/reborn_memory_retired_vocabulary.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! Zero-legacy gate for the memory lifecycle-capabilities rework (#3537).
//!
//! The rework retired a vocabulary: the `[memory]` operation families
//! (`MemoryOperationKind` / `operations = [...]` / the mandatory
//! `document_store` family), the single dual-lane `retrieve_context` provider
//! method (replaced by explicit `read_long_term` / `read_short_term` lane
//! methods), and the Rust-declared `builtin.profile_set` capability (now
//! `ironclaw.memory.profile_set`, declared by the bound provider's manifest).
//! This test pins all of it at **zero occurrences** across Reborn code
//! (`crates/`, including the WebUI frontend sources, and
//! `tests/integration/`) so none of it can be reintroduced silently — same
//! shape as `reborn_retired_taxonomy.rs`.
//!
//! Sanctioned exceptions are path-scoped, not term-scoped:
//! - the v1 gateway enclave is being strangled wholesale, not policed
//! term-by-term;
//! - this test names every term on purpose.

use std::path::{Path, PathBuf};

fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|path| path.parent())
.expect("architecture crate under crates")
.to_path_buf()
}

/// Retired memory vocabulary. A hit outside the sanctioned paths is a
/// regression, not a style issue.
const RETIRED_TERMS: &[&str] = &[
// The operation-family enum (replaced by MemoryLifecycleHook).
"MemoryOperationKind",
// The dual-lane provider method (replaced by the explicit lane methods).
"retrieve_context",
// The `[memory].operations` manifest key (replaced by `lifecycle = [...]`
// plus the provider's own `[[tools]]`).
"operations = [",
// The Rust-declared builtin profile tool (now the provider-declared
// `ironclaw.memory.profile_set`).
"builtin.profile_set",
// The mandatory operation family (the `[[tools]]` array IS the
// document-tool surface; no enum variant may mean "assume four tools").
"document_store",
];

/// Path fragments allowed to reference retired vocabulary.
const SANCTIONED_PATHS: &[&str] = &[
// The v1 gateway is a legacy enclave being strangled wholesale — not
// policed term-by-term (same footing as `src/`).
"crates/ironclaw_gateway/",
// This gate names every term on purpose.
"reborn_memory_retired_vocabulary.rs",
];

fn is_sanctioned(path: &str) -> bool {
SANCTIONED_PATHS
.iter()
.any(|fragment| path.contains(fragment))
}

/// A scan error is a gate failure, not a skip: an unreadable directory or
/// file could hide a reintroduced term.
fn scan_dir(root: &Path, dir: &Path, hits: &mut Vec<String>) -> std::io::Result<()> {
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if name == "target" || name == "node_modules" || name == ".git" {
continue;
}
scan_dir(root, &path, hits)?;
continue;
}
let is_rust = name.ends_with(".rs");
let is_frontend = name.ends_with(".ts")
|| name.ends_with(".tsx")
|| name.ends_with(".mts")
|| name.ends_with(".mjs")
|| name.ends_with(".js");
let is_manifest = name.ends_with(".toml");
if !(is_rust || is_frontend || is_manifest) {
continue;
}
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if is_sanctioned(&relative) {
continue;
}
let contents = std::fs::read_to_string(&path)
.map_err(|error| std::io::Error::new(error.kind(), format!("{relative}: {error}")))?;
for term in RETIRED_TERMS {
if contents.contains(term) {
hits.push(format!("{relative}: `{term}`"));
}
}
}
Ok(())
}

#[test]
fn reborn_code_never_references_retired_memory_vocabulary() {
let root = workspace_root();
let mut hits = Vec::new();
scan_dir(&root, &root.join("crates"), &mut hits).expect("scan crates/ without I/O errors");
scan_dir(&root, &root.join("tests/integration"), &mut hits)
.expect("scan tests/integration without I/O errors");
hits.sort();
hits.dedup();
assert!(
hits.is_empty(),
"retired memory vocabulary reintroduced (the bound provider's manifest \
is the single source of truth: `[[tools]]` is the tool surface, \
`[memory].lifecycle` gates every host-initiated call):\n{}",
hits.join("\n")
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const EXPECTED_UNGATED_SEED: &[&str] = &[
"builtin.trace_commons.status",
"builtin.trace_commons.credits",
"builtin.trace_commons.onboard",
"builtin.profile_set",
"ironclaw.memory.profile_set",
// Reviewed rename, not an addition: the memory tools moved from the builtin
// package (`builtin.memory_*`) to the always-on `ironclaw.memory` package
// (#3537) with the same read-only effect posture; `ironclaw.memory.write`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,6 @@ const FROZEN_PATH_COUNTS: &[FrozenPathCount] = &[
path: "crates/ironclaw_extension_host/src/channel_host.rs",
count: 2,
},
FrozenPathCount {
category: "test-support",
item_kind: "field",
path: "crates/ironclaw_host_runtime/src/first_party_tools/memory.rs",
count: 1,
},
FrozenPathCount {
category: "test-support",
item_kind: "field",
Expand Down Expand Up @@ -262,12 +256,6 @@ const FROZEN_PATH_COUNTS: &[FrozenPathCount] = &[
path: "crates/ironclaw_host_api/src/product_adapter/auth.rs",
count: 2,
},
FrozenPathCount {
category: "test-support",
item_kind: "method",
path: "crates/ironclaw_host_runtime/src/first_party_tools/memory.rs",
count: 1,
},
FrozenPathCount {
category: "test-support",
item_kind: "method",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ fn project_capabilities(
for raw in parsed.capabilities {
// `from_raw` errors keep their typed `ManifestV2Error` variants so
// capability validation reports identically however the capability
// is declared.
// is declared. v2 contract projection opens no extra id namespace
// (the reserved memory-tool namespace is a v3 `[memory]` allowance).
let capability =
CapabilityDeclV2::from_raw(raw, context.extension_id, context.host_port_catalog)?;
CapabilityDeclV2::from_raw(raw, context.extension_id, context.host_port_catalog, None)?;
if !seen.insert(capability.id.clone()) {
return Err(ManifestV2Error::DuplicateCapability { id: capability.id }.into());
}
Expand Down
14 changes: 13 additions & 1 deletion crates/ironclaw_extensions/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -704,12 +704,24 @@ fn capability_descriptors_from_manifest(
manifest: &ExtensionManifest,
) -> Result<Vec<CapabilityDescriptor>, ExtensionError> {
let expected_prefix = format!("{}.", manifest.id.as_str());
// Descriptor-layer mirror of the parse-time provider-prefix rule. The one
// extra namespace: a HOST-BUNDLED manifest may declare tools under the
// reserved stable memory-tool namespace (`ironclaw.memory.*`), so a
// swapped memory backend keeps the stable tool ids. The primary
// enforcement is the v3 parser (`[memory]` requires a first_party runtime,
// which requires a host-bundled source); this check keeps the namespace
// closed to every non-host-bundled package as defense in depth.
let reserved_memory_prefix = format!("{}.", ironclaw_host_api::MEMORY_TOOL_ID_NAMESPACE);
let mut seen_capabilities = HashSet::new();
manifest
.capabilities
.iter()
.map(|capability| {
if !capability.id.as_str().starts_with(&expected_prefix) {
let in_reserved_memory_namespace = manifest.source == ManifestSource::HostBundled
&& capability.id.as_str().starts_with(&reserved_memory_prefix);
if !capability.id.as_str().starts_with(&expected_prefix)
&& !in_reserved_memory_namespace
{
return Err(ExtensionError::InvalidManifest {
reason: format!(
"capability id {} must be provider-prefixed with {}",
Expand Down
18 changes: 13 additions & 5 deletions crates/ironclaw_extensions/src/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1054,15 +1054,23 @@ impl CapabilityDeclV2 {
raw: RawCapabilityV2,
extension_id: &ExtensionId,
host_port_catalog: &HostPortCatalog,
extra_id_namespace: Option<&str>,
) -> Result<Self, ManifestV2Error> {
let id = CapabilityId::new(raw.id)?;
// Provider-prefix check without an intermediate `format!` allocation:
// capability id must be `<extension_id>.<...>` (the dot is required so
// `foo.bar` cannot squat `foo`'s namespace via `foobar.baz`).
let prefixed = id
.as_str()
.strip_prefix(extension_id.as_str())
.is_some_and(|rest| rest.starts_with('.'));
// `foo.bar` cannot squat `foo`'s namespace via `foobar.baz`). A caller
// may open exactly one extra reserved namespace — the v3 parser passes
// the stable memory-tool namespace for `[memory]`-declaring manifests
// (host-bundled only), so a swapped memory backend keeps the stable
// `ironclaw.memory.*` tool ids.
let in_namespace = |namespace: &str| {
id.as_str()
.strip_prefix(namespace)
.is_some_and(|rest| rest.starts_with('.'))
};
let prefixed =
in_namespace(extension_id.as_str()) || extra_id_namespace.is_some_and(in_namespace);
if !prefixed {
return Err(ManifestV2Error::CapabilityIdNotPrefixed {
id,
Expand Down
68 changes: 40 additions & 28 deletions crates/ironclaw_extensions/src/v3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ use std::collections::BTreeMap;
use ironclaw_host_api::{
ChannelDescriptor, ChannelDescriptorError, EffectKind, ExtensionId,
HOST_RUNTIME_HTTP_EGRESS_PORT_ID, HostApiError, HostPortCatalog, MemoryDescriptor,
MemoryOperationKind, NetworkScheme, NetworkTargetPattern, OriginGateMatrix, PermissionMode,
RecipeValidationError, RequestedTrustClass, RuntimeCredentialAccountSetup,
RuntimeCredentialRequirementSource, RuntimeCredentialTarget, VendorAuthRecipe, VendorId,
NetworkScheme, NetworkTargetPattern, OriginGateMatrix, PermissionMode, RecipeValidationError,
RequestedTrustClass, RuntimeCredentialAccountSetup, RuntimeCredentialRequirementSource,
RuntimeCredentialTarget, VendorAuthRecipe, VendorId,
};
use serde::Deserialize;
use thiserror::Error;
Expand Down Expand Up @@ -312,25 +312,15 @@ pub(crate) fn parse_v3(
});
}
// A `[memory]` surface declares the extension a backend for the host memory
// adapter. It is host-bundled + first_party only (the host owns the memory
// tool surface and the compose-time provider binding), and must back the
// mandatory document-store family.
if let Some(memory) = &raw.memory {
if !matches!(runtime, ExtensionRuntimeV2::FirstParty { .. }) {
return Err(ManifestV3Error::InvalidMemory {
reason: "[memory] requires a first_party runtime".to_string(),
});
}
if memory.operations.is_empty() {
return Err(ManifestV3Error::InvalidMemory {
reason: "[memory].operations must not be empty".to_string(),
});
}
if !memory.backs(MemoryOperationKind::DocumentStore) {
return Err(ManifestV3Error::InvalidMemory {
reason: "[memory].operations must include \"document_store\"".to_string(),
});
}
// adapter. It is host-bundled + first_party only (the host owns the
// compose-time provider binding). The manifest's `[[tools]]` array is the
// provider's tool surface and `lifecycle` — any subset, including empty —
// declares the host-initiated hooks it participates in, so no further
// shape constraint applies here.
if raw.memory.is_some() && !matches!(runtime, ExtensionRuntimeV2::FirstParty { .. }) {
return Err(ManifestV3Error::InvalidMemory {
reason: "[memory] requires a first_party runtime".to_string(),
});
}
let sandboxed_runtime = matches!(
runtime,
Expand Down Expand Up @@ -385,6 +375,13 @@ pub(crate) fn parse_v3(

// Normalize tools (or the synthesized MCP connection template) into the
// internal capability model, reusing the v2 validated construction path.
// A `[memory]`-declaring manifest (host-bundled only — checked above) may
// declare tools under the reserved stable memory namespace, with its
// requested gating clamped below.
let memory_tool_namespace = raw
.memory
.is_some()
.then_some(ironclaw_host_api::MEMORY_TOOL_ID_NAMESPACE);
let mut referenced_vendors: BTreeMap<VendorId, ()> = BTreeMap::new();
let mut capabilities = Vec::new();
let mut mcp_template_credentials = None;
Expand Down Expand Up @@ -429,7 +426,7 @@ pub(crate) fn parse_v3(
origin_gate_matrix: mcp.origin_gate_matrix.clone(),
};
capabilities.push(
CapabilityDeclV2::from_raw(raw_capability, &id, host_port_catalog).map_err(
CapabilityDeclV2::from_raw(raw_capability, &id, host_port_catalog, None).map_err(
|error| ManifestV3Error::Invalid {
reason: error.to_string(),
},
Expand Down Expand Up @@ -513,12 +510,27 @@ pub(crate) fn parse_v3(
origin_gate_matrix: tool.origin_gate_matrix,
},
};
// Requested, not granted: a memory provider's declared tool gating is
// clamped by the host — `ungated` survives only where the reviewed
// allowlist grants it, exactly as host trust policy already clamps
// `trust = "first_party_requested"`.
let mut raw_capability = raw_capability;
if memory_tool_namespace.is_some()
&& let Some(matrix) = raw_capability.origin_gate_matrix.take()
{
raw_capability.origin_gate_matrix =
Some(matrix.clamp_requested_for_memory_tool(&raw_capability.id));
}
capabilities.push(
CapabilityDeclV2::from_raw(raw_capability, &id, host_port_catalog).map_err(
|error| ManifestV3Error::Invalid {
reason: error.to_string(),
},
)?,
CapabilityDeclV2::from_raw(
raw_capability,
&id,
host_port_catalog,
memory_tool_namespace,
)
.map_err(|error| ManifestV3Error::Invalid {
reason: error.to_string(),
})?,
);
}

Expand Down
Loading
Loading