fix: append Subject migration after retained Plugin plan schema

This commit is contained in:
2026-10-30 01:54:50 +09:00
45 changed files with 5274 additions and 51 deletions
+49
View File
@@ -4093,3 +4093,52 @@ mod tests {
assert_eq!(diagnostics[0].kind, "cycle");
}
}
#[cfg(test)]
mod plugin_preflight_tests {
use super::*;
#[test]
fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() {
let fixture: serde_json::Value = serde_json::from_str(include_str!(
"../../../resources/config-schema/fixtures/plugin-preflight.json"
))
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"plugin-preflight",
"plugins",
"1",
fixture["schema"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let path = VirtualPath::parse("main.dcdl").unwrap();
let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle);
for case in fixture["cases"].as_array().unwrap() {
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path.clone(),
ConfigContentType::Decodal,
case["source"].as_str().unwrap(),
)
.unwrap()])
.unwrap();
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract);
match case.get("diagnostic") {
Some(kind) => {
let errors = result.unwrap_err();
assert_eq!(errors[0].kind, kind.as_str().unwrap());
assert_eq!(errors[0].path.as_str(), "main.dcdl");
}
None => {
let evaluated = result.unwrap();
assert_eq!(
evaluated.projections[0]
.data_json
.pointer("/plugins/enabled/echo/config/mode"),
Some(&case["mode"])
);
}
}
}
}
}
+7 -5
View File
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
/// never starts a local MCP subprocess.
#[serde(default)]
pub mcp: McpConfig,
/// Omission retains the lower layer; an explicit section replaces the entire
/// selection list, including an empty list. Never unions Plugin grants.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<crate::PluginProfileConfig>,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
@@ -795,11 +799,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
"unknown field in manifest: memory (removed; configure feature.memory)",
));
}
if value.get("plugins").is_some() {
return Err(toml::de::Error::custom(
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
));
}
if value
.get("feature")
.and_then(toml::Value::as_table)
@@ -930,6 +929,7 @@ impl WorkerManifestConfig {
),
feature: self.feature.merge(upper.feature),
mcp: merge_mcp_config(self.mcp, upper.mcp),
plugins: upper.plugins.or(self.plugins),
compaction: merge_option(
self.compaction,
upper.compaction,
@@ -1445,6 +1445,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
permissions,
feature: FeatureConfig::from(cfg.feature),
mcp: cfg.mcp,
plugins: cfg.plugins.unwrap_or_default(),
compaction,
web: cfg.web,
skills: cfg.skills,
@@ -1492,6 +1493,7 @@ mod tests {
permissions: None,
feature: FeatureConfigPartial::default(),
mcp: McpConfig::default(),
plugins: None,
session: None,
compaction: None,
web: None,
+10 -4
View File
@@ -7,6 +7,7 @@ mod model;
pub mod model_catalog;
pub mod paths;
pub mod plugin;
mod plugin_profile;
mod profile;
mod scope;
@@ -24,6 +25,7 @@ pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
};
pub use paths::user_profiles_path;
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection};
pub use profile::{
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
/// consumer boundary.
#[serde(default)]
pub mcp: McpConfig,
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
/// their serialized fingerprint inputs.
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
pub plugins: PluginProfileConfig,
#[serde(default)]
pub compaction: Option<CompactionConfig>,
/// First-class web tools configuration. Network access remains fail-closed
@@ -1116,6 +1123,7 @@ impl Default for CompactionConfig {
impl WorkerManifest {
pub fn requires_persisted_execution_snapshot(&self) -> bool {
self.profile.is_some()
|| !self.plugins.is_empty()
|| self.feature.memory.workspace_settings.is_some()
|| self.feature.subjektiv.workspace_settings.is_some()
}
@@ -1595,9 +1603,7 @@ model_id = "claude-sonnet-4-20250514"
);
let error = WorkerManifest::from_toml(&toml).unwrap_err();
assert!(
error
.to_string()
.contains("dynamic Plugins are not supported"),
error.to_string().contains("unknown field `enabled`"),
"unexpected error: {error}"
);
}
@@ -1617,7 +1623,7 @@ model_id = "claude-sonnet-4-20250514"
}),
);
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
assert!(error.to_string().contains("unknown field `plugins`"));
assert!(error.to_string().contains("unknown field `resolved`"));
let mut nested = base;
nested
+269
View File
@@ -0,0 +1,269 @@
//! Narrowing requests for Workspace-managed Plugin installations.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// Profile input only, not Workspace enablement or a validated execution plan.
///
/// The Workspace owner must resolve each installation against its enabled exact
/// artifact and reject selections outside its Tool/HTTPS/logging ceiling. Neither
/// these names nor config values grant authority, select package paths, or cause
/// discovery, dynamic registration, or guest execution.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginProfileConfig {
#[serde(default)]
pub selections: Vec<PluginProfileSelection>,
}
impl PluginProfileConfig {
pub fn is_empty(&self) -> bool {
self.selections.is_empty()
}
}
/// One installation's explicit subset; omitted surfaces and capabilities are off.
///
/// `tools` and `https_slots` are package-declared names, not public Tool names,
/// URLs, host paths, or connection bindings. `config` contains non-secret override
/// candidates only: the Workspace schema validator must reject them unless the
/// package's closed typed schema permits those overrides. Deserialization alone
/// cannot certify that arbitrary package-specific values are non-secret or valid.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginProfileSelection {
pub installation_id: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub logging: bool,
#[serde(default)]
pub https_slots: Vec<String>,
#[serde(default)]
pub config: BTreeMap<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ProfileSource, WorkerManifest, WorkerManifestConfig,
read_persisted_worker_manifest_snapshot, resolve_profile_artifact_value,
write_persisted_worker_manifest_snapshot,
};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
fn partial(plugins: Value) -> WorkerManifestConfig {
serde_json::from_value(json!({
"worker": { "name": "plugin-worker" },
"plugins": plugins,
}))
.unwrap()
}
fn resolve_profile(plugins: Value) -> crate::ResolvedProfile {
let dir = tempfile::tempdir().unwrap();
resolve_profile_artifact_value(
json!({ "plugins": plugins }),
ProfileSource::Archive {
archive_id: "profile-input".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap()
}
#[test]
fn omitted_plugin_selections_and_grants_default_off() {
let omitted = WorkerManifestConfig::from_toml("[worker]\nname = 'plain'\n").unwrap();
assert!(
serde_json::to_value(&omitted)
.unwrap()
.get("plugins")
.is_none()
);
let manifest = WorkerManifest::try_from(omitted).unwrap();
assert!(manifest.plugins.selections.is_empty());
assert!(!manifest.requires_persisted_execution_snapshot());
assert!(
serde_json::to_value(manifest)
.unwrap()
.get("plugins")
.is_none()
);
let manifest = WorkerManifest::try_from(partial(json!({
"selections": [{ "installation_id": "installation-a" }]
})))
.unwrap();
let selection = &manifest.plugins.selections[0];
assert!(selection.tools.is_empty());
assert!(!selection.logging);
assert!(selection.https_slots.is_empty());
assert!(selection.config.is_empty());
assert!(manifest.requires_persisted_execution_snapshot());
}
#[test]
fn plugin_partial_merge_replaces_instead_of_unioning_and_can_clear() {
let lower = partial(json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read", "write"],
"logging": true, "https_slots": ["remote"], "config": {"limit": 10}
}] }));
let omitted = lower.clone().merge(WorkerManifestConfig::default());
assert_eq!(omitted.plugins, lower.plugins);
let upper = partial(json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read"]
}] }));
let merged = lower.clone().merge(upper.clone());
assert_eq!(merged.plugins, upper.plugins);
let round_trip: WorkerManifestConfig =
serde_json::from_value(serde_json::to_value(&merged).unwrap()).unwrap();
assert_eq!(round_trip.plugins, merged.plugins);
for empty in [json!({}), json!({ "selections": [] })] {
let cleared = lower.clone().merge(partial(empty));
assert_eq!(cleared.plugins, Some(PluginProfileConfig::default()));
let manifest = WorkerManifest::try_from(cleared).unwrap();
assert!(manifest.plugins.selections.is_empty());
}
}
#[test]
fn plugin_profile_toml_and_resolved_snapshots_preserve_typed_overrides() {
let dir = tempfile::tempdir().unwrap();
let source = r#"
[[plugins.selections]]
installation_id = "installation-a"
tools = ["lookup"]
logging = true
https_slots = ["api"]
[plugins.selections.config]
limit = 5
labels = ["one", "two"]
[plugins.selections.config.nested]
strict = true
"#;
let parsed = WorkerManifestConfig::from_toml(source).unwrap();
let authored: toml::Value = toml::from_str(source).unwrap();
let resolved = resolve_profile_artifact_value(
serde_json::to_value(authored).unwrap(),
ProfileSource::Archive {
archive_id: "typed-profile".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap();
assert_eq!(Some(&resolved.manifest.plugins), parsed.plugins.as_ref());
assert_eq!(
resolved.manifest.plugins.selections[0].config["nested"],
json!({"strict": true})
);
let snapshot = write_persisted_worker_manifest_snapshot(&resolved.manifest).unwrap();
let restored = read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
assert_eq!(restored.plugins, resolved.manifest.plugins);
assert_eq!(
write_persisted_worker_manifest_snapshot(&restored).unwrap(),
snapshot
);
assert_eq!(
serde_json::to_value(restored).unwrap(),
resolved.manifest_snapshot
);
let toml_round_trip = toml::to_string(&resolved.manifest).unwrap();
assert_eq!(
WorkerManifest::from_toml(&toml_round_trip).unwrap().plugins,
resolved.manifest.plugins
);
}
#[test]
fn plugin_fields_change_serialized_profile_fingerprint_input() {
let baseline = json!({ "selections": [{
"installation_id": "installation-a", "tools": ["read"],
"logging": false, "https_slots": [], "config": {"limit": 5}
}] });
let fingerprint = |plugins| {
let resolved = resolve_profile(plugins);
Sha256::digest(serde_json::to_vec(&resolved.manifest_snapshot).unwrap())
};
let original = fingerprint(baseline.clone());
assert_eq!(fingerprint(baseline.clone()), original);
for (field, value) in [
("installation_id", json!("installation-b")),
("tools", json!(["write"])),
("logging", json!(true)),
("https_slots", json!(["api"])),
("config", json!({"limit": 6})),
] {
let mut changed = baseline.clone();
changed["selections"][0][field] = value;
assert_ne!(fingerprint(changed), original, "{field} was lost");
}
assert_eq!(
fingerprint(json!({})),
fingerprint(json!({"selections": []}))
);
}
#[test]
fn plugin_config_rejects_legacy_package_and_unknown_authority_fields() {
let dir = tempfile::tempdir().unwrap();
let mut invalid = vec![
json!({ "enabled": [{ "id": "project:legacy" }] }),
json!({ "packages": ["/tmp/legacy.yoi-plugin"] }),
json!({ "resolved": [{ "package_path": "/tmp/legacy.yoi-plugin" }] }),
json!({ "enable": true }),
];
for (field, value) in [
("package_path", json!("/tmp/legacy.yoi-plugin")),
("path", json!("./plugin")),
("url", json!("https://example.invalid/plugin")),
("artifact_digest", json!("sha256:uninstalled")),
("secret", json!("not-a-secret-reference")),
("data", json!(true)),
("events", json!(["changed"])),
("websocket", json!(true)),
("workdir", json!(true)),
("unknown", json!(true)),
] {
let mut selection = json!({ "installation_id": "installation-a" });
selection[field] = value;
invalid.push(json!({ "selections": [selection] }));
}
invalid.extend([
json!({ "selections": [{ "tools": ["read"] }] }),
json!({ "selections": [{ "installation_id": "a", "config": "raw" }] }),
]);
for plugins in invalid {
let partial_input = json!({ "plugins": plugins.clone() });
assert!(
serde_json::from_value::<WorkerManifestConfig>(partial_input.clone()).is_err(),
"{plugins}"
);
let toml = toml::to_string(&partial_input).unwrap();
assert!(WorkerManifestConfig::from_toml(&toml).is_err(), "{toml}");
let error = resolve_profile_artifact_value(
partial_input,
ProfileSource::Archive {
archive_id: "invalid".into(),
source: "test".into(),
},
dir.path(),
"plugin-worker",
)
.unwrap_err();
assert!(
error.to_string().contains("failed to decode Profile"),
"{error}"
);
}
}
}
+3
View File
@@ -652,6 +652,7 @@ fn resolve_profile_value(
permissions: profile.permissions,
feature: profile.feature,
mcp: profile.mcp,
plugins: profile.plugins,
compaction,
web: profile.web,
skills: profile.skills,
@@ -700,6 +701,8 @@ struct ProfileConfig {
#[serde(default)]
mcp: McpConfig,
#[serde(default)]
plugins: Option<crate::PluginProfileConfig>,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,
+19
View File
@@ -1213,6 +1213,20 @@ pub trait ServerApi {
#[path] workspace_id: String,
) -> Result<WorkspacePromptProjection, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/config/source-tree/preview",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true
)]
async fn workspace_config_tree_preview(
&self,
#[path] workspace_id: String,
#[body] request: ConfigCommitRequest,
) -> Result<WorkspaceConfigTreeResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/config/source-tree/commit",
status = 201,
@@ -12824,6 +12838,11 @@ mod tests {
HttpMethod::Get,
"/api/w/{workspace_id}/config/projections/prompts",
),
(
"workspace_config_tree_preview",
HttpMethod::Post,
"/api/w/{workspace_id}/config/source-tree/preview",
),
(
"workspace_config_tree_commit",
HttpMethod::Post,
+3
View File
@@ -1006,6 +1006,9 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
}),
feature: manifest.feature.clone().into(),
mcp: manifest.mcp.clone(),
// Reusable selection is not a plan or delegation authority. Backend launch
// must resolve it again within the child's own Workspace/delegation bound.
plugins: Some(manifest.plugins.clone()),
compaction: manifest
.compaction
.as_ref()
@@ -510,6 +510,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
(
107,
"Immutable Worker Plugin plans and retained pins".into()
),
(
108,
"Subject Ticket responsibility and current-body notification routing"
.into()
)
+96 -12
View File
@@ -206,7 +206,18 @@ impl SqliteWorkspaceStore {
let state = match load_state(&tx, workspace_id)? {
Some(state) => state,
None => {
let state = initial_state_with_schema(schema_bundle.clone())?;
let mut state = initial_state_with_schema(schema_bundle.clone())?;
if has_plugin_provider(&schema_bundle) {
let (contract, evaluation, _) =
crate::plugin_settings::evaluate_catalog_config(
&tx,
workspace_id,
&state.snapshot,
schema_bundle.clone(),
)?;
state.contract = contract;
state.projection_digest = evaluation.projection_digest;
}
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
state
}
@@ -215,6 +226,10 @@ impl SqliteWorkspaceStore {
tx.commit()?;
Ok((state, requires_toolchain_refresh, base_identity))
})?;
let desired_schema = crate::plugin_settings::materialization_schema(
desired_schema,
&state.contract.schema_bundle,
)?;
let main_needs_normalization = state
.snapshot
.get(&main_config_path())
@@ -223,7 +238,17 @@ impl SqliteWorkspaceStore {
|| state.contract.schema_bundle.fingerprint != desired_schema.fingerprint
|| main_needs_normalization
{
let candidate = evaluate_candidate(state, base_identity, &[], desired_schema)?;
let candidate = self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
evaluate_candidate_in_transaction(
&tx,
workspace_id,
state,
base_identity,
&[],
desired_schema,
)
})?;
return self.commit_evaluated_workspace_config(workspace_id, &candidate);
}
Ok(state)
@@ -277,18 +302,23 @@ impl SqliteWorkspaceStore {
request: &ConfigCommitRequest,
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> {
let (current, base_identity) = self.with_conn(|conn| {
self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
let current = load_state(&tx, workspace_id)?.ok_or_else(config_not_materialized)?;
let identity = load_evaluation_identity(&tx, workspace_id)?;
tx.commit()?;
Ok((current, identity))
})?;
validate_entrypoint_request(&request.entrypoints)?;
if current.snapshot.digest != request.base_digest {
return Err(config_conflict("base content digest mismatch"));
}
evaluate_candidate(current, base_identity, &request.changes, schema_bundle)
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
validate_entrypoint_request(&request.entrypoints)?;
if current.snapshot.digest != request.base_digest {
return Err(config_conflict("base content digest mismatch"));
}
evaluate_candidate_in_transaction(
&tx,
workspace_id,
current,
base_identity,
&request.changes,
schema_bundle,
)
})
}
pub fn evaluate_workspace_config_candidate(
@@ -328,8 +358,28 @@ impl SqliteWorkspaceStore {
{
return Err(config_conflict("base evaluation identity mismatch"));
}
if has_plugin_provider(&current.contract.schema_bundle)
&& !has_plugin_provider(&candidate.contract.schema_bundle)
{
return Err(config_conflict("Plugin schema authority cannot be removed"));
}
let snapshot = candidate.snapshot.clone();
snapshot.validate().map_err(config_error)?;
if has_plugin_provider(&candidate.contract.schema_bundle) {
let (contract, evaluation, resolved) =
crate::plugin_settings::evaluate_catalog_config(
&tx,
workspace_id,
&snapshot,
candidate.contract.schema_bundle.clone(),
)?;
if contract != candidate.contract || evaluation != candidate.evaluation {
return Err(config_conflict(
"Plugin catalog/schema evaluation changed since Preview",
));
}
crate::plugin_settings::sync_config_references(&tx, workspace_id, &resolved)?;
}
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
tx.execute(
"DELETE FROM workspace_config_entries WHERE workspace_id = ?1",
@@ -452,6 +502,40 @@ fn load_evaluation_identity(
.map_err(Error::from)
}
fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool {
bundle
.contributions
.iter()
.any(|provider| provider.provider_id == "builtin:plugins")
}
fn evaluate_candidate_in_transaction(
tx: &rusqlite::Transaction<'_>,
workspace_id: &str,
current: WorkspaceConfigState,
base_identity: (String, String),
changes: &[ConfigTreeChange],
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<EvaluatedConfigCandidate> {
if !has_plugin_provider(&schema_bundle) {
return evaluate_candidate(current, base_identity, changes, schema_bundle);
}
let mut providers = schema_bundle.contributions.clone();
providers.retain(|provider| provider.provider_id != "builtin:plugins");
providers.push(crate::plugin_settings::PluginConfigSchemaProvider.contribution()?);
let bootstrap = WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?;
let mut candidate = evaluate_candidate(current, base_identity, changes, bootstrap)?;
let (contract, evaluation, _) = crate::plugin_settings::evaluate_catalog_config(
tx,
workspace_id,
&candidate.snapshot,
schema_bundle,
)?;
candidate.contract = contract;
candidate.evaluation = evaluation;
Ok(candidate)
}
fn evaluate_candidate(
current: WorkspaceConfigState,
base_identity: (String, String),
+3
View File
@@ -12,7 +12,10 @@ pub mod hosts;
pub mod memory_backend;
pub mod memory_staging;
pub mod observation;
pub mod plugin_binding;
pub mod plugin_catalog;
pub mod plugin_plan;
pub mod plugin_settings;
pub mod profile_settings;
pub mod prompt_settings;
pub mod records;
@@ -0,0 +1,371 @@
//! Static Workspace HTTPS policy, not network admission. Runtime must recheck live
//! binding state, resolve/check every DNS address, pin the connection, verify TLS,
//! inject credentials, and enforce these bounds (including decompressed bytes).
//! Plans retain the binding identity and digest, never this destination policy.
use std::collections::{BTreeMap, BTreeSet};
use std::net::{Ipv4Addr, Ipv6Addr};
use serde::{Deserialize, Serialize};
use server_api::PluginHttpMethod;
use sha2::{Digest, Sha256};
use url::{Host, Url};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PluginNetworkPolicy {
/// DNS names are provisional until all resolved addresses pass live checks.
/// Private-network exceptions are deliberately not supported by this policy.
#[default]
PublicInternet,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionLimits {
pub timeout_ms: u32,
pub max_request_bytes: u32,
pub max_response_bytes: u32,
pub max_header_bytes: u32,
}
/// Trusted server operational ceiling, supplied separately from Workspace input.
/// Deserializing a binding cannot set or expand this ceiling.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginConnectionPolicyCeiling {
pub limits: PluginConnectionLimits,
}
impl Default for PluginConnectionPolicyCeiling {
fn default() -> Self {
Self {
limits: PluginConnectionLimits {
timeout_ms: 15_000,
max_request_bytes: 1024 * 1024,
max_response_bytes: 1024 * 1024,
max_header_bytes: 16 * 1024,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginConnectionBinding {
/// Canonical origin only: no trailing slash, path, query, credentials or fragment.
pub origin: String,
#[serde(default)]
pub network: PluginNetworkPolicy,
pub methods: Vec<PluginHttpMethod>,
pub path_prefixes: Vec<String>,
#[serde(default)]
pub request_headers: Vec<String>,
#[serde(default)]
pub response_headers: Vec<String>,
/// Empty means no credential. Otherwise an opaque Workspace-local secret ID;
/// never a value, token, provider URL, or filesystem reference.
#[serde(default)]
pub secret_ref: String,
pub limits: PluginConnectionLimits,
}
/// Safe diagnostic categories contain no caller-supplied URLs, headers or secrets.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PluginBindingError {
#[error("invalid plugin binding identity")]
InvalidIdentity,
#[error("plugin connection requires an exact canonical HTTPS origin")]
InvalidOrigin,
#[error("plugin connection destination is not public internet")]
NonPublicDestination,
#[error("plugin connection methods must be nonempty and unique")]
InvalidMethods,
#[error("plugin connection paths must be nonempty, unique canonical segment prefixes")]
InvalidPaths,
#[error("plugin connection header allowlist is invalid or contains a reserved header")]
InvalidHeaders,
#[error("plugin connection secret must be an opaque reference")]
InvalidSecretReference,
#[error("plugin connection limits must be positive and within the server ceiling")]
InvalidLimits,
#[error("too many plugin bindings")]
TooManyBindings,
}
impl PluginConnectionBinding {
pub fn validate(
&self,
ceiling: &PluginConnectionPolicyCeiling,
) -> Result<(), PluginBindingError> {
validate_origin(&self.origin)?;
if self.methods.is_empty() || self.methods.len() > 7 {
return Err(PluginBindingError::InvalidMethods);
}
let mut methods = BTreeSet::new();
if self
.methods
.iter()
.any(|method| !methods.insert(method_name(method)))
{
return Err(PluginBindingError::InvalidMethods);
}
if self.path_prefixes.is_empty()
|| self.path_prefixes.len() > 64
|| !unique_strings(&self.path_prefixes)
|| self.path_prefixes.iter().any(|path| !canonical_path(path))
{
return Err(PluginBindingError::InvalidPaths);
}
for headers in [&self.request_headers, &self.response_headers] {
if headers.len() > 64
|| !unique_strings(headers)
|| headers.iter().any(|header| !allowed_header(header))
{
return Err(PluginBindingError::InvalidHeaders);
}
}
if !self.secret_ref.is_empty() && !opaque_reference(&self.secret_ref) {
return Err(PluginBindingError::InvalidSecretReference);
}
for (value, maximum) in [
(self.limits.timeout_ms, ceiling.limits.timeout_ms),
(
self.limits.max_request_bytes,
ceiling.limits.max_request_bytes,
),
(
self.limits.max_response_bytes,
ceiling.limits.max_response_bytes,
),
(
self.limits.max_header_bytes,
ceiling.limits.max_header_bytes,
),
] {
if value == 0 || maximum == 0 || value > maximum {
return Err(PluginBindingError::InvalidLimits);
}
}
Ok(())
}
/// Versioned SHA-256 of policy content. Allowlists have set semantics, so their
/// order does not affect identity. Call `validate` before accepting a digest.
/// Binding map identity is separate and must also be pinned by the caller.
pub fn digest(&self) -> String {
let mut canonical = self.clone();
canonical.methods.sort_by_key(method_name);
canonical.path_prefixes.sort();
canonical.request_headers.sort();
canonical.response_headers.sort();
let bytes = serde_json::to_vec(&canonical).expect("binding contains only JSON primitives");
let mut hash = Sha256::new();
hash.update(b"yoi-plugin-connection-policy-v1\0");
hash.update(bytes);
let hex: String = hash
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{hex}")
}
/// Static segment-boundary matching only; this is not runtime authorization.
/// Conservative paths exclude percent encodings to avoid decoder disagreement.
pub fn allows_path(&self, path: &str) -> bool {
self.path_prefixes
.iter()
.any(|prefix| path_within(prefix, path))
}
}
/// Validates the Workspace-owned map without copying any destination into a plan.
/// The map key is the binding identity; pin that key and `binding.digest()` only.
pub fn validate_bindings(
bindings: &BTreeMap<String, PluginConnectionBinding>,
ceiling: &PluginConnectionPolicyCeiling,
) -> Result<(), PluginBindingError> {
if bindings.len() > 256 {
return Err(PluginBindingError::TooManyBindings);
}
for (identity, binding) in bindings {
if !opaque_reference(identity) {
return Err(PluginBindingError::InvalidIdentity);
}
binding.validate(ceiling)?;
}
Ok(())
}
/// Conservative canonical path containment on segment boundaries, not a raw
/// string prefix. Runtime must use the same canonical path for checking/sending.
pub fn path_within(prefix: &str, path: &str) -> bool {
canonical_path(prefix)
&& canonical_path(path)
&& (prefix == "/"
|| path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/')))
}
fn method_name(method: &PluginHttpMethod) -> &'static str {
match method {
PluginHttpMethod::Get => "GET",
PluginHttpMethod::Head => "HEAD",
PluginHttpMethod::Post => "POST",
PluginHttpMethod::Put => "PUT",
PluginHttpMethod::Patch => "PATCH",
PluginHttpMethod::Delete => "DELETE",
PluginHttpMethod::Options => "OPTIONS",
}
}
fn opaque_reference(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-'))
}
fn unique_strings(values: &[String]) -> bool {
values.iter().collect::<BTreeSet<_>>().len() == values.len()
}
fn canonical_path(path: &str) -> bool {
if path == "/" {
return true;
}
path.len() <= 2048
&& path.starts_with('/')
&& path.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'/' | b'-'
| b'_'
| b'.'
| b'~'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'='
| b':'
| b'@'
)
})
&& path[1..]
.split('/')
.all(|part| !matches!(part, "" | "." | ".."))
}
fn allowed_header(header: &str) -> bool {
!header.is_empty()
&& header.len() <= 128
&& header
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !header.starts_with("proxy-")
&& !header.starts_with("sec-")
&& !matches!(
header,
"authorization"
| "www-authenticate"
| "cookie"
| "set-cookie"
| "host"
| "connection"
| "keep-alive"
| "transfer-encoding"
| "te"
| "trailer"
| "upgrade"
| "content-length"
| "expect"
| "forwarded"
| "via"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-api-key"
| "api-key"
)
}
fn validate_origin(origin: &str) -> Result<(), PluginBindingError> {
if origin.len() > 512 || !origin.is_ascii() {
return Err(PluginBindingError::InvalidOrigin);
}
let url = Url::parse(origin).map_err(|_| PluginBindingError::InvalidOrigin)?;
if url.scheme() != "https" || url.origin().ascii_serialization() != origin {
return Err(PluginBindingError::InvalidOrigin);
}
let public = match url.host().ok_or(PluginBindingError::InvalidOrigin)? {
Host::Domain(host) => {
let labels: Vec<_> = host.split('.').collect();
labels.len() >= 2
&& host.len() <= 253
&& labels.iter().all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.as_bytes()[0].is_ascii_alphanumeric()
&& label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
&& label
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
})
&& ![
"localhost",
"local",
"internal",
"home",
"lan",
"test",
"invalid",
"onion",
"arpa",
]
.iter()
.any(|suffix| host == *suffix || host.ends_with(&format!(".{suffix}")))
}
Host::Ipv4(ip) => public_ipv4(ip),
Host::Ipv6(ip) => public_ipv6(ip),
};
if !public {
return Err(PluginBindingError::NonPublicDestination);
}
Ok(())
}
fn public_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, c, _] = ip.octets();
// Conservative special-purpose exclusion; no Workspace-configurable bypass.
!matches!(a, 0 | 10 | 127 | 224..=255)
&& !(a == 100 && (64..=127).contains(&b))
&& !(a == 169 && b == 254)
&& !(a == 172 && (16..=31).contains(&b))
&& !(a == 192 && (b == 0 || b == 168 || (b == 88 && c == 99)))
&& !(a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100)))
&& !(a == 203 && b == 0 && c == 113)
}
fn public_ipv6(ip: Ipv6Addr) -> bool {
let [a, b, ..] = ip.segments();
// Only current global-unicast space; reject transition/special-purpose and
// documentation ranges as well as mapped IPv4, local, multicast and ULA.
(0x2000..=0x3fff).contains(&a)
&& !(a == 0x2001 && (b <= 0x01ff || b == 0x0db8))
&& a != 0x2002
&& !(a == 0x3fff && b <= 0x0fff)
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,529 @@
use super::*;
use serde_json::{Value, json};
fn input() -> Value {
json!({
"origin": "https://api.example.com",
"methods": ["GET", "POST"],
"path_prefixes": ["/api", "/v2"],
"request_headers": ["accept", "content-type"],
"response_headers": ["content-type", "etag"],
"secret_ref": "secret-123",
"limits": {
"timeout_ms": 1000,
"max_request_bytes": 1024,
"max_response_bytes": 4096,
"max_header_bytes": 512
}
})
}
fn binding(value: Value) -> PluginConnectionBinding {
serde_json::from_value(value).unwrap()
}
#[test]
fn origin_input_table_requires_canonical_exact_https_and_public_destination() {
for (origin, expected) in [
("https://api.example.com", Ok(())),
("https://api.example.com:8443", Ok(())),
("https://8.8.8.8", Ok(())),
("https://[2606:4700:4700::1111]", Ok(())),
(
"http://api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com/",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com/api",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com?token=value",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com#fragment",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://user:password@api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://@api.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com:443",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://API.example.com",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com\\evil",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://api.example.com\n",
Err(PluginBindingError::InvalidOrigin),
),
(
"https://*.example.com",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://localhost",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://metadata.google.internal",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://service.local",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://example.com.",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://127.0.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://10.0.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://172.16.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://192.168.1.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://169.254.169.254",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://100.64.0.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://192.0.2.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://198.51.100.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://203.0.113.1",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://224.1.2.3",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://0.0.0.0",
Err(PluginBindingError::NonPublicDestination),
),
("https://2130706433", Err(PluginBindingError::InvalidOrigin)),
("https://0177.0.0.1", Err(PluginBindingError::InvalidOrigin)),
(
"https://[::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[::ffff:7f00:1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[fd00::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[fe80::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[2001:db8::1]",
Err(PluginBindingError::NonPublicDestination),
),
(
"https://[2002:7f00:1::]",
Err(PluginBindingError::NonPublicDestination),
),
] {
let mut value = input();
value["origin"] = json!(origin);
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
expected,
"{origin}"
);
}
}
#[test]
fn path_input_table_rejects_traversal_and_preserves_segment_boundaries() {
for (path, accepted) in [
("/", true),
("/api", true),
("/api/v1", true),
("api", false),
("//evil.example", false),
("/api/", false),
("/api//v1", false),
("/api/../admin", false),
("/./api", false),
("/api\\admin", false),
("/api?x=1", false),
("/api#x", false),
("/api/%2e%2e/admin", false),
("/api%2fadmin", false),
("/api/%252e%252e", false),
("/api/%5Cadmin", false),
("https://evil.example/api", false),
("/api/\n", false),
] {
let mut value = input();
value["path_prefixes"] = json!([path]);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{path}"
);
}
let value = binding(input());
for (path, allowed) in [
("/api", true),
("/api/v1", true),
("/apix", false),
("/admin", false),
("/api/../admin", false),
] {
assert_eq!(value.allows_path(path), allowed, "{path}");
}
for paths in [json!([]), json!(["/api", "/api"])] {
let mut value = input();
value["path_prefixes"] = paths;
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidPaths)
);
}
}
#[test]
fn header_input_table_rejects_guest_credentials_and_transport_controls() {
for field in ["request_headers", "response_headers"] {
for (name, accepted) in [
("accept", true),
("content-type", true),
("x-request-id", true),
("authorization", false),
("cookie", false),
("set-cookie", false),
("host", false),
("proxy-authorization", false),
("connection", false),
("content-length", false),
("transfer-encoding", false),
("upgrade", false),
("te", false),
("trailer", false),
("keep-alive", false),
("sec-websocket-key", false),
("x-api-key", false),
("Content-Type", false),
("x-a\r\n", false),
("x-a: value", false),
("", false),
] {
let mut value = input();
value[field] = json!([name]);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{field}:{name}"
);
}
let mut value = input();
value[field] = json!(["accept", "accept"]);
assert_eq!(
binding(value).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidHeaders)
);
}
}
#[test]
fn limits_input_table_requires_positive_finite_values_within_each_server_bound() {
let ceiling = PluginConnectionPolicyCeiling::default();
for (field, max) in [
("timeout_ms", ceiling.limits.timeout_ms),
("max_request_bytes", ceiling.limits.max_request_bytes),
("max_response_bytes", ceiling.limits.max_response_bytes),
("max_header_bytes", ceiling.limits.max_header_bytes),
] {
for (amount, accepted) in [(0, false), (1, true), (max, true), (max + 1, false)] {
let mut value = input();
value["limits"][field] = json!(amount);
assert_eq!(
binding(value).validate(&ceiling).is_ok(),
accepted,
"{field}:{amount}"
);
}
for invalid in [
json!(-1),
json!(1.5),
json!(4294967296u64),
json!("unlimited"),
Value::Null,
] {
let mut value = input();
value["limits"][field] = invalid;
assert!(
serde_json::from_value::<PluginConnectionBinding>(value).is_err(),
"{field}"
);
}
}
let mut zero_ceiling = ceiling;
zero_ceiling.limits.timeout_ms = 0;
assert_eq!(
binding(input()).validate(&zero_ceiling),
Err(PluginBindingError::InvalidLimits)
);
}
#[test]
fn secret_and_identity_inputs_accept_only_opaque_workspace_references() {
for (reference, accepted) in [
("", true),
("secret_123-a", true),
("/etc/secret", false),
("file:///etc/secret", false),
("https://secret.example/token", false),
("Bearer token", false),
("env:API_KEY", false),
("../secret", false),
] {
let mut value = input();
value["secret_ref"] = json!(reference);
assert_eq!(
binding(value)
.validate(&PluginConnectionPolicyCeiling::default())
.is_ok(),
accepted,
"{reference}"
);
}
for (identity, accepted) in [
("api-binding", true),
("", false),
("../binding", false),
("https://example.com", false),
] {
let bindings = BTreeMap::from([(identity.to_string(), binding(input()))]);
assert_eq!(
validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).is_ok(),
accepted
);
}
}
#[test]
fn json_boundary_is_closed_and_methods_network_are_not_ambient_authority() {
for (field, value) in [
("secret_value", json!("password")),
("token", json!("token")),
("proxy", json!("https://proxy.example")),
("host_path", json!("/tmp")),
("tls_verify", json!(false)),
("allow_private", json!(true)),
("network", json!("private")),
("methods", json!(["CONNECT"])),
] {
let mut source = input();
source[field] = value;
assert!(
serde_json::from_value::<PluginConnectionBinding>(source).is_err(),
"{field}"
);
}
for methods in [json!([]), json!(["GET", "GET"])] {
let mut source = input();
source["methods"] = methods;
assert_eq!(
binding(source).validate(&PluginConnectionPolicyCeiling::default()),
Err(PluginBindingError::InvalidMethods)
);
}
let mut source = input();
source["limits"]["unlimited"] = json!(true);
assert!(serde_json::from_value::<PluginConnectionBinding>(source).is_err());
}
#[test]
fn policy_digest_is_order_independent_and_covers_every_policy_field() {
let original = binding(input());
let mut reordered = original.clone();
reordered.methods.reverse();
reordered.path_prefixes.reverse();
reordered.request_headers.reverse();
reordered.response_headers.reverse();
assert_eq!(original.digest(), reordered.digest());
assert_eq!(
original.digest(),
"sha256:df6bd126eb6e825349a3c2e40f48f9d18732969a0775fe6f1b7f11b1667869c9"
);
assert_eq!(original.digest().len(), 71);
assert!(original.digest().starts_with("sha256:"));
for (field, value) in [
("origin", json!("https://other.example.com")),
("methods", json!(["GET"])),
("path_prefixes", json!(["/v2"])),
("request_headers", json!([])),
("response_headers", json!([])),
("secret_ref", json!("other-secret")),
] {
let mut source = input();
source[field] = value;
assert_ne!(original.digest(), binding(source).digest(), "{field}");
}
for field in [
"timeout_ms",
"max_request_bytes",
"max_response_bytes",
"max_header_bytes",
] {
let mut source = input();
source["limits"][field] = json!(2);
assert_ne!(original.digest(), binding(source).digest(), "{field}");
}
}
#[test]
fn policy_collections_and_references_have_finite_size_bounds() {
let ceiling = PluginConnectionPolicyCeiling::default();
for (count, accepted) in [(64, true), (65, false)] {
for field in ["path_prefixes", "request_headers", "response_headers"] {
let mut source = input();
source[field] = json!(
(0..count)
.map(|n| {
if field == "path_prefixes" {
format!("/path-{n}")
} else {
format!("x-header-{n}")
}
})
.collect::<Vec<_>>()
);
assert_eq!(
binding(source).validate(&ceiling).is_ok(),
accepted,
"{field}:{count}"
);
}
}
for (count, accepted) in [(256, true), (257, false)] {
let bindings = (0..count)
.map(|n| (format!("binding-{n}"), binding(input())))
.collect();
assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted);
}
for (size, accepted) in [(128, true), (129, false)] {
let mut source = input();
source["secret_ref"] = json!("a".repeat(size));
assert_eq!(binding(source).validate(&ceiling).is_ok(), accepted);
let bindings = BTreeMap::from([("a".repeat(size), binding(input()))]);
assert_eq!(validate_bindings(&bindings, &ceiling).is_ok(), accepted);
}
}
fn evaluate_schema(source: &str) -> Result<Value, String> {
use config_source::{
ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeSnapshot,
SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
};
let path = VirtualPath::parse("main.dcdl").unwrap();
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path.clone(),
ConfigContentType::Decodal,
source,
)
.unwrap()])
.unwrap();
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:plugins",
"plugins",
"1",
include_str!("../../../../resources/config-schema/plugins.dcdl"),
)
.unwrap()])
.unwrap();
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(
&ToolchainContract::with_schema_bundle(1, vec![path], 1, bundle),
);
result
.map(|evaluated| evaluated.projections[0].data_json.clone())
.map_err(|error| format!("{error:?}"))
}
#[test]
fn bootstrap_schema_defaults_maps_and_projects_typed_binding_to_rust() {
assert_eq!(
evaluate_schema("{}").unwrap(),
json!({"plugins": {"enabled": {}, "bindings": {}}})
);
let projected = evaluate_schema(r#"{
plugins = {
enabled = { demo = {
installation_id = "plugin-1"; expected_last_operation = "op-1";
package_digest = "package"; artifact_digest = "artifact"; tools = ["query"];
}; };
bindings = { api = {
origin = "https://api.example.com"; methods = ["GET"]; path_prefixes = ["/api"];
limits = { timeout_ms = 1000; max_request_bytes = 1024; max_response_bytes = 4096; max_header_bytes = 512; };
}; };
};
}"#).unwrap();
assert_eq!(projected["plugins"]["enabled"]["demo"]["logging"], false);
assert_eq!(
projected["plugins"]["enabled"]["demo"]["connections"],
json!({})
);
assert_eq!(projected["plugins"]["enabled"]["demo"]["config"], json!({}));
let bindings: BTreeMap<String, PluginConnectionBinding> =
serde_json::from_value(projected["plugins"]["bindings"].clone()).unwrap();
validate_bindings(&bindings, &PluginConnectionPolicyCeiling::default()).unwrap();
}
#[test]
fn bootstrap_schema_rejects_unknown_fields_and_wrong_binding_shapes() {
for source in [
"{ plugins = { token = \"value\"; }; }",
"{ plugins = { bindings = { api = { origin = 123; }; }; }; }",
"{ plugins = { enabled = { demo = { arbitrary = true; }; }; }; }",
"{ plugins = { bindings = { api = \"https://api.example.com\"; }; }; }",
] {
assert!(evaluate_schema(source).is_err(), "{source}");
}
}
@@ -30,7 +30,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
// Plugin catalog remains migration 106 even when startup applies later steps.
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
assert_eq!(after.last().unwrap(), &(107, "Subject Ticket responsibility and current-body notification routing".into()));
assert_eq!(after.last().unwrap(), &(108, "Subject Ticket responsibility and current-body notification routing".into()));
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
@@ -66,6 +66,10 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
expected.push((
107,
"Immutable Worker Plugin plans and retained pins".into(),
));
expected.push((
108,
"Subject Ticket responsibility and current-body notification routing".into(),
));
assert_eq!(workspace_schema_migration_history(c)?, expected);
+419
View File
@@ -0,0 +1,419 @@
//! Immutable, Server-owned Plugin execution snapshots and same-database pins.
//!
//! The config owner supplies an already validated, non-secret projection; this
//! module never reads config, selects a Profile, fetches packages or resolves
//! credentials. Host-bound execution identities are trusted inputs, not caller
//! authentication. Service entrypoints must authenticate/authorize every call,
//! including replay and reads. A saved plan is an upper bound, not live authority.
//!
//! Owner transaction functions compose with Worker creation in the catalog DB.
//! Their local savepoints also roll back partial pin acquisition if an owner
//! catches an error and commits other work. There is no cross-store compensation.
use crate::{Error, Result, plugin_catalog::PluginCatalogError};
use manifest::plugin;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use server_api::{PluginArtifact, PluginGrantMetadata};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
mod store;
pub use store::*;
/// Included by the config owner's ordered database migration, never at runtime.
pub const SCHEMA_SQL: &str = include_str!("plugin_plan/schema.sql");
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigProvenance {
pub content_digest: String,
pub toolchain_fingerprint: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileProvenance {
pub identity: String,
pub projection_digest: String,
}
/// Supplied by the authenticated Host launch/delegation boundary, never by guest
/// or model input. These are identities only, not credentials or provider handles.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostExecutionIdentity {
pub runtime_id: String,
pub worker_id: String,
pub principal_id: String,
pub delegation_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BindingPolicyReference {
pub slot: String,
pub binding_id: String,
pub policy_digest: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginSurface {
Tool,
}
/// Only capabilities implemented by the initial Tool Host are representable.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum PluginGrant {
Logging,
Https { slot: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginSchemaIdentity {
pub identity: String,
pub version: String,
pub digest: String,
pub namespace: String,
}
/// The upstream config/schema authority must validate config values as non-secret
/// and intersect grants/surfaces with Workspace, Profile and delegation bounds.
/// Binding policies remain with their owner: only identities and digests travel.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginPinProjection {
pub config: Value,
pub surfaces: Vec<PluginSurface>,
pub tools: Vec<String>,
pub grants: Vec<PluginGrant>,
pub bindings: Vec<BindingPolicyReference>,
}
/// All fields are private: construct from admitted artifact metadata, not a
/// hand-built wire snapshot. Deserialization alone does not establish admission.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginPin {
installation_id: String,
expected_last_operation: String,
package_id: String,
package_version: String,
package_digest: String,
artifact_digest: String,
artifact_size: u32,
package_schema_version: u32,
component_world: String,
required_imports: Vec<String>,
config_schema: PluginSchemaIdentity,
config_digest: String,
projection: PluginPinProjection,
}
impl WorkerPluginPin {
pub fn from_artifact(
installation_id: String,
expected_last_operation: String,
artifact: &PluginArtifact,
mut projection: PluginPinProjection,
) -> Result<Self> {
if artifact.revoked {
return conflict();
}
if !valid_identity(&installation_id)
|| !valid_identity(&expected_last_operation)
|| !valid_digest(&artifact.package_digest)
|| !valid_digest(&artifact.artifact_digest)
|| artifact.artifact_size == 0
|| artifact.schema_version != plugin::SUPPORTED_PLUGIN_API_VERSION
|| !matches!(
artifact.world.as_str(),
plugin::PLUGIN_COMPONENT_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
)
|| !valid_digest(&artifact.config_schema.digest)
|| !valid_identity(&artifact.package_id)
|| !valid_identity(&artifact.version)
|| !valid_identity(&artifact.config_schema.identity)
|| !valid_identity(&artifact.config_schema.version)
|| !valid_identity(&artifact.config_schema.namespace)
|| !projection.config.is_object()
{
return invalid();
}
projection.surfaces.sort();
projection.surfaces.dedup();
projection.tools.sort();
projection.tools.dedup();
projection.grants.sort();
projection.grants.dedup();
projection.bindings.sort();
projection.config = canonical_value(&projection.config);
if (!projection.tools.is_empty() && !projection.surfaces.contains(&PluginSurface::Tool))
|| projection.tools.iter().any(|name| {
!valid_identity(name) || !artifact.tools.iter().any(|tool| tool.name == *name)
})
|| projection
.bindings
.windows(2)
.any(|b| b[0].slot == b[1].slot)
|| projection.bindings.iter().any(|binding| {
!valid_identity(&binding.slot)
|| !valid_identity(&binding.binding_id)
|| !valid_digest(&binding.policy_digest)
|| !artifact
.connections
.iter()
.any(|slot| slot.name == binding.slot)
})
|| projection.grants.iter().any(|grant| match grant {
PluginGrant::Logging => !artifact
.requested_grants
.contains(&PluginGrantMetadata::Logging),
PluginGrant::Https { slot } => {
!valid_identity(slot)
|| !artifact
.requested_grants
.contains(&PluginGrantMetadata::Https { slot: slot.clone() })
|| !projection
.bindings
.iter()
.any(|binding| binding.slot == *slot)
}
})
|| projection.bindings.iter().any(|binding| {
!projection.grants.contains(&PluginGrant::Https {
slot: binding.slot.clone(),
})
})
{
return invalid();
}
let mut required_imports = artifact.required_imports.clone();
required_imports.sort();
required_imports.dedup();
Ok(Self {
installation_id,
expected_last_operation,
package_id: artifact.package_id.clone(),
package_version: artifact.version.clone(),
package_digest: artifact.package_digest.clone(),
artifact_digest: artifact.artifact_digest.clone(),
artifact_size: artifact.artifact_size,
package_schema_version: artifact.schema_version,
component_world: artifact.world.clone(),
required_imports,
config_schema: PluginSchemaIdentity {
identity: artifact.config_schema.identity.clone(),
version: artifact.config_schema.version.clone(),
digest: artifact.config_schema.digest.clone(),
namespace: artifact.config_schema.namespace.clone(),
},
config_digest: digest_json(&projection.config)?,
projection,
})
}
pub fn installation_id(&self) -> &str {
&self.installation_id
}
pub fn expected_last_operation(&self) -> &str {
&self.expected_last_operation
}
pub fn package_id(&self) -> &str {
&self.package_id
}
pub fn package_version(&self) -> &str {
&self.package_version
}
pub fn package_schema_version(&self) -> u32 {
self.package_schema_version
}
pub fn component_world(&self) -> &str {
&self.component_world
}
pub fn required_imports(&self) -> &[String] {
&self.required_imports
}
pub fn package_digest(&self) -> &str {
&self.package_digest
}
pub fn artifact_digest(&self) -> &str {
&self.artifact_digest
}
pub fn artifact_size(&self) -> u32 {
self.artifact_size
}
pub fn config_schema(&self) -> &PluginSchemaIdentity {
&self.config_schema
}
pub fn config_digest(&self) -> &str {
&self.config_digest
}
pub fn projection(&self) -> &PluginPinProjection {
&self.projection
}
pub(super) fn matches_artifact(&self, artifact: &PluginArtifact) -> Result<bool> {
Ok(Self::from_artifact(
self.installation_id.clone(),
self.expected_last_operation.clone(),
artifact,
self.projection.clone(),
)? == *self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginPlanInput {
pub workspace_id: String,
pub config: ConfigProvenance,
pub profile: ProfileProvenance,
pub execution: HostExecutionIdentity,
pub plugins: Vec<WorkerPluginPin>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerPluginExecutionPlan {
format_version: u32,
input: WorkerPluginPlanInput,
}
impl WorkerPluginExecutionPlan {
pub fn canonicalize(mut input: WorkerPluginPlanInput) -> Result<Self> {
if !valid_identity(&input.workspace_id)
|| !valid_digest(&input.config.content_digest)
|| !valid_identity(&input.config.toolchain_fingerprint)
|| !valid_identity(&input.profile.identity)
|| !valid_digest(&input.profile.projection_digest)
|| !valid_identity(&input.execution.runtime_id)
|| !valid_identity(&input.execution.worker_id)
|| !valid_identity(&input.execution.principal_id)
|| input
.execution
.delegation_id
.as_ref()
.is_some_and(|id| !valid_identity(id))
|| input.plugins.len() > 128
{
return invalid();
}
input
.plugins
.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
// One selection per installation; two aliases of one package instead use
// distinct installation identities, as required by the catalog contract.
if input
.plugins
.windows(2)
.any(|pins| pins[0].installation_id == pins[1].installation_id)
{
return invalid();
}
// Public Tool names use immutable installation identities, never aliases.
let mut names = BTreeSet::new();
for pin in &input.plugins {
for tool in &pin.projection.tools {
let name = format!("{}__{}", pin.installation_id, tool);
if name.len() > 128 || !names.insert(name) {
return invalid();
}
}
}
Ok(Self {
format_version: 1,
input,
})
}
pub fn workspace_id(&self) -> &str {
&self.input.workspace_id
}
pub fn config(&self) -> &ConfigProvenance {
&self.input.config
}
pub fn profile(&self) -> &ProfileProvenance {
&self.input.profile
}
pub fn execution(&self) -> &HostExecutionIdentity {
&self.input.execution
}
pub fn plugins(&self) -> &[WorkerPluginPin] {
&self.input.plugins
}
pub fn fingerprint(&self) -> Result<String> {
digest_json(self)
}
pub fn canonical_json(&self) -> Result<String> {
encode_canonical(self)
}
pub(super) fn validate_canonical(&self) -> Result<()> {
if self.format_version != 1 || Self::canonicalize(self.input.clone())? != *self {
return invalid();
}
Ok(())
}
}
fn invalid<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::Invalid))
}
fn conflict<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::Conflict))
}
fn not_found<T>() -> Result<T> {
Err(Error::PluginCatalog(PluginCatalogError::NotFound))
}
fn valid_identity(value: &str) -> bool {
!value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
}
fn valid_digest(value: &str) -> bool {
value.strip_prefix("sha256:").is_some_and(|hex| {
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
fn canonical_value(value: &Value) -> Value {
match value {
Value::Object(fields) => {
let mut keys: Vec<_> = fields.keys().collect();
keys.sort();
Value::Object(
keys.into_iter()
.map(|key| (key.clone(), canonical_value(&fields[key])))
.collect(),
)
}
Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
_ => value.clone(),
}
}
fn encode_canonical(value: &impl Serialize) -> Result<String> {
let value = serde_json::to_value(value)
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))?;
serde_json::to_string(&canonical_value(&value))
.map_err(|_| Error::PluginCatalog(PluginCatalogError::Invalid))
}
fn digest_json(value: &impl Serialize) -> Result<String> {
let bytes = encode_canonical(value)?;
Ok(hash_bytes(bytes.as_bytes()))
}
fn hash_bytes(bytes: &[u8]) -> String {
let hex: String = Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{hex}")
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,51 @@
//! Ordered production migration 106 -> 107. A marker failure rolls back new DDL
//! without changing the already applied catalog migration or its saved rows.
use super::*;
#[test]
fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_matches_fresh() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("retained.db");
tests::prepare_retained_schema(&path, 106);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
let before = workspace_schema_migration_history(&conn).unwrap();
conn.execute_batch("CREATE TRIGGER interrupt_plan_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=107 BEGIN SELECT RAISE(ABORT,'plan marker failure'); END;").unwrap();
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
assert_eq!(workspace_schema_migration_history(&conn).unwrap(), before);
assert!(!table_exists(&conn, "plugin_execution_plans").unwrap());
crate::plugin_catalog::verify(&conn).unwrap();
conn.execute_batch("DROP TRIGGER interrupt_plan_migration")
.unwrap();
drop(conn);
let updated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
updated
.with_conn(|conn| {
let mut expected = before.clone();
expected.push((
107,
"Immutable Worker Plugin plans and retained pins".into(),
));
expected.push((
108,
"Subject Ticket responsibility and current-body notification routing".into(),
));
assert_eq!(workspace_schema_migration_history(conn)?, expected);
subject_assignment_migration::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?;
assert_eq!(
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))?,
0
);
conn.execute_batch("DROP TRIGGER plugin_plan_snapshot_immutable")?;
Ok(())
})
.unwrap();
drop(updated);
assert!(
matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin plan storage schema has drifted"))
);
}
@@ -0,0 +1,27 @@
CREATE TABLE plugin_execution_plans (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
plan_id TEXT NOT NULL,
actor TEXT NOT NULL CHECK(length(actor) BETWEEN 1 AND 512),
operation_key TEXT NOT NULL CHECK(length(operation_key) BETWEEN 1 AND 128),
fingerprint TEXT NOT NULL CHECK(length(fingerprint)=71 AND substr(fingerprint,1,7)='sha256:' AND substr(fingerprint,8) NOT GLOB '*[^0-9a-f]*'),
plan_json TEXT NOT NULL CHECK(json_valid(plan_json)),
pin_state TEXT NOT NULL CHECK(pin_state IN ('pending','active','restorable','released')),
PRIMARY KEY(workspace_id, plan_id),
UNIQUE(workspace_id, actor, operation_key)
);
CREATE TRIGGER plugin_plan_snapshot_immutable
BEFORE UPDATE OF workspace_id,plan_id,actor,operation_key,fingerprint,plan_json ON plugin_execution_plans
BEGIN SELECT RAISE(ABORT,'Plugin execution plan is immutable'); END;
CREATE TRIGGER plugin_plan_retention_terminal BEFORE UPDATE OF pin_state ON plugin_execution_plans
WHEN NOT (
OLD.pin_state=NEW.pin_state OR
(OLD.pin_state='pending' AND NEW.pin_state IN ('active','released')) OR
(OLD.pin_state='active' AND NEW.pin_state IN ('restorable','released')) OR
(OLD.pin_state='restorable' AND NEW.pin_state IN ('active','released'))
)
BEGIN SELECT RAISE(ABORT,'invalid Plugin execution plan pin transition'); END;
-- Preserve the immutable create replay after retention release. Workspace deletion
-- may cascade; deleting an individual receipt must not make its key reusable.
CREATE TRIGGER plugin_plan_replay_retained BEFORE DELETE ON plugin_execution_plans
WHEN EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=OLD.workspace_id)
BEGIN SELECT RAISE(ABORT,'Plugin execution plan replay must be retained'); END;
@@ -0,0 +1,331 @@
use super::*;
use crate::{
plugin_catalog::{self, PluginReference, PluginReferenceKind},
store::SqliteWorkspaceStore,
};
use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanPinState {
Pending,
Active,
Restorable,
Released,
}
impl PlanPinState {
fn name(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Active => "active",
Self::Restorable => "restorable",
Self::Released => "released",
}
}
fn reference_kind(self) -> Option<PluginReferenceKind> {
match self {
Self::Pending => Some(PluginReferenceKind::Pending),
Self::Active => Some(PluginReferenceKind::Active),
Self::Restorable => Some(PluginReferenceKind::Restorable),
Self::Released => None,
}
}
fn can_transition(self, target: Self) -> bool {
self == target
|| matches!(
(self, target),
(Self::Pending, Self::Active | Self::Released)
| (Self::Active, Self::Restorable | Self::Released)
| (Self::Restorable, Self::Active | Self::Released)
)
}
}
/// Immutable creation result. `initial_pin_state` describes creation, never the
/// current retention state. Replay after release must not resurrect any pins.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginPlanCreateReceipt {
pub plan_id: String,
pub fingerprint: String,
pub initial_pin_state: PlanPinState,
pub plan: WorkerPluginExecutionPlan,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StoredPluginPlan {
pub plan_id: String,
pub fingerprint: String,
pub pin_state: PlanPinState,
pub plan: WorkerPluginExecutionPlan,
}
// A local savepoint keeps owner functions atomic even if a caller handles their
// error without aborting its surrounding Worker-create transaction.
fn atomic<T>(tx: &Transaction<'_>, work: impl FnOnce() -> Result<T>) -> Result<T> {
tx.execute_batch("SAVEPOINT plugin_plan_owner")?;
match work() {
Ok(value) => {
tx.execute_batch("RELEASE plugin_plan_owner")?;
Ok(value)
}
Err(error) => {
tx.execute_batch("ROLLBACK TO plugin_plan_owner; RELEASE plugin_plan_owner")?;
Err(error)
}
}
}
/// The caller owns the authenticated create key and an immediate transaction in
/// the *same database* as catalog and Worker creation. Include the returned plan
/// fingerprint in the Worker launch/create fingerprint. New creation rechecks
/// current config/Profile/binding provenance and admitted metadata in this same
/// transaction. Owner-supplied narrowed snapshots cannot exceed that authority.
///
/// Replay precedes catalog admission: an immutable historical result is returned
/// even after revocation, uninstall or release. It is not fresh execution authority.
pub fn plugin_plan_create(
tx: &Transaction<'_>,
actor: &str,
operation_key: &str,
plan: &WorkerPluginExecutionPlan,
) -> Result<PluginPlanCreateReceipt> {
if !valid_identity(actor) || !valid_identity(operation_key) || operation_key.len() > 128 {
return invalid();
}
plan.validate_canonical()?;
let fingerprint = plan.fingerprint()?;
atomic(tx, || {
let saved = tx.query_row(
"SELECT plan_id,fingerprint FROM plugin_execution_plans WHERE workspace_id=?1 AND actor=?2 AND operation_key=?3",
params![plan.workspace_id(), actor, operation_key],
|row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?)),
).optional()?;
if let Some((plan_id, saved_fingerprint)) = saved {
if saved_fingerprint != fingerprint {
return conflict();
}
return receipt(plugin_plan_load(tx, plan.workspace_id(), &plan_id)?);
}
for pin in plan.plugins() {
plugin_catalog::plugin_artifact_admit(
tx,
plan.workspace_id(),
&pin.installation_id,
&pin.expected_last_operation,
&pin.package_digest,
&pin.artifact_digest,
)?;
}
crate::plugin_settings::validate_plan_source(tx, plan)?;
let plan_id = format!("pep-{}", uuid::Uuid::now_v7());
tx.execute(
"INSERT INTO plugin_execution_plans(workspace_id,plan_id,actor,operation_key,fingerprint,plan_json,pin_state) VALUES(?1,?2,?3,?4,?5,?6,'pending')",
params![plan.workspace_id(), plan_id, actor, operation_key, fingerprint, plan.canonical_json()?],
)?;
for pin in plan.plugins() {
let artifact = plugin_catalog::plugin_artifact_admit(
tx,
plan.workspace_id(),
&pin.installation_id,
&pin.expected_last_operation,
&pin.package_digest,
&pin.artifact_digest,
)?;
if !pin.matches_artifact(&artifact)? {
return conflict();
}
plugin_catalog::plugin_reference_put(
tx,
plan.workspace_id(),
&PluginReference {
kind: PluginReferenceKind::Pending,
owner: plan_id.clone(),
installation_id: pin.installation_id.clone(),
expected_last_operation: pin.expected_last_operation.clone(),
package_digest: pin.package_digest.clone(),
artifact_digest: pin.artifact_digest.clone(),
},
)?;
}
receipt(plugin_plan_load(tx, plan.workspace_id(), &plan_id)?)
})
}
fn receipt(saved: StoredPluginPlan) -> Result<PluginPlanCreateReceipt> {
Ok(PluginPlanCreateReceipt {
plan_id: saved.plan_id,
fingerprint: saved.fingerprint,
initial_pin_state: PlanPinState::Pending,
plan: saved.plan,
})
}
/// Reads only persisted immutable input, never current config or a latest artifact.
/// `Released` snapshots remain readable for audit/replay but cannot be restored.
pub fn plugin_plan_load(
conn: &Connection,
workspace: &str,
plan_id: &str,
) -> Result<StoredPluginPlan> {
let row = conn.query_row(
"SELECT fingerprint,plan_json,pin_state FROM plugin_execution_plans WHERE workspace_id=?1 AND plan_id=?2",
params![workspace, plan_id],
|row| Ok((row.get::<_,String>(0)?, row.get::<_,String>(1)?, row.get::<_,String>(2)?)),
).optional()?;
let Some((fingerprint, json, state)) = row else {
return not_found();
};
let plan: WorkerPluginExecutionPlan = serde_json::from_str(&json)
.map_err(|_| Error::Store("invalid persisted Plugin execution plan".into()))?;
plan.validate_canonical()?;
if plan.workspace_id() != workspace || plan.fingerprint()? != fingerprint {
return Err(Error::Store(
"Plugin execution plan integrity mismatch".into(),
));
}
let pin_state = match state.as_str() {
"pending" => PlanPinState::Pending,
"active" => PlanPinState::Active,
"restorable" => PlanPinState::Restorable,
"released" => PlanPinState::Released,
_ => {
return Err(Error::Store(
"invalid Plugin execution plan pin state".into(),
));
}
};
Ok(StoredPluginPlan {
plan_id: plan_id.into(),
fingerprint,
pin_state,
plan,
})
}
/// Moves pins atomically with the caller's Worker lifecycle update. State is CAS
/// compared; a stale transition is not silently replayed. Releasing is always
/// possible after revoke and never re-admits an artifact. Releasing twice with
/// expected=Released is a harmless no-op. No transition out of Released is allowed.
///
/// Acquiring a different retained kind rechecks live catalog admission. It uses
/// the installation's current operation (read inside the write transaction), not
/// the creation-time observation: unrelated artifact additions do not invalidate
/// immutable snapshots. Revoked targets cannot acquire new pins; an unsuccessful
/// transition retains all old refs. A revoked Worker may release its pins, but
/// cannot become restorable/active. T-793 must still enforce live operation checks.
pub fn plugin_plan_transition(
tx: &Transaction<'_>,
workspace: &str,
plan_id: &str,
expected: PlanPinState,
target: PlanPinState,
) -> Result<StoredPluginPlan> {
atomic(tx, || {
let mut saved = plugin_plan_load(tx, workspace, plan_id)?;
if saved.pin_state != expected || !expected.can_transition(target) {
return conflict();
}
if expected == target {
return Ok(saved);
}
if let Some(kind) = target.reference_kind() {
for pin in saved.plan.plugins() {
let operation: String = tx.query_row(
"SELECT last_operation FROM plugin_installations WHERE workspace_id=?1 AND installation_id=?2",
params![workspace, pin.installation_id], |row| row.get(0),
)?;
plugin_catalog::plugin_reference_put(
tx,
workspace,
&PluginReference {
kind,
owner: plan_id.into(),
installation_id: pin.installation_id.clone(),
expected_last_operation: operation,
package_digest: pin.package_digest.clone(),
artifact_digest: pin.artifact_digest.clone(),
},
)?;
}
}
if let Some(kind) = expected.reference_kind() {
for pin in saved.plan.plugins() {
plugin_catalog::plugin_reference_remove(
tx,
workspace,
kind,
plan_id,
&pin.installation_id,
&pin.artifact_digest,
)?;
}
}
let changed = tx.execute(
"UPDATE plugin_execution_plans SET pin_state=?1 WHERE workspace_id=?2 AND plan_id=?3 AND pin_state=?4",
params![target.name(),workspace,plan_id,expected.name()],
)?;
if changed != 1 {
return conflict();
}
saved.pin_state = target;
Ok(saved)
})
}
impl SqliteWorkspaceStore {
pub fn plugin_plan_create(
&self,
actor: &str,
operation_key: &str,
plan: &WorkerPluginExecutionPlan,
) -> Result<PluginPlanCreateReceipt> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let receipt = plugin_plan_create(&tx, actor, operation_key, plan)?;
tx.commit()?;
Ok(receipt)
})
}
pub fn plugin_plan_load(&self, workspace: &str, plan_id: &str) -> Result<StoredPluginPlan> {
self.with_conn(|conn| plugin_plan_load(conn, workspace, plan_id))
}
pub fn plugin_plan_transition(
&self,
workspace: &str,
plan_id: &str,
expected: PlanPinState,
target: PlanPinState,
) -> Result<StoredPluginPlan> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let saved = plugin_plan_transition(&tx, workspace, plan_id, expected, target)?;
tx.commit()?;
Ok(saved)
})
}
}
pub(crate) fn verify_plan_schema(conn: &Connection) -> Result<()> {
let expected = Connection::open_in_memory()?;
expected.execute_batch(SCHEMA_SQL)?;
let mut statement = expected.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND tbl_name='plugin_execution_plans' ORDER BY name")?;
for item in statement.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})? {
let (name, sql) = item?;
let actual = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name=?1",
[&name],
|row| row.get::<_, String>(0),
)
.optional()?;
if actual.as_deref() != Some(&sql) {
return Err(Error::Store(
"Plugin plan storage schema has drifted".into(),
));
}
}
Ok(())
}
@@ -0,0 +1,779 @@
//! Real file-backed SQLite, production catalog operations and a checked-in PDK
//! Component. Execution/Host side effects are intentionally outside this boundary.
use super::*;
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
use rusqlite::{Connection, TransactionBehavior, params};
use server_api::*;
use std::fs;
use tempfile::{TempDir, tempdir};
struct Fixture {
dir: TempDir,
store: SqliteWorkspaceStore,
}
impl Fixture {
async fn new() -> Self {
let dir = tempdir().unwrap();
let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap();
for workspace in ["a", "b"] {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: workspace.into(),
owner_account_id: "owner-account".into(),
display_name: workspace.into(),
state: "active".into(),
created_at: "2026-10-28T00:00:00Z".into(),
updated_at: "2026-10-28T00:00:00Z".into(),
})
.await
.unwrap();
}
Self { dir, store }
}
fn install(&self, alias: &str) -> PluginOperationResponse {
let upload = self
.store
.plugin_upload(
&self.dir.path().join("blobs"),
"a",
"actor",
&package("0.1.0"),
)
.unwrap();
self.store
.plugin_install(
"a",
"actor",
&PluginInstallRequest {
upload_id: upload.upload_id,
alias: alias.into(),
operation_key: alias.into(),
},
)
.unwrap()
}
fn count(&self, table: &str) -> i64 {
self.store
.with_conn(|conn| {
Ok(conn.query_row(
&format!(
"SELECT count(*) FROM {table}{}",
if table == "plugin_references" {
" WHERE kind != 'config'"
} else {
""
}
),
[],
|r| r.get(0),
)?)
})
.unwrap()
}
fn kind(&self) -> String {
self.store
.with_conn(|conn| {
Ok(conn.query_row(
"SELECT kind FROM plugin_references WHERE kind != 'config' LIMIT 1",
[],
|r| r.get(0),
)?)
})
.unwrap()
}
fn transition(&self, id: &str, from: PlanPinState, to: PlanPinState) -> StoredPluginPlan {
self.store
.plugin_plan_transition("a", id, from, to)
.unwrap()
}
}
fn package(version: &str) -> Vec<u8> {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("plugin.toml"),
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml")
.replace("version = \"0.1.0\"", &format!("version = \"{version}\"")),
)
.unwrap();
fs::write(
dir.path().join("plugin.component.wasm"),
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
)
.unwrap();
let package = plugin::read_plugin_directory(
dir.path(),
plugin::PluginSourceKind::Explicit,
&plugin::PluginPackageLimits::default(),
)
.unwrap();
plugin::pack_plugin_package(&package, &plugin::PluginPackageLimits::default())
.unwrap()
.bytes
}
fn digest(seed: &str) -> String {
hash_bytes(seed.as_bytes())
}
fn projection() -> PluginPinProjection {
PluginPinProjection {
config: serde_json::json!({}),
surfaces: vec![PluginSurface::Tool],
tools: vec!["example_echo".into()],
grants: vec![],
bindings: vec![],
}
}
fn pin(installed: &PluginOperationResponse) -> WorkerPluginPin {
WorkerPluginPin::from_artifact(
installed.installation.installation_id.clone(),
installed.operation_id.clone(),
&installed.installation.artifacts[0],
projection(),
)
.unwrap()
}
fn input(plugins: Vec<WorkerPluginPin>) -> WorkerPluginPlanInput {
WorkerPluginPlanInput {
workspace_id: "a".into(),
config: ConfigProvenance {
content_digest: digest("config"),
toolchain_fingerprint: digest("toolchain"),
},
profile: ProfileProvenance {
identity: "profile:coder".into(),
projection_digest: digest("profile"),
},
execution: HostExecutionIdentity {
runtime_id: "runtime".into(),
worker_id: "worker".into(),
principal_id: "worker-principal".into(),
delegation_id: Some("delegation".into()),
},
plugins,
}
}
fn plan(plugins: Vec<WorkerPluginPin>) -> WorkerPluginExecutionPlan {
WorkerPluginExecutionPlan::canonicalize(input(plugins)).unwrap()
}
fn error<T: std::fmt::Debug>(result: Result<T>, expected: PluginCatalogError) {
assert!(
matches!(result,Err(Error::PluginCatalog(actual)) if actual == expected),
"expected {expected:?}, got {result:?}"
);
}
fn uninstall(
f: &Fixture,
installation: &PluginOperationResponse,
key: &str,
) -> Result<PluginOperationResponse> {
f.store.plugin_uninstall(
"a",
"actor",
&installation.installation.installation_id,
&PluginUninstallRequest {
expected_last_operation: installation.operation_id.clone(),
operation_key: key.into(),
},
)
}
#[tokio::test]
async fn canonical_order_and_fingerprint_bind_all_provenance_and_both_digests() {
let f = Fixture::new().await;
let a = f.install("one");
let b = f.install("two");
let p = plan(vec![pin(&a), pin(&b)]);
assert_eq!(p, plan(vec![pin(&b), pin(&a)]));
assert_eq!(
p.fingerprint().unwrap(),
plan(vec![pin(&b), pin(&a)]).fingerprint().unwrap()
);
let mut changes = Vec::new();
let base = input(vec![pin(&a), pin(&b)]);
let mut changed = base.clone();
changed.config.content_digest = digest("new config");
changes.push(changed);
let mut changed = base.clone();
changed.config.toolchain_fingerprint = digest("new toolchain");
changes.push(changed);
let mut changed = base.clone();
changed.profile.projection_digest = digest("new profile");
changes.push(changed);
let mut changed = base.clone();
changed.execution.delegation_id = Some("different".into());
changes.push(changed);
let mut changed = base.clone();
changed.plugins[0].package_digest = digest("new package");
changes.push(changed);
let mut changed = base.clone();
changed.plugins[0].artifact_digest = digest("new artifact");
changes.push(changed);
let mut changed = base;
changed.plugins[0].expected_last_operation = "new observation".into();
changes.push(changed);
for changed in changes {
assert_ne!(
p.fingerprint().unwrap(),
WorkerPluginExecutionPlan::canonicalize(changed)
.unwrap()
.fingerprint()
.unwrap()
);
}
error(
WorkerPluginExecutionPlan::canonicalize(input(vec![pin(&a), pin(&a)])),
PluginCatalogError::Invalid,
);
}
#[tokio::test]
async fn canonical_json_sorts_nested_config_and_selected_sets_without_copying_catalog_payloads() {
let f = Fixture::new().await;
let installed = f.install("one");
let mut artifact = installed.installation.artifacts[0].clone();
artifact.tools.push(PluginToolMetadata {
name: "other".into(),
..artifact.tools[0].clone()
});
artifact.requested_grants = vec![
PluginGrantMetadata::Logging,
PluginGrantMetadata::Https { slot: "api".into() },
];
artifact.connections = vec![PluginConnectionMetadata {
name: "api".into(),
methods: vec![PluginHttpMethod::Get],
path_prefixes: vec!["/".into()],
}];
let mut first = projection();
first.config = serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":[{"z":0,"a":false}]}"#).unwrap();
first.tools = vec!["other".into(), "example_echo".into()];
first.grants = vec![
PluginGrant::Https { slot: "api".into() },
PluginGrant::Logging,
];
first.bindings = vec![BindingPolicyReference {
slot: "api".into(),
binding_id: "binding".into(),
policy_digest: digest("policy"),
}];
let mut second = first.clone();
second.config = serde_json::from_str(r#"{"a":[{"a":false,"z":0}],"z":{"a":1,"b":2}}"#).unwrap();
second.tools.reverse();
second.grants.reverse();
let construct = |projection| {
plan(vec![
WorkerPluginPin::from_artifact(
installed.installation.installation_id.clone(),
installed.operation_id.clone(),
&artifact,
projection,
)
.unwrap(),
])
};
let canonical = construct(first);
assert_eq!(canonical, construct(second));
let json = canonical.canonical_json().unwrap();
for excluded in [
"revocation_reason",
"description",
"input_schema",
"output_schema",
"dcdl",
"artifact_url",
"host_path",
"secret_value",
"provider_handle",
"token",
"package_bytes",
] {
assert!(
!json.contains(&format!("\"{excluded}\"")),
"unexpected {excluded}"
);
}
let mut unknown = serde_json::to_value(&canonical).unwrap();
unknown["input"]["execution"]["token"] = Value::String("credential".into());
assert!(serde_json::from_value::<WorkerPluginExecutionPlan>(unknown).is_err());
}
#[tokio::test]
async fn create_replay_is_immutable_across_restart_and_rejects_changed_input() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let receipt = f
.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap();
assert_eq!(receipt.initial_pin_state, PlanPinState::Pending);
assert_eq!(f.kind(), "pending");
f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
);
let reopened = SqliteWorkspaceStore::open_for_test(f.dir.path().join("server.db")).unwrap();
assert_eq!(
reopened
.plugin_plan_create("actor", "launch", &plan)
.unwrap(),
receipt
);
assert_eq!(
reopened
.plugin_plan_load("a", &receipt.plan_id)
.unwrap()
.pin_state,
PlanPinState::Active
);
let mut changed = plan.input.clone();
changed.profile.projection_digest = digest("new profile");
error(
reopened.plugin_plan_create(
"actor",
"launch",
&WorkerPluginExecutionPlan::canonicalize(changed).unwrap(),
),
PluginCatalogError::Conflict,
);
assert_eq!(f.count("plugin_execution_plans"), 1);
assert_eq!(f.count("plugin_references"), 1);
assert_eq!(
reopened
.plugin_plan_load("a", &receipt.plan_id)
.unwrap()
.plan,
plan
);
error(
reopened.plugin_plan_load("b", &receipt.plan_id),
PluginCatalogError::NotFound,
);
}
#[tokio::test]
async fn retained_pin_lifecycle_blocks_uninstall_and_released_replay_never_reacquires() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let receipt = f
.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap();
error(
uninstall(&f, &installed, "uninstall-pending"),
PluginCatalogError::Referenced,
);
f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
);
assert_eq!(f.kind(), "active");
error(
uninstall(&f, &installed, "uninstall-active"),
PluginCatalogError::Referenced,
);
f.transition(
&receipt.plan_id,
PlanPinState::Active,
PlanPinState::Restorable,
);
assert_eq!(f.kind(), "restorable");
error(
uninstall(&f, &installed, "uninstall-restorable"),
PluginCatalogError::Referenced,
);
f.transition(
&receipt.plan_id,
PlanPinState::Restorable,
PlanPinState::Active,
);
f.transition(
&receipt.plan_id,
PlanPinState::Active,
PlanPinState::Released,
);
f.transition(
&receipt.plan_id,
PlanPinState::Released,
PlanPinState::Released,
);
assert_eq!(f.count("plugin_references"), 0);
f.disable();
uninstall(&f, &installed, "uninstall-released").unwrap();
assert_eq!(
f.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap(),
receipt
);
assert_eq!(f.count("plugin_references"), 0);
error(
f.store.plugin_plan_transition(
"a",
&receipt.plan_id,
PlanPinState::Released,
PlanPinState::Active,
),
PluginCatalogError::Conflict,
);
error(
f.store
.plugin_plan_create("actor", "different-launch", &plan),
PluginCatalogError::NotFound,
);
assert_eq!(f.count("plugin_execution_plans"), 1);
f.store
.with_conn(|conn| {
assert!(
conn.execute("UPDATE plugin_execution_plans SET plan_json=plan_json", [])
.is_err()
);
assert!(
conn.execute("UPDATE plugin_execution_plans SET pin_state='pending'", [])
.is_err()
);
assert!(
conn.execute("DELETE FROM plugin_execution_plans", [])
.is_err()
);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn revoke_blocks_create_and_new_pin_kinds_but_not_release_or_historical_replay() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let receipt = f
.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap();
f.store
.plugin_revoke(
"a",
"actor",
&installed.installation.installation_id,
&PluginRevokeRequest {
artifact_digest: installed.installation.artifacts[0].artifact_digest.clone(),
expected_last_operation: installed.operation_id.clone(),
reason: "revoked".into(),
operation_key: "revoke".into(),
},
)
.unwrap();
assert_eq!(
f.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap(),
receipt
);
error(
f.store.plugin_plan_transition(
"a",
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
),
PluginCatalogError::Conflict,
);
assert_eq!(f.kind(), "pending");
error(
f.store.plugin_plan_create("actor", "new-launch", &plan),
PluginCatalogError::Conflict,
);
assert_eq!(f.count("plugin_execution_plans"), 1);
f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Released,
);
assert_eq!(f.count("plugin_references"), 0);
}
#[tokio::test]
async fn artifact_addition_does_not_reselect_or_invalidate_existing_plan_lifecycle() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let receipt = f
.store
.plugin_plan_create("actor", "launch", &plan)
.unwrap();
let upload = f
.store
.plugin_upload(&f.dir.path().join("blobs"), "a", "actor", &package("0.2.0"))
.unwrap();
f.store
.plugin_add_artifact(
"a",
"actor",
&installed.installation.installation_id,
&PluginAddArtifactRequest {
upload_id: upload.upload_id,
expected_last_operation: installed.operation_id.clone(),
operation_key: "update".into(),
},
)
.unwrap();
let active = f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
);
assert_eq!(active.plan, plan);
assert_eq!(
active.plan.plugins()[0].artifact_digest(),
installed.installation.artifacts[0].artifact_digest
);
error(
f.store
.plugin_plan_create("actor", "new-with-stale-observation", &plan),
PluginCatalogError::Conflict,
);
}
#[tokio::test]
async fn owner_savepoint_and_outer_transaction_rollback_leave_no_partial_plan_or_pins() {
let f = Fixture::new().await;
let first = f.install("one");
let second = f.install("two");
let mut bad_input = f.configure(&[&first, &second]).input;
let mut pins = bad_input.plugins.clone();
pins.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
pins[1].artifact_size += 1;
bad_input.plugins = pins;
let bad_plan = WorkerPluginExecutionPlan::canonicalize(bad_input).unwrap();
f.store
.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
error(
plugin_plan_create(&tx, "actor", "bad", &bad_plan),
PluginCatalogError::Conflict,
);
// Even if the caller commits after a handled error, first pin is gone.
tx.commit()?;
Ok(())
})
.unwrap();
assert_eq!(f.count("plugin_execution_plans"), 0);
assert_eq!(f.count("plugin_references"), 0);
let valid = f.configure(&[&first]);
f.store
.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
plugin_plan_create(&tx, "actor", "outer-rollback", &valid)?;
tx.rollback()?;
Ok(())
})
.unwrap();
assert_eq!(f.count("plugin_execution_plans"), 0);
assert_eq!(f.count("plugin_references"), 0);
// The same key is usable after rollback because neither domain fact committed.
f.store
.plugin_plan_create("actor", "outer-rollback", &valid)
.unwrap();
}
#[tokio::test]
async fn cross_workspace_and_exact_digest_mismatches_fail_without_retaining_references() {
let f = Fixture::new().await;
let installed = f.install("one");
let base = f.configure(&[&installed]);
let mut other_workspace = base.input.clone();
other_workspace.workspace_id = "b".into();
error(
f.store.plugin_plan_create(
"actor",
"cross",
&WorkerPluginExecutionPlan::canonicalize(other_workspace).unwrap(),
),
PluginCatalogError::NotFound,
);
for change_package in [true, false] {
let mut changed = base.input.clone();
if change_package {
changed.plugins[0].package_digest = digest("wrong-package");
} else {
changed.plugins[0].artifact_digest = digest("wrong-artifact");
}
let result = f.store.plugin_plan_create(
"actor",
"digests",
&WorkerPluginExecutionPlan::canonicalize(changed).unwrap(),
);
error(
result,
if change_package {
PluginCatalogError::Conflict
} else {
PluginCatalogError::NotFound
},
);
}
assert_eq!(f.count("plugin_execution_plans"), 0);
assert_eq!(f.count("plugin_references"), 0);
}
#[tokio::test]
async fn sqlite_write_fence_serializes_uninstall_and_pin_creation_across_connections() {
let f = Fixture::new().await;
let installed = f.install("one");
let plan = f.configure(&[&installed]);
let mut other = Connection::open(f.dir.path().join("server.db")).unwrap();
other.busy_timeout(std::time::Duration::ZERO).unwrap();
f.store
.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
plugin_plan_create(&tx, "actor", "launch", &plan)?;
// A second writer cannot pass the catalog deletion fence before commit.
assert!(
other
.transaction_with_behavior(TransactionBehavior::Immediate)
.is_err()
);
tx.commit()?;
Ok(())
})
.unwrap();
let tx = other
.transaction_with_behavior(TransactionBehavior::Immediate)
.unwrap();
assert!(tx.execute("UPDATE plugin_installations SET uninstalled=1 WHERE workspace_id='a' AND installation_id=?1", params![installed.installation.installation_id]).is_err());
tx.rollback().unwrap();
error(
uninstall(&f, &installed, "after-committed-pin"),
PluginCatalogError::Referenced,
);
// Opposite ordering: uninstall wins before a new plan's owner acquires pins.
let unpinned = f.install("unpinned");
let unpinned_plan = f.configure(&[&unpinned]);
f.disable();
uninstall(&f, &unpinned, "uninstall-first").unwrap();
error(
f.store
.plugin_plan_create("actor", "after-uninstall", &unpinned_plan),
PluginCatalogError::NotFound,
);
}
impl Fixture {
fn configure(&self, installed: &[&PluginOperationResponse]) -> WorkerPluginExecutionPlan {
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigSchemaProvider};
use config_source::{ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle};
let bundle = WorkspaceConfigSchemaBundle::compose([
crate::plugin_settings::PluginConfigSchemaProvider
.contribution()
.unwrap(),
crate::profile_settings::ProfileConfigSchemaProvider
.contribution()
.unwrap(),
])
.unwrap();
let state = self
.store
.ensure_workspace_config_materialized_with_schema("a", "1", bundle.clone())
.unwrap();
let mut enabled = serde_json::Map::new();
let mut selections = Vec::new();
for (index, item) in installed.iter().enumerate() {
let a = &item.installation.artifacts[0];
enabled.insert(
format!("plugin_{index}"),
serde_json::json!({
"installation_id": item.installation.installation_id,
"expected_last_operation": item.operation_id,
"package_digest": a.package_digest, "artifact_digest": a.artifact_digest,
"tools": ["example_echo"], "config": {},
}),
);
selections.push(serde_json::json!({"installation_id":item.installation.installation_id,"tools":["example_echo"]}));
}
let value = serde_json::json!({"plugins":{"enabled": enabled}, "profile":{"entries":[{"selector":"project:tool","profile":{"plugins":{"selections":selections}}}]}});
let path = VirtualPath::parse("main.dcdl").unwrap();
let candidate = self
.store
.evaluate_workspace_config_candidate_with_schema(
"a",
&ConfigCommitRequest {
base_digest: state.snapshot.digest.clone(),
entrypoints: vec![path.clone()],
changes: vec![ConfigTreeChange::Update {
path: path.clone(),
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
content: format!(
"{} as WorkspaceConfigSchema",
crate::plugin_settings::render_value(&value).unwrap()
),
}],
},
bundle,
)
.unwrap();
let state = self
.store
.commit_evaluated_workspace_config("a", &candidate)
.unwrap();
self.store
.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
crate::plugin_settings::resolve_worker_plugin_plan(
&tx,
"a",
&state.snapshot.digest,
"project:tool",
input(vec![]).execution,
)
})
.unwrap()
}
fn disable(&self) {
self.configure(&[]);
}
}
impl SqliteWorkspaceStore {
// Data-only donor for cross-owner migration preservation tests. The caller
// imports explicit rows into real retained DDL, never this donor's schema or
// migration ledgers. Production catalog/config/plan APIs generate the bytes.
pub(crate) async fn plugin_plan_migration_donor() -> (
TempDir,
Self,
Vec<(String, PluginPlanCreateReceipt, PlanPinState)>,
) {
let f = Fixture::new().await;
let one = f.install("migration-one");
let two = f.install("migration-two");
let plan = f.configure(&[&one, &two]);
assert_ne!(
plan.plugins()[0].package_digest,
plan.plugins()[0].artifact_digest
);
let mut receipts = Vec::new();
for (key, state) in [
("pending", PlanPinState::Pending),
("active", PlanPinState::Active),
("restorable", PlanPinState::Restorable),
("released", PlanPinState::Released),
] {
let receipt = f.store.plugin_plan_create("actor", key, &plan).unwrap();
if matches!(state, PlanPinState::Active | PlanPinState::Restorable) {
f.transition(
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Active,
);
}
if state == PlanPinState::Restorable {
f.transition(&receipt.plan_id, PlanPinState::Active, state);
} else if state == PlanPinState::Released {
f.transition(&receipt.plan_id, PlanPinState::Pending, state);
}
receipts.push((key.into(), receipt, state));
}
(f.dir, f.store, receipts)
}
}
@@ -0,0 +1,709 @@
//! Catalog-backed Plugin configuration. The virtual config tree remains the only
//! configuration authority; static metadata resolution never instantiates a guest.
use std::collections::{BTreeMap, BTreeSet};
use config_source::{
ConfigSchemaContribution, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment,
ToolchainContract, WorkspaceConfigSchemaBundle,
};
use rusqlite::Transaction;
use serde::{Deserialize, Serialize};
use server_api::{PluginArtifact, PluginGrantMetadata};
use crate::config_source::WorkspaceConfigSchemaProvider;
use crate::plugin_binding::PluginConnectionBinding;
use crate::plugin_catalog::{
PluginReference, PluginReferenceKind, plugin_artifact_admit, plugin_reference_put,
plugin_reference_remove,
};
use crate::{Error, Result};
const PROVIDER: &str = "builtin:plugins";
const BOOTSTRAP: &str = include_str!("../../../resources/config-schema/plugins.dcdl");
const CONFIG_OWNER: &str = "workspace-config";
#[derive(Default)]
pub struct PluginConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for PluginConfigSchemaProvider {
fn contribution(&self) -> Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new(
PROVIDER,
"plugins",
config_source::digest_bytes(BOOTSTRAP.as_bytes()),
BOOTSTRAP,
)
.map_err(config_error)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginEnablement {
pub installation_id: String,
pub expected_last_operation: String,
pub package_digest: String,
pub artifact_digest: String,
pub tools: Vec<String>,
pub logging: bool,
pub connections: BTreeMap<String, String>,
pub config: serde_json::Value,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginSettings {
pub enabled: BTreeMap<String, PluginEnablement>,
pub bindings: BTreeMap<String, PluginConnectionBinding>,
}
#[derive(Debug, Clone)]
pub struct ResolvedPluginSettings {
pub settings: PluginSettings,
pub artifacts: BTreeMap<String, PluginArtifact>,
}
fn config_error(error: impl std::fmt::Display) -> Error {
Error::InvalidInput(format!("Plugin config: {error}"))
}
fn contract(bundle: WorkspaceConfigSchemaBundle) -> ToolchainContract {
ToolchainContract::with_schema_bundle(
config_source::DEFAULT_SCHEMA_VERSION,
vec![
config_source::VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT)
.expect("main path"),
],
config_source::DEFAULT_IMPORT_POLICY_VERSION,
bundle,
)
}
fn evaluate(
snapshot: &ConfigTreeSnapshot,
contract: &ToolchainContract,
) -> Result<EvaluationResult> {
SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(contract)
.map_err(|diagnostics| {
config_error(
serde_json::to_string(&diagnostics)
.unwrap_or_else(|_| "invalid static config".into()),
)
})
}
pub fn settings_from_evaluation(evaluation: &EvaluationResult) -> Result<PluginSettings> {
let value = evaluation
.projections
.first()
.ok_or_else(|| config_error("missing projection"))?;
let root = &value.data_json;
match root.get("plugins") {
Some(value) => serde_json::from_value(value.clone()).map_err(config_error),
None => Ok(PluginSettings::default()),
}
}
/// Resolve core references before composing the closed artifact-specific schema.
/// Both reads use the owner's transaction; the resulting bundle fixes the exact
/// catalog operation as well as both byte identities, not a generic counter.
pub(crate) fn evaluate_catalog_config(
tx: &Transaction<'_>,
workspace: &str,
snapshot: &ConfigTreeSnapshot,
bundle: WorkspaceConfigSchemaBundle,
) -> Result<(ToolchainContract, EvaluationResult, ResolvedPluginSettings)> {
let mut providers = bundle
.contributions
.into_iter()
.filter(|p| p.provider_id != PROVIDER)
.collect::<Vec<_>>();
providers.push(PluginConfigSchemaProvider.contribution()?);
let bootstrap =
contract(WorkspaceConfigSchemaBundle::compose(providers.clone()).map_err(config_error)?);
let core = evaluate(snapshot, &bootstrap)?;
let settings = settings_from_evaluation(&core)?;
crate::plugin_binding::validate_bindings(
&settings.bindings,
&crate::plugin_binding::PluginConnectionPolicyCeiling::default(),
)
.map_err(config_error)?;
if settings.enabled.len() > 128 {
return Err(config_error("too many enablements"));
}
let mut artifacts = BTreeMap::new();
let mut schema_identities = BTreeMap::new();
let mut installations = BTreeSet::new();
for (alias, enabled) in &settings.enabled {
if !installations.insert(&enabled.installation_id) {
return Err(config_error("duplicate enabled installation"));
}
if !safe_name(alias) {
return Err(config_error("invalid enablement alias"));
}
let artifact = plugin_artifact_admit(
tx,
workspace,
&enabled.installation_id,
&enabled.expected_last_operation,
&enabled.package_digest,
&enabled.artifact_digest,
)?;
validate_enablement(enabled, &artifact, &settings)?;
let schema = &artifact.config_schema;
let key = (schema.identity.clone(), schema.version.clone());
let value = (schema.namespace.clone(), schema.digest.clone());
if let Some(previous) = schema_identities.insert(key, value.clone()) {
if previous != value {
return Err(config_error("schema identity collision"));
}
}
artifacts.insert(alias.clone(), artifact);
}
// Each alias has its own closed schema. Reusing an artifact with distinct
// bindings is intentional and does not manufacture another schema authority.
let enabled_fields = artifacts.iter().map(|(alias, artifact)| {
format!("{} = {{ installation_id = String; expected_last_operation = String; package_digest = String; artifact_digest = String; tools = [...String]; logging = Bool default false; connections = {{ ...String }} default {{}}; config = ({}); }};", alias, artifact.config_schema.dcdl)
}).collect::<Vec<_>>().join("\n");
let enabled_schema = if enabled_fields.is_empty() {
"{} default {}".to_string()
} else {
format!("{{{enabled_fields}}}")
};
let (prefix, remaining) = BOOTSTRAP
.split_once("enabled = ")
.ok_or_else(|| config_error("missing bootstrap enablement"))?;
let (_, suffix) = remaining
.split_once(";\n bindings = ")
.ok_or_else(|| config_error("missing bootstrap bindings"))?;
let source = format!("{prefix}enabled = {enabled_schema};\n bindings = {suffix}");
let identities = settings
.enabled
.iter()
.map(|(alias, e)| {
(
alias,
&e.installation_id,
&e.expected_last_operation,
&e.package_digest,
&e.artifact_digest,
&artifacts[alias].config_schema,
)
})
.collect::<Vec<_>>();
let version = format!(
"{}:{}",
config_source::digest_bytes(BOOTSTRAP.as_bytes()),
config_source::digest_bytes(&serde_json::to_vec(&identities).map_err(config_error)?)
);
providers.retain(|p| p.provider_id != PROVIDER);
providers.push(
ConfigSchemaContribution::new(PROVIDER, "plugins", version, source)
.map_err(config_error)?,
);
let full_contract =
contract(WorkspaceConfigSchemaBundle::compose(providers).map_err(config_error)?);
let full = evaluate(snapshot, &full_contract)?;
let final_settings = settings_from_evaluation(&full)?;
let resolved = ResolvedPluginSettings {
settings: final_settings,
artifacts,
};
validate_profile_selections(&full, &resolved)?;
Ok((full_contract, full, resolved))
}
fn safe_name(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.as_bytes()
.first()
.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
}
fn validate_enablement(
enabled: &PluginEnablement,
artifact: &PluginArtifact,
settings: &PluginSettings,
) -> Result<()> {
if artifact.schema_version != 2
|| !matches!(
artifact.world.as_str(),
manifest::plugin::PLUGIN_COMPONENT_TOOL_WORLD
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
| manifest::plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
| manifest::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
)
|| artifact.required_imports.iter().any(|i| {
!matches!(
i.as_str(),
"yoi:host/errors@2.0.0"
| "yoi:host/connections@2.0.0"
| "yoi:host/http@2.0.0"
| "yoi:host/logging@2.0.0"
)
})
{
return Err(config_error("unsupported Component API"));
}
let tools = enabled.tools.iter().collect::<BTreeSet<_>>();
if tools.len() != enabled.tools.len()
|| tools
.iter()
.any(|name| !artifact.tools.iter().any(|tool| &tool.name == *name))
{
return Err(config_error("unknown or duplicate Tool selection"));
}
if enabled.logging
&& !artifact
.requested_grants
.contains(&PluginGrantMetadata::Logging)
{
return Err(config_error("logging exceeds package request"));
}
for (slot, binding) in &enabled.connections {
let requested = artifact
.connections
.iter()
.find(|s| &s.name == slot)
.ok_or_else(|| config_error("undeclared connection slot"))?;
let binding = settings
.bindings
.get(binding)
.ok_or_else(|| config_error("missing connection binding"))?;
// A binding can be narrower than the package request, never wider.
if binding
.methods
.iter()
.any(|m| !requested.methods.contains(m))
|| binding.path_prefixes.iter().any(|path| {
!requested.path_prefixes.iter().any(|prefix| {
prefix == "/"
|| path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
})
})
{
return Err(config_error("binding exceeds package request"));
}
}
if serde_json::to_vec(&enabled.config)
.map_err(config_error)?
.len()
> 64 * 1024
{
return Err(config_error("non-secret config exceeds 64 KiB"));
}
Ok(())
}
pub(crate) fn sync_config_references(
tx: &Transaction<'_>,
workspace: &str,
resolved: &ResolvedPluginSettings,
) -> Result<()> {
let existing = {
let mut statement = tx.prepare("SELECT installation_id, artifact_digest FROM plugin_references WHERE workspace_id=?1 AND kind='config' AND owner=?2")?;
statement
.query_map(rusqlite::params![workspace, CONFIG_OWNER], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?
};
for (installation, digest) in existing {
plugin_reference_remove(
tx,
workspace,
PluginReferenceKind::Config,
CONFIG_OWNER,
&installation,
&digest,
)?;
}
for enabled in resolved.settings.enabled.values() {
plugin_reference_put(
tx,
workspace,
&PluginReference {
kind: PluginReferenceKind::Config,
owner: CONFIG_OWNER.into(),
installation_id: enabled.installation_id.clone(),
expected_last_operation: enabled.expected_last_operation.clone(),
package_digest: enabled.package_digest.clone(),
artifact_digest: enabled.artifact_digest.clone(),
},
)?;
}
Ok(())
}
fn validate_profile_selections(
evaluation: &EvaluationResult,
resolved: &ResolvedPluginSettings,
) -> Result<()> {
let root = &evaluation.projections[0].data_json;
if let Some(entries) = root
.pointer("/profile/entries")
.and_then(serde_json::Value::as_array)
{
for entry in entries {
if let Some(plugins) = entry.pointer("/profile/plugins") {
let plugins: manifest::PluginProfileConfig =
serde_json::from_value(plugins.clone()).map_err(config_error)?;
project_profile_plugins(resolved, &plugins)?;
}
}
}
Ok(())
}
fn project_profile_plugins(
resolved: &ResolvedPluginSettings,
profile: &manifest::PluginProfileConfig,
) -> Result<Vec<crate::plugin_plan::WorkerPluginPin>> {
use crate::plugin_plan::{
BindingPolicyReference, PluginGrant, PluginPinProjection, PluginSurface, WorkerPluginPin,
};
let mut selected = BTreeSet::new();
let mut pins = Vec::new();
for selection in &profile.selections {
if !selected.insert(&selection.installation_id) {
return Err(config_error("duplicate Profile installation"));
}
let (alias, enabled) = resolved
.settings
.enabled
.iter()
.find(|(_, e)| e.installation_id == selection.installation_id)
.ok_or_else(|| {
config_error("Profile selects an installation not enabled by Workspace")
})?;
if selection
.tools
.iter()
.any(|tool| !enabled.tools.contains(tool))
|| (selection.logging && !enabled.logging)
|| selection
.https_slots
.iter()
.any(|slot| !enabled.connections.contains_key(slot))
{
return Err(config_error(
"Profile exceeds Workspace surface/grant ceiling",
));
}
let mut config = enabled.config.clone();
let object = config
.as_object_mut()
.ok_or_else(|| config_error("non-secret config must be an object"))?;
for (key, value) in &selection.config {
object.insert(key.clone(), value.clone());
}
let config =
evaluate_effective_config(&config, &resolved.artifacts[alias].config_schema.dcdl)?;
let mut grants = Vec::new();
if selection.logging {
grants.push(PluginGrant::Logging);
}
let mut bindings = Vec::new();
for slot in &selection.https_slots {
let binding_id = &enabled.connections[slot];
let binding = &resolved.settings.bindings[binding_id];
grants.push(PluginGrant::Https { slot: slot.clone() });
bindings.push(BindingPolicyReference {
slot: slot.clone(),
binding_id: binding_id.clone(),
policy_digest: binding.digest(),
});
}
pins.push(WorkerPluginPin::from_artifact(
enabled.installation_id.clone(),
enabled.expected_last_operation.clone(),
&resolved.artifacts[alias],
PluginPinProjection {
config,
surfaces: if selection.tools.is_empty() {
vec![]
} else {
vec![PluginSurface::Tool]
},
tools: selection.tools.clone(),
grants,
bindings,
},
)?);
}
Ok(pins)
}
fn evaluate_effective_config(value: &serde_json::Value, schema: &str) -> Result<serde_json::Value> {
use config_source::{ConfigContentType, ConfigEntry, VirtualPath};
if serde_json::to_vec(value).map_err(config_error)?.len() > 64 * 1024 {
return Err(config_error("non-secret config exceeds 64 KiB"));
}
let path = VirtualPath::parse(crate::config_source::MAIN_CONFIG_ENTRYPOINT).expect("main path");
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path,
ConfigContentType::Decodal,
format!(
"{{ config = {}; }} as WorkspaceConfigSchema",
render_value(value)?
),
)
.map_err(config_error)?])
.map_err(config_error)?;
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"plugin-effective-config",
"config",
"1",
format!("{{ config = ({schema}); }}"),
)
.map_err(config_error)?])
.map_err(config_error)?;
// Static singleton fields can concretize omitted nested override values.
// Pin the complete projection, not just the candidate that passed validation.
let evaluated = evaluate(&snapshot, &contract(bundle))?;
let config = evaluated
.projections
.first()
.and_then(|projection| projection.data_json.get("config"))
.filter(|config| config.is_object())
.ok_or_else(|| config_error("effective Plugin config projection must be an object"))?;
if serde_json::to_vec(config).map_err(config_error)?.len() > 64 * 1024 {
return Err(config_error("non-secret config exceeds 64 KiB"));
}
Ok(config.clone())
}
pub(crate) fn render_value(value: &serde_json::Value) -> Result<String> {
Ok(match value {
serde_json::Value::Object(fields) => format!(
"{{ {} }}",
fields
.iter()
.map(|(key, value)| Ok(format!(
"{} = {};",
if safe_name(key) {
key.clone()
} else {
return Err(config_error("config key is not a static identifier"));
},
render_value(value)?
)))
.collect::<Result<Vec<_>>>()?
.join(" ")
),
serde_json::Value::Array(values) => format!(
"[{}]",
values
.iter()
.map(render_value)
.collect::<Result<Vec<_>>>()?
.join(", ")
),
serde_json::Value::Null => return Err(config_error("null is not a Plugin config value")),
value => serde_json::to_string(value).map_err(config_error)?,
})
}
/// Build a maximum immutable snapshot from the current Workspace config and an
/// actual registered Profile. The Host supplies execution/delegation identities;
/// a caller-provided projection, binding digest or package payload is never used.
/// The owning Worker-create transaction may further narrow this snapshot, then
/// persist it with `plugin_plan_create` and its own launch fingerprint atomically.
/// This does not admit execution, resolve secrets or start/restore a Worker.
pub fn resolve_worker_plugin_plan(
tx: &Transaction<'_>,
workspace: &str,
expected_content_digest: &str,
profile_selector: &str,
execution: crate::plugin_plan::HostExecutionIdentity,
) -> Result<crate::plugin_plan::WorkerPluginExecutionPlan> {
use crate::plugin_plan::{
ConfigProvenance, ProfileProvenance, WorkerPluginExecutionPlan, WorkerPluginPlanInput,
};
let state = crate::config_source::load_state(tx, workspace)?
.ok_or_else(|| config_error("Workspace config is not materialized"))?;
if state.snapshot.digest != expected_content_digest {
return Err(Error::WorkspaceConfigConflict(
"plan source content changed".into(),
));
}
let (contract, evaluation, resolved) = evaluate_catalog_config(
tx,
workspace,
&state.snapshot,
state.contract.schema_bundle.clone(),
)?;
if state.contract != contract || state.projection_digest != evaluation.projection_digest {
return Err(Error::WorkspaceConfigConflict(
"plan source catalog/schema changed".into(),
));
}
let profile = resolve_profile(&evaluation, &state.snapshot.digest, profile_selector)?;
let projection_digest = config_source::digest_bytes(
&serde_json::to_vec(&profile.manifest_snapshot).map_err(config_error)?,
);
WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput {
workspace_id: workspace.into(),
config: ConfigProvenance {
content_digest: state.snapshot.digest,
toolchain_fingerprint: contract.fingerprint,
},
profile: ProfileProvenance {
identity: profile_selector.into(),
projection_digest,
},
execution,
plugins: project_profile_plugins(&resolved, &profile.manifest.plugins)?,
})
}
fn resolve_profile(
evaluation: &EvaluationResult,
content_digest: &str,
selector: &str,
) -> Result<manifest::ResolvedProfile> {
let root = &evaluation.projections[0].data_json;
if selector.starts_with("project:") {
let value = root
.pointer("/profile/entries")
.and_then(serde_json::Value::as_array)
.and_then(|entries| {
entries.iter().find(|entry| {
entry.get("selector").and_then(serde_json::Value::as_str) == Some(selector)
})
})
.and_then(|entry| entry.get("profile"))
.ok_or_else(|| config_error("unknown registered Profile"))?;
manifest::resolve_profile_artifact_value(
value.clone(),
manifest::ProfileSource::Archive {
archive_id: format!("workspace-config:{content_digest}"),
source: selector.into(),
},
std::path::Path::new("/"),
"plugin-plan-projection",
)
.map_err(config_error)
} else if selector.starts_with("builtin:") {
let registry = manifest::ProfileDiscovery::with_sources(None, None)
.discover()
.map_err(config_error)?;
manifest::ProfileResolver::new()
.with_workspace_base("/")
.resolve_from_registry(
&manifest::ProfileSelector::source_named(
manifest::ProfileRegistrySource::Builtin,
selector.trim_start_matches("builtin:"),
),
&registry,
manifest::ProfileResolveOptions::with_worker_name("plugin-plan-projection"),
)
.map_err(config_error)
} else {
Err(config_error("unknown registered Profile selector"))
}
}
/// Check the owner-supplied launch snapshot against config authority on first
/// create. Exact replay deliberately uses the saved snapshot instead of rereading
/// changed config. Delegation can only remove pins/tools/grants from this maximum.
pub(crate) fn validate_plan_source(
tx: &Transaction<'_>,
plan: &crate::plugin_plan::WorkerPluginExecutionPlan,
) -> Result<()> {
let maximum = resolve_worker_plugin_plan(
tx,
plan.workspace_id(),
&plan.config().content_digest,
&plan.profile().identity,
plan.execution().clone(),
)?;
if maximum.config() != plan.config() || maximum.profile() != plan.profile() {
return Err(config_error("plan source provenance mismatch"));
}
for pin in plan.plugins() {
let upper = maximum
.plugins()
.iter()
.find(|p| p.installation_id() == pin.installation_id())
.ok_or_else(|| config_error("plan exceeds selected Profile"))?;
let lower = pin.projection();
let max = upper.projection();
if pin.package_digest() != upper.package_digest()
|| pin.artifact_digest() != upper.artifact_digest()
|| pin.expected_last_operation() != upper.expected_last_operation()
|| pin.config_digest() != upper.config_digest()
|| pin.config_schema() != upper.config_schema()
|| lower.config != max.config
|| lower.tools.iter().any(|tool| !max.tools.contains(tool))
|| lower.grants.iter().any(|grant| !max.grants.contains(grant))
|| lower
.bindings
.iter()
.any(|binding| !max.bindings.contains(binding))
|| lower
.surfaces
.iter()
.any(|surface| !max.surfaces.contains(surface))
{
return Err(config_error("plan exceeds config/Profile upper bound"));
}
}
Ok(())
}
#[cfg(test)]
mod tests;
pub(crate) fn base_schema_bundle(
bundle: &WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigSchemaBundle> {
let mut contributions = bundle
.contributions
.iter()
.filter(|p| p.provider_id != PROVIDER)
.cloned()
.collect::<Vec<_>>();
if bundle
.contributions
.iter()
.any(|p| p.provider_id == PROVIDER)
{
contributions.push(PluginConfigSchemaProvider.contribution()?);
}
WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error)
}
// Saved config retains the schema that validated it, even when live catalog
// admission changes. Management must remain available to disable/rebind it after
// restart. New preview/create still rechecks the live catalog inside its tx.
pub(crate) fn materialization_schema(
desired: WorkspaceConfigSchemaBundle,
saved: &WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigSchemaBundle> {
let prefix = format!("{}:", config_source::digest_bytes(BOOTSTRAP.as_bytes()));
let active = saved
.contributions
.iter()
.find(|p| p.provider_id == PROVIDER && p.version.starts_with(&prefix));
let Some(active) = active else {
return Ok(desired);
};
let contributions = desired.contributions.into_iter().map(|p| {
if p.provider_id == PROVIDER {
active.clone()
} else {
p
}
});
WorkspaceConfigSchemaBundle::compose(contributions).map_err(config_error)
}
@@ -0,0 +1,584 @@
//! Real catalog + virtual config + SQLite transactions. HTTP authentication and
//! guest execution are outside this suite; the canonical Server config path is used.
use super::*;
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState};
use crate::store::{ControlPlaneStore, WorkspaceRecord};
use config_source::{ConfigTreeChange, VirtualPath, WorkspaceConfigSchemaBundle};
use serde_json::json;
use server_api::*;
use tempfile::{TempDir, tempdir};
struct Fixture {
dir: TempDir,
store: crate::SqliteWorkspaceStore,
installed: PluginOperationResponse,
}
impl Fixture {
async fn new() -> Self {
Self::with_schema("{ mode = String; }").await
}
async fn with_schema(schema: &str) -> Self {
let dir = tempdir().unwrap();
let store = crate::SqliteWorkspaceStore::open_for_test(dir.path().join("db")).unwrap();
for id in ["a", "b"] {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: id.into(),
owner_account_id: "owner-account".into(),
display_name: id.into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
}
let upload = store
.plugin_upload(
&dir.path().join("artifacts"),
"a",
"owner",
&package_with_schema("0.1.0", schema),
)
.unwrap();
let installed = store
.plugin_install(
"a",
"owner",
&PluginInstallRequest {
upload_id: upload.upload_id,
alias: "echo".into(),
operation_key: "install".into(),
},
)
.unwrap();
for id in ["a", "b"] {
store
.ensure_workspace_config_materialized_with_schema(id, "1", bundle())
.unwrap();
}
Self {
dir,
store,
installed,
}
}
fn source(&self) -> serde_json::Value {
let a = &self.installed.installation.artifacts[0];
json!({"plugins":{"enabled":{"echo":{
"installation_id":self.installed.installation.installation_id,
"expected_last_operation":self.installed.operation_id,
"package_digest":a.package_digest,"artifact_digest":a.artifact_digest,
"tools":["example_echo"],"logging":false,"connections":{},"config":{"mode":"hello"}
}},"bindings":{}},"profile":{"default_profile":"project:tool","entries":[{"selector":"project:tool","profile":{"plugins":{"selections":[{"installation_id":self.installed.installation.installation_id,"tools":["example_echo"],"logging":false}]}},"label":"","description":""}]}})
}
fn candidate(
&self,
workspace: &str,
value: serde_json::Value,
) -> Result<crate::config_source::EvaluatedConfigCandidate> {
let state = self.store.load_workspace_config(workspace)?.unwrap();
let path = VirtualPath::parse("main.dcdl").unwrap();
let request = ConfigCommitRequest {
base_digest: state.snapshot.digest.clone(),
entrypoints: vec![path.clone()],
changes: vec![ConfigTreeChange::Update {
path: path.clone(),
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
content: format!("{} as WorkspaceConfigSchema", render_value(&value)?),
}],
};
self.store
.evaluate_workspace_config_candidate_with_schema(workspace, &request, bundle())
}
fn commit(&self, value: serde_json::Value) -> WorkspaceConfigState {
let c = self.candidate("a", value).unwrap();
self.store
.commit_evaluated_workspace_config("a", &c)
.unwrap()
}
fn refs(&self) -> i64 {
self.store
.with_conn(|c| {
Ok(c.query_row(
"SELECT count(*) FROM plugin_references WHERE kind='config'",
[],
|r| r.get(0),
)?)
})
.unwrap()
}
fn plan(&self, state: &WorkspaceConfigState) -> crate::plugin_plan::WorkerPluginExecutionPlan {
self.store
.with_conn(|c| {
let tx = c.unchecked_transaction()?;
resolve_worker_plugin_plan(
&tx,
"a",
&state.snapshot.digest,
"project:tool",
crate::plugin_plan::HostExecutionIdentity {
runtime_id: "runtime".into(),
worker_id: "worker".into(),
principal_id: "principal".into(),
delegation_id: None,
},
)
})
.unwrap()
}
}
fn bundle() -> WorkspaceConfigSchemaBundle {
WorkspaceConfigSchemaBundle::compose([
PluginConfigSchemaProvider.contribution().unwrap(),
crate::profile_settings::ProfileConfigSchemaProvider
.contribution()
.unwrap(),
])
.unwrap()
}
fn package(version: &str) -> Vec<u8> {
package_with_schema(version, "{ mode = String; }")
}
fn package_with_schema(version: &str, schema: &str) -> Vec<u8> {
use manifest::plugin::*;
let d = tempdir().unwrap();
let source =
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml")
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""))
.replace(
"config_schema = \"{}\"",
&format!("config_schema = {}", serde_json::to_string(schema).unwrap()),
);
std::fs::write(d.path().join("plugin.toml"), source).unwrap();
std::fs::write(
d.path().join("plugin.component.wasm"),
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm"),
)
.unwrap();
let parsed = read_plugin_directory(
d.path(),
PluginSourceKind::Explicit,
&PluginPackageLimits::default(),
)
.unwrap();
pack_plugin_package(&parsed, &PluginPackageLimits::default())
.unwrap()
.bytes
}
#[tokio::test]
async fn config_two_phase_closed_schema_and_profile_narrowing_reject_unknown_abstract_and_expansion()
{
let f = Fixture::new().await;
let source = f.source();
let candidate = f.candidate("a", source.clone()).unwrap();
assert!(
candidate
.contract
.schema_bundle
.source
.contains("mode = String")
);
let native = SnapshotEnvironment::new(candidate.snapshot.clone())
.evaluate_contract(&candidate.contract)
.unwrap();
assert_eq!(native, candidate.evaluation);
for pointer in [
"/plugins/enabled/echo/config/extra",
"/plugins/enabled/echo/logging",
"/profile/entries/0/profile/plugins/selections/0/logging",
] {
let mut bad = source.clone();
if pointer.ends_with("extra") {
bad["plugins"]["enabled"]["echo"]["config"]["extra"] = json!(true);
} else {
*bad.pointer_mut(pointer)
.unwrap_or_else(|| panic!("missing {pointer}")) = json!(true);
}
assert!(f.candidate("a", bad).is_err(), "accepted {pointer}");
}
let mut invalid = source.clone();
invalid["plugins"]["enabled"]["echo"]["config"]["mode"] = json!(3);
assert!(f.candidate("a", invalid).is_err());
assert!(f.candidate("b", source).is_err());
assert_eq!(f.refs(), 0);
}
#[tokio::test]
async fn config_refs_commit_atomically_block_uninstall_and_release_on_disable() {
let f = Fixture::new().await;
f.commit(f.source());
assert_eq!(f.refs(), 1);
assert!(
f.store
.plugin_uninstall(
"a",
"owner",
&f.installed.installation.installation_id,
&PluginUninstallRequest {
expected_last_operation: f.installed.operation_id.clone(),
operation_key: "remove".into()
}
)
.is_err()
);
f.commit(json!({"plugins":{},"profile":{"entries":[]}}));
assert_eq!(f.refs(), 0);
f.store
.plugin_uninstall(
"a",
"owner",
&f.installed.installation.installation_id,
&PluginUninstallRequest {
expected_last_operation: f.installed.operation_id.clone(),
operation_key: "remove-after-disable".into(),
},
)
.unwrap();
}
#[tokio::test]
async fn catalog_add_after_preview_rejects_stale_candidate_and_preserves_config_refs() {
let f = Fixture::new().await;
let candidate = f.candidate("a", f.source()).unwrap();
let upload = f
.store
.plugin_upload(
&f.dir.path().join("artifacts"),
"a",
"owner",
&package("0.2.0"),
)
.unwrap();
f.store
.plugin_add_artifact(
"a",
"owner",
&f.installed.installation.installation_id,
&PluginAddArtifactRequest {
upload_id: upload.upload_id,
expected_last_operation: f.installed.operation_id.clone(),
operation_key: "update".into(),
},
)
.unwrap();
assert!(
f.store
.commit_evaluated_workspace_config("a", &candidate)
.is_err()
);
assert_eq!(f.refs(), 0);
}
#[tokio::test]
async fn immutable_plan_create_replay_and_config_changes_do_not_hot_reload() {
use crate::plugin_plan::*;
let f = Fixture::new().await;
let state = f.commit(f.source());
let plan = f.plan(&state);
assert_eq!(plan, f.plan(&state));
let receipt = f.store.plugin_plan_create("host", "create", &plan).unwrap();
let json = plan.canonical_json().unwrap();
assert!(!json.contains("https://"));
assert!(!json.contains(f.dir.path().to_str().unwrap()));
let mut source = f.source();
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["tools"] = json!([]);
f.commit(source);
assert_eq!(
f.store.plugin_plan_create("host", "create", &plan).unwrap(),
receipt
);
let saved = f.store.plugin_plan_load("a", &receipt.plan_id).unwrap();
assert_eq!(saved.plan, plan);
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
assert_eq!(
reopened.plugin_plan_load("a", &receipt.plan_id).unwrap(),
saved
);
reopened
.plugin_plan_transition(
"a",
&receipt.plan_id,
PlanPinState::Pending,
PlanPinState::Released,
)
.unwrap();
assert_eq!(
reopened
.plugin_plan_create("host", "create", &plan)
.unwrap(),
receipt
);
}
#[tokio::test]
async fn profile_override_is_closed_schema_validated_and_plan_only_uses_effective_config() {
let f = Fixture::new().await;
let mut source = f.source();
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
json!({"mode":"override"});
let state = f.commit(source.clone());
let plan = f.plan(&state);
assert_eq!(
plan.plugins()[0].projection().config,
json!({"mode":"override"})
);
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
json!({"unknown":"not permitted"});
assert!(f.candidate("a", source).is_err());
}
#[tokio::test]
async fn public_create_rejects_stale_or_forged_config_profile_and_projection_without_pins() {
use crate::plugin_plan::WorkerPluginExecutionPlan;
let f = Fixture::new().await;
let state = f.commit(f.source());
let plan = f.plan(&state);
for pointer in [
"/input/config/content_digest",
"/input/profile/projection_digest",
"/input/plugins/0/config_digest",
] {
let mut forged = serde_json::to_value(&plan).unwrap();
*forged.pointer_mut(pointer).unwrap() = json!(config_source::digest_bytes(b"forged"));
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
assert!(
f.store
.plugin_plan_create("host", pointer, &forged)
.is_err()
);
}
let mut forged = serde_json::to_value(&plan).unwrap();
forged["input"]["plugins"][0]["projection"]["config"] = json!({"mode":"caller-injected"});
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
assert!(
f.store
.plugin_plan_create("host", "value-injection", &forged)
.is_err()
);
let mut next = f.source();
next["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["tools"] = json!([]);
f.commit(next);
assert!(
f.store
.plugin_plan_create("host", "stale-plan", &plan)
.is_err()
);
f.store
.with_conn(|c| {
assert_eq!(
c.query_row("SELECT count(*) FROM plugin_execution_plans", [], |r| r
.get::<_, i64>(0))?,
0
);
assert_eq!(
c.query_row(
"SELECT count(*) FROM plugin_references WHERE kind != 'config'",
[],
|r| r.get::<_, i64>(0)
)?,
0
);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn config_persistence_failure_rolls_back_reference_changes_and_restart_keeps_management_after_revoke()
{
let f = Fixture::new().await;
let before = f.store.load_workspace_config("a").unwrap().unwrap();
let candidate = f.candidate("a", f.source()).unwrap();
f.store.with_conn(|c| { c.execute_batch("CREATE TRIGGER stop_config BEFORE INSERT ON workspace_config_entries BEGIN SELECT RAISE(ABORT,'config failure'); END;")?; Ok(()) }).unwrap();
assert!(
f.store
.commit_evaluated_workspace_config("a", &candidate)
.is_err()
);
assert_eq!(f.refs(), 0);
assert_eq!(f.store.load_workspace_config("a").unwrap().unwrap(), before);
f.store
.with_conn(|c| {
c.execute_batch("DROP TRIGGER stop_config")?;
Ok(())
})
.unwrap();
let committed = f
.store
.commit_evaluated_workspace_config("a", &candidate)
.unwrap();
let a = &f.installed.installation.artifacts[0];
f.store
.plugin_revoke(
"a",
"owner",
&f.installed.installation.installation_id,
&PluginRevokeRequest {
artifact_digest: a.artifact_digest.clone(),
expected_last_operation: f.installed.operation_id.clone(),
reason: "withdraw".into(),
operation_key: "withdraw".into(),
},
)
.unwrap();
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
assert_eq!(
reopened
.ensure_workspace_config_materialized_with_schema("a", "2", bundle())
.unwrap(),
committed
);
assert!(f.candidate("a", f.source()).is_err());
f.commit(json!({"plugins":{},"profile":{"entries":[]}}));
assert_eq!(f.refs(), 0);
}
#[tokio::test]
async fn https_config_binding_is_validated_pinned_by_digest_and_never_copied_to_plan() {
use crate::plugin_plan::WorkerPluginExecutionPlan;
use manifest::plugin::*;
let f = Fixture::new().await;
let d = tempdir().unwrap();
std::fs::write(
d.path().join("plugin.toml"),
include_str!("../../../../resources/plugin/templates/rust-component-https/plugin.toml"),
)
.unwrap();
std::fs::write(
d.path().join("plugin.component.wasm"),
include_bytes!("../../../plugin-pdk/tests/fixtures/https-tool-v2.component.wasm"),
)
.unwrap();
let limits = PluginPackageLimits::default();
let bytes = pack_plugin_package(
&read_plugin_directory(d.path(), PluginSourceKind::Explicit, &limits).unwrap(),
&limits,
)
.unwrap()
.bytes;
let upload = f
.store
.plugin_upload(&f.dir.path().join("artifacts"), "a", "owner", &bytes)
.unwrap();
let installed = f
.store
.plugin_install(
"a",
"owner",
&PluginInstallRequest {
upload_id: upload.upload_id,
alias: "https".into(),
operation_key: "https-install".into(),
},
)
.unwrap();
let artifact = &installed.installation.artifacts[0];
let mut source = f.source();
source["plugins"]["enabled"]["echo"] = json!({"installation_id":installed.installation.installation_id,"expected_last_operation":installed.operation_id,"package_digest":artifact.package_digest,"artifact_digest":artifact.artifact_digest,"tools":["example_status"],"connections":{"api":"remote"},"config":{}});
source["plugins"]["bindings"] = json!({"remote":{"origin":"https://api.example.com","methods":["GET"],"path_prefixes":["/status"],"request_headers":["accept"],"response_headers":["content-type"],"secret_ref":"credential_ref","limits":{"timeout_ms":1000,"max_request_bytes":1024,"max_response_bytes":1024,"max_header_bytes":512}}});
source["profile"]["entries"][0]["profile"]["plugins"]["selections"] = json!([{"installation_id":installed.installation.installation_id,"tools":["example_status"],"https_slots":["api"]}]);
let state = f.commit(source.clone());
let plan = f.plan(&state);
let encoded = plan.canonical_json().unwrap();
for excluded in [
"https://api.example.com",
"credential_ref",
"origin",
"request_headers",
"secret_ref",
] {
assert!(!encoded.contains(excluded), "leaked {excluded}");
}
assert_eq!(
plan.plugins()[0].projection().bindings[0].binding_id,
"remote"
);
let receipt = f
.store
.plugin_plan_create("host", "https-plan", &plan)
.unwrap();
let mut forged = serde_json::to_value(&plan).unwrap();
forged["input"]["plugins"][0]["projection"]["bindings"][0]["policy_digest"] =
json!(config_source::digest_bytes(b"forged-policy"));
let forged: WorkerPluginExecutionPlan = serde_json::from_value(forged).unwrap();
assert!(
f.store
.plugin_plan_create("host", "forged-policy", &forged)
.is_err()
);
for path in ["/statusish", "/%2e%2e/private"] {
let mut bad = source.clone();
bad["plugins"]["bindings"]["remote"]["path_prefixes"] = json!([path]);
assert!(f.candidate("a", bad).is_err());
}
source["plugins"]["bindings"]["remote"]["origin"] = json!("https://other.example.com");
f.commit(source);
assert!(
f.store
.plugin_plan_create("host", "stale-binding", &plan)
.is_err()
);
assert_eq!(
f.store
.plugin_plan_create("host", "https-plan", &plan)
.unwrap(),
receipt
);
}
#[tokio::test]
async fn nested_profile_override_pins_evaluated_singletons_through_create_restart_and_replay() {
use crate::plugin_plan::{WorkerPluginExecutionPlan, WorkerPluginPlanInput};
let f = Fixture::with_schema("{ nested = { mode = String; count = 1; }; }").await;
let mut source = f.source();
source["plugins"]["enabled"]["echo"]["config"] = json!({"nested":{"mode":"base","count":1}});
source["profile"]["entries"][0]["profile"]["plugins"]["selections"][0]["config"] =
json!({"nested":{"mode":"override"}});
let state = f.commit(source);
let plan = f.plan(&state);
let expected = json!({"nested":{"count":1,"mode":"override"}});
assert_eq!(plan.plugins()[0].projection().config, expected);
assert_eq!(
serde_json::to_value(&plan).unwrap()["input"]["plugins"][0]["config_digest"],
config_source::digest_bytes(&serde_json::to_vec(&expected).unwrap())
);
let receipt = f
.store
.plugin_plan_create("host", "singletons", &plan)
.unwrap();
assert_eq!(receipt.plan.plugins()[0].projection().config, expected);
let reopened = crate::SqliteWorkspaceStore::open_for_test(f.dir.path().join("db")).unwrap();
let stored = reopened.plugin_plan_load("a", &receipt.plan_id).unwrap();
assert_eq!(stored.plan, plan);
assert_eq!(
reopened
.plugin_plan_create("host", "singletons", &plan)
.unwrap(),
receipt
);
// Even a canonically rehashed candidate cannot substitute the raw override
// for the complete projection reconstructed at the public create fence.
let mut forged = serde_json::to_value(&plan).unwrap()["input"].clone();
forged["plugins"][0]["projection"]["config"] = json!({"nested":{"mode":"override"}});
let input: WorkerPluginPlanInput = serde_json::from_value(forged).unwrap();
let incomplete = WorkerPluginExecutionPlan::canonicalize(input).unwrap();
assert!(
reopened
.plugin_plan_create("host", "raw-override", &incomplete)
.is_err()
);
}
#[test]
fn effective_config_size_limit_applies_after_schema_concretization() {
let schema = format!("{{ value = {}; }}", json!("a".repeat(64 * 1024)));
let error = evaluate_effective_config(&json!({}), &schema).unwrap_err();
assert!(
error
.to_string()
.contains("non-secret config exceeds 64 KiB"),
"{error}"
);
}
@@ -106,6 +106,10 @@ mod schema_convergence {
},
WorkspaceSchemaMigrationStep {
version: 107,
name: "Immutable Worker Plugin plans and retained pins".into(),
},
WorkspaceSchemaMigrationStep {
version: 108,
name: "Subject Ticket responsibility and current-body notification routing".into(),
},
]
@@ -125,7 +129,8 @@ mod schema_convergence {
expected.push((104, "Converge Passkey results and Job input retention".into()));
expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into()));
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
expected.push((107, "Subject Ticket responsibility and current-body notification routing".into()));
expected.push((107, "Immutable Worker Plugin plans and retained pins".into()));
expected.push((108, "Subject Ticket responsibility and current-body notification routing".into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected);
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
@@ -1,8 +1,11 @@
// Included in store::tests so both paths use the real retained migration chain.
fn state_contract_schema_signature(conn: &Connection, table: &str) -> Vec<Vec<String>> {
// Retained host generations alone add this exact Workspace-parent FK to
// the three principal tables. All other FKs (including composite owner FKs)
// remain in the signature; do not discard foreign-key contracts wholesale.
let queries = [
"SELECT name, type, CAST(\"notnull\" AS TEXT), COALESCE(dflt_value, '<NULL>'), CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name",
"SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) ORDER BY \"table\", \"from\", \"to\", seq",
"SELECT \"table\", \"from\", \"to\", on_update, on_delete, match, CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) WHERE NOT (?1 IN ('ticket_worker_assignments','ticket_current_worker_assignments','ticket_assignment_operations') AND \"table\"='workspaces' AND \"from\"='workspace_id' AND \"to\"='workspace_id' AND on_update='NO ACTION' AND on_delete='CASCADE' AND match='NONE' AND seq=0) ORDER BY \"table\", \"from\", \"to\", seq",
"SELECT CAST(i.\"unique\" AS TEXT), CAST(i.partial AS TEXT), (SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1, 2, 3",
"SELECT name, sql FROM sqlite_schema WHERE type='trigger' AND tbl_name=?1 ORDER BY name",
];
@@ -52,8 +55,16 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
"typed_ticket_case_history",
"typed_ticket_legacy_workflow_snapshot",
"ticket_current_worker_assignments",
"ticket_worker_assignments",
"ticket_assignment_operations",
"ticket_assignment_work_releases",
"artifacts",
"plugin_installations",
"plugin_artifacts",
"plugin_uploads",
"plugin_operations",
"plugin_references",
"plugin_execution_plans",
"workspace_config_trees",
"workspace_config_tree_history",
"workspace_memory_settings",
+33 -1
View File
@@ -4425,7 +4425,8 @@ impl WorkspaceApi {
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider))
.with_provider(Arc::new(RuntimeConfigSchemaProvider))
.with_provider(Arc::new(RepositoryAccessConfigSchemaProvider))
.with_provider(Arc::new(skills::SkillConfigSchemaProvider));
.with_provider(Arc::new(skills::SkillConfigSchemaProvider))
.with_provider(Arc::new(crate::plugin_settings::PluginConfigSchemaProvider));
let materialization_store = config_store.clone();
let workspace_id = config.workspace_id.clone();
let schema = config_schema_registry.compose()?;
@@ -7510,6 +7511,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::workspace_prompt_projection(
service.clone(),
))
.merge(server_api::server_api_axum::workspace_config_tree_preview(
service.clone(),
))
.merge(server_api::server_api_axum::workspace_config_tree_commit(
service.clone(),
))
@@ -9358,6 +9362,34 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn workspace_config_tree_preview(
&self,
workspace_id: String,
request: server_api::ConfigCommitRequest,
) -> std::result::Result<server_api::WorkspaceConfigTreeResponse, server_api::RepositoryApiError>
{
let api = self.workspace_api()?.clone();
api.store
.db_executor()
.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let candidate = prepare_workspace_config_tree(
&api,
&workspace_id,
&config_commit_request_from_api(request)?,
)?;
Ok(workspace_config_state_to_api(
crate::config_source::WorkspaceConfigState {
snapshot: candidate.snapshot,
contract: candidate.contract,
projection_digest: candidate.evaluation.projection_digest,
},
))
})
.await
.map_err(|error: ApiError| error.into_repository_api_error())
}
async fn workspace_config_tree_commit(
&self,
workspace_id: String,
@@ -412,3 +412,32 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog
assert_eq!(bytes, package_bytes("0.1.0"));
shutdown_workspace_feature_storage(&reopened).await.unwrap();
}
#[tokio::test]
async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() {
let dir=tempfile::tempdir().unwrap();
let api=test_api(dir.path()).await;
let owner=seed_test_workspace_owner_token(api.store.as_ref(),"plugin-config-owner").await;
let reader=seed_test_api_token(api.store.as_ref(),"plugin-config-reader");
let app=build_router(api.clone());
let prefix=format!("/api/w/{TEST_WORKSPACE_ID}");
let upload=request(app.clone(),Some(&owner),"POST",&format!("{prefix}/plugin-uploads"),package_bytes("0.1.0"),true,StatusCode::OK).await;
let installed=json(app.clone(),&owner,&format!("{prefix}/plugins"),serde_json::json!({"upload_id":upload["upload_id"],"alias":"configured","operation_key":"config-install"}),StatusCode::OK).await;
let state=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await;
let a=&installed["installation"]["artifacts"][0];
let source=format!("{{ plugins.enabled.echo = {{ installation_id = {}; expected_last_operation = {}; package_digest = {}; artifact_digest = {}; tools = [\"example_echo\"]; config = {{}}; }}; }} as WorkspaceConfigSchema",installed["installation"]["installation_id"],installed["operation_id"],a["package_digest"],a["artifact_digest"]);
let change=serde_json::json!({"base_digest":state["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]});
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::FORBIDDEN).await;
json(app.clone(),&reader,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::FORBIDDEN).await;
let preview=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/preview"),change.clone(),StatusCode::OK).await;
let unchanged=get(app.clone(),Some(&owner),&format!("{prefix}/config/source-tree"),StatusCode::OK).await;
assert_eq!(unchanged["snapshot"]["digest"],state["snapshot"]["digest"]);
let saved=json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change.clone(),StatusCode::CREATED).await;
assert_eq!(saved,preview);
assert_ne!(saved["snapshot"]["digest"],state["snapshot"]["digest"]);
json(app.clone(),&owner,&format!("{prefix}/config/source-tree/commit"),change,StatusCode::CONFLICT).await;
json(app.clone(),&owner,&format!("{prefix}/plugins/{}/uninstall",installed["installation"]["installation_id"].as_str().unwrap()),serde_json::json!({"expected_last_operation":installed["operation_id"],"operation_key":"config-blocks-uninstall"}),StatusCode::CONFLICT).await;
let current=api.config_store.load_workspace_config(TEST_WORKSPACE_ID).unwrap().unwrap();
let candidates=current.contract.schema_bundle.contributions.iter().filter(|c|c.provider_id=="builtin:plugins").count();
assert_eq!(candidates,1);
}
@@ -908,6 +908,18 @@ async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_contr
released,
};
observed(waiting).await.unwrap(); // actual SQL transaction owns the Feature connection
// Force close to be queued before the sole DB worker can start it. Pending
// admission alone must not be mistaken for an available second queue slot.
let (started, running) = oneshot::channel();
let (start_close, held) = mpsc::channel();
fixture
.executor
.dispatch(move || {
let _ = started.send(());
let _ = held.recv_timeout(WATCHDOG);
})
.unwrap();
observed(running).await.unwrap();
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut close = Box::pin(crate::db_executor::with_operation_lifetime(
Arc::new(CountJobLifetime(leases.clone())),
@@ -917,6 +929,30 @@ async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_contr
futures::poll!(&mut close).is_pending(),
"Feature connection close must not wait on Tokio"
);
assert!(matches!(
fixture.executor.dispatch(|| {}),
Err(Error::DbCapacityExceeded)
));
start_close.send(()).unwrap();
let fenced = observed(async {
loop {
match fixture
.api
.feature_storage
.open(&fixture.api.subjektiv_registration)
{
Err(crate::FeatureStorageError::ShuttingDown) => break,
Ok(_) => tokio::task::yield_now().await,
Err(error) => panic!("unexpected Feature admission failure: {error}"),
}
}
});
tokio::select! {
_ = fenced => {},
result = &mut close => panic!("close completed while its connection is held: {result:?}"),
}
// Observing the real shutdown fence proves close was dequeued and started;
// only now can the one waiting slot be filled deterministically.
runtime_progress_while_locked(&lock).await;
assert_eq!(
observed(fixture.host_executor.run(|| Ok::<_, Error>("control")))
@@ -595,7 +595,10 @@ pub(super) async fn commit(
if candidate.base_digest != state.snapshot.digest
|| candidate.base_toolchain_fingerprint != state.contract.fingerprint
|| candidate.base_projection_digest != state.projection_digest
|| candidate.contract.schema_bundle.fingerprint != schema_fingerprint
|| crate::plugin_settings::base_schema_bundle(&candidate.contract.schema_bundle)
.map_err(pre_error)?
.fingerprint
!= schema_fingerprint
{
return Err(fail(409, "stale_validator"));
}
+42 -9
View File
@@ -129,7 +129,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 107;
const LATEST_SCHEMA_VERSION: i64 = 108;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -469,8 +469,13 @@ const MIGRATIONS: &[Migration] = &[
},
Migration {
version: 107,
name: "Immutable Worker Plugin plans and retained pins",
apply: migrate_plugin_plans_v106_to_v107,
},
Migration {
version: 108,
name: "Subject Ticket responsibility and current-body notification routing",
apply: migrate_subject_assignments_v106_to_v107,
apply: migrate_subject_assignments_v107_to_v108,
},
];
@@ -15823,17 +15828,23 @@ fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_subject_assignments_v106_to_v107(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 106 {
fn migrate_subject_assignments_v107_to_v108(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 107 {
return Err(Error::Store(
"Subject assignment migration requires schema 106".into(),
"Subject assignment migration requires schema 107".into(),
));
}
// Refuse preexisting drift before the new tail can commit any changes.
// Migration 107 and its historical identity remain frozen.
verify_schema_history(conn, 107)?;
crate::plugin_catalog::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?;
let tx = conn.unchecked_transaction()?;
subject_assignment_migration::install(&tx)?;
notification_routing::install_subject_recipients(&tx)?;
subject_assignment_migration::verify(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,?1)",
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(108,?1)",
["Subject Ticket responsibility and current-body notification routing"],
)?;
tx.commit()?;
@@ -15858,12 +15869,26 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
})
}
fn migrate_plugin_plans_v106_to_v107(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 106 {
return Err(Error::Store(
"Plugin plan migration requires schema 106".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,'Immutable Worker Plugin plans and retained pins')", [])?;
tx.commit()?;
Ok(())
}
// Fresh composition uses the posts contract. Retained fixtures pass frozen host
// and component DDL to the shared cross-domain constraint composition below.
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
conn.execute_batch(include_str!("activation_schema.sql"))?;
conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
conn.execute_batch(crate::plugin_plan::SCHEMA_SQL)?;
Ok(())
}
@@ -16905,6 +16930,7 @@ fn apply_migrations_with_secret_source(
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
crate::plugin_catalog::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?;
subject_assignment_migration::verify(conn)?;
verify_workspace_runtime_binding_schema(conn)?;
verify_workspace_runtime_verification_schema(conn)?;
@@ -19601,7 +19627,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()},
WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()},
WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()},
WorkspaceSchemaMigrationStep {version:107, name:"Subject Ticket responsibility and current-body notification routing".into()},
WorkspaceSchemaMigrationStep {version:107, name:"Immutable Worker Plugin plans and retained pins".into()},
WorkspaceSchemaMigrationStep {version:108, name:"Subject Ticket responsibility and current-body notification routing".into()},
]
);
@@ -19718,7 +19745,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(104, "Converge Passkey results and Job input retention".to_string()),
(105, "Durable Subject deletion snapshots and Job admission fences".to_string()),
(106, crate::plugin_catalog::MIGRATION_NAME.to_string()),
(107, "Subject Ticket responsibility and current-body notification routing".to_string()),
(107, "Immutable Worker Plugin plans and retained pins".to_string()),
(108, "Subject Ticket responsibility and current-body notification routing".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -23007,7 +23035,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(104, "Converge Passkey results and Job input retention".into()),
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
(107, "Subject Ticket responsibility and current-body notification routing".into())]);
(107, "Immutable Worker Plugin plans and retained pins".into()),
(108, "Subject Ticket responsibility and current-body notification routing".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}
@@ -28551,3 +28580,7 @@ mod frozen_runtime_binding_tests;
#[cfg(test)]
#[path = "plugin_catalog/migration_tests.rs"]
mod plugin_catalog_migration_tests;
#[cfg(test)]
#[path = "plugin_plan/migration_tests.rs"]
mod plugin_plan_migration_tests;
@@ -27,9 +27,14 @@ mod subject_assignment_migration_tests {
conn.execute_batch("DELETE FROM __yoi_schema_migrations;
INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,'workspace schema baseline');").unwrap();
}
seed_workspace(&conn);
seed_legacy_evidence(&conn);
(directory, path, conn)
}
fn seed_legacy_evidence(conn: &Connection) {
seed_workspace(conn);
for status in ["waiting", "inprogress", "done", "unknown", "released"] {
seed_ticket(&conn, status);
seed_ticket(conn, status);
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
VALUES('w',?1,?1,'orchestrator','workspace_agent','workspace-orchestrator','original actor','original time')",[status]).unwrap();
if status == "unknown" {
@@ -72,14 +77,16 @@ mod subject_assignment_migration_tests {
VALUES('w','waiting','implementation','worker','worker','r','worker','original assigner','original time');
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,runtime_id,worker_id,updated_at)
VALUES('w','waiting','worker','implementation','worker','r','worker','original time');
INSERT INTO ticket_implementation_delegations VALUES('w','implementation','waiting');
INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap();
if table_exists(conn, "ticket_implementation_results").unwrap() {
conn.execute_batch(" INSERT INTO ticket_implementation_delegations VALUES('w','implementation','waiting');
INSERT INTO ticket_implementation_results VALUES('w','waiting','result-op','saved-result','r','worker','{ \"body\" : \"original result\", \"kind\" : \"result\" }','result time');
INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose)
SELECT sequence,'w','saved-notification','deliver','original reason','attention' FROM notification_sources WHERE input_event_id='implementation-result:saved-result';
INSERT INTO notification_requests VALUES('w','saved-notification','original common request','{ \"target\" : \"old body\" }');
INSERT INTO notification_acknowledgements VALUES('w','saved-notification','{ \"accepted\" : \"old body\" }');
INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time');
INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap();
INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time');").unwrap();
}
// A live body candidate and the old singleton exist simultaneously. A
// migration must not infer stable-agent responsibility from either one.
conn.execute_batch("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at)
@@ -88,7 +95,6 @@ mod subject_assignment_migration_tests {
VALUES('w','r','body','Body','normal','1','2');
INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at)
VALUES('w','subjektiv:tempting-subject','r','body',1,'1','2');").unwrap();
(directory, path, conn)
}
fn seed_workspace(conn: &Connection) {
@@ -407,9 +413,7 @@ mod subject_assignment_migration_tests {
};
assert_eq!(workspace_parents(new)?, 0, "fresh standalone-first preimage: {table}");
assert_eq!(workspace_parents(&conn)?, if table=="ticket_current_worker_assignments" {0} else {1}, "retained Workspace preimage: {table}");
let contract = |db: &Connection| state_contract_schema_signature(db,table).into_iter()
.filter(|row| !row.first().is_some_and(|name| name=="workspaces"))
.collect::<Vec<_>>();
let contract = |db: &Connection| state_contract_schema_signature(db,table);
assert_eq!(contract(&conn),contract(new),"{table}");
for db in [&conn,new] {
let ddl:String=db.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",[table],|row|row.get(0))?;
@@ -839,4 +843,545 @@ mod subject_assignment_migration_tests {
assert_eq!(conn.query_row("SELECT request_fingerprint FROM ticket_assignment_operations WHERE operation_id='cached-subject'", [], |row| row.get::<_, String>(0)).unwrap(), "cached fingerprint");
assert_subject_contract(&conn);
}
mod subject_assignment_final_tail {
use super::*;
use crate::plugin_plan::{
self, PlanPinState, PluginPlanCreateReceipt, WorkerPluginExecutionPlan,
WorkerPluginPlanInput,
};
const PLAN_NAME: &str = "Immutable Worker Plugin plans and retained pins";
const SUBJECT_NAME: &str =
"Subject Ticket responsibility and current-body notification routing";
type Rows = Vec<Vec<rusqlite::types::Value>>;
fn raw(path: &Path) -> Connection {
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
conn
}
fn retained(version: i64) -> (tempfile::TempDir, std::path::PathBuf, Connection) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tail.db");
prepare_retained_schema(&path, version);
let conn = raw(&path);
seed_legacy_evidence(&conn);
assert_component13(&conn);
assert!(!table_exists(&conn, "plugin_execution_plans").unwrap());
assert_eq!(
table_exists(&conn, "plugin_installations").unwrap(),
version >= 106
);
(dir, path, conn)
}
// Explicit data dependencies only. Real 107 DDL is installed by the frozen
// migration, not copied from a latest store or relabelled after downgrade.
async fn populated107() -> (
tempfile::TempDir,
std::path::PathBuf,
Connection,
Vec<(String, PluginPlanCreateReceipt, PlanPinState)>,
) {
let (dir, path, conn) = retained(106);
migrate_plugin_plans_v106_to_v107(&conn).unwrap();
let (_donor_dir, donor, receipts) =
SqliteWorkspaceStore::plugin_plan_migration_donor().await;
for table in [
"accounts",
"workspaces",
"workspace_config_trees",
"workspace_config_entries",
"workspace_config_tree_history",
"plugin_installations",
"plugin_artifacts",
"plugin_uploads",
"plugin_operations",
"plugin_execution_plans",
"plugin_references",
] {
let columns = conn
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap()
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
let names = columns.join(",");
let rows = donor
.with_conn(|source| {
Ok(fixture_query_rows(
source,
&format!("SELECT {names} FROM {table} ORDER BY rowid"),
))
})
.unwrap();
for row in rows {
conn.execute(
&format!(
"INSERT INTO {table} ({names}) VALUES ({})",
vec!["?"; columns.len()].join(",")
),
rusqlite::params_from_iter(row),
)
.unwrap();
}
}
assert_eq!(
workspace_schema_migration_history(&conn).unwrap().last(),
Some(&(107, PLAN_NAME.into()))
);
assert_pre_subject(&conn, 107);
crate::plugin_catalog::verify(&conn).unwrap();
plugin_plan::verify_plan_schema(&conn).unwrap();
for (_, receipt, state) in &receipts {
let loaded = plugin_plan::plugin_plan_load(&conn, "a", &receipt.plan_id).unwrap();
assert_eq!(loaded.plan, receipt.plan);
assert_eq!(loaded.pin_state, *state);
}
assert_health(&conn);
drop(conn);
let conn = raw(&path);
(dir, path, conn, receipts)
}
fn assert_pre_subject(conn: &Connection, version: i64) {
assert_eq!(current_schema_version(conn).unwrap(), version);
assert_component13(conn);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_schema WHERE name LIKE 'ticket_subject_%' OR name LIKE 'ticket_legacy_orchestrator_%'", [], |r|r.get::<_,i64>(0)).unwrap(), 0);
let kinds: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
kinds.contains("CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))")
);
assert!(!kinds.contains("'subject'"));
let capture: String = conn
.query_row(
"SELECT sql FROM sqlite_schema WHERE name='notification_assignment_change'",
[],
|r| r.get(0),
)
.unwrap();
assert!(!capture.contains("'subject'"));
}
fn assert_health(conn: &Connection) {
assert_eq!(
fixture_query_rows(conn, "PRAGMA integrity_check"),
vec![vec![rusqlite::types::Value::Text("ok".into())]]
);
assert!(fixture_query_rows(conn, "PRAGMA foreign_key_check").is_empty());
assert_closed_schema_writes(conn);
}
fn host_history(conn: &Connection) -> Rows {
fixture_query_rows(
conn,
"SELECT rowid,* FROM __yoi_schema_migrations ORDER BY version",
)
}
// Save the exact source columns, including rowids and original JSON bytes.
// Only the owning diagnostic enrichment and two append-only ledgers are
// excluded from the success oracle; failures compare ALL data and schema.
fn data(conn: &Connection, preserved: bool) -> Vec<(String, Rows)> {
let tables = conn
.prepare("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
tables
.into_iter()
.filter(|table| !preserved || !table.contains("schema_migrations"))
.map(|table| {
let columns = conn
.prepare(&format!("PRAGMA table_info(\"{table}\")"))
.unwrap()
.query_map([], |r| r.get::<_, String>(1))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
.into_iter()
.filter(|column| {
!(preserved
&& table == "ticket_worker_assignments"
&& column == "tracking_migration_diagnostic")
})
.map(|column| format!("\"{column}\""))
.collect::<Vec<_>>()
.join(",");
// Later historical migrations may introduce a new sequence.
// Existing append identities, particularly notification sources,
// must not move; allow only newly introduced sequence names.
let sequence_filter = if preserved && table == "sqlite_sequence" {
let names = conn
.prepare("SELECT quote(name) FROM sqlite_sequence ORDER BY name")
.unwrap()
.query_map([], |r| r.get::<_, String>(0))
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
if names.is_empty() {
" WHERE 0".into()
} else {
format!(" WHERE name IN ({})", names.join(","))
}
} else {
String::new()
};
let query = format!(
"SELECT rowid,{columns} FROM \"{table}\"{sequence_filter} ORDER BY rowid"
);
let rows = fixture_query_rows(conn, &query);
(query, rows)
})
.collect()
}
fn assert_data(conn: &Connection, before: &[(String, Rows)]) {
for (query, rows) in before {
assert_eq!(&fixture_query_rows(conn, query), rows, "{query}");
}
}
fn snapshot(conn: &Connection) -> (i64, Rows, Vec<(String, Rows)>) {
(cookie(conn), schema(conn), data(conn, false))
}
fn assert_final(conn: &Connection, version: i64, host: &Rows, component: &Rows) {
let history = host_history(conn);
assert_eq!(&history[..host.len()], host);
let expected = expected_tail(version);
assert_eq!(
workspace_schema_migration_history(conn)
.unwrap()
.into_iter()
.filter(|(v, _)| *v > version)
.collect::<Vec<_>>(),
expected
);
assert_eq!(history.len(), host.len() + expected.len());
assert_component14_append(conn, component);
crate::plugin_catalog::verify(conn).unwrap();
plugin_plan::verify_plan_schema(conn).unwrap();
verify(conn).unwrap();
assert_health(conn);
// Reuse the owner's real constraint probe, rolled back so it is not
// confused with migration writes or preserved notification evidence.
let tx = conn.unchecked_transaction().unwrap();
assert_subject_contract(&tx);
tx.rollback().unwrap();
for status in ["waiting", "inprogress", "done", "unknown", "released"] {
let actual: String = conn.query_row("SELECT tracking_migration_diagnostic FROM ticket_worker_assignments WHERE assignment_id=?1", [status], |r|r.get(0)).unwrap();
assert_eq!(
actual,
if status == "unknown" {
format!("Existing ambiguity\n{LEGACY_DIAGNOSTIC}")
} else {
LEGACY_DIAGNOSTIC.into()
}
);
}
}
fn expected_tail(version: i64) -> Vec<(i64, String)> {
match version {
96 => vec![
(
97,
"MR notifications and authenticated implementation results".into(),
),
(98, "Assignment-independent implementation reports".into()),
(
99,
"Explicit hosting administrators and self-registration policy".into(),
),
(
100,
"Provisional users and scoped passkey activation".into(),
),
(101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into()),
(
104,
"Converge Passkey results and Job input retention".into(),
),
(
105,
"Durable Subject deletion snapshots and Job admission fences".into(),
),
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
(107, PLAN_NAME.into()),
(108, SUBJECT_NAME.into()),
],
106 => vec![(107, PLAN_NAME.into()), (108, SUBJECT_NAME.into())],
107 => vec![(108, SUBJECT_NAME.into())],
_ => panic!("unsupported fixture origin"),
}
}
fn plan_steps(version: i64) -> Vec<WorkspaceSchemaMigrationStep> {
expected_tail(version)
.into_iter()
.map(|(version, name)| WorkspaceSchemaMigrationStep { version, name })
.collect()
}
#[test]
fn retained96_and106_preflight_apply_and_reopen_preserve_original_evidence() {
for version in [96, 106] {
let (_dir, path, conn) = retained(version);
assert_pre_subject(&conn, version);
let before = snapshot(&conn);
let rows = data(&conn, true);
let host = host_history(&conn);
let component = component_history(&conn);
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, version);
assert_eq!(plan.migrations, plan_steps(version));
assert_eq!(
snapshot(&conn),
before,
"read-only preflight changed source"
);
drop(conn);
assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan);
let conn = raw(&path);
assert_data(&conn, &rows);
assert_final(&conn, version, &host, &component);
let final_snapshot = snapshot(&conn);
drop(conn);
drop(SqliteWorkspaceStore::open_for_test(&path).unwrap());
let conn = raw(&path);
assert_eq!(snapshot(&conn), final_snapshot);
assert!(
SqliteWorkspaceStore::migration_plan(&path)
.unwrap()
.migrations
.is_empty()
);
}
}
fn plugin_schema(conn: &Connection) -> Rows {
fixture_query_rows(
conn,
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema WHERE tbl_name LIKE 'plugin_%' ORDER BY type,name",
)
}
#[tokio::test]
async fn applied107_only108_preserves_canonical_plans_pins_and_released_replay() {
let (_dir, path, conn, receipts) = populated107().await;
let before = snapshot(&conn);
let rows = data(&conn, true);
let plans = plugin_schema(&conn);
let contracts = principal_table_contracts(&conn);
let host = host_history(&conn);
let component = component_history(&conn);
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 107);
assert_eq!(
plan.migrations,
vec![WorkspaceSchemaMigrationStep {
version: 108,
name: SUBJECT_NAME.into()
}]
);
assert_eq!(snapshot(&conn), before);
drop(conn);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let conn = raw(&path);
assert_data(&conn, &rows);
assert_final(&conn, 107, &host, &component);
assert_eq!(plugin_schema(&conn), plans);
assert_eq!(principal_table_contracts(&conn), contracts);
let final_snapshot = snapshot(&conn);
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let conn = raw(&path);
assert_eq!(snapshot(&conn), final_snapshot);
for (key, receipt, state) in receipts {
let saved = store.plugin_plan_load("a", &receipt.plan_id).unwrap();
assert_eq!(saved.plan, receipt.plan);
assert_eq!(saved.fingerprint, receipt.fingerprint);
assert_eq!(saved.pin_state, state);
assert_eq!(
store
.plugin_plan_create("actor", &key, &receipt.plan)
.unwrap(),
receipt
);
if state == PlanPinState::Released {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM plugin_references WHERE owner=?1",
[&receipt.plan_id],
|r| r.get::<_, i64>(0)
)
.unwrap(),
0
);
}
let changed = WorkerPluginExecutionPlan::canonicalize(WorkerPluginPlanInput {
workspace_id: "a".into(),
config: receipt.plan.config().clone(),
profile: receipt.plan.profile().clone(),
execution: crate::plugin_plan::HostExecutionIdentity {
worker_id: "changed-worker".into(),
..receipt.plan.execution().clone()
},
plugins: receipt.plan.plugins().to_vec(),
})
.unwrap();
assert!(matches!(
store.plugin_plan_create("actor", &key, &changed),
Err(Error::PluginCatalog(
crate::plugin_catalog::PluginCatalogError::Conflict
))
));
}
assert_data(&conn, &rows);
}
fn interrupt108(conn: &Connection) {
conn.execute_batch("CREATE TRIGGER interrupt_subject_tail BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=108 BEGIN SELECT RAISE(ABORT,'subject tail marker failure'); END;").unwrap();
}
#[tokio::test]
async fn marker108_failure_rolls_back_all_subject_changes_retains107_and_raw_retries() {
let (_dir, path, conn, _receipts) = populated107().await;
interrupt108(&conn);
let before = snapshot(&conn);
let host = host_history(&conn);
let component = component_history(&conn);
assert!(SqliteWorkspaceStore::migration_plan(&path).is_err());
assert_eq!(snapshot(&conn), before);
let error = apply_migrations(&conn).unwrap_err();
assert!(
error.to_string().contains("subject tail marker failure"),
"{error}"
);
assert_eq!(snapshot(&conn), before);
assert_pre_subject(&conn, 107);
assert_health(&conn);
drop(conn);
let conn = raw(&path);
assert_eq!(snapshot(&conn), before);
conn.execute_batch("DROP TRIGGER interrupt_subject_tail")
.unwrap();
assert_eq!(
SqliteWorkspaceStore::migration_plan(&path)
.unwrap()
.migrations,
plan_steps(107)
);
apply_migrations(&conn).unwrap();
assert_final(&conn, 107, &host, &component);
}
#[test]
fn real106_commits107_before108_failure_but_dryrun_never_changes_source() {
let (_dir, path, conn) = retained(106);
interrupt108(&conn);
let before = snapshot(&conn);
let host = host_history(&conn);
let component = component_history(&conn);
let capture = fixture_query_rows(
&conn,
"SELECT name,sql FROM sqlite_schema WHERE name LIKE 'notification_%' AND type='trigger' ORDER BY name",
);
assert!(SqliteWorkspaceStore::migration_plan(&path).is_err());
assert_eq!(snapshot(&conn), before);
assert!(SqliteWorkspaceStore::migrate_database(&path).is_err());
assert_eq!(snapshot(&conn), before);
assert!(
apply_migrations(&conn)
.unwrap_err()
.to_string()
.contains("subject tail marker failure")
);
assert_pre_subject(&conn, 107);
assert!(table_exists(&conn, "plugin_execution_plans").unwrap());
plugin_plan::verify_plan_schema(&conn).unwrap();
let history = host_history(&conn);
assert_eq!(&history[..host.len()], &host);
assert_eq!(history.len(), host.len() + 1);
assert_eq!(
workspace_schema_migration_history(&conn).unwrap().last(),
Some(&(107, PLAN_NAME.into()))
);
assert_eq!(component_history(&conn), component);
assert_eq!(
fixture_query_rows(
&conn,
"SELECT name,sql FROM sqlite_schema WHERE name LIKE 'notification_%' AND type='trigger' ORDER BY name"
),
capture
);
assert_health(&conn);
let failed = snapshot(&conn);
drop(conn);
let conn = raw(&path);
assert_eq!(snapshot(&conn), failed);
conn.execute_batch("DROP TRIGGER interrupt_subject_tail")
.unwrap();
assert_eq!(
SqliteWorkspaceStore::migration_plan(&path)
.unwrap()
.migrations,
plan_steps(107)
);
apply_migrations(&conn).unwrap();
assert_final(&conn, 106, &host, &component);
}
#[tokio::test]
async fn preexisting_plan_or_catalog_drift_refuses108_without_repair() {
for trigger in ["plugin_plan_snapshot_immutable", "plugin_reference_live"] {
let (_dir, path, conn, _receipts) = populated107().await;
conn.execute_batch(&format!("DROP TRIGGER {trigger}"))
.unwrap();
let before = snapshot(&conn);
assert!(SqliteWorkspaceStore::migration_plan(&path).is_err());
assert_eq!(snapshot(&conn), before);
assert!(apply_migrations(&conn).is_err());
assert_eq!(snapshot(&conn), before);
assert_pre_subject(&conn, 107);
drop(conn);
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
assert_eq!(snapshot(&raw(&path)), before);
}
}
#[tokio::test]
async fn current_missing_host_guard_or_plan_drift_reopen_refuses_without_repair() {
for trigger in [
"ticket_legacy_orchestrator_delivery_no_insert",
"ticket_legacy_orchestrator_start_no_insert",
"plugin_plan_snapshot_immutable",
] {
let (_dir, path, conn, _receipts) = populated107().await;
apply_migrations(&conn).unwrap();
conn.execute_batch(&format!("DROP TRIGGER {trigger}"))
.unwrap();
let before = snapshot(&conn);
drop(conn);
assert!(SqliteWorkspaceStore::open_for_test(&path).is_err());
let conn = raw(&path);
assert_eq!(snapshot(&conn), before);
assert_health(&conn);
}
}
}
}
@@ -16,6 +16,7 @@ use crate::{Error, Result};
/// Explicit domain-owned purge inventory. The deletion operation tombstone is intentionally
/// excluded so retries and audit remain available after the Workspace row is gone.
const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"plugin_execution_plans",
"plugin_references",
"plugin_operations",
"plugin_uploads",
@@ -508,6 +509,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
// commit; update guards remain installed throughout.
let mut ticket_delete_guards = Vec::new();
for (table, trigger) in [
("plugin_execution_plans", "plugin_plan_replay_retained"),
("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"),
("notification_job_targets", "notification_job_targets_no_delete"),
("notification_job_legacy_receipts", "notification_job_legacy_no_delete"),