feat: integrate atomic configuration and policy adoption

This commit is contained in:
2026-11-05 01:40:38 +09:00
59 changed files with 10837 additions and 487 deletions
Generated
+1
View File
@@ -7581,6 +7581,7 @@ dependencies = [
"content-history-adapters",
"content-history-store",
"error-contract",
"feature-permissions",
"feature-storage",
"flate2",
"flow",
@@ -107,6 +107,32 @@ fn has_identity(connection: &Connection) -> bool {
.unwrap()
}
#[test]
fn lifecycle_verification_never_provisions_or_adopts_optional_storage() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("storage");
let (manager, registration) = registered(&root);
let scoped = manager.scope(SCOPE).unwrap();
scoped.verify_owned_if_present(&registration).unwrap();
assert!(!root.exists());
let path = legacy(&root, SCOPE);
assert_identity_error(scoped.verify_owned_if_present(&registration));
assert!(!has_identity(&Connection::open(&path).unwrap()));
let database = scoped
.adopt_existing(&registration, REQUEST, EVIDENCE, validate_legacy)
.unwrap();
let identity = database.identity().clone();
scoped.verify_owned_if_present(&registration).unwrap();
assert_eq!(values(&database), vec!["preserved"]);
drop(database);
fs::remove_file(&path).unwrap();
assert!(matches!(scoped.verify_owned_if_present(&registration),
Err(FeatureStorageError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound));
assert!(!path.exists());
assert!(matches!(scoped.open_existing(&registration, &identity),
Err(FeatureStorageError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound));
}
#[test]
fn provision_then_reopen_retains_physical_identity_and_committed_data() {
let temp = tempfile::tempdir().unwrap();
+25
View File
@@ -1006,6 +1006,31 @@ impl ScopedFeatureStorage {
)
}
/// Validate optional storage for a lifecycle consumer without authorizing
/// adoption or provisioning. Existing files require completed physical
/// ownership; missing bound files fail closed. This is not an open permit:
/// subsequent database access must still validate stored identity and fences.
pub fn verify_owned_if_present(&self, registration: &RegisteredFeature) -> Result<()> {
self.manager.check_registration(registration)?;
if !self.manager.inner.root.try_exists()? {
return Ok(());
}
let lifecycle = self.manager.lifecycle(&self.scope_id);
let _lease = lifecycle.lock(false)?;
let path = self
.manager
.feature_dir(&self.scope_id)
.join(format!("{}.sqlite", registration.feature_id));
if let Some(binding) = lifecycle.lookup(&self.scope_id, &registration.feature_id)? {
binding.check_file(&path)?;
} else if path.try_exists()? {
return Err(FeatureStorageError::Identity(
"lifecycle consumer cannot adopt unowned Feature storage".into(),
));
}
Ok(())
}
/// Host startup compatibility for a registered optional Feature. Does not
/// provision missing optional storage. An existing legacy DB still requires
/// explicit adoption authority and persisted attribution validation.
+1
View File
@@ -6,5 +6,6 @@ HTTP contract shared by Workspace Server and Rust clients. Owns public DTOs, API
- [`ServerApi`] — HTTP operations and transport metadata
- [`ServerRequestContext`] — Request context passed to Server authentication adapters
- [`configuration_adoption`] — Owner-only source preview, adoption and exact-request reconciliation
- [`RequestActor`] — Human identity and authentication method
- [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result
@@ -0,0 +1,7 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
print!(
"{}",
server_api::configuration_adoption::configuration_contract_openapi()?.to_json()?
);
Ok(())
}
@@ -0,0 +1,14 @@
#[cfg(feature = "typescript")]
fn main() {
print!(
"{}",
server_api::normalized_typescript(
server_api::configuration_adoption::configuration_typescript()
)
);
}
#[cfg(not(feature = "typescript"))]
fn main() {
eprintln!("generate_configuration_adoption_types requires --features typescript");
std::process::exit(2);
}
@@ -0,0 +1,192 @@
//! Workspace-owner source adoption transport. The Server authenticates the owner
//! before reading source, previewing, sealing migration data, adopting or reconciling.
//! A result lookup is not a new adoption and absence is not proof of failure.
use crate::{
ConfigCommitRequest, RepositoryApiError, ServerRequestContext, WorkspaceConfigTreeResponse,
};
use feature_permissions::{PermissionAssignment, PermissionCatalog};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationAdoptionRequest {
pub operation_id: String,
pub expected_head: Option<String>,
pub expected_catalog_digest: String,
pub expected_legacy_export: Option<String>,
pub source: ConfigCommitRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimePolicyApplication {
NextCurrentPolicyCheck,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationAdoptionReceipt {
pub workspace_id: String,
pub operation_id: String,
pub history_head: String,
pub state: WorkspaceConfigTreeResponse,
pub source_stored: bool,
pub policy_committed: bool,
pub runtime_application: RuntimePolicyApplication,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationSubjectPolicy {
pub subject_id: String,
pub change_id: String,
pub execution: PermissionAssignment,
pub delegation: PermissionAssignment,
/// Exact retained domain restrictions, not editable payloads on permission keys.
pub legacy_constraints: Option<crate::authorization::SubjectPermissions>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConfigurationMigrationDiagnostic {
Converted {
subject_id: String,
operation: String,
},
DomainConstraint {
subject_id: String,
permission: crate::authorization::ScopedPermission,
},
ExplicitConfigurationRequired {
subject_id: String,
permission: crate::authorization::ScopedPermission,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationSourceResponse {
/// Before the first adoption this is an editable, evaluated migration draft.
/// source_stored distinguishes it from the unchanged stored source.
pub state: WorkspaceConfigTreeResponse,
pub history_head: Option<String>,
pub source_stored: bool,
pub catalog: PermissionCatalog,
pub catalog_digest: String,
pub expected_legacy_export: Option<String>,
pub policies: Vec<ConfigurationSubjectPolicy>,
pub migration_diagnostics: Vec<ConfigurationMigrationDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfigurationPreviewResponse {
pub expected_head: Option<String>,
pub state: WorkspaceConfigTreeResponse,
pub changed_paths: Vec<String>,
pub changed_subjects: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
pub enum ConfigurationReconciliation {
Adopted {
receipt: ConfigurationAdoptionReceipt,
},
PendingTransfer {
transfer_identity: String,
export_digest: String,
},
/// No matching evidence was observed. This is not permission to change the ID.
NotObserved,
}
#[api_macros::api(reqwest, axum, openapi)]
pub trait ConfigurationAdoptionApi {
#[get("/api/w/{workspace_id}/config/adoption",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true)]
async fn configuration_source(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
) -> Result<ConfigurationSourceResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/config/adoption/preview",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
async fn configuration_preview(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationPreviewResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/config/adoption/commit",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
async fn configuration_adopt(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationAdoptionReceipt, RepositoryApiError>;
#[post("/api/w/{workspace_id}/config/adoption/reconcile",status=200,error_status=400,additional_error_statuses=[401,403,404,409,500],bearer_auth=true,browser_auth=true,normalize_body_errors=true)]
async fn configuration_reconcile(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[body] request: ConfigurationAdoptionRequest,
) -> Result<ConfigurationReconciliation, RepositoryApiError>;
}
impl api_macros::openapi::OpenApiSchema for ConfigurationSourceResponse {}
impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionRequest {}
impl api_macros::openapi::OpenApiSchema for ConfigurationPreviewResponse {}
impl api_macros::openapi::OpenApiSchema for ConfigurationAdoptionReceipt {}
impl api_macros::openapi::OpenApiSchema for ConfigurationReconciliation {}
pub fn configuration_contract_openapi()
-> Result<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
use sha2::{Digest, Sha256};
let document = |source_digest: &str| {
configuration_adoption_api_openapi(api_macros::openapi::OpenApiInfo {
title: "Workspace configuration adoption",
version: env!("CARGO_PKG_VERSION"),
source_digest,
})
};
let normalized =
document("sha256:0000000000000000000000000000000000000000000000000000000000000000")?
.to_json()?;
let digest: String = Sha256::digest(normalized.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
document(&format!("sha256:{digest}"))
}
#[cfg(feature = "typescript")]
pub fn configuration_typescript() -> String {
use ts_rs::TS;
let config = ts_rs::Config::default();
let declarations = [
ConfigurationAdoptionRequest::decl(&config),
RuntimePolicyApplication::decl(&config),
ConfigurationAdoptionReceipt::decl(&config),
ConfigurationSubjectPolicy::decl(&config),
ConfigurationMigrationDiagnostic::decl(&config),
ConfigurationSourceResponse::decl(&config),
ConfigurationPreviewResponse::decl(&config),
ConfigurationReconciliation::decl(&config),
];
format!(
"// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.\n// Do not edit manually.\n\nimport type {{ ConfigCommitRequest, WorkspaceConfigTreeResponse }} from \"./legacy-server-api.ts\";\nimport type {{ ScopedPermission, SubjectPermissions }} from \"./memory-api.ts\";\nimport type {{ PermissionAssignment, PermissionCatalog }} from \"./feature-permissions.generated.ts\";\n\n{}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
.collect::<Vec<_>>()
.join("\n\n")
)
}
+2
View File
@@ -8,8 +8,10 @@ pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization;
mod chat;
pub mod configuration_adoption;
pub mod feature_permissions;
pub use chat::*;
pub use configuration_adoption::*;
mod subject_body;
pub use subject_body::*;
mod chat_transport;
+11
View File
@@ -319,6 +319,11 @@ impl SubjektivStore {
UNION SELECT subject_id,runtime_id,worker_id FROM journal_writer_bindings WHERE subject_id=?1
UNION SELECT subject_id,runtime_id,worker_id FROM worker_execution_grants WHERE subject_id=?1
UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM worker_execution_grants WHERE subject_id=?1",[subject])?;
if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? {
tx.execute("INSERT OR IGNORE INTO subject_deleted_workers(subject_id,runtime_id,worker_id)
SELECT subject_id,runtime_id,worker_id FROM canonical_worker_execution_grants WHERE subject_id=?1
UNION SELECT subject_id,json_extract(record_json,'$.origin_runtime_id'),json_extract(record_json,'$.origin_worker_id') FROM canonical_worker_execution_grants WHERE subject_id=?1", [subject])?;
}
tx.execute("INSERT INTO subject_deleted_sessions(subject_id,session_id)
SELECT subject_id,session_id FROM subject_session_attributions WHERE subject_id=?1
UNION SELECT subject_id,source_session_id FROM journals WHERE subject_id=?1
@@ -401,6 +406,12 @@ impl SubjektivStore {
[subject],
)?;
}
if permissions::transfer::has_table(tx, "canonical_worker_execution_grants")? {
tx.execute(
"DELETE FROM canonical_worker_execution_grants WHERE subject_id=?1",
[subject],
)?;
}
for table in DIRECT {
tx.execute(
&format!("DELETE FROM {table} WHERE subject_id=?1"),
+13 -1
View File
@@ -8,6 +8,10 @@ pub use deletion::{DeletionSubjectJob, SubjectDeletion};
pub mod journal;
mod legacy_migrations;
pub mod permissions;
pub use permissions::{
CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, PermissionTransferReceipt,
PermissionTransferSnapshot,
};
mod schema;
mod subject_creation;
use legacy_migrations::{
@@ -95,6 +99,10 @@ pub enum SubjektivError {
SubjectPermissionsConflict(String),
#[error("permission operation `{0}` was reused with different input")]
PermissionOperationConflict(String),
#[error("legacy permission authority is permanently sealed")]
PermissionAuthoritySealed,
#[error("permission transfer conflicts with identity, export or retained receipt")]
PermissionTransferConflict,
#[error("authorization denied: {0}")]
AuthorizationDenied(String),
#[error("Worker execution grant binding conflicts for `{0}`")]
@@ -689,6 +697,7 @@ pub const REGISTRATION: FeatureRegistration =
#[derive(Clone)]
pub struct SubjektivStore {
database: FeatureDatabase,
canonical_permission_source: Option<std::sync::Arc<dyn permissions::CanonicalPermissionSource>>,
}
impl SubjektivStore {
@@ -746,7 +755,10 @@ impl SubjektivStore {
}
}
})?;
Ok(Self { database })
Ok(Self {
database,
canonical_permission_source: None,
})
}
pub fn scope_id(&self) -> &str {
+30 -13
View File
@@ -12,6 +12,12 @@ use server_api::authorization::{
};
use sha2::{Digest, Sha256};
pub(crate) mod transfer;
pub use transfer::{
CanonicalPermissionSource, DatabaseIdentity, PermissionAuthorityGuard, PermissionSourceExport,
PermissionTransferReceipt, PermissionTransferSnapshot,
};
pub const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000;
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
@@ -23,9 +29,9 @@ impl SubjektivStore {
/// Unset sources are `None` (deny), including migrated Subjects. This is an admin read,
/// not an effective-authorization decision; retired Subjects retain their source evidence.
pub fn subject_permissions(&self, subject_id: &str) -> Result<Option<SubjectPermissions>> {
self.database.try_with_connection(|connection| {
self.database.try_transaction(|connection| {
require_subject_in_connection(connection, subject_id)?;
source_in_connection(connection, subject_id)
self.current_permission_source(connection, subject_id)
})
}
@@ -43,7 +49,8 @@ impl SubjektivStore {
/// Management replacement rejects new unsupported operation/resource pairs while
/// retaining exact legacy entries in their original execution/delegation lane.
/// Validation shares the existing CAS/receipt transaction so a stale editor cannot
/// retain a removed legacy grant. Exact replay remains historical evidence.
/// retain a removed legacy grant. Before sealing, exact replay remains historical evidence;
/// once sealed, replacements reject even an otherwise exact historical receipt replay.
pub fn set_managed_subject_permissions(
&self,
subject_id: &str,
@@ -71,6 +78,7 @@ impl SubjektivStore {
.map(|byte| format!("{byte:02x}"))
.collect();
self.database.try_transaction(|tx| {
transfer::require_unsealed(tx)?;
require_subject(tx, subject_id)?;
let receipt: Option<(String, String)> = tx.query_row(
"SELECT request_fingerprint, change_id FROM subject_permission_receipts WHERE operation_id=?1",
@@ -129,7 +137,7 @@ impl SubjektivStore {
};
}
require_active_subject(tx, &grant.subject_id)?;
let source = source_in_connection(tx, &grant.subject_id)?
let source = self.current_permission_source(tx, &grant.subject_id)?
.ok_or_else(|| denied("Subject permissions are unset"))?;
if source.change_id != grant.source_change_id {
return Err(denied("Subject permission source is no longer current"));
@@ -148,7 +156,12 @@ impl SubjektivStore {
require_covered(&origin.redelegation, &grant.permissions)?;
require_covered(&origin.redelegation, &grant.redelegation)?;
}
tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)",
let table = if transfer::is_sealed(tx)? {
// Canonical IDs must not be mirrored into legacy source history to satisfy its FK.
crate::storage_formats::install_permission_transfer(tx)?;
"canonical_worker_execution_grants"
} else { "worker_execution_grants" };
tx.execute(&format!("INSERT INTO {table}(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)"),
params![grant.runtime_id, grant.worker_id, grant.subject_id, grant.source_change_id, serde_json::to_string(grant)?])?;
Ok(())
})
@@ -164,9 +177,12 @@ impl SubjektivStore {
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
let mut statement = connection.prepare(
"SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2",
)?;
let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? {
"SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2"
} else {
"SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2"
};
let mut statement = connection.prepare(sql)?;
let mut rows = statement.query(params![subject_id, MAX_SUBJECT_WORKER_EXECUTION_GRANTS + 1])?;
let mut grants = Vec::new();
while let Some(row) = rows.next()? {
@@ -216,12 +232,13 @@ fn grant_in_connection(
// Absence must never become non-Subject fallback authority after purge.
deletion::require_live_worker(connection, runtime, worker)?;
let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? {
"SELECT record_json FROM (SELECT runtime_id,worker_id,subject_id,record_json FROM worker_execution_grants UNION ALL SELECT runtime_id,worker_id,subject_id,record_json FROM canonical_worker_execution_grants) g WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=g.subject_id)"
} else {
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)"
};
let raw: Option<String> = connection
.query_row(
"SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2 AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.subject_id=worker_execution_grants.subject_id)",
params![runtime, worker],
|row| row.get(0),
)
.query_row(sql, params![runtime, worker], |row| row.get(0))
.optional()?;
raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into))
.transpose()
@@ -0,0 +1,417 @@
//! One-way preparation of a permission-domain transfer. No Server adoption or dual commit.
use super::*;
use rusqlite::TransactionBehavior;
use std::cell::RefCell;
use std::sync::Arc;
/// Host connection to the canonical permission domain, not an ACL or grant issuer.
/// Implementations must resolve the exact Workspace/Subject pair and propagate failures.
/// Calls are synchronous under the FeatureDatabase guard; do not reenter this store.
pub trait CanonicalPermissionSource: Send + Sync {
fn subject_permissions(
&self,
workspace_id: &str,
subject_id: &str,
) -> Result<Option<SubjectPermissions>>;
}
/// Serializable transfer DTO for FeatureStorage's typed identity, never a physical path.
/// FeatureStorage owns identity validation; this DTO does not interpret storage metadata.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DatabaseIdentity {
pub scope_id: String,
pub feature_id: String,
pub physical_id: String,
}
impl From<&feature_storage::DatabaseIdentity> for DatabaseIdentity {
fn from(identity: &feature_storage::DatabaseIdentity) -> Self {
Self {
scope_id: identity.scope.clone(),
feature_id: identity.feature.clone(),
physical_id: identity.physical.clone(),
}
}
}
impl DatabaseIdentity {
fn storage_identity(&self) -> feature_storage::DatabaseIdentity {
feature_storage::DatabaseIdentity {
scope: self.scope_id.clone(),
feature: self.feature_id.clone(),
physical: self.physical_id.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PermissionSourceExport {
pub subject_id: String,
/// `None` is unset; `Some` with empty vectors is an explicit revocation.
pub permissions: Option<SubjectPermissions>,
}
/// Complete, Subject-ID-sorted policy snapshot. History and grants stay in the old database.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PermissionTransferSnapshot {
pub identity: DatabaseIdentity,
pub sources: Vec<PermissionSourceExport>,
/// SHA-256 of the versioned identity and sorted policy vector, excluding this field.
pub export_digest: String,
}
/// Immutable preparation evidence, not evidence that Server settings have been adopted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PermissionTransferReceipt {
pub transfer_id: String,
pub snapshot: PermissionTransferSnapshot,
}
/// Short-lived Feature authority fence for a synchronous Host operation.
///
/// The IMMEDIATE transaction is acquired before the Host enters its Server transaction,
/// serializing a legacy logical-source check through Server commit against cutover even
/// across processes. Sealing commits only the Feature transaction, not the Server operation.
/// The outer native connection/lifecycle guard remains held until the action returns;
/// callbacks must not reenter this store, including through a canonical source provider.
/// No raw connection or physical storage path is exposed.
pub struct PermissionAuthorityGuard<'connection> {
transaction: RefCell<Option<Transaction<'connection>>>,
expected: DatabaseIdentity,
}
impl PermissionAuthorityGuard<'_> {
/// Gate a legacy logical-source writer before entering its Server transaction.
/// The physical write lock remains held through the enclosing action's return.
pub fn require_unsealed(&self) -> Result<()> {
let transaction = self.transaction.borrow();
let tx = transaction
.as_ref()
.ok_or(SubjektivError::PermissionAuthoritySealed)?;
require_unsealed(tx)
}
/// Validate Subject metadata and deletion fences only, without canonical-provider calls.
/// All such validation must precede sealing; this guard is unusable after a seal attempt.
pub fn require_subject(&self, subject: &str) -> Result<()> {
validate_authorization_identifier(subject).map_err(SubjektivError::InvalidRecord)?;
let transaction = self.transaction.borrow();
let tx = transaction
.as_ref()
.ok_or(SubjektivError::PermissionAuthoritySealed)?;
require_subject_in_connection(tx, subject)?;
Ok(())
}
/// Commit the exact Feature seal independently of the enclosing action's result.
/// Exact receipt retries use the same identity, fingerprint and export checks, and
/// commit their read-only transaction too. Failure drops the transaction to roll back;
/// success releases the SQLite write lock but not the outer native connection guard.
pub fn seal_permission_sources(
&self,
transfer_id: &str,
expected_export_digest: &str,
) -> Result<PermissionTransferReceipt> {
let tx = self
.transaction
.borrow_mut()
.take()
.ok_or(SubjektivError::PermissionAuthoritySealed)?;
let receipt =
seal_in_transaction(&tx, &self.expected, transfer_id, expected_export_digest)?;
tx.commit()?;
Ok(receipt)
}
}
impl SubjektivStore {
/// Hold the native Feature connection/lifecycle fence across a synchronous Host action.
/// An IMMEDIATE Feature transaction is entered before invoking the action (and thus before
/// any nested Server transaction). Without a seal it rolls back on every return path.
/// A successful seal survives even an action error: this is not a distributed transaction.
/// Callbacks must use this guard for validation and must not reenter Feature storage.
/// Hosts can retain Server error/status types by returning `Ok(server_result)` as `T`.
pub fn with_permission_authority_guard<T>(
&self,
expected: &DatabaseIdentity,
action: impl FnOnce(&PermissionAuthorityGuard<'_>) -> Result<T>,
) -> Result<T> {
if self.database.identity() != &expected.storage_identity() {
return Err(SubjektivError::PermissionTransferConflict);
}
// try_with_connection verifies the native physical identity before/after the action
// and retains its connection mutex and lifecycle lease even after the seal commits.
self.database.try_with_connection(|connection| {
let tx = Transaction::new_unchecked(connection, TransactionBehavior::Immediate)?;
require_store_scope(&tx, self.scope_id())?;
let guard = PermissionAuthorityGuard {
transaction: RefCell::new(Some(tx)),
expected: expected.clone(),
};
action(&guard)
})
}
pub fn with_canonical_permission_source(
mut self,
source: Arc<dyn CanonicalPermissionSource>,
) -> Self {
self.canonical_permission_source = Some(source);
self
}
/// Reads physical identity under the FeatureStorage connection guard and scope check.
pub fn database_identity(&self) -> Result<DatabaseIdentity> {
let identity = DatabaseIdentity::from(self.database.identity());
self.permission_transfer_transaction(&identity, |_| Ok(identity.clone()))
}
/// Reads one coherent pre-seal snapshot without installing schema or changing authority.
/// After a transfer, returns the retained exact snapshot, never a changed/deleted live set.
pub fn preview_permission_transfer(
&self,
expected: &DatabaseIdentity,
) -> Result<PermissionTransferSnapshot> {
self.permission_transfer_transaction(expected, |tx| {
if let Some(receipt) = retained_receipt(tx)? {
require_receipt_identity(&receipt, expected)?;
return Ok(receipt.snapshot);
}
require_unsealed(tx)?;
export_in_connection(tx, expected.clone())
})
}
/// Observe saved transfer evidence without exporting, sealing or changing policy.
pub fn permission_transfer_receipt(
&self,
expected: &DatabaseIdentity,
) -> Result<Option<PermissionTransferReceipt>> {
self.permission_transfer_transaction(expected, |tx| {
let receipt = retained_receipt(tx)?;
if let Some(receipt) = &receipt {
require_receipt_identity(receipt, expected)?;
} else {
require_unsealed(tx)?;
}
Ok(receipt)
})
}
/// Permanently seals the old source writer after preview/CAS. All changes, the exact export
/// and its identity-bound receipt commit in one old-DB IMMEDIATE transaction. The Host must
/// authorize/preview first and bind transfer_id to the complete Server request fingerprint.
/// A successful seal does not adopt any Server configuration. Never call two commits atomic.
pub fn seal_permission_sources(
&self,
expected: &DatabaseIdentity,
transfer_id: &str,
expected_export_digest: &str,
) -> Result<PermissionTransferReceipt> {
self.permission_transfer_transaction(expected, |tx| {
seal_in_transaction(tx, expected, transfer_id, expected_export_digest)
})
}
fn permission_transfer_transaction<T>(
&self,
expected: &DatabaseIdentity,
action: impl FnOnce(&Transaction<'_>) -> Result<T>,
) -> Result<T> {
if *expected != DatabaseIdentity::from(self.database.identity()) {
return Err(SubjektivError::PermissionTransferConflict);
}
// Validate the caller's native expected identity without holding a separate guard
// across transaction entry. try_transaction rechecks the same immutable bound identity
// before/after the operation and owns commit, including OutcomeUnknown errors.
drop(self.database.guard(&expected.storage_identity())?);
self.database.try_transaction(|tx| {
require_store_scope(tx, self.scope_id())?;
action(tx)
})
}
pub(super) fn current_permission_source(
&self,
connection: &Connection,
subject: &str,
) -> Result<Option<SubjectPermissions>> {
if !is_sealed(connection)? {
return source_in_connection(connection, subject);
}
let provider = self.canonical_permission_source.as_ref()
.ok_or_else(|| denied("legacy permission authority is sealed; canonical permission source is not connected"))?;
let source = provider.subject_permissions(self.scope_id(), subject)?;
if let Some(source) = &source {
validate_authorization_identifier(&source.change_id)
.map_err(SubjektivError::InvalidRecord)?;
validate_scope(self.scope_id(), &source.execution)?;
validate_scope(self.scope_id(), &source.delegation)?;
}
Ok(source)
}
}
/// Shared exact seal/retry logic. The caller owns the transaction and its commit boundary.
fn seal_in_transaction(
tx: &Transaction<'_>,
expected: &DatabaseIdentity,
transfer_id: &str,
expected_export_digest: &str,
) -> Result<PermissionTransferReceipt> {
validate_authorization_identifier(transfer_id).map_err(SubjektivError::InvalidRecord)?;
if let Some(receipt) = retained_receipt(tx)? {
require_receipt_identity(&receipt, expected)?;
if receipt.transfer_id != transfer_id
|| receipt.snapshot.export_digest != expected_export_digest
{
return Err(SubjektivError::PermissionTransferConflict);
}
return Ok(receipt);
}
// A published Permission18 seal is not ours to rewrite or relabel as this receipt.
require_unsealed(tx)?;
let snapshot = export_in_connection(tx, expected.clone())?;
if snapshot.export_digest != expected_export_digest {
return Err(SubjektivError::PermissionTransferConflict);
}
crate::storage_formats::install_permission_transfer(tx)?;
if !has_table(tx, "permission_source_transfer_receipt")? {
return Err(SubjektivError::InvalidRecord(
"permission transfer requires schema 1..17".into(),
));
}
let receipt = PermissionTransferReceipt {
transfer_id: transfer_id.into(),
snapshot,
};
let raw = serde_json::to_string(&receipt)?;
tx.execute(
"INSERT INTO permission_authority_seal(singleton,transfer_id,export_json) VALUES(1,?1,?2)",
params![transfer_id, serde_json::to_string(&receipt.snapshot)?],
)?;
tx.execute("INSERT INTO permission_source_transfer_receipt(singleton,schema_version,receipt_json) VALUES(1,1,?1)", [&raw])?;
Ok(receipt)
}
pub(crate) fn has_table(connection: &Connection, name: &str) -> Result<bool> {
Ok(connection.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?1)",
[name],
|row| row.get(0),
)?)
}
pub(crate) fn is_sealed(connection: &Connection) -> Result<bool> {
if !has_table(connection, "permission_authority_seal")? {
return Ok(false);
}
Ok(connection.query_row(
"SELECT EXISTS(SELECT 1 FROM permission_authority_seal)",
[],
|row| row.get(0),
)?)
}
pub(super) fn require_unsealed(connection: &Connection) -> Result<()> {
if is_sealed(connection)? {
return Err(SubjektivError::PermissionAuthoritySealed);
}
Ok(())
}
fn require_store_scope(connection: &Connection, scope: &str) -> Result<()> {
let stored: String = connection.query_row(
"SELECT workspace_id FROM store_scope WHERE singleton=1",
[],
|row| row.get(0),
)?;
if stored != scope {
return Err(SubjektivError::PermissionTransferConflict);
}
Ok(())
}
fn require_receipt_identity(
receipt: &PermissionTransferReceipt,
expected: &DatabaseIdentity,
) -> Result<()> {
if receipt.snapshot.identity != *expected {
return Err(SubjektivError::PermissionTransferConflict);
}
Ok(())
}
fn retained_receipt(connection: &Connection) -> Result<Option<PermissionTransferReceipt>> {
if !has_table(connection, "permission_source_transfer_receipt")? {
return Ok(None);
}
let raw: Option<String> = connection.query_row(
"SELECT receipt_json FROM permission_source_transfer_receipt WHERE singleton=1 AND schema_version=1",
[], |row| row.get(0),
).optional()?;
let receipt: Option<PermissionTransferReceipt> =
raw.map(|raw| serde_json::from_str(&raw)).transpose()?;
if receipt.is_some() && !is_sealed(connection)? {
return Err(SubjektivError::PermissionTransferConflict);
}
Ok(receipt)
}
fn export_in_connection(
connection: &Connection,
identity: DatabaseIdentity,
) -> Result<PermissionTransferSnapshot> {
let orphaned: bool = connection.query_row(
"SELECT EXISTS(SELECT 1 FROM subject_permissions p LEFT JOIN subjects s ON s.subject_id=p.subject_id WHERE s.subject_id IS NULL)",
[], |row| row.get(0),
)?;
if orphaned {
return Err(SubjektivError::PermissionTransferConflict);
}
let mut statement = connection.prepare(
"SELECT s.subject_id,p.change_id,c.record_json FROM subjects s
LEFT JOIN subject_permissions p ON p.subject_id=s.subject_id
LEFT JOIN subject_permission_changes c ON c.subject_id=p.subject_id AND c.change_id=p.change_id
ORDER BY s.subject_id",
)?;
let mut rows = statement.query([])?;
let mut sources = Vec::new();
while let Some(row) = rows.next()? {
let subject_id: String = row.get(0)?;
validate_authorization_identifier(&subject_id).map_err(SubjektivError::InvalidRecord)?;
let change: Option<String> = row.get(1)?;
let raw: Option<String> = row.get(2)?;
let permissions: Option<SubjectPermissions> =
raw.map(|raw| serde_json::from_str(&raw)).transpose()?;
if permissions.as_ref().map(|source| &source.change_id) != change.as_ref() {
return Err(SubjektivError::PermissionTransferConflict);
}
if let Some(source) = &permissions {
validate_authorization_identifier(&source.change_id)
.map_err(SubjektivError::InvalidRecord)?;
validate_scope(&identity.scope_id, &source.execution)?;
validate_scope(&identity.scope_id, &source.delegation)?;
}
sources.push(PermissionSourceExport {
subject_id,
permissions,
});
}
let export_digest = Sha256::digest(serde_json::to_vec(&(1_u32, &identity, &sources))?)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
Ok(PermissionTransferSnapshot {
identity,
sources,
export_digest,
})
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,49 @@
-- Permission transfer domain v1, independent of published common migration numbers.
-- This receipt is preparation evidence only, never Server configuration adoption.
CREATE TABLE permission_source_transfer_receipt (
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
schema_version INTEGER NOT NULL CHECK(schema_version=1),
receipt_json TEXT NOT NULL
);
CREATE TRIGGER permission_source_transfer_no_replace BEFORE INSERT ON permission_source_transfer_receipt
WHEN EXISTS(SELECT 1 FROM permission_source_transfer_receipt)
BEGIN SELECT RAISE(ABORT, 'permission transfer receipt already exists'); END;
CREATE TRIGGER permission_source_transfer_no_update BEFORE UPDATE ON permission_source_transfer_receipt
BEGIN SELECT RAISE(ABORT, 'permission transfer receipt is immutable'); END;
CREATE TRIGGER permission_source_transfer_no_delete BEFORE DELETE ON permission_source_transfer_receipt
BEGIN SELECT RAISE(ABORT, 'permission transfer receipt is retained'); END;
-- Published Permission18's UPDATE/DELETE guards alone do not fence INSERT OR REPLACE.
CREATE TRIGGER permission_authority_seal_no_replace BEFORE INSERT ON permission_authority_seal
WHEN EXISTS(SELECT 1 FROM permission_authority_seal)
BEGIN SELECT RAISE(ABORT, 'permission authority seal already exists'); END;
CREATE TRIGGER subject_creation_receipts_no_replace BEFORE INSERT ON subject_creation_receipts
WHEN EXISTS(SELECT 1 FROM subject_creation_receipts WHERE operation_id=NEW.operation_id OR subject_id=NEW.subject_id)
BEGIN SELECT RAISE(ABORT, 'Subject creation receipt already exists'); END;
-- Execution binding evidence only, not a policy mirror. Canonical source IDs cannot reference
-- legacy subject_permission_changes. Preserve that table/FK and all old grant rows unchanged.
CREATE TABLE canonical_worker_execution_grants (
runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT,
source_change_id TEXT NOT NULL,
record_json TEXT NOT NULL,
PRIMARY KEY(runtime_id,worker_id)
);
CREATE TRIGGER canonical_worker_execution_grants_no_replace BEFORE INSERT ON canonical_worker_execution_grants
WHEN EXISTS(SELECT 1 FROM canonical_worker_execution_grants WHERE runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id)
OR EXISTS(SELECT 1 FROM worker_execution_grants WHERE runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id)
BEGIN SELECT RAISE(ABORT, 'Worker execution grant already exists'); END;
CREATE TRIGGER canonical_worker_execution_grants_no_update BEFORE UPDATE ON canonical_worker_execution_grants
BEGIN SELECT RAISE(ABORT, 'Worker execution grants are immutable'); END;
CREATE TRIGGER canonical_worker_execution_grants_no_delete BEFORE DELETE ON canonical_worker_execution_grants
WHEN NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)
BEGIN SELECT RAISE(ABORT, 'Worker execution grants are retained'); END;
CREATE TRIGGER canonical_worker_execution_grants_insert_guard BEFORE INSERT ON canonical_worker_execution_grants
WHEN NOT EXISTS(SELECT 1 FROM permission_authority_seal)
OR EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=NEW.subject_id)
OR EXISTS(SELECT 1 FROM subject_deleted_workers WHERE
(runtime_id=NEW.runtime_id AND worker_id=NEW.worker_id)
OR (runtime_id=json_extract(NEW.record_json,'$.origin_runtime_id')
AND worker_id=json_extract(NEW.record_json,'$.origin_worker_id')))
BEGIN SELECT RAISE(ABORT, 'canonical grant requires sealed authority and live Subject execution identities'); END;
@@ -0,0 +1,935 @@
//! Real FeatureDatabase tests. No Server adoption, Host authentication or cross-DB atomicity claim.
use super::*;
use crate::tests::{open_store, role};
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissionsUpdate, WorkerExecutionGrant,
};
use std::collections::BTreeMap;
use std::sync::{Barrier, Mutex};
fn read_permission() -> ScopedPermission {
ScopedPermission {
operation: "file.read".into(),
resource: PermissionResource::Workdir {
working_directory_id: "wd".into(),
path: ".".into(),
recursive: true,
},
}
}
fn replace(
store: &SubjektivStore,
subject: &str,
operation: &str,
delegation: Vec<ScopedPermission>,
) -> (SubjectPermissionsUpdate, SubjectPermissions) {
let update = SubjectPermissionsUpdate {
operation_id: operation.into(),
expected_change_id: store
.subject_permissions(subject)
.unwrap()
.map(|source| source.change_id),
execution: vec![],
delegation,
};
let source = store.set_subject_permissions(subject, &update).unwrap();
(update, source)
}
fn child(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant {
WorkerExecutionGrant {
subject_id: subject.into(),
source_change_id: source.into(),
runtime_id: "runtime".into(),
worker_id: worker.into(),
origin_runtime_id: "runtime".into(),
origin_worker_id: "host-authenticated-root".into(),
profile: "builtin:coder".into(),
profile_digest: "resolved-profile".into(),
permissions: vec![read_permission()],
redelegation: vec![],
}
}
fn counts(store: &SubjektivStore) -> Vec<i64> {
store
.database
.try_with_connection::<_, SubjektivError>(|connection| {
[
"subject_permissions",
"subject_permission_changes",
"subject_permission_receipts",
"worker_execution_grants",
]
.iter()
.map(|table| {
Ok(
connection.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
row.get(0)
})?,
)
})
.collect()
})
.unwrap()
}
fn seal(store: &SubjektivStore) -> PermissionTransferReceipt {
let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap();
store
.seal_permission_sources(
&identity,
"server-request-fingerprint",
&preview.export_digest,
)
.unwrap()
}
#[test]
fn identity_dto_round_trips_the_native_contract_and_checks_every_expected_component() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let identity = store.database_identity().unwrap();
let native = store.database.identity();
assert_eq!(identity.scope_id, native.scope);
assert_eq!(identity.feature_id, native.feature);
assert_eq!(identity.physical_id, native.physical);
let encoded = serde_json::to_value(&identity).unwrap();
assert_eq!(
encoded,
serde_json::json!({
"scope_id": native.scope,
"feature_id": native.feature,
"physical_id": native.physical,
})
);
let decoded: DatabaseIdentity = serde_json::from_value(encoded).unwrap();
let restored = decoded.storage_identity();
assert_eq!(restored.scope, native.scope);
assert_eq!(restored.feature, native.feature);
assert_eq!(restored.physical, native.physical);
drop(store.database.guard(&restored).unwrap());
let preview = store.preview_permission_transfer(&decoded).unwrap();
for component in ["scope", "feature", "physical"] {
let mut wrong = decoded.clone();
match component {
"scope" => wrong.scope_id.push_str("-different"),
"feature" => wrong.feature_id.push_str("-different"),
"physical" => wrong.physical_id.push_str("-different"),
_ => unreachable!(),
}
assert!(matches!(
store.preview_permission_transfer(&wrong),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store.seal_permission_sources(&wrong, "transfer", &preview.export_digest),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store.with_permission_authority_guard::<()>(&wrong, |_| {
panic!("an identity mismatch must not enter the guarded action")
}),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store.permission_transfer_transaction::<()>(&wrong, |_| {
panic!("an identity mismatch must not enter the SQL transaction body")
}),
Err(SubjektivError::PermissionTransferConflict)
));
}
assert!(
!store
.database
.try_with_connection::<_, SubjektivError>(|c| has_table(
c,
"permission_source_transfer_receipt"
))
.unwrap()
);
}
#[test]
fn complete_export_preserves_unset_empty_limited_grants_and_history_and_replays_after_reopen() {
let temp = tempfile::tempdir().unwrap();
let (manager, scope, store) = open_store(temp.path(), "workspace-a");
let unset = store.create_subject(role()).unwrap().id;
let empty = store.create_subject(role()).unwrap().id;
let limited = store.create_subject(role()).unwrap().id;
store
.database
.try_transaction::<_, SubjektivError>(|tx| {
tx.execute(
"DELETE FROM subject_permissions WHERE subject_id=?1",
[&unset],
)?;
Ok(())
})
.unwrap();
let (_, empty_source) = replace(&store, &empty, "empty", vec![]);
let (old_update, limited_source) =
replace(&store, &limited, "limited", vec![read_permission()]);
let grant = child(&limited, &limited_source.change_id, "historical-child");
store.bind_worker_execution_grant(&grant).unwrap();
let before = counts(&store);
let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap();
assert_eq!(preview.sources.len(), 3);
assert!(
preview
.sources
.windows(2)
.all(|pair| pair[0].subject_id < pair[1].subject_id)
);
assert_eq!(
preview
.sources
.iter()
.find(|row| row.subject_id == unset)
.unwrap()
.permissions,
None
);
assert_eq!(
preview
.sources
.iter()
.find(|row| row.subject_id == empty)
.unwrap()
.permissions,
Some(empty_source)
);
assert_eq!(
preview
.sources
.iter()
.find(|row| row.subject_id == limited)
.unwrap()
.permissions,
Some(limited_source.clone())
);
assert_eq!(
preview,
store.preview_permission_transfer(&identity).unwrap()
);
// Preview is physically read-only, including the domain schema.
assert!(
!store
.database
.try_with_connection::<_, SubjektivError>(|c| has_table(
c,
"permission_source_transfer_receipt"
))
.unwrap()
);
let receipt = store
.seal_permission_sources(&identity, "transfer", &preview.export_digest)
.unwrap();
assert_eq!(receipt.snapshot, preview);
assert_eq!(counts(&store), before);
assert_eq!(
store
.worker_execution_grant("runtime", "historical-child")
.unwrap(),
Some(grant.clone())
);
store.bind_worker_execution_grant(&grant).unwrap(); // Historical replay, not source authority.
assert!(matches!(
store.set_subject_permissions(&limited, &old_update),
Err(SubjektivError::PermissionAuthoritySealed)
));
assert!(matches!(
store.set_managed_subject_permissions(&limited, &old_update),
Err(SubjektivError::PermissionAuthoritySealed)
));
assert!(matches!(
store.seal_permission_sources(&identity, "different", &preview.export_digest),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store.seal_permission_sources(&identity, "transfer", "different-export"),
Err(SubjektivError::PermissionTransferConflict)
));
let mut wrong_identity = identity.clone();
wrong_identity.scope_id = "workspace-b".into();
assert!(matches!(
store.seal_permission_sources(&wrong_identity, "transfer", &preview.export_digest),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store.preview_permission_transfer(&wrong_identity),
Err(SubjektivError::PermissionTransferConflict)
));
drop(store);
scope.shutdown().unwrap();
drop(scope);
drop(manager);
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
assert_eq!(identity, store.database_identity().unwrap());
assert_eq!(
receipt,
store
.seal_permission_sources(&identity, "transfer", &preview.export_digest)
.unwrap()
);
assert_eq!(
preview,
store.preview_permission_transfer(&identity).unwrap()
);
assert_eq!(counts(&store), before);
assert!(matches!(
store.subject_permissions(&limited),
Err(SubjektivError::AuthorizationDenied(_))
));
}
#[test]
fn preview_cas_rejects_old_writer_change_without_sealing_or_installing_schema() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap();
replace(&store, &subject, "old-writer-after-preview", vec![]);
assert!(matches!(
store.seal_permission_sources(&identity, "transfer", &preview.export_digest),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(
!store
.database
.try_with_connection::<_, SubjektivError>(|c| is_sealed(c))
.unwrap()
);
assert!(
!store
.database
.try_with_connection::<_, SubjektivError>(|c| has_table(
c,
"permission_source_transfer_receipt"
))
.unwrap()
);
replace(&store, &subject, "still-writable", vec![]);
let fresh = store.preview_permission_transfer(&identity).unwrap();
assert_ne!(fresh.export_digest, preview.export_digest);
store
.seal_permission_sources(&identity, "new-request", &fresh.export_digest)
.unwrap();
}
#[test]
fn receipt_failure_rolls_back_seal_and_reopens_with_old_writer_intact() {
let temp = tempfile::tempdir().unwrap();
let (manager, scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap();
store
.database
.transaction(crate::storage_formats::install_permission_transfer)
.unwrap();
store.database.try_with_connection::<_, SubjektivError>(|connection| {
connection.execute_batch("CREATE TEMP TRIGGER fail_permission_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;
Ok(())
}).unwrap();
assert!(
store
.seal_permission_sources(&identity, "transfer", &preview.export_digest)
.is_err()
);
assert!(
!store
.database
.try_with_connection::<_, SubjektivError>(|c| is_sealed(c))
.unwrap()
);
assert!(
store
.database
.try_with_connection::<_, SubjektivError>(|c| retained_receipt(c))
.unwrap()
.is_none()
);
replace(&store, &subject, "writer-after-rollback", vec![]);
drop(store);
scope.shutdown().unwrap();
drop(scope);
drop(manager);
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
assert!(store.subject_permissions(&subject).unwrap().is_some());
seal(&store);
}
struct Canonical {
sources: BTreeMap<String, Option<SubjectPermissions>>,
calls: Mutex<Vec<(String, String)>>,
}
impl CanonicalPermissionSource for Canonical {
fn subject_permissions(
&self,
workspace: &str,
subject: &str,
) -> Result<Option<SubjectPermissions>> {
self.calls
.lock()
.unwrap()
.push((workspace.into(), subject.into()));
if workspace != "workspace-a" {
return Err(denied("foreign Workspace"));
}
self.sources
.get(subject)
.cloned()
.ok_or_else(|| denied("canonical source unavailable"))
}
}
#[test]
fn sealed_reads_and_new_grants_use_only_exact_injected_source_and_creation_does_not_write_old_policy()
{
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let (_, old) = replace(&store, &subject, "old", vec![read_permission()]);
let receipt = seal(&store);
assert!(matches!(
store.subject_permissions(&subject),
Err(SubjektivError::AuthorizationDenied(_))
));
assert!(matches!(
store.bind_worker_execution_grant(&child(&subject, &old.change_id, "no-provider")),
Err(SubjektivError::AuthorizationDenied(_))
));
let before = counts(&store);
let created = store
.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal")
.unwrap();
assert_eq!(
created,
store
.create_subject_idempotent(role(), "new behavior".into(), "create-after-seal")
.unwrap()
);
let non_idempotent = store.create_subject(role()).unwrap();
assert!(
store
.subject_permission_enrollment(&created.id)
.unwrap()
.is_some()
);
assert!(
store
.subject_permission_enrollment(&non_idempotent.id)
.unwrap()
.is_some()
);
assert!(
store
.subject_permission_enrollment(&subject)
.unwrap()
.is_none()
);
assert_eq!(counts(&store), before);
store.database.try_with_connection::<_, SubjektivError>(|connection| {
for id in [&created.id, &non_idempotent.id] {
let raw: String = connection.query_row("SELECT permission_enrollment_json FROM subject_creation_receipts WHERE subject_id=?1", [id], |row| row.get(0))?;
let enrollment: SubjectPermissions = serde_json::from_str(&raw)?;
assert!(!enrollment.execution.is_empty());
assert!(source_in_connection(connection, id)?.is_none());
}
Ok(())
}).unwrap();
let canonical = SubjectPermissions {
change_id: "canonical-change".into(),
execution: vec![],
delegation: vec![read_permission()],
};
let provider = Arc::new(Canonical {
sources: BTreeMap::from([
(subject.clone(), Some(canonical.clone())),
(created.id.clone(), None),
]),
calls: Mutex::new(vec![]),
});
let store = store.with_canonical_permission_source(provider.clone());
assert_eq!(
store.subject_permissions(&subject).unwrap(),
Some(canonical.clone())
);
assert_eq!(store.subject_permissions(&created.id).unwrap(), None); // Never resurrect a default.
assert!(store.subject_permissions(&non_idempotent.id).is_err()); // Provider failure propagates.
assert!(
store
.bind_worker_execution_grant(&child(&subject, &old.change_id, "stale-source"))
.is_err()
);
store
.bind_worker_execution_grant(&child(&subject, &canonical.change_id, "canonical-child"))
.unwrap();
assert_eq!(
store
.list_subject_worker_execution_grants(&subject)
.unwrap(),
vec![child(&subject, &canonical.change_id, "canonical-child")]
);
assert_eq!(
store
.worker_execution_grant("runtime", "canonical-child")
.unwrap(),
Some(child(&subject, &canonical.change_id, "canonical-child"))
);
for sql in [
"INSERT OR REPLACE INTO canonical_worker_execution_grants SELECT * FROM canonical_worker_execution_grants",
"UPDATE canonical_worker_execution_grants SET source_change_id='forged'",
"DELETE FROM canonical_worker_execution_grants",
] {
assert!(
store
.database
.try_with_connection::<_, SubjektivError>(|c| {
c.execute_batch(sql)?;
Ok(())
})
.is_err()
);
}
assert!(
provider
.calls
.lock()
.unwrap()
.iter()
.all(|(workspace, id)| workspace == "workspace-a"
&& [
subject.as_str(),
created.id.as_str(),
non_idempotent.id.as_str()
]
.contains(&id.as_str()))
);
assert_eq!(
store
.preview_permission_transfer(&receipt.snapshot.identity)
.unwrap(),
receipt.snapshot
);
store
.begin_subject_deletion(&subject, "delete-canonical-subject")
.unwrap();
let workers = store.deletion_subject_workers(&subject).unwrap();
assert!(workers.contains(&("runtime".into(), "canonical-child".into())));
assert!(workers.contains(&("runtime".into(), "host-authenticated-root".into())));
assert!(
store
.finish_subject_deletion(&subject, "delete-canonical-subject")
.unwrap()
.completed
);
assert!(
store
.worker_execution_grant("runtime", "canonical-child")
.is_err()
);
assert_eq!(
store
.preview_permission_transfer(&receipt.snapshot.identity)
.unwrap(),
receipt.snapshot
);
}
#[test]
fn sql_source_writers_and_receipt_replacement_stay_fenced_but_explicit_erasure_remains_possible() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
replace(&store, &subject, "old-receipt", vec![]);
let receipt = seal(&store);
// Reacquire a managed store handle; tests must not infer/open physical storage paths.
let old_writer = SubjektivStore::from_database(store.database.clone()).unwrap();
assert!(
old_writer
.database
.try_transaction::<_, SubjektivError>(|tx| {
tx.execute(
"UPDATE subject_permissions SET change_id=change_id WHERE subject_id=?1",
[&subject],
)?;
Ok(())
})
.is_err()
);
drop(old_writer);
for sql in [
"INSERT OR REPLACE INTO subject_permissions SELECT * FROM subject_permissions",
"UPDATE subject_permissions SET change_id=change_id",
"DELETE FROM subject_permissions",
"INSERT OR REPLACE INTO subject_permission_changes SELECT * FROM subject_permission_changes",
"UPDATE subject_permission_changes SET record_json=record_json",
"DELETE FROM subject_permission_changes",
"INSERT OR REPLACE INTO subject_permission_receipts SELECT * FROM subject_permission_receipts",
"UPDATE subject_permission_receipts SET request_fingerprint=request_fingerprint",
"DELETE FROM subject_permission_receipts",
"INSERT INTO worker_execution_grants SELECT 'runtime','late-writer',subject_id,change_id,'{}' FROM subject_permissions LIMIT 1",
"INSERT OR REPLACE INTO permission_authority_seal SELECT * FROM permission_authority_seal",
"UPDATE permission_authority_seal SET transfer_id='changed'",
"DELETE FROM permission_authority_seal",
"INSERT OR REPLACE INTO permission_source_transfer_receipt SELECT * FROM permission_source_transfer_receipt",
"UPDATE permission_source_transfer_receipt SET receipt_json='{}'",
"DELETE FROM permission_source_transfer_receipt",
] {
assert!(
store
.database
.try_with_connection::<_, SubjektivError>(|c| {
c.execute_batch(sql)?;
Ok(())
})
.is_err(),
"{sql}"
);
}
store.database.try_transaction::<_, SubjektivError>(|tx| {
tx.execute("INSERT INTO subject_deletions(subject_id,operation_id,completed,started_at) VALUES(?1,'explicit-delete',0,'now')", [&subject])?;
tx.execute("DELETE FROM subject_permissions WHERE subject_id=?1", [&subject])?;
tx.execute("DELETE FROM subject_permission_receipts WHERE subject_id=?1", [&subject])?;
tx.execute("DELETE FROM subject_permission_changes WHERE subject_id=?1", [&subject])?;
Ok(())
}).unwrap();
assert_eq!(
store
.preview_permission_transfer(&receipt.snapshot.identity)
.unwrap(),
receipt.snapshot
);
assert_eq!(
store
.seal_permission_sources(
&receipt.snapshot.identity,
&receipt.transfer_id,
&receipt.snapshot.export_digest
)
.unwrap(),
receipt
);
}
#[test]
fn concurrent_old_writer_and_seal_serialize_instead_of_accepting_a_changed_export() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let initial = store.subject_permissions(&subject).unwrap().unwrap();
let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap();
let barrier = Arc::new(Barrier::new(2));
let writer = store.clone();
let start = barrier.clone();
let thread = std::thread::spawn(move || {
start.wait();
writer.set_subject_permissions(
&subject,
&SubjectPermissionsUpdate {
operation_id: "racing-old-writer".into(),
expected_change_id: Some(initial.change_id),
execution: vec![],
delegation: vec![],
},
)
});
barrier.wait();
let sealed = store.seal_permission_sources(&identity, "transfer", &preview.export_digest);
let written = thread.join().unwrap();
match (sealed, written) {
(Ok(receipt), Err(SubjektivError::PermissionAuthoritySealed)) => {
assert_eq!(receipt.snapshot, preview)
}
(Err(SubjektivError::PermissionTransferConflict), Ok(_)) => assert!(
!store
.database
.try_with_connection::<_, SubjektivError>(is_sealed)
.unwrap()
),
other => panic!("unexpected serialized outcomes: {other:?}"),
}
}
#[test]
fn provider_source_outside_exact_workspace_is_rejected_without_legacy_fallback() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
seal(&store);
let provider = Arc::new(Canonical {
sources: BTreeMap::from([(
subject.clone(),
Some(SubjectPermissions {
change_id: "foreign-source".into(),
execution: vec![ScopedPermission {
operation: "file.read".into(),
resource: PermissionResource::Workspace {
workspace_id: "workspace-b".into(),
},
}],
delegation: vec![read_permission()],
}),
)]),
calls: Mutex::new(vec![]),
});
let store = store.with_canonical_permission_source(provider);
assert!(store.subject_permissions(&subject).is_err());
assert!(
store
.bind_worker_execution_grant(&child(&subject, "foreign-source", "foreign-child"))
.is_err()
);
assert!(
store
.worker_execution_grant("runtime", "foreign-child")
.unwrap()
.is_none()
);
}
#[test]
fn authority_guard_gates_legacy_callback_and_releases_an_unsealed_transaction() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let identity = store.database_identity().unwrap();
let calls = std::cell::Cell::new(0);
let legacy_write = || {
store.with_permission_authority_guard(&identity, |guard| {
assert!(
!guard.transaction.borrow().as_ref().unwrap().is_autocommit(),
"the Feature transaction must already be active on callback entry"
);
guard.require_unsealed()?;
guard.require_subject(&subject)?;
// Stand-in for a logical Server write/commit. Never reenter Feature storage here.
calls.set(calls.get() + 1);
Ok(())
})
};
legacy_write().unwrap();
assert_eq!(calls.get(), 1);
store
.database
.try_with_connection::<_, SubjektivError>(|connection| {
assert!(connection.is_autocommit());
assert!(!has_table(
connection,
"permission_source_transfer_receipt"
)?);
assert!(!is_sealed(connection)?);
Ok(())
})
.unwrap();
// A logical write does not mutate Feature, and an early callback failure does not seal.
let failed: Result<()> = store.with_permission_authority_guard(&identity, |guard| {
guard.require_unsealed()?;
Err(SubjektivError::InvalidRecord("callback before seal".into()))
});
assert!(matches!(failed, Err(SubjektivError::InvalidRecord(_))));
replace(&store, &subject, "writer-after-guard-return", vec![]);
seal(&store);
assert!(matches!(
legacy_write(),
Err(SubjektivError::PermissionAuthoritySealed)
));
assert_eq!(calls.get(), 1, "a sealed legacy callback must not execute");
}
#[test]
fn authority_guard_metadata_checks_do_not_call_canonical_source_and_honor_deletion() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let deleting = store.create_subject(role()).unwrap().id;
seal(&store);
store
.begin_subject_deletion(&deleting, "deleting-before-guard")
.unwrap();
let provider = Arc::new(Canonical {
sources: BTreeMap::new(), // Any provider call would fail, and could reenter Server.
calls: Mutex::new(vec![]),
});
let store = store.with_canonical_permission_source(provider.clone());
let identity = store.database_identity().unwrap();
store
.with_permission_authority_guard(&identity, |guard| {
guard.require_subject(&subject)?; // Metadata still exists even when authority is sealed.
assert!(matches!(
guard.require_subject("missing-subject"),
Err(SubjektivError::SubjectNotFound(_))
));
assert!(matches!(
guard.require_subject(&deleting),
Err(SubjektivError::SubjectNotFound(_))
));
assert!(matches!(
guard.require_unsealed(),
Err(SubjektivError::PermissionAuthoritySealed)
));
Ok(())
})
.unwrap();
assert!(provider.calls.lock().unwrap().is_empty());
}
#[test]
fn authority_guard_seal_survives_callback_failure_and_exact_retry_preserves_server_error() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap();
let before = counts(&store);
let failed: Result<()> = store.with_permission_authority_guard(&identity, |guard| {
guard.require_subject(&subject)?;
let receipt =
guard.seal_permission_sources("server-fingerprint", &preview.export_digest)?;
assert_eq!(receipt.snapshot, preview);
assert!(
guard.transaction.borrow().is_none(),
"seal must end the Feature transaction before adoption"
);
assert!(matches!(
guard.require_subject(&subject),
Err(SubjektivError::PermissionAuthoritySealed)
));
assert!(matches!(
guard.require_unsealed(),
Err(SubjektivError::PermissionAuthoritySealed)
));
Err(SubjektivError::InvalidRecord(
"callback after committed seal".into(),
))
});
assert!(matches!(failed, Err(SubjektivError::InvalidRecord(_))));
let receipt = store
.permission_transfer_receipt(&identity)
.unwrap()
.unwrap();
assert_eq!(receipt.transfer_id, "server-fingerprint");
assert_eq!(receipt.snapshot, preview);
assert_eq!(counts(&store), before);
let nested: Result<std::result::Result<(), u16>> =
store.with_permission_authority_guard(&identity, |guard| {
guard.require_subject(&subject)?;
assert_eq!(
guard.seal_permission_sources("server-fingerprint", &preview.export_digest)?,
receipt
);
assert!(
guard.transaction.borrow().is_none(),
"receipt retry must also end its transaction"
);
// Preserve the Server error/status independently of the Feature result.
Ok(Err(409))
});
assert!(matches!(nested, Ok(Err(409))));
assert_eq!(
store.permission_transfer_receipt(&identity).unwrap(),
Some(receipt)
);
for (transfer, digest) in [
("another-request", preview.export_digest.as_str()),
("server-fingerprint", "changed-export"),
] {
assert!(matches!(
store.with_permission_authority_guard(&identity, |guard| {
guard.seal_permission_sources(transfer, digest)
}),
Err(SubjektivError::PermissionTransferConflict)
));
}
assert_eq!(counts(&store), before);
}
#[test]
fn authority_guard_seal_sql_failure_rolls_back_before_callback_returns() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
store.create_subject(role()).unwrap();
let identity = store.database_identity().unwrap();
let preview = store.preview_permission_transfer(&identity).unwrap();
store
.database
.transaction(crate::storage_formats::install_permission_transfer)
.unwrap();
store.database.try_with_connection::<_, SubjektivError>(|connection| {
connection.execute_batch("CREATE TEMP TRIGGER fail_guard_transfer BEFORE INSERT ON main.permission_source_transfer_receipt BEGIN SELECT RAISE(ABORT,'injected guarded receipt failure'); END;")?;
Ok(())
}).unwrap();
store
.with_permission_authority_guard(&identity, |guard| {
assert!(
guard
.seal_permission_sources("transfer", &preview.export_digest)
.is_err()
);
assert!(guard.transaction.borrow().is_none());
// Failure consumes the transaction; a caught error must not enable an unfenced writer.
assert!(guard.require_unsealed().is_err());
assert!(
guard
.seal_permission_sources("transfer", &preview.export_digest)
.is_err()
);
Ok(())
})
.unwrap();
store
.database
.try_with_connection::<_, SubjektivError>(|connection| {
assert!(connection.is_autocommit());
assert!(!is_sealed(connection)?);
assert!(retained_receipt(connection)?.is_none());
connection.execute_batch("DROP TRIGGER fail_guard_transfer")?;
Ok(())
})
.unwrap();
store
.with_permission_authority_guard(&identity, |guard| {
guard.require_unsealed()?;
guard.seal_permission_sources("transfer", &preview.export_digest)
})
.unwrap();
}
#[test]
fn distinct_physical_database_identity_cannot_preview_or_seal_even_in_same_workspace() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let (_manager_a, _scope_a, store_a) = open_store(first.path(), "workspace-a");
let (_manager_b, _scope_b, store_b) = open_store(second.path(), "workspace-a");
let a = store_a.database_identity().unwrap();
let b = store_b.database_identity().unwrap();
assert_ne!(a, b);
let preview = store_a.preview_permission_transfer(&a).unwrap();
assert!(matches!(
store_b.preview_permission_transfer(&a),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store_b.seal_permission_sources(&a, "transfer", &preview.export_digest),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(matches!(
store_b.with_permission_authority_guard::<()>(&a, |_| {
panic!("a different physical database must not enter the guarded action")
}),
Err(SubjektivError::PermissionTransferConflict)
));
assert!(
!store_b
.database
.try_with_connection::<_, SubjektivError>(is_sealed)
.unwrap()
);
}
@@ -579,3 +579,159 @@ fn common_receipt_write_failure_rolls_back_in_sqlite_then_engine_retry_succeeds(
drop(db);
scope.shutdown().unwrap();
}
#[test]
fn permission_transfer_extension_reopens_on_every_published_family_without_relabelling_receipts() {
for format in PUBLISHED {
let temp = tempfile::tempdir().unwrap();
published_fixture(temp.path(), format);
let (scope, db) = open(temp.path(), REGISTRATION);
let saved = db
.with_connection(|connection| Ok(receipts(connection)))
.unwrap();
let old_seal = db
.with_connection(|connection| {
if format == Format::Permission18 {
Ok(Some(connection.query_row(
"SELECT export_json FROM permission_authority_seal",
[],
|row| row.get::<_, String>(0),
)?))
} else {
Ok(None)
}
})
.unwrap();
db.transaction(install_permission_transfer).unwrap();
db.with_connection(|connection| {
assert_eq!(receipts(connection), saved);
assert_eq!(
domain_shape(connection)?,
reference_shape_with_transfer(format, true)?
);
if let Some(raw) = &old_seal {
assert_eq!(
connection.query_row(
"SELECT export_json FROM permission_authority_seal",
[],
|row| row.get::<_, String>(0)
)?,
*raw
);
}
assert_foreign_keys(connection);
Ok(())
})
.unwrap();
let store = SubjektivStore::from_database(db.clone()).unwrap();
if old_seal.is_some() {
let identity = store.database_identity().unwrap();
assert!(matches!(
store.preview_permission_transfer(&identity),
Err(crate::SubjektivError::PermissionAuthoritySealed)
));
assert!(matches!(
store.seal_permission_sources(&identity, "new-transfer", "export"),
Err(crate::SubjektivError::PermissionAuthoritySealed)
));
struct CanonicalEmpty;
impl crate::CanonicalPermissionSource for CanonicalEmpty {
fn subject_permissions(
&self,
workspace: &str,
_subject: &str,
) -> crate::Result<Option<server_api::authorization::SubjectPermissions>>
{
assert_eq!(workspace, SCOPE);
Ok(Some(server_api::authorization::SubjectPermissions {
change_id: "canonical-source".into(),
execution: vec![],
delegation: vec![],
}))
}
}
let connected = store
.clone()
.with_canonical_permission_source(std::sync::Arc::new(CanonicalEmpty));
let subject = connected.list_subjects(1).unwrap().items[0].id.clone();
assert_eq!(
connected
.subject_permissions(&subject)
.unwrap()
.unwrap()
.change_id,
"canonical-source"
);
connected
.bind_worker_execution_grant(&server_api::authorization::WorkerExecutionGrant {
subject_id: subject,
source_change_id: "canonical-source".into(),
runtime_id: "runtime".into(),
worker_id: "canonical-child".into(),
origin_runtime_id: "runtime".into(),
origin_worker_id: "root".into(),
profile: "builtin:coder".into(),
profile_digest: "resolved-profile".into(),
permissions: vec![],
redelegation: vec![],
})
.unwrap();
}
drop(store);
drop(db);
scope.shutdown().unwrap();
let (scope, db) = open(temp.path(), REGISTRATION);
db.with_connection(|connection| {
assert_eq!(receipts(connection), saved);
assert_eq!(
domain_shape(connection)?,
reference_shape_with_transfer(format, true)?
);
assert_foreign_keys(connection);
Ok(())
})
.unwrap();
drop(db);
scope.shutdown().unwrap();
}
}
#[test]
fn permission_extension_ddl_rolls_back_as_a_unit_and_missing_fence_rejects_reopen() {
let temp = tempfile::tempdir().unwrap();
let (scope, db) = open(temp.path(), REGISTRATION);
let before_shape = db.with_connection(domain_shape).unwrap();
let before_rows = db
.with_connection(|connection| Ok(all_rows(connection)))
.unwrap();
let result = db.transaction(|tx| {
install_permission_transfer(tx)?;
tx.execute_batch("INSERT INTO injected_missing_table VALUES(1)")?;
Ok(())
});
assert!(result.is_err());
db.with_connection(|connection| {
assert_eq!(domain_shape(connection)?, before_shape);
assert_eq!(all_rows(connection), before_rows);
Ok(())
})
.unwrap();
db.transaction(install_permission_transfer).unwrap();
db.with_connection(|connection| {
connection
.execute_batch("DROP TRIGGER permission_transfer_fence_subject_permissions_INSERT")?;
Ok(())
})
.unwrap();
drop(db);
scope.shutdown().unwrap();
let scope = feature_storage::FeatureStorage::new(temp.path())
.scope(SCOPE)
.unwrap();
let registered = scope.register(REGISTRATION).unwrap();
assert!(matches!(
scope.open(&registered),
Err(FeatureStorageError::MigrationHistory { .. })
));
scope.shutdown().unwrap();
}
+78 -4
View File
@@ -81,7 +81,16 @@ pub(super) fn validate(
applied: &[(u32, String)],
) -> feature_storage::Result<()> {
let format = recognize(applied)?;
let expected = reference_shape(format)?;
let transfer = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='permission_source_transfer_receipt')",
[], |row| row.get::<_, bool>(0),
)?;
if transfer && matches!(format, Format::Common(count) if count < COMMON_COUNT) {
return Err(incompatible(
"permission transfer requires complete common 1..17",
));
}
let expected = reference_shape_with_transfer(format, transfer)?;
let actual = domain_shape(tx)?;
if actual != expected {
let difference = actual
@@ -115,9 +124,17 @@ fn domain_shape(connection: &Connection) -> feature_storage::Result<Vec<SchemaOb
Ok(shape)
}
#[cfg(test)]
fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>> {
reference_shape_with_transfer(format, false)
}
fn reference_shape_with_transfer(
format: Format,
transfer: bool,
) -> feature_storage::Result<Vec<SchemaObject>> {
// Cache reference shapes only; every target is reread under its caller's transaction.
static SHAPES: [OnceLock<Vec<SchemaObject>>; 25] = [const { OnceLock::new() }; 25];
static SHAPES: [OnceLock<Vec<SchemaObject>>; 50] = [const { OnceLock::new() }; 50];
let index = match format {
Format::Common(count) => count,
Format::Permission18 => 18,
@@ -127,7 +144,7 @@ fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>>
Format::Consolidation20 => 22,
Format::Fixed20 => 23,
Format::Cancellation20 => 24,
};
} + if transfer { 25 } else { 0 };
if let Some(shape) = SHAPES[index].get() {
return Ok(shape.clone());
}
@@ -184,11 +201,66 @@ fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>>
tx.execute_batch(CONSOLIDATION_SQL)?;
}
}
if transfer {
install_transfer_shape(&tx, format == Format::Permission18)?;
}
let shape = domain_shape(&tx)?;
let _ = SHAPES[index].set(shape.clone());
Ok(shape)
}
/// Installs a separate permission-domain v1 shape without changing common/published receipts.
/// Existing published seals and their serialized exports are never rewritten or relabelled.
/// The FeatureDatabase guard and caller's transaction cover validation and installation.
pub(super) fn install_permission_transfer(tx: &Transaction<'_>) -> feature_storage::Result<()> {
let mut statement = tx.prepare(&format!(
"SELECT version,name FROM {HISTORY_TABLE} ORDER BY version"
))?;
let applied = statement
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<Vec<(u32, String)>, _>>()?;
let format = recognize(&applied)?;
if matches!(format, Format::Common(count) if count < COMMON_COUNT) {
return Ok(());
}
validate(tx, &applied)?;
let installed: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='permission_source_transfer_receipt')",
[], |row| row.get(0),
)?;
if !installed {
install_transfer_shape(tx, format == Format::Permission18)?;
}
validate(tx, &applied)
}
fn install_transfer_shape(
tx: &Transaction<'_>,
published_permission: bool,
) -> feature_storage::Result<()> {
if !published_permission {
tx.execute_batch(PERMISSION_SEAL_SQL)?;
}
tx.execute_batch(include_str!("permissions/transfer.sql"))?;
// Seal every legacy writer. New execution binding evidence has no legacy-source FK and
// lives in the extension table, leaving published grants and their triggers intact.
for table in PERMISSION_FENCE_TABLES {
for action in ["INSERT", "UPDATE", "DELETE"] {
let deletion_exception = if action == "DELETE" {
" AND NOT EXISTS(SELECT 1 FROM subject_deletions WHERE subject_id=OLD.subject_id)"
} else {
""
};
tx.execute_batch(&format!(
"CREATE TRIGGER permission_transfer_fence_{table}_{action} BEFORE {action} ON {table}
WHEN EXISTS(SELECT 1 FROM permission_authority_seal){deletion_exception}
BEGIN SELECT RAISE(ABORT, 'legacy permission authority is sealed'); END;"
))?;
}
}
Ok(())
}
const PERMISSION_FENCE_TABLES: [&str; 4] = [
"subject_permissions",
"subject_permission_changes",
@@ -196,7 +268,9 @@ const PERMISSION_FENCE_TABLES: [&str; 4] = [
"worker_execution_grants",
];
// Frozen physical shape evidence, not target migrations. Permission SQL is from
// Published DDL is retained byte-for-byte as physical shape evidence. Only the permission
// fragment is also reused by the explicit separate-domain initializer above, never as migration 18.
// Permission SQL is from
// 9edebfa5936f2411adb6affffc793d3e93cc21bd:crates/subjektiv/src/permissions/authority.rs.
// Journal/consolidation SQL is from 7b39a44ca698c474287e5511f674f2e8baae4c03:
// crates/subjektiv/src/{consolidation_write_bindings.sql,journal/final_scope.sql,
+37 -7
View File
@@ -1,6 +1,7 @@
//! New Subjects receive their self-scoped conversation source in the creation transaction.
//! This path is never called by migration, launch, restore or permission reads. Receipts
//! retain only the issued identity and input fingerprint across erasure, preventing replay
//! Before sealing, new Subjects receive their self-scoped source in the creation transaction.
//! After sealing, creation retains enrollment evidence only; current reads never revive defaults.
//! This path is never called by migration, launch, restore or permission reads. Receipts retain
//! identity, input fingerprint and any post-seal enrollment across erasure, preventing replay
//! from recreating an erased Subject or restoring a revoked policy.
use super::*;
use server_api::authorization::{SubjectPermissions, subject_conversation_permissions};
@@ -18,6 +19,25 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
}
impl SubjektivStore {
/// Creation-only enrollment evidence for the canonical domain. Never a current permission
/// source or a read-time default; the Host must not apply it to an existing/unset policy.
pub fn subject_permission_enrollment(
&self,
subject_id: &str,
) -> Result<Option<SubjectPermissions>> {
self.database.try_transaction(|tx| {
require_subject(tx, subject_id)?;
if !permissions::transfer::has_table(tx, "permission_authority_seal")? {
return Ok(None);
}
let raw: Option<String> = tx.query_row(
"SELECT permission_enrollment_json FROM subject_creation_receipts WHERE subject_id=?1",
[subject_id], |row| row.get::<_, Option<String>>(0),
).optional()?.flatten();
raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)).transpose()
})
}
pub(super) fn create_subject_request(
&self,
role: SubjectRole,
@@ -60,10 +80,20 @@ impl SubjektivStore {
execution: subject_conversation_permissions(self.scope_id(), &record.id),
delegation: vec![],
};
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
params![record.id,source.change_id,serde_json::to_string(&source)?])?;
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?;
if let Some(operation) = operation_id {
let sealed = permissions::transfer::is_sealed(tx)?;
if !sealed {
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
params![record.id,source.change_id,serde_json::to_string(&source)?])?;
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?;
}
if sealed {
// Retain creation-only enrollment for the canonical domain, never a legacy
// source write or a read-time default. Even non-idempotent creation has evidence.
let enrollment_operation = operation_id.map(str::to_owned)
.unwrap_or_else(|| format!("subject-enrollment:{}", record.id));
tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint,permission_enrollment_json) VALUES(?1,?2,?3,?4)",
params![enrollment_operation,record.id,fingerprint,serde_json::to_string(&source)?])?;
} else if let Some(operation) = operation_id {
tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?;
}
Ok(record)
@@ -0,0 +1,134 @@
//! Server-side registration of the compiled builtin operation declarations.
//!
//! This is a trusted builtin loader, not a wire-catalog importer. Namespace
//! ownership is listed here explicitly; neither a permission spelling nor a
//! management request chooses an owner. The existing operation descriptors own
//! exact names. No dotted-prefix children are inferred. Profiles/provider scopes
//! and actual operation authorization remain independent of this metadata load.
use super::super::FeatureInstallError;
use feature_permissions::{
FeatureIdentity, NamespaceDeclaration, NamespaceId, NamespacePermissions, PermissionDefinition,
PermissionHost, PermissionKey, PermissionReference, PermissionSetId, SetContribution,
WorkspaceId,
};
use std::collections::BTreeMap;
const OWNERS: &[(&str, &str)] = &[
("filesystem", "builtin:workdir"),
("bash", "builtin:bash"),
("workdir", "builtin:workdir"),
("worker", "builtin:worker"),
("subworker", "builtin:sub-worker-control"),
("ticket", "builtin:ticket"),
("objective", "builtin:objective"),
("merge_request", "builtin:merge-request"),
("repository", "builtin:repository"),
("drive", "builtin:drive"),
("workspace_config", "builtin:workspace-config"),
("memory", "builtin:memory"),
("journal", "builtin:journal-recording"),
("chat", "builtin:subjektiv-chat"),
("session", "builtin:session-explore"),
("web", "builtin:web"),
("notification", "builtin:notification"),
];
pub fn builtin_permission_host(
workspace: WorkspaceId,
) -> Result<PermissionHost, FeatureInstallError> {
let invalid = |e: &dyn std::fmt::Display| FeatureInstallError::InvalidDescriptor(e.to_string());
let mut host = PermissionHost::new(workspace);
let mut features = BTreeMap::new();
// MCP's scalar startup permission is the retained T-787 exception, not a
// payload-free operation key. Do not invent mcp.* authority or a Set for it.
// Legacy migration retains it as an explicit-configuration-required diagnostic.
let operations = server_api::authorization::subject_permission_operations()
.into_iter()
.filter(|operation| operation.operation != "mcp")
.collect::<Vec<_>>();
for (namespace, owner) in OWNERS {
let feature = match features.get(owner) {
Some(feature) => feature,
None => {
let feature = host
.register_feature(FeatureIdentity::parse(*owner).map_err(|e| invalid(&e))?)
.map_err(|e| invalid(&e))?;
features.entry(owner).or_insert(feature)
}
};
let id = NamespaceId::parse(*namespace).map_err(|e| invalid(&e))?;
let handle = host
.bind_namespace(feature, id.clone())
.map_err(|e| invalid(&e))?;
let permissions = operations
.iter()
.filter(|operation| operation.operation.split('.').next() == Some(namespace))
.map(|operation| {
Ok(PermissionDefinition {
key: PermissionKey::parse(operation.operation.clone())
.map_err(|e| invalid(&e))?,
description: operation.description.clone(),
children: vec![],
})
})
.collect::<Result<Vec<_>, FeatureInstallError>>()?;
let keys = permissions
.iter()
.map(|definition| definition.key.clone())
.collect();
host.declare(
&handle,
NamespaceDeclaration {
permissions,
sets: vec![SetContribution {
set: PermissionSetId::parse(*namespace).map_err(|e| invalid(&e))?,
children: vec![PermissionReference::Namespace(NamespacePermissions {
namespace: id,
keys,
})],
}],
},
)
.map_err(|e| invalid(&e))?;
}
// A new builtin operation needs an intentional namespace ownership decision.
for operation in operations {
if !OWNERS
.iter()
.any(|(namespace, _)| operation.operation.split('.').next() == Some(*namespace))
{
return Err(FeatureInstallError::InvalidDescriptor(format!(
"builtin operation has no namespace owner: {}",
operation.operation
)));
}
}
Ok(host)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_keys_are_complete_exact_and_have_no_inferred_children() {
let host = builtin_permission_host(WorkspaceId::parse("workspace-a").unwrap()).unwrap();
let catalog = host.catalog();
let mut keys = std::collections::BTreeSet::new();
for namespace in catalog.namespaces {
for permission in namespace.declaration.permissions {
assert!(permission.children.is_empty());
assert!(keys.insert(permission.key.as_str().to_owned()));
}
}
assert!(!keys.contains("mcp"));
assert_eq!(
keys,
server_api::authorization::subject_permission_operations()
.into_iter()
.filter(|operation| operation.operation != "mcp")
.map(|o| o.operation)
.collect()
);
}
}
+4
View File
@@ -146,3 +146,7 @@ impl FeaturePermissionRegistry {
#[cfg(test)]
mod tests;
#[path = "builtin/permission_catalog.rs"]
mod builtin_catalog;
pub use builtin_catalog::builtin_permission_host;
+4
View File
@@ -21,6 +21,10 @@ axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures.workspace = true
config-source.workspace = true
content-history.workspace = true
content-history-store.workspace = true
content-history-adapters.workspace = true
feature-permissions.workspace = true
feature-storage.workspace = true
workspace-drive.workspace = true
subjektiv.workspace = true
+1
View File
@@ -9,3 +9,4 @@ Backend that manages persistent Workspace resources and authorization and provid
- [`hosts`] / [`worker_source`] — Runtime delegation and request source verification
- [`backend_job`] — Immutable input, attempt, and structured result boundaries
- [`workspace_catalog`] — Workspace creation and replay contracts
- [`config_adoption`] / [`policy_config`] — Owner source adoption, cutover boundaries and payload-free Subject assignments
@@ -0,0 +1,545 @@
//! Owner-only adoption of Workspace source and Subject policy in the Server transaction.
//!
//! The old Feature database is exported and permanently sealed before its immutable
//! transfer is adopted here. That preparation is not a distributed commit: failure
//! after seal leaves an explicit, resumable transfer, never a legacy fallback.
//! Normal saves never touch the old database. Object preparation, source,
//! projection, policy, editing head and receipt commit in one caller-owned transaction.
//!
//! # Editing and reconciliation
//!
//! Owner authentication precedes all source, preview, adoption and reconciliation
//! operations. The public transport contract belongs to
//! [`server_api::configuration_adoption`]. It extends the existing virtual tree;
//! imports resolve within one snapshot and no host filesystem path grants access.
//! Before cutover, retrieval returns an evaluated editable migration draft, not a
//! saved change. Preview is read-only and reports changed paths and Subjects.
//!
//! Content identity and editing history are distinct. Adoption compares the
//! observed history head as well as the source digest, so A -> B -> A cannot
//! authorize an old editor. The request fingerprint binds Workspace, authenticated
//! owner and the entire request (including catalog, export, head and changes).
//! Reuse of an operation ID with different input is a conflict. Exact replay
//! returns historical evidence before stale-head checks; it never reapplies policy.
//!
//! After a lost response, retain the full request and reconcile it with that same
//! ID. An adopted receipt proves that request's stored result, not today's head.
//! A pending transfer proves the immutable legacy seal, not Server adoption.
//! Absence of either is not evidence of failure or permission to generate a new
//! ID. Retry the exact request; read current source before a distinct edit.
//!
//! # Cutover boundary
//!
//! Initial cutover and legacy logical config writes acquire the guarded Feature
//! authority transaction before Server storage. Sealing commits independently;
//! the outer Feature connection guard remains held through the Server result.
//! After seal, both old policy writes and old logical source writes reject,
//! including when Server persistence rolls back. Reopen/retry uses the same
//! identity-bound export and request. No in-memory mutex is the durable fence.
//! Validation (including blocking migration diagnostics) precedes seal. Failure
//! there retains legacy authority and the previous source/policy/head/receipt.
//! A failure after seal retains the old Server source but fails closed for runtime
//! reads until the same transfer is adopted. This is not a distributed atomic
//! commit or a guarantee for arbitrary machine loss at every instruction.
//!
//! Existing explicit permissions are never automatically merged or replaced,
//! even when empty. Unsupported operations or child-expanding conversions block
//! adoption rather than dropping entries. Exact limited legacy constraints and
//! change identities remain in the domain, separate from editable permission keys.
//! History and child grants are retained, not regenerated as new authorizations.
//!
//! A receipt separately confirms source storage and policy commit. Its typed
//! runtime application means the next current-policy check, not interruption,
//! reload or termination of an already-running Worker. Legacy consumers cannot
//! turn a new unrepresented key into an all-target grant.
use std::collections::{BTreeMap, BTreeSet};
use config_source::{ConfigTreeSnapshot, WorkspaceConfigSchemaBundle};
use content_history::HistoryId;
use content_history_store::{Access, Authority, ScopeKey, ScopedStore};
use feature_permissions::PermissionHost;
use rusqlite::{Connection, OptionalExtension, Transaction, params};
use serde::{Deserialize, Serialize};
use crate::config_source::{ConfigCommitRequest, WorkspaceConfigState};
use crate::policy_config::{self, AdoptedSubjectPolicy, PermissionSource};
use crate::{Error, Result, SqliteWorkspaceStore};
pub(crate) const SCHEMA: &str = include_str!("config_adoption.sql");
pub(crate) const MIGRATION_NAME: &str = "Atomic Workspace source and Subject policy adoption";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigurationAdoptionRequest {
pub operation_id: String,
/// Editing history identity, not a content digest. Null is only the initial head.
pub expected_head: Option<String>,
pub expected_catalog_digest: String,
pub expected_legacy_export: Option<String>,
pub source: ConfigCommitRequest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimePolicyApplication {
NextCurrentPolicyCheck,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigurationAdoptionResult {
pub workspace_id: String,
pub operation_id: String,
pub history_head: String,
pub state: WorkspaceConfigState,
pub source_stored: bool,
pub policy_committed: bool,
/// Consumers must reload current policy before their next protected operation.
/// This is not confirmation that a running Worker has stopped or reloaded.
pub runtime_application: RuntimePolicyApplication,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigurationPreview {
pub expected_head: Option<String>,
pub state: WorkspaceConfigState,
pub changed_paths: Vec<String>,
pub changed_subjects: Vec<String>,
}
/// Only the trusted migration boundary constructs this from a verified Feature
/// export. The transfer identity binds Workspace, physical DB, export and the full
/// adoption request. It is not an HTTP request or an authority credential.
pub(crate) struct ConfigurationPolicyTransfer {
pub transfer_identity: String,
pub physical_identity: String,
pub export_digest: String,
pub policies: BTreeMap<String, AdoptedSubjectPolicy>,
}
pub(crate) struct CanonicalConfigurationSource {
pub store: SqliteWorkspaceStore,
pub host: PermissionHost,
}
impl subjektiv::CanonicalPermissionSource for CanonicalConfigurationSource {
fn subject_permissions(
&self,
workspace: &str,
subject: &str,
) -> subjektiv::Result<Option<server_api::authorization::SubjectPermissions>> {
if self.host.catalog().workspace.as_str() != workspace {
return Err(subjektiv::SubjektivError::PermissionTransferConflict);
}
self.store
.adopted_subject_policy(workspace, subject)
.and_then(|policy| {
policy
.map(|policy| policy.legacy_read(&self.host))
.transpose()
})
.map_err(|error| {
subjektiv::SubjektivError::InvalidRecord(format!(
"canonical configuration policy unavailable: {error}"
))
})
}
}
pub(crate) struct ConfigurationCurrent {
pub state: WorkspaceConfigState,
pub history_head: Option<String>,
pub canonical: bool,
pub policies: BTreeMap<String, AdoptedSubjectPolicy>,
}
pub(crate) struct ConfigurationMigrationBaseline<'a> {
pub original: &'a WorkspaceConfigState,
pub state: &'a WorkspaceConfigState,
pub policies: &'a BTreeMap<String, AdoptedSubjectPolicy>,
pub diagnostics: &'a [policy_config::MigrationDiagnostic],
pub export_digest: &'a str,
}
struct OwnerScope<'a>(&'a str);
impl Authority for OwnerScope<'_> {
fn allows(&self, key: &ScopeKey, access: Access) -> bool {
key.workspace == self.0
&& key.target == "configuration"
&& matches!(access, Access::Read | Access::Write)
}
}
fn content_error(e: impl std::fmt::Display) -> Error {
Error::Store(format!("configuration content: {e}"))
}
fn conflict(message: &str) -> Error {
Error::WorkspaceConfigConflict(message.into())
}
fn json<T: Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(|e| Error::Store(e.to_string()))
}
fn decode<T: serde::de::DeserializeOwned>(value: &str) -> Result<T> {
serde_json::from_str(value).map_err(|e| Error::RegistryInconsistency(e.to_string()))
}
fn scope(workspace: &str) -> ScopeKey {
ScopeKey {
workspace: workspace.into(),
target: "configuration".into(),
}
}
fn require_owner(conn: &Connection, workspace: &str, account: &str) -> Result<()> {
let allowed: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')",
params![workspace, account], |r| r.get(0),
)?;
if !allowed {
return Err(Error::WorkspacePermissionDenied(
"configuration management requires the Workspace owner".into(),
));
}
Ok(())
}
pub(crate) fn head(conn: &Connection, workspace: &str) -> Result<Option<HistoryId>> {
let bytes: Option<Vec<u8>> = conn
.query_row(
"SELECT head FROM content_scopes WHERE workspace=?1 AND target='configuration'",
[workspace],
|r| r.get(0),
)
.optional()?
.flatten();
bytes
.map(|bytes| {
bytes.try_into().map(HistoryId::from_bytes).map_err(|_| {
Error::RegistryInconsistency("invalid configuration editing head".into())
})
})
.transpose()
}
pub(crate) fn request_fingerprint(
workspace: &str,
account: &str,
request: &ConfigurationAdoptionRequest,
) -> Result<String> {
server_api::authorization::validate_authorization_identifier(&request.operation_id)
.map_err(Error::InvalidInput)?;
Ok(config_source::digest_bytes(
json(&(workspace, account, request))?.as_bytes(),
))
}
pub(crate) fn canonical(conn: &Connection, workspace: &str) -> Result<bool> {
Ok(conn.query_row(
"SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",
[workspace],
|row| row.get(0),
)?)
}
pub fn catalog_digest(host: &PermissionHost) -> Result<String> {
Ok(config_source::digest_bytes(
json(&host.catalog())?.as_bytes(),
))
}
fn policies(conn: &Connection, workspace: &str) -> Result<BTreeMap<String, AdoptedSubjectPolicy>> {
let mut query = conn.prepare("SELECT subject_id,policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 ORDER BY subject_id")?;
let mut result = BTreeMap::new();
for row in query.query_map([workspace], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})? {
let (subject, raw) = row?;
let policy: AdoptedSubjectPolicy = decode(&raw)?;
if subject != policy.source.subject_id {
return Err(Error::RegistryInconsistency(
"Subject policy identity mismatch".into(),
));
}
result.insert(subject, policy);
}
Ok(result)
}
fn receipt(
conn: &Connection,
workspace: &str,
operation: &str,
) -> Result<Option<(String, ConfigurationAdoptionResult)>> {
let row: Option<(String,String)> = conn.query_row(
"SELECT request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 AND operation_id=?2",
params![workspace,operation], |r| Ok((r.get(0)?,r.get(1)?)),
).optional()?;
row.map(|(fingerprint, result)| Ok((fingerprint, decode(&result)?)))
.transpose()
}
fn changed_paths(before: &ConfigTreeSnapshot, after: &ConfigTreeSnapshot) -> Vec<String> {
before
.entries
.keys()
.chain(after.entries.keys())
.collect::<BTreeSet<_>>()
.into_iter()
.filter(|path| before.entries.get(*path) != after.entries.get(*path))
.map(|path| path.as_str().to_owned())
.collect()
}
fn changed_subjects(
before: &BTreeMap<String, AdoptedSubjectPolicy>,
after: &BTreeMap<String, AdoptedSubjectPolicy>,
) -> Vec<String> {
before
.keys()
.chain(after.keys())
.collect::<BTreeSet<_>>()
.into_iter()
.filter(|subject| before.get(*subject) != after.get(*subject))
.cloned()
.collect()
}
impl SqliteWorkspaceStore {
pub(crate) fn configuration_current(
&self,
workspace: &str,
account: &str,
) -> Result<ConfigurationCurrent> {
self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
require_owner(&tx, workspace, account)?;
Ok(ConfigurationCurrent {
state: crate::config_source::load_state(&tx, workspace)?.ok_or_else(|| {
Error::InvalidInput("Workspace source not materialized".into())
})?,
history_head: head(&tx, workspace)?.map(|id| id.to_string()),
canonical: canonical(&tx, workspace)?,
policies: policies(&tx, workspace)?,
})
})
}
pub(crate) fn configuration_request_result(
&self,
workspace: &str,
account: &str,
request: &ConfigurationAdoptionRequest,
) -> Result<Option<ConfigurationAdoptionResult>> {
let fingerprint = request_fingerprint(workspace, account, request)?;
self.with_conn(|conn| {
require_owner(conn, workspace, account)?;
match receipt(conn, workspace, &request.operation_id)? {
Some((saved, result)) if saved == fingerprint => Ok(Some(result)),
Some(_) => Err(conflict("operation ID reused with another request")),
None => Ok(None),
}
})
}
/// Authentication supplies `account`; it is never accepted from an adoption DTO.
pub fn configuration_head(&self, workspace: &str, account: &str) -> Result<Option<String>> {
self.with_conn(|conn| {
require_owner(conn, workspace, account)?;
Ok(head(conn, workspace)?.map(|id| id.to_string()))
})
}
/// Result lookup is a read. A historical receipt never reactivates policy.
pub fn configuration_adoption_result(
&self,
workspace: &str,
account: &str,
operation: &str,
) -> Result<Option<ConfigurationAdoptionResult>> {
self.with_conn(|conn| {
require_owner(conn, workspace, account)?;
Ok(receipt(conn, workspace, operation)?.map(|(_, result)| result))
})
}
pub(crate) fn preview_configuration_adoption(
&self,
workspace: &str,
account: &str,
request: &ConfigurationAdoptionRequest,
schema: WorkspaceConfigSchemaBundle,
host: &PermissionHost,
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
mut require_subject: impl FnMut(&str) -> Result<()>,
) -> Result<ConfigurationPreview> {
self.with_conn(|conn| {
require_owner(conn, workspace, account)?;
let tx = conn.unchecked_transaction()?;
let (current, state, source) = prepare(
&tx,
workspace,
request,
schema,
host,
baseline,
&mut require_subject,
)?;
let before = match baseline {
Some(baseline) => baseline.policies.clone(),
None => policies(&tx, workspace)?,
};
let mut after = before.clone();
policy_config::apply(&mut after, &source, "preview");
Ok(ConfigurationPreview {
expected_head: request.expected_head.clone(),
changed_paths: changed_paths(&current.snapshot, &state.snapshot),
changed_subjects: changed_subjects(&before, &after),
state,
})
})
}
/// The transfer callback may seal the old DB only after all validation and
/// current-head checks. If subsequent Server persistence fails, that specific
/// transfer remains pending; callers reconcile/retry the same request, not a new ID.
pub(crate) fn adopt_configuration(
&self,
workspace: &str,
account: &str,
request: &ConfigurationAdoptionRequest,
schema: WorkspaceConfigSchemaBundle,
host: &PermissionHost,
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
mut require_subject: impl FnMut(&str) -> Result<()>,
mut prepare_transfer: impl FnMut(&str) -> Result<ConfigurationPolicyTransfer>,
) -> Result<ConfigurationAdoptionResult> {
let fingerprint = request_fingerprint(workspace, account, request)?;
self.with_immediate_transaction(|tx| {
require_owner(tx,workspace,account)?;
if let Some((saved,result)) = receipt(tx,workspace,&request.operation_id)? {
if saved != fingerprint { return Err(conflict("operation ID reused with another request")); }
return Ok(result);
}
let (_,state,source) = prepare(tx,workspace,request,schema,host,baseline,&mut require_subject)?;
let authority_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)", [workspace], |r|r.get(0))?;
if authority_exists && (baseline.is_some() || request.expected_legacy_export.is_some()) {
return Err(conflict("legacy transfer is already adopted; reload current source"));
}
if !authority_exists && request.expected_legacy_export.is_none() {
return Err(conflict("legacy export identity must be observed before transfer"));
}
let mut current_policies = if authority_exists { policies(tx,workspace)? } else {
let transfer = prepare_transfer(&fingerprint)?;
if transfer.transfer_identity != fingerprint || request.expected_legacy_export.as_deref() != Some(transfer.export_digest.as_str()) {
return Err(conflict("transfer does not bind the complete adoption request and export"));
}
tx.execute("INSERT INTO workspace_configuration_authorities VALUES(?1,?2,?3,?4)",params![workspace,transfer.transfer_identity,transfer.physical_identity,transfer.export_digest])?;
transfer.policies
};
// Client-selected operation IDs are not policy change identities: reusing
// an old legacy change ID must never make a stale child grant current.
policy_config::apply(&mut current_policies,&source,&format!("configuration:{fingerprint}"));
crate::config_source::sync_adopted_config_references(tx, workspace, &state)?;
let authority = OwnerScope(workspace);
let content = ScopedStore::new(tx,scope(workspace),&authority);
let prepared = content_history_adapters::settings::prepare(&state.snapshot).map_err(content_error)?;
for (id,object) in &prepared.objects { content.put(*id,&object.encode()).map_err(content_error)?; }
tx.execute("DELETE FROM workspace_config_entries WHERE workspace_id=?1",[workspace])?;
crate::config_source::insert_materialized_state(tx,workspace,&state,&chrono::Utc::now().to_rfc3339())?;
tx.execute("DELETE FROM workspace_configuration_policies WHERE workspace_id=?1",[workspace])?;
for (subject,policy) in &current_policies {
tx.execute("INSERT INTO workspace_configuration_policies VALUES(?1,?2,?3)", params![workspace,subject,json(policy)?])?;
}
let new_head = content.adopt(prepared.root,head(tx,workspace)?,account.into(),chrono::Utc::now().timestamp_micros()).map_err(content_error)?;
let result = ConfigurationAdoptionResult {
workspace_id: workspace.into(), operation_id: request.operation_id.clone(),
history_head: new_head.to_string(), state, source_stored: true, policy_committed: true,
runtime_application: RuntimePolicyApplication::NextCurrentPolicyCheck,
};
tx.execute("INSERT INTO workspace_configuration_receipts VALUES(?1,?2,?3,?4)",params![workspace,request.operation_id,fingerprint,json(&result)?])?;
Ok(result)
})
}
pub fn adopted_subject_policy(
&self,
workspace: &str,
subject: &str,
) -> Result<Option<AdoptedSubjectPolicy>> {
self.with_conn(|conn| {
let canonical: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_configuration_authorities WHERE workspace_id=?1)",[workspace],|r|r.get(0))?;
if !canonical { return Err(Error::InvalidInput("Workspace policy transfer is not adopted".into())); }
let raw: Option<String> = conn.query_row("SELECT policy_json FROM workspace_configuration_policies WHERE workspace_id=?1 AND subject_id=?2",params![workspace,subject],|r|r.get(0)).optional()?;
raw.map(|raw|decode(&raw)).transpose()
})
}
}
fn prepare(
tx: &Transaction<'_>,
workspace: &str,
request: &ConfigurationAdoptionRequest,
schema: WorkspaceConfigSchemaBundle,
host: &PermissionHost,
baseline: Option<&ConfigurationMigrationBaseline<'_>>,
require_subject: &mut impl FnMut(&str) -> Result<()>,
) -> Result<(WorkspaceConfigState, WorkspaceConfigState, PermissionSource)> {
if let Some(baseline) = baseline {
if baseline.diagnostics.iter().any(|diagnostic| {
matches!(
diagnostic,
policy_config::MigrationDiagnostic::ExplicitConfigurationRequired { .. }
)
}) {
return Err(Error::InvalidInput("legacy permissions require explicit configuration before adoption; legacy authority was not sealed".into()));
}
}
if host.catalog().workspace.as_str() != workspace {
return Err(Error::WorkspacePermissionDenied(
"permission registry belongs to another Workspace".into(),
));
}
if catalog_digest(host)? != request.expected_catalog_digest {
return Err(conflict("Feature catalog changed"));
}
if head(tx, workspace)?.map(|id| id.to_string()) != request.expected_head {
return Err(conflict("editing history head changed"));
}
let current = crate::config_source::load_state(tx, workspace)?
.ok_or_else(|| Error::InvalidInput("Workspace source not materialized".into()))?;
let editing_base = if let Some(baseline) = baseline {
if *baseline.original != current
|| request.expected_legacy_export.as_deref() != Some(baseline.export_digest)
{
return Err(conflict("migration source or export changed"));
}
baseline.state
} else {
&current
};
if editing_base.snapshot.digest != request.source.base_digest {
return Err(conflict("source content changed"));
}
crate::config_source::validate_entrypoint_request(&request.source.entrypoints)?;
let candidate = crate::config_source::evaluate_candidate_in_transaction(
tx,
workspace,
editing_base.clone(),
crate::config_source::load_evaluation_identity(tx, workspace)?,
&request.source.changes,
schema,
)?;
crate::config_source::validate_workspace_config_candidate_projections(workspace, &candidate)?;
crate::repository_access::validate_repository_access_in_transaction(tx, workspace, &candidate)?;
let source = policy_config::source(&candidate.evaluation)?;
policy_config::validate_source(host, &source, require_subject)?;
let state = WorkspaceConfigState {
snapshot: candidate.snapshot,
contract: candidate.contract,
projection_digest: candidate.evaluation.projection_digest,
};
Ok((current, state, source))
}
#[cfg(test)]
#[path = "config_adoption_tests.rs"]
mod tests;
@@ -0,0 +1,31 @@
CREATE TABLE workspace_configuration_authorities (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
transfer_identity TEXT NOT NULL,
legacy_physical_identity TEXT NOT NULL,
legacy_export_digest TEXT NOT NULL
);
CREATE TABLE workspace_configuration_policies (
workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT,
subject_id TEXT NOT NULL,
policy_json TEXT NOT NULL,
PRIMARY KEY(workspace_id, subject_id)
);
CREATE TABLE workspace_configuration_receipts (
workspace_id TEXT NOT NULL REFERENCES workspace_configuration_authorities(workspace_id) ON DELETE RESTRICT,
operation_id TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
result_json TEXT NOT NULL,
PRIMARY KEY(workspace_id, operation_id)
);
CREATE TRIGGER workspace_configuration_authorities_no_update BEFORE UPDATE ON workspace_configuration_authorities BEGIN
SELECT RAISE(ABORT, 'configuration authority transfers are immutable');
END;
CREATE TRIGGER workspace_configuration_authorities_no_delete BEFORE DELETE ON workspace_configuration_authorities BEGIN
SELECT RAISE(ABORT, 'configuration authority transfers are retained');
END;
CREATE TRIGGER workspace_configuration_receipts_no_update BEFORE UPDATE ON workspace_configuration_receipts BEGIN
SELECT RAISE(ABORT, 'configuration adoption receipts are immutable');
END;
CREATE TRIGGER workspace_configuration_receipts_no_delete BEFORE DELETE ON workspace_configuration_receipts BEGIN
SELECT RAISE(ABORT, 'configuration adoption receipts are retained');
END;
@@ -0,0 +1,417 @@
//! Adoption component tests use the real Server SQLite transaction and content
//! store. The legacy transfer callback is empty in this fixture; physical Feature
//! transfer and authenticated HTTP are separate integration coverage.
use super::*;
use crate::config_source::{MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigSchemaProvider};
use crate::policy_config::PermissionConfigSchemaProvider;
use config_source::{ConfigTreeChange, VirtualPath};
use feature_permissions::*;
const WORKSPACE: &str = "adoption-ws";
const OWNER: &str = "adoption-owner";
fn host(children: bool) -> PermissionHost {
let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap());
let feature = host
.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap())
.unwrap();
let namespace = host
.bind_namespace(&feature, NamespaceId::parse("ticket").unwrap())
.unwrap();
host.declare(
&namespace,
NamespaceDeclaration {
permissions: vec![
PermissionDefinition {
key: PermissionKey::parse("ticket.hoge").unwrap(),
description: String::new(),
children: if children {
vec![PermissionKey::parse("ticket.hoge.fuga").unwrap()]
} else {
vec![]
},
},
PermissionDefinition {
key: PermissionKey::parse("ticket.hoge.fuga").unwrap(),
description: String::new(),
children: vec![],
},
],
sets: vec![SetContribution {
set: PermissionSetId::parse("ticket").unwrap(),
children: vec![PermissionReference::Namespace(NamespacePermissions {
namespace: NamespaceId::parse("ticket").unwrap(),
keys: vec![PermissionKey::parse("ticket.hoge").unwrap()],
})],
}],
},
)
.unwrap();
host
}
fn schema() -> WorkspaceConfigSchemaBundle {
WorkspaceConfigSchemaBundle::compose([
PermissionConfigSchemaProvider.contribution().unwrap(),
crate::prompt_settings::PromptConfigSchemaProvider
.contribution()
.unwrap(),
])
.unwrap()
}
fn open(path: &std::path::Path) -> SqliteWorkspaceStore {
SqliteWorkspaceStore::open_for_test(path).unwrap()
}
fn fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
let dir = tempfile::tempdir().unwrap();
let db = open(&dir.path().join("server.sqlite"));
db.with_immediate_transaction(|tx| {
tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('adoption-owner','user','adoption-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('adoption-ws','adoption-owner','Adoption','active','1','1');")?;
Ok(())
}).unwrap();
db.ensure_workspace_config_materialized_with_schema(WORKSPACE, "now", schema())
.unwrap();
(dir, db)
}
fn request(
db: &SqliteWorkspaceStore,
host: &PermissionHost,
id: &str,
source: Option<&str>,
) -> ConfigurationAdoptionRequest {
let current = db.load_workspace_config(WORKSPACE).unwrap().unwrap();
let path = VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).unwrap();
let changes = source
.map(|source| {
vec![ConfigTreeChange::Update {
path: path.clone(),
expected_digest: current.snapshot.get(&path).unwrap().content_digest.clone(),
content: source.into(),
}]
})
.unwrap_or_default();
ConfigurationAdoptionRequest {
operation_id: id.into(),
expected_legacy_export: db
.configuration_head(WORKSPACE, OWNER)
.unwrap()
.is_none()
.then(|| "fixture-empty".into()),
expected_head: db.configuration_head(WORKSPACE, OWNER).unwrap(),
expected_catalog_digest: catalog_digest(host).unwrap(),
source: ConfigCommitRequest {
base_digest: current.snapshot.digest,
changes,
entrypoints: vec![path],
},
}
}
fn adopt(
db: &SqliteWorkspaceStore,
host: &PermissionHost,
request: &ConfigurationAdoptionRequest,
) -> Result<ConfigurationAdoptionResult> {
db.adopt_configuration(
WORKSPACE,
OWNER,
request,
schema(),
host,
None,
|s| {
if s == "subject-a" {
Ok(())
} else {
Err(Error::InvalidInput("unknown Subject".into()))
}
},
|fingerprint| {
Ok(ConfigurationPolicyTransfer {
transfer_identity: fingerprint.into(),
physical_identity: "fixture-physical".into(),
export_digest: "fixture-empty".into(),
policies: BTreeMap::new(),
})
},
)
}
const A: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge"]; }]; }; }]; }; } as WorkspaceConfigSchema"#;
const B: &str = r#"{ permissions = { subjects = [{ subject_id = "subject-a"; execution = { namespaces = [{ namespace = "ticket"; keys = ["ticket.hoge.fuga"]; }]; }; }]; }; } as WorkspaceConfigSchema"#;
#[test]
fn editing_head_rejects_aba_and_parallel_editors_but_receipts_reconcile_lost_responses() {
let (_dir, db) = fixture();
let host = host(false);
let initial = request(&db, &host, "initial", Some(A));
let first = adopt(&db, &host, &initial).unwrap();
let stale = request(&db, &host, "stale-a", Some(B));
adopt(&db, &host, &request(&db, &host, "to-b", Some(B))).unwrap();
let final_a = adopt(&db, &host, &request(&db, &host, "back-a", Some(A))).unwrap();
assert_eq!(first.state.snapshot, final_a.state.snapshot);
assert_ne!(first.history_head, final_a.history_head);
assert!(adopt(&db, &host, &stale).is_err());
// Exact replay precedes stale checks and returns only the saved old result.
assert_eq!(adopt(&db, &host, &initial).unwrap(), first);
assert_eq!(
db.configuration_adoption_result(WORKSPACE, OWNER, "initial")
.unwrap(),
Some(first)
);
assert_eq!(
db.configuration_head(WORKSPACE, OWNER).unwrap(),
Some(final_a.history_head)
);
let mut changed = initial;
changed.source.changes.clear();
assert!(adopt(&db, &host, &changed).is_err());
}
#[test]
fn failure_at_each_storage_boundary_rolls_back_and_reopens_the_previous_adoption() {
for (table, event) in [
("content_objects", "INSERT"),
("workspace_config_entries", "INSERT"),
("workspace_configuration_policies", "INSERT"),
("content_scopes", "UPDATE"),
("workspace_configuration_receipts", "INSERT"),
] {
let (dir, db) = fixture();
let host = host(false);
let before = adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap();
let policy = db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap();
let candidate = request(&db, &host, "failing", Some(B));
db.with_conn(|conn| {conn.execute_batch(&format!("CREATE TEMP TRIGGER injected_failure BEFORE {event} ON {table} BEGIN SELECT RAISE(ABORT,'injected {table}'); END;"))?;Ok(())}).unwrap();
assert!(adopt(&db, &host, &candidate).is_err(), "{table}");
drop(db);
let reopened = open(&dir.path().join("server.sqlite"));
assert_eq!(
reopened.load_workspace_config(WORKSPACE).unwrap(),
Some(before.state),
"{table}"
);
assert_eq!(
reopened.configuration_head(WORKSPACE, OWNER).unwrap(),
Some(before.history_head),
"{table}"
);
assert_eq!(
reopened
.adopted_subject_policy(WORKSPACE, "subject-a")
.unwrap(),
policy,
"{table}"
);
assert!(
reopened
.configuration_adoption_result(WORKSPACE, OWNER, "failing")
.unwrap()
.is_none(),
"{table}"
);
adopt(&reopened, &host, &candidate).unwrap();
}
}
#[test]
fn invalid_references_subjects_structure_and_owner_do_not_prepare_a_transfer() {
let (_dir, db) = fixture();
let host = host(false);
let initial = db.load_workspace_config(WORKSPACE).unwrap();
for source in [
"not valid Decodal {",
r#"{permissions={subjects=[{subject_id="subject-a"; execution={sets=["missing"];};}];};} as WorkspaceConfigSchema"#,
r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=["ticket.unknown"];}];};}];};} as WorkspaceConfigSchema"#,
r#"{permissions={subjects=[{subject_id="missing";}];};} as WorkspaceConfigSchema"#,
r#"{permissions={subjects=[{subject_id="subject-a";},{subject_id="subject-a";}];};} as WorkspaceConfigSchema"#,
r#"{permissions={subjects=[{subject_id="subject-a"; execution={namespaces=[{namespace="ticket";keys=[{key="ticket.hoge"; payload="forbidden";}];}];};}];};} as WorkspaceConfigSchema"#,
] {
let request = request(&db, &host, "bad", Some(source));
let result = db.adopt_configuration(
WORKSPACE,
OWNER,
&request,
schema(),
&host,
None,
|s| {
if s == "subject-a" {
Ok(())
} else {
Err(Error::InvalidInput("unknown Subject".into()))
}
},
|_| panic!("validation failure must precede seal"),
);
assert!(result.is_err(), "{source}");
assert_eq!(db.load_workspace_config(WORKSPACE).unwrap(), initial);
assert_eq!(db.configuration_head(WORKSPACE, OWNER).unwrap(), None);
}
let request = request(&db, &host, "other-owner", None);
assert!(
db.adopt_configuration(
WORKSPACE,
"non-owner",
&request,
schema(),
&host,
None,
|_| Ok(()),
|_| panic!("owner refusal must precede seal")
)
.is_err()
);
}
#[test]
fn omission_is_unchanged_explicit_remove_unsets_and_noop_does_not_reissue_policy_identity() {
let (_dir, db) = fixture();
let host = host(false);
adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap();
let original = db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap();
let preview = db
.preview_configuration_adoption(
WORKSPACE,
OWNER,
&request(&db, &host, "noop", None),
schema(),
&host,
None,
|_| Ok(()),
)
.unwrap();
assert!(preview.changed_paths.is_empty());
assert!(preview.changed_subjects.is_empty());
adopt(&db, &host, &request(&db, &host, "noop", None)).unwrap();
assert_eq!(
db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(),
original
);
adopt(
&db,
&host,
&request(&db, &host, "omit", Some("{} as WorkspaceConfigSchema")),
)
.unwrap();
assert_eq!(
db.adopted_subject_policy(WORKSPACE, "subject-a").unwrap(),
original
);
adopt(
&db,
&host,
&request(
&db,
&host,
"remove",
Some(r#"{permissions={remove_subjects=["subject-a"];};} as WorkspaceConfigSchema"#),
),
)
.unwrap();
assert!(
db.adopted_subject_policy(WORKSPACE, "subject-a")
.unwrap()
.is_none()
);
}
#[test]
fn feature_children_are_explicit_and_multisegment_keys_are_preserved_in_saved_assignments() {
for children in [false, true] {
let (_dir, db) = fixture();
let host = host(children);
adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap();
let policy = db
.adopted_subject_policy(WORKSPACE, "subject-a")
.unwrap()
.unwrap();
let resolution = host.resolve(Some(&policy.source.execution.assignment()));
assert_eq!(
resolution
.projection()
.allowed
.contains_key(&PermissionKey::parse("ticket.hoge.fuga").unwrap()),
children
);
assert!(
host.resolve(Some(&policy.source.delegation.assignment()))
.projection()
.allowed
.is_empty()
);
adopt(&db, &host, &request(&db, &host, "multi", Some(B))).unwrap();
let policy = db
.adopted_subject_policy(WORKSPACE, "subject-a")
.unwrap()
.unwrap();
assert_eq!(
policy.source.execution.namespaces[0].keys[0].as_str(),
"ticket.hoge.fuga"
);
}
}
#[test]
fn client_operation_id_cannot_reuse_a_previous_policy_change_identity() {
let (_dir, db) = fixture();
let host = host(false);
adopt(&db, &host, &request(&db, &host, "initial", Some(A))).unwrap();
let before = db
.adopted_subject_policy(WORKSPACE, "subject-a")
.unwrap()
.unwrap();
let change = request(&db, &host, &before.change_id, Some(B));
adopt(&db, &host, &change).unwrap();
let after = db
.adopted_subject_policy(WORKSPACE, "subject-a")
.unwrap()
.unwrap();
assert_ne!(after.change_id, before.change_id);
assert_ne!(after.source, before.source);
}
#[test]
fn independent_database_connections_serialize_concurrent_web_and_cli_heads() {
let (dir, db) = fixture();
let registry = host(false);
adopt(&db, &registry, &request(&db, &registry, "initial", Some(A))).unwrap();
let other = open(&dir.path().join("server.sqlite"));
let web = request(&db, &registry, "web-edit", Some(B));
let cli = request(
&other,
&registry,
"cli-edit",
Some("{} as WorkspaceConfigSchema"),
);
assert_eq!(web.expected_head, cli.expected_head);
let barrier = std::sync::Barrier::new(2);
let (web, cli) = std::thread::scope(|scope| {
let web = scope.spawn(|| {
barrier.wait();
adopt(&db, &host(false), &web)
});
let cli = scope.spawn(|| {
barrier.wait();
adopt(&other, &host(false), &cli)
});
(web.join().unwrap(), cli.join().unwrap())
});
let (winner, loser) = match (web, cli) {
(Ok(winner), Err(loser)) | (Err(loser), Ok(winner)) => (winner, loser),
results => panic!("exactly one observed head may adopt: {results:?}"),
};
assert!(matches!(loser, Error::WorkspaceConfigConflict(_)));
assert_eq!(
db.load_workspace_config(WORKSPACE).unwrap(),
Some(winner.state)
);
assert_eq!(
db.configuration_head(WORKSPACE, OWNER).unwrap(),
Some(winner.history_head)
);
assert_eq!(
other
.configuration_current(WORKSPACE, OWNER)
.unwrap()
.history_head,
db.configuration_head(WORKSPACE, OWNER).unwrap()
);
}
+71 -39
View File
@@ -183,49 +183,56 @@ impl SqliteWorkspaceStore {
schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigState> {
let desired_schema = schema_bundle.clone();
let (state, requires_toolchain_refresh, base_identity) = self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let workspace_exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
[workspace_id],
|row| row.get(0),
)?;
if !workspace_exists {
return Err(Error::WorkspaceIdMismatch);
}
let stored_decodal_version = tx
let (state, requires_toolchain_refresh, base_identity, canonical) =
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let workspace_exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
[workspace_id],
|row| row.get(0),
)?;
if !workspace_exists {
return Err(Error::WorkspaceIdMismatch);
}
let stored_decodal_version = tx
.query_row(
"SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1",
[workspace_id],
|row| row.get::<_, String>(0),
)
.optional()?;
let requires_toolchain_refresh = stored_decodal_version
.as_deref()
.is_some_and(|version| version != DECODAL_VERSION);
let state = match load_state(&tx, workspace_id)? {
Some(state) => state,
None => {
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;
let requires_toolchain_refresh = stored_decodal_version
.as_deref()
.is_some_and(|version| version != DECODAL_VERSION);
let state = match load_state(&tx, workspace_id)? {
Some(state) => state,
None => {
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
}
insert_materialized_state(&tx, workspace_id, &state, materialized_at)?;
state
}
};
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
tx.commit()?;
Ok((state, requires_toolchain_refresh, base_identity))
})?;
};
let base_identity = load_evaluation_identity(&tx, workspace_id)?;
let canonical = crate::config_adoption::canonical(&tx, workspace_id)?;
tx.commit()?;
Ok((state, requires_toolchain_refresh, base_identity, canonical))
})?;
// Once adopted, schema changes must be previewed/adopted by the owner,
// not persisted as a second startup writer outside the policy/history tx.
if canonical {
return Ok(state);
}
let desired_schema = crate::plugin_settings::materialization_schema(
desired_schema,
&state.contract.schema_bundle,
@@ -340,6 +347,9 @@ impl SqliteWorkspaceStore {
) -> Result<WorkspaceConfigState> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
if crate::config_adoption::canonical(&tx, workspace_id)? {
return Err(config_conflict("configuration authority is adopted; use owner adoption with the observed editing head and operation ID"));
}
let workspace_exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
[workspace_id],
@@ -490,7 +500,7 @@ fn format_candidate_sources(
}
// Read persisted provenance, not load_state's upgraded in-memory contract.
fn load_evaluation_identity(
pub(crate) fn load_evaluation_identity(
conn: &rusqlite::Connection,
workspace_id: &str,
) -> Result<(String, String)> {
@@ -509,7 +519,7 @@ fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool {
.any(|provider| provider.provider_id == "builtin:plugins")
}
fn evaluate_candidate_in_transaction(
pub(crate) fn evaluate_candidate_in_transaction(
tx: &rusqlite::Transaction<'_>,
workspace_id: &str,
current: WorkspaceConfigState,
@@ -566,6 +576,28 @@ fn evaluate_candidate(
})
}
pub(crate) fn sync_adopted_config_references(
tx: &rusqlite::Transaction<'_>,
workspace: &str,
state: &WorkspaceConfigState,
) -> Result<()> {
if has_plugin_provider(&state.contract.schema_bundle) {
let (contract, evaluation, resolved) = crate::plugin_settings::evaluate_catalog_config(
tx,
workspace,
&state.snapshot,
state.contract.schema_bundle.clone(),
)?;
if contract != state.contract || evaluation.projection_digest != state.projection_digest {
return Err(config_conflict(
"Plugin catalog/schema changed during adoption",
));
}
crate::plugin_settings::sync_config_references(tx, workspace, &resolved)?;
}
Ok(())
}
pub(crate) fn load_state(
conn: &rusqlite::Connection,
workspace_id: &str,
@@ -792,7 +824,7 @@ fn config_not_materialized() -> Error {
Error::RegistryInconsistency("workspace config tree is not materialized".to_string())
}
fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> {
pub(crate) fn validate_entrypoint_request(entrypoints: &[VirtualPath]) -> Result<()> {
if entrypoints == [main_config_path()] {
Ok(())
} else {
@@ -0,0 +1,595 @@
//! Read-only preparation of an editable Workspace permission migration draft.
//!
//! The authenticated owner boundary lives in Server/core. This helper checks the
//! guarded stores and exact Workspace attribution, but neither saves the draft nor
//! seals the legacy writer. Adoption must check `original`, the actual editing
//! head and export identity/digest again, and reject blocking diagnostics before
//! sealing. A successful preview alone is not permission authority.
use std::collections::{BTreeMap, BTreeSet};
use config_source::{
ConfigContentType, ConfigTreeChange, ConfigTreeSnapshot, SnapshotEnvironment,
ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle,
};
use feature_permissions::{NamespacePermissions, PermissionHost};
use subjektiv::{PermissionTransferSnapshot, SubjektivStore};
use crate::config_source::{
ConfigCommitRequest, MAIN_CONFIG_ENTRYPOINT, WorkspaceConfigState,
validate_workspace_config_candidate_projections,
};
use crate::policy_config::{
self, AdoptedSubjectPolicy, AssignmentSource, MigrationDiagnostic, PermissionSource,
};
use crate::{Error, Result, SqliteWorkspaceStore};
pub(crate) struct MigrationSeed {
pub original: WorkspaceConfigState,
pub state: WorkspaceConfigState,
pub snapshot: PermissionTransferSnapshot,
pub policies: BTreeMap<String, AdoptedSubjectPolicy>,
pub diagnostics: Vec<MigrationDiagnostic>,
}
pub(crate) fn preview_migration(
store: &SqliteWorkspaceStore,
legacy: &SubjektivStore,
workspace: &str,
schema: WorkspaceConfigSchemaBundle,
host: &PermissionHost,
) -> Result<MigrationSeed> {
if legacy.scope_id() != workspace || host.catalog().workspace.as_str() != workspace {
return Err(Error::WorkspaceIdMismatch);
}
// This API validates native physical identity under the FeatureDatabase guard.
// Do not replace it with path inspection or SQL against the legacy database.
let identity = legacy.database_identity().map_err(legacy_error)?;
if identity.scope_id != workspace || identity.feature_id != subjektiv::SUBJEKTIV_FEATURE_ID {
return Err(Error::WorkspaceIdMismatch);
}
let original = store.with_conn(|conn| {
// with_conn also owns the managed Server catalog identity/retention guard.
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND state='active')",
[workspace],
|row| row.get(0),
)?;
if !exists {
return Err(Error::WorkspaceIdMismatch);
}
crate::config_source::load_state(conn, workspace)?.ok_or_else(|| {
Error::RegistryInconsistency("workspace config tree is not materialized".into())
})
})?;
let snapshot = legacy
.preview_permission_transfer(&identity)
.map_err(legacy_error)?;
if snapshot.identity != identity {
return Err(Error::WorkspaceConfigConflict(
"legacy export identity changed".into(),
));
}
let (policies, diagnostics) = migrate_policies(host, &snapshot)?;
let source = PermissionSource {
subjects: policies
.values()
.map(|policy| policy.source.clone())
.collect(),
remove_subjects: vec![],
};
let changes = draft_changes(&original, &source)?;
// Caller's real application provider bundle (including PromptSchema) is
// required: a migration-specific substitute cannot validate other settings.
let candidate = store.evaluate_workspace_config_candidate_with_schema(
workspace,
&ConfigCommitRequest {
base_digest: original.snapshot.digest.clone(),
changes,
entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)?],
},
schema,
)?;
validate_workspace_config_candidate_projections(workspace, &candidate)?;
let evaluated_source = policy_config::source(&candidate.evaluation)?;
if evaluated_source != source {
return Err(Error::InvalidInput(
"migration import did not produce the exact generated Subject assignments".into(),
));
}
let subjects: BTreeSet<_> = snapshot
.sources
.iter()
.map(|item| item.subject_id.as_str())
.collect();
policy_config::validate_source(host, &evaluated_source, |subject| {
if subjects.contains(subject) {
Ok(())
} else {
Err(Error::SubjektivSubjectNotFound(subject.into()))
}
})?;
let state = WorkspaceConfigState {
snapshot: candidate.snapshot,
contract: candidate.contract,
projection_digest: candidate.evaluation.projection_digest,
};
Ok(MigrationSeed {
original,
state,
snapshot,
policies,
diagnostics,
})
}
fn legacy_error(error: subjektiv::SubjektivError) -> Error {
Error::Store(format!("legacy permission migration preview: {error}"))
}
fn path(value: &str) -> Result<VirtualPath> {
VirtualPath::parse(value).map_err(|error| Error::InvalidInput(error.to_string()))
}
fn migrate_policies(
host: &PermissionHost,
snapshot: &PermissionTransferSnapshot,
) -> Result<(
BTreeMap<String, AdoptedSubjectPolicy>,
Vec<MigrationDiagnostic>,
)> {
let mut policies = BTreeMap::new();
let mut diagnostics = vec![];
let mut seen = BTreeSet::new();
for item in &snapshot.sources {
if !seen.insert(&item.subject_id) {
return Err(Error::RegistryInconsistency(
"duplicate Subject in legacy export".into(),
));
}
// None stays unset. In particular, never enroll it in a default Set.
let Some(legacy) = &item.permissions else {
continue;
};
let (policy, mut converted) =
policy_config::migrate_subject(host, &item.subject_id, legacy)?;
policies.insert(item.subject_id.clone(), policy);
// ExplicitConfigurationRequired must survive preview, even though the
// unsupported operation is absent from the editable assignment. Core must
// block adoption on it; this is not a silently successful conversion.
diagnostics.append(&mut converted);
}
Ok((policies, diagnostics))
}
fn draft_changes(
original: &WorkspaceConfigState,
source: &PermissionSource,
) -> Result<Vec<ConfigTreeChange>> {
// Evaluate with the existing open schema, not application defaults. This
// distinguishes an explicit `permissions = {}` (including imports/dotted
// fields) from an omitted field without introducing another Decodal parser.
let open_contract = ToolchainContract::with_schema_bundle(
original.contract.schema_version,
original.contract.entrypoints.clone(),
original.contract.import_policy_version,
WorkspaceConfigSchemaBundle::empty(),
);
let raw = SnapshotEnvironment::new(original.snapshot.clone())
.evaluate_contract(&open_contract)
.map_err(|diagnostics| {
Error::InvalidInput(format!(
"cannot inspect current configuration before permission migration: {diagnostics:?}"
))
})?;
if raw
.projections
.iter()
.any(|projection| projection.data_json.get("permissions").is_some())
{
return Err(Error::InvalidInput(
"current source explicitly defines permissions; automatic migration cannot merge or replace it; preserve and reconcile that source explicitly".into(),
));
}
let generated_path = unused_source_path(&original.snapshot)?;
let main_path = path(MAIN_CONFIG_ENTRYPOINT)?;
let main = original.snapshot.get(&main_path).ok_or_else(|| {
Error::RegistryInconsistency("workspace config tree has no main.dcdl".into())
})?;
if main.content_type != ConfigContentType::Decodal {
return Err(Error::InvalidInput(
"main.dcdl is not Decodal source".into(),
));
}
// Use the existing syntax formatter to supply a final field separator when
// needed. It preserves comments; no local lexer or alternate language rules.
let formatted_main = SnapshotEnvironment::new(original.snapshot.clone())
.format(&main.content)
.map_err(|error| {
Error::InvalidInput(format!("cannot format migration entrypoint: {error}"))
})?;
Ok(vec![
ConfigTreeChange::Create {
path: generated_path.clone(),
content_type: ConfigContentType::Decodal,
content: render_permission_source(source),
},
ConfigTreeChange::Update {
path: main_path,
expected_digest: main.content_digest.clone(),
content: append_import(&formatted_main, &generated_path)?,
},
])
}
fn unused_source_path(snapshot: &ConfigTreeSnapshot) -> Result<VirtualPath> {
// With N entries, one of these N+1 names is unused. Never overwrite even an
// unreferenced preexisting migration file.
for suffix in 0..=snapshot.entries.len() {
let name = if suffix == 0 {
"permissions/migrated.dcdl".to_owned()
} else {
format!("permissions/migrated-{suffix}.dcdl")
};
let candidate = path(&name)?;
if snapshot.get(&candidate).is_none() {
return Ok(candidate);
}
}
unreachable!("N entries cannot occupy N+1 distinct migration paths")
}
fn append_import(source: &str, imported: &VirtualPath) -> Result<String> {
// Materialized main.dcdl is a literal outer object with a trailing schema
// assertion. Limit this edit to that existing representation: no rewrites of
// arbitrary expressions, no JSON round-trip, no replacement of other fields.
let trimmed = source.trim_end();
let object = trimmed
.strip_suffix("as WorkspaceConfigSchema")
.map(str::trim_end)
.unwrap_or(trimmed);
if !object.starts_with('{') || !object.ends_with('}') {
return Err(Error::InvalidInput(
"permission migration requires main.dcdl's outer object before its WorkspaceConfigSchema assertion; existing source was not changed".into(),
));
}
let close = object.len() - 1;
let import = quote(&format!("./{}", imported.as_str()));
Ok(format!(
"{}\n permissions = import {import};\n{}",
&source[..close],
&source[close..]
))
}
// A deliberately specific, payload-free renderer. JSON quoting is used only for
// string literals, never as a renderer for the configuration/assignment objects.
fn quote(value: &str) -> String {
serde_json::to_string(value).expect("a string serializes")
}
fn render_strings<'a>(values: impl IntoIterator<Item = &'a str>) -> String {
format!(
"[{}]",
values.into_iter().map(quote).collect::<Vec<_>>().join(", ")
)
}
fn render_namespaces(values: &[NamespacePermissions]) -> String {
let items = values
.iter()
.map(|value| {
format!(
"{{ namespace = {}; keys = {}; }}",
quote(value.namespace.as_str()),
render_strings(value.keys.iter().map(|key| key.as_str())),
)
})
.collect::<Vec<_>>();
format!("[{}]", items.join(", "))
}
fn render_assignment(source: &AssignmentSource) -> String {
format!(
"{{ sets = {}; namespaces = {}; exclude_sets = {}; exclude_namespaces = {}; revoked = {}; }}",
render_strings(source.sets.iter().map(|set| set.as_str())),
render_namespaces(&source.namespaces),
render_strings(source.exclude_sets.iter().map(|set| set.as_str())),
render_namespaces(&source.exclude_namespaces),
source.revoked,
)
}
fn render_permission_source(source: &PermissionSource) -> String {
let subjects = source
.subjects
.iter()
.map(|item| {
format!(
" {{ subject_id = {}; execution = {}; delegation = {}; }}",
quote(&item.subject_id),
render_assignment(&item.execution),
render_assignment(&item.delegation),
)
})
.collect::<Vec<_>>()
.join(",\n");
format!(
"{{\n subjects = [\n{subjects}\n ];\n remove_subjects = {};\n}}\n",
render_strings(source.remove_subjects.iter().map(String::as_str)),
)
}
#[cfg(test)]
mod tests {
use super::*;
use config_source::{ConfigEntry, EvaluationResult};
use feature_permissions::{
FeatureIdentity, NamespaceDeclaration, NamespaceId, PermissionDefinition, PermissionKey,
PermissionSetId, WorkspaceId,
};
use server_api::authorization::{PermissionResource, ScopedPermission, SubjectPermissions};
use subjektiv::{DatabaseIdentity, PermissionSourceExport};
const WORKSPACE: &str = "migration-workspace";
fn host(children: bool) -> PermissionHost {
let mut host = PermissionHost::new(WorkspaceId::parse(WORKSPACE).unwrap());
let feature = host
.register_feature(FeatureIdentity::parse("builtin:file").unwrap())
.unwrap();
let namespace = host
.bind_namespace(&feature, NamespaceId::parse("file").unwrap())
.unwrap();
host.declare(
&namespace,
NamespaceDeclaration {
permissions: vec![
PermissionDefinition {
key: PermissionKey::parse("file.read").unwrap(),
description: String::new(),
children: if children {
vec![PermissionKey::parse("file.read.text").unwrap()]
} else {
vec![]
},
},
PermissionDefinition {
key: PermissionKey::parse("file.read.text").unwrap(),
description: String::new(),
children: vec![],
},
],
sets: vec![],
},
)
.unwrap();
host
}
fn limited(operation: &str) -> ScopedPermission {
ScopedPermission {
operation: operation.into(),
resource: PermissionResource::Workdir {
working_directory_id: "one-workdir".into(),
path: "only-this.txt".into(),
recursive: false,
},
}
}
fn export(sources: Vec<PermissionSourceExport>) -> PermissionTransferSnapshot {
PermissionTransferSnapshot {
identity: DatabaseIdentity {
scope_id: WORKSPACE.into(),
feature_id: subjektiv::SUBJEKTIV_FEATURE_ID.into(),
physical_id: "typed-test-identity".into(),
},
sources,
export_digest: "not-used-by-pure-conversion-tests".into(),
}
}
fn current(main: &str, extra: Vec<ConfigEntry>) -> WorkspaceConfigState {
let mut current = crate::config_source::initial_state().unwrap();
let mut entries = vec![
ConfigEntry::new(
path(MAIN_CONFIG_ENTRYPOINT).unwrap(),
ConfigContentType::Decodal,
main,
)
.unwrap(),
];
entries.extend(extra);
current.snapshot = ConfigTreeSnapshot::from_entries(entries).unwrap();
current
}
fn evaluate(snapshot: ConfigTreeSnapshot) -> EvaluationResult {
let contract = crate::config_source::initial_state().unwrap().contract;
SnapshotEnvironment::new(snapshot)
.evaluate_contract(&contract)
.unwrap()
}
#[test]
fn unset_empty_and_limited_policies_keep_change_identity_and_constraints() {
let empty = SubjectPermissions {
change_id: "explicit-empty".into(),
execution: vec![],
delegation: vec![],
};
let limited = SubjectPermissions {
change_id: "keep-limited-change".into(),
execution: vec![limited("file.read")],
delegation: vec![limited("file.read.text")],
};
let snapshot = export(vec![
PermissionSourceExport {
subject_id: "unset".into(),
permissions: None,
},
PermissionSourceExport {
subject_id: "empty".into(),
permissions: Some(empty.clone()),
},
PermissionSourceExport {
subject_id: "limited".into(),
permissions: Some(limited.clone()),
},
]);
let host = host(false);
let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap();
assert!(!policies.contains_key("unset"));
assert_eq!(policies["empty"].change_id, empty.change_id);
assert_eq!(
policies["empty"].source.execution,
AssignmentSource::default()
);
assert_eq!(
policies["empty"].source.delegation,
AssignmentSource::default()
);
assert_eq!(policies["limited"].legacy_read(&host).unwrap(), limited);
assert_eq!(diagnostics.len(), 2);
assert!(
diagnostics
.iter()
.all(|d| matches!(d, MigrationDiagnostic::DomainConstraint { .. }))
);
}
#[test]
fn unsupported_or_child_expanding_operations_return_full_blocking_diagnostics() {
for (host, permission) in [
(host(false), limited("file.unknown")),
(host(true), limited("file.read")),
] {
let legacy = SubjectPermissions {
change_id: "keep-change".into(),
execution: vec![permission.clone()],
delegation: vec![],
};
let snapshot = export(vec![PermissionSourceExport {
subject_id: "blocked".into(),
permissions: Some(legacy.clone()),
}]);
let (policies, diagnostics) = migrate_policies(&host, &snapshot).unwrap();
assert_eq!(
diagnostics,
vec![MigrationDiagnostic::ExplicitConfigurationRequired {
subject_id: "blocked".into(),
permission,
}]
);
assert_eq!(policies["blocked"].legacy_constraints, Some(legacy));
assert!(policies["blocked"].source.execution.namespaces.is_empty());
assert!(policies["blocked"].source.execution.sets.is_empty());
}
}
#[test]
fn renderer_round_trips_sets_namespaces_exclusions_revocation_and_quoted_strings() {
let assignment = AssignmentSource {
sets: vec![PermissionSetId::parse("shared").unwrap()],
namespaces: vec![NamespacePermissions {
namespace: NamespaceId::parse("file").unwrap(),
keys: vec![PermissionKey::parse("file.read.text").unwrap()],
}],
exclude_sets: vec![PermissionSetId::parse("excluded").unwrap()],
exclude_namespaces: vec![NamespacePermissions {
namespace: NamespaceId::parse("file").unwrap(),
keys: vec![PermissionKey::parse("file.read").unwrap()],
}],
revoked: true,
};
let source = PermissionSource {
subjects: vec![policy_config::SubjectAssignmentSource {
subject_id: "subject-\"quoted\\日本語".into(),
execution: assignment,
delegation: AssignmentSource::default(),
}],
remove_subjects: vec!["removed".into()],
};
let rendered = render_permission_source(&source);
assert!(rendered.contains("sets = [\"shared\"]"));
assert!(rendered.contains("keys = [\"file.read.text\"]"));
assert!(!rendered.contains("legacy_constraints"));
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
path(MAIN_CONFIG_ENTRYPOINT).unwrap(),
ConfigContentType::Decodal,
format!("{{ permissions = {rendered}; }} as WorkspaceConfigSchema"),
)
.unwrap()])
.unwrap();
assert_eq!(policy_config::source(&evaluate(snapshot)).unwrap(), source);
}
#[test]
fn draft_preserves_main_comments_other_settings_and_existing_migration_files() {
let main = "{\n # permissions in a comment are not a field\n other = { text = \"brace } and permissions\"; };\n} as WorkspaceConfigSchema\n";
let occupied = ConfigEntry::new(
path("permissions/migrated.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ keep = true; }\n",
)
.unwrap();
let original = current(main, vec![occupied.clone()]);
let source = PermissionSource::default();
let changes = draft_changes(&original, &source).unwrap();
let draft = original.snapshot.apply(&changes).unwrap();
assert_eq!(draft.get(&occupied.path), Some(&occupied));
assert!(
draft
.get(&path("permissions/migrated-1.dcdl").unwrap())
.is_some()
);
let updated_main = &draft
.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap())
.unwrap()
.content;
assert!(updated_main.contains("# permissions in a comment are not a field"));
assert!(updated_main.contains("brace } and permissions"));
assert!(updated_main.contains("permissions = import \"./permissions/migrated-1.dcdl\";"));
assert!(updated_main.ends_with("} as WorkspaceConfigSchema\n"));
let before = evaluate(original.snapshot.clone());
let after = evaluate(draft);
assert_eq!(
after.projections[0].data_json["other"],
before.projections[0].data_json["other"]
);
assert_eq!(policy_config::source(&after).unwrap(), source);
assert_eq!(
original
.snapshot
.get(&path(MAIN_CONFIG_ENTRYPOINT).unwrap())
.unwrap()
.content,
main
);
}
#[test]
fn existing_explicit_permissions_are_not_merged_or_discarded_even_when_empty() {
for main in [
"{ permissions = {}; } as WorkspaceConfigSchema",
"{ permissions.subjects = []; } as WorkspaceConfigSchema",
"{ permissions = import \"./permissions/existing.dcdl\"; } as WorkspaceConfigSchema",
] {
let existing = ConfigEntry::new(
path("permissions/existing.dcdl").unwrap(),
ConfigContentType::Decodal,
"{ subjects = []; }",
)
.unwrap();
let original = current(main, vec![existing]);
let before = original.clone();
let error = draft_changes(&original, &PermissionSource::default()).unwrap_err();
assert!(
error.to_string().contains("explicitly defines permissions"),
"{error}"
);
assert_eq!(original, before);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
//! Contract fixture for the actual Server database/transaction owner and the content adapter.
//! Existing authorization is represented by an exact-target Host decision; this is not a route
//! or production policy cutover. Domain schema is installed only in this isolated fixture.
//! or production policy cutover. The Server lifecycle installs the content schema;
//! this fixture installs only its business data and exact-target Host decision.
use config_source::{
ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract,
VirtualPath,
@@ -88,7 +89,6 @@ fn server_owned_transaction_adopts_sources_projection_history_and_rolls_back_fai
let path = dir.path().join("server.sqlite");
let db = SqliteWorkspaceStore::open_for_test(&path).unwrap();
db.with_immediate_transaction(|tx| {
tx.execute_batch(content_history_store::SCHEMA)?;
tx.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('history-owner','user','history-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('history-ws','history-owner','History fixture','active','1','1');")?;
Ok(())
}).unwrap();
@@ -127,7 +127,6 @@ fn server_owned_transaction_adopts_sources_projection_history_and_rolls_back_fai
fn ticket_snapshot_and_history_restoration_do_not_rewind_separate_business_events() {
let db = SqliteWorkspaceStore::in_memory_for_test().unwrap();
db.with_immediate_transaction(|tx| {
tx.execute_batch(content_history_store::SCHEMA)?;
// Business state is deliberately not a field accepted by the content adapter.
tx.execute_batch("CREATE TEMP TABLE business_events(kind TEXT,value TEXT); INSERT INTO business_events VALUES('state','closed'),('assignee','current-worker'),('relation','depends-on-current');")?;
let s=scoped(tx);
+5
View File
@@ -5,7 +5,11 @@ pub mod authority;
pub mod backend_job;
pub mod companion;
pub mod config;
pub mod config_adoption;
pub mod config_source;
mod configuration_migration;
#[cfg(test)]
mod configuration_transfer_tests;
#[cfg(test)]
mod content_history_tests;
pub mod db_executor;
@@ -24,6 +28,7 @@ pub mod plugin_plan;
pub mod plugin_receipt;
pub mod plugin_service;
pub mod plugin_settings;
pub mod policy_config;
pub mod profile_settings;
pub mod prompt_settings;
pub mod records;
@@ -11,7 +11,9 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap();
let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap();
for version in [50, 105] {
let path = dir.path().join(format!("v{version}.db"));
let root = dir.path().join(format!("v{version}"));
std::fs::create_dir(&root).unwrap();
let path = root.join("workspace.db");
if version == 50 {
tests::prepare_schema_v50(&path, Some("workspace-a"));
} else {
@@ -30,7 +32,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(), &(113, "Ticket work-decomposition parent links".into()));
assert_eq!(after.last().unwrap(), &(122, crate::config_adoption::MIGRATION_NAME.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);
@@ -83,6 +85,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
));
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
expected.push((113, "Ticket work-decomposition parent links".into()));
expected.push((122, crate::config_adoption::MIGRATION_NAME.into()));
assert_eq!(workspace_schema_migration_history(c)?, expected);
crate::plugin_catalog::verify_with_data(c)?;
Ok(())
@@ -101,7 +104,13 @@ fn plugin_catalog_startup_rejects_drifted_reference_fence() {
})
.unwrap();
drop(store);
assert!(
matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin catalog storage schema has drifted"))
);
match SqliteWorkspaceStore::open_for_test(path) {
Err(Error::Store(message)) => assert!(
message.contains("Server storage format: physical Base mismatch")
&& message.contains("plugin_reference_live"),
"{message}"
),
Err(error) => panic!("unexpected rejection: {error}"),
Ok(_) => panic!("startup accepted a missing reference fence"),
}
}
@@ -41,6 +41,7 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match
));
expected.push((112, "Ticket Service outbox and fenced consumers".into()));
expected.push((113, "Ticket work-decomposition parent links".into()));
expected.push((122, crate::config_adoption::MIGRATION_NAME.into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected);
subject_assignment_migration::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?;
@@ -0,0 +1,377 @@
//! Subject assignments in the existing Workspace virtual configuration.
//!
//! An omitted Subject is unchanged; removal is explicit. Assignments contain only
//! references to Feature-owned Sets and Namespaces. Legacy resource restrictions
//! remain separate domain constraints, never payloads on permission keys.
//!
//! Assigning a Subject replaces both execution and delegation assignments for
//! that Subject. An omitted lane is empty, not its old value. `{}` is explicit
//! empty; `revoked = true` denies that lane without altering the other lane.
//! `remove_subjects` removes policy, not the Subject, and never restores a default.
//! Omitting `permissions`, or omitting an individual Subject, preserves policy.
//! Consequently deleting an assignment from source alone is not revocation.
//!
//! For example, when the catalog declares `filesystem.read` and `subject-a` exists:
//!
//! ```text
//! {
//! permissions = {
//! subjects = [{
//! subject_id = "subject-a";
//! execution = {
//! namespaces = [{ namespace = "filesystem"; keys = ["filesystem.read"]; }];
//! };
//! delegation = {};
//! }];
//! remove_subjects = [];
//! };
//! } as WorkspaceConfigSchema
//! ```
//!
//! `sets` and `exclude_sets` reference Feature-owned shared definitions;
//! `namespaces` and `exclude_namespaces` reference exact keys in their declared
//! Namespace. This source does not redeclare Sets, infer children from dot
//! prefixes, attach payloads/arguments to keys or issue provider capabilities.
//! Multi-segment keys remain exact. Only catalog-declared children expand them.
//! Unknown references are rejected even in revoked assignments, so reactivation
//! cannot conceal invalid configuration.
use std::collections::{BTreeMap, BTreeSet};
use config_source::{ConfigSchemaContribution, EvaluationResult};
use feature_permissions::{
NamespaceId, NamespacePermissions, PermissionAssignment, PermissionDiagnostic, PermissionHost,
PermissionKey, PermissionReference, PermissionSetId,
};
use serde::{Deserialize, Serialize};
use server_api::authorization::{ScopedPermission, SubjectPermissions};
use crate::config_source::WorkspaceConfigSchemaProvider;
use crate::{Error, Result};
const SCHEMA: &str = r#"{
permissions = {
subjects = [...{
subject_id = String;
execution = {
sets = [...String] default [];
namespaces = [...{ namespace = String; keys = [...String]; }] default [];
exclude_sets = [...String] default [];
exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default [];
revoked = Bool default false;
} default {};
delegation = {
sets = [...String] default [];
namespaces = [...{ namespace = String; keys = [...String]; }] default [];
exclude_sets = [...String] default [];
exclude_namespaces = [...{ namespace = String; keys = [...String]; }] default [];
revoked = Bool default false;
} default {};
}] default [];
remove_subjects = [...String] default [];
};
}"#;
#[derive(Debug, Default)]
pub struct PermissionConfigSchemaProvider;
impl WorkspaceConfigSchemaProvider for PermissionConfigSchemaProvider {
fn contribution(&self) -> Result<ConfigSchemaContribution> {
ConfigSchemaContribution::new("builtin:permissions", "permissions", "1", SCHEMA)
.map_err(|e| Error::Config(e.to_string()))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AssignmentSource {
#[serde(default)]
pub sets: Vec<PermissionSetId>,
#[serde(default)]
pub namespaces: Vec<NamespacePermissions>,
#[serde(default)]
pub exclude_sets: Vec<PermissionSetId>,
#[serde(default)]
pub exclude_namespaces: Vec<NamespacePermissions>,
#[serde(default)]
pub revoked: bool,
}
impl AssignmentSource {
pub fn assignment(&self) -> PermissionAssignment {
let references = |sets: &[PermissionSetId], namespaces: &[NamespacePermissions]| {
sets.iter()
.cloned()
.map(PermissionReference::Set)
.chain(
namespaces
.iter()
.cloned()
.map(PermissionReference::Namespace),
)
.collect()
};
PermissionAssignment {
grants: references(&self.sets, &self.namespaces),
exclusions: references(&self.exclude_sets, &self.exclude_namespaces),
revoked: self.revoked,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubjectAssignmentSource {
pub subject_id: String,
#[serde(default)]
pub execution: AssignmentSource,
#[serde(default)]
pub delegation: AssignmentSource,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PermissionSource {
#[serde(default)]
pub subjects: Vec<SubjectAssignmentSource>,
#[serde(default)]
pub remove_subjects: Vec<String>,
}
/// Retained exact legacy restrictions belong to the domain, not the editable source.
/// Keeping the original change identity preserves the distinction between live and
/// already stale child grants during representation-only migration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AdoptedSubjectPolicy {
pub source: SubjectAssignmentSource,
pub change_id: String,
pub legacy_constraints: Option<SubjectPermissions>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
pub enum MigrationDiagnostic {
Converted {
subject_id: String,
operation: String,
},
DomainConstraint {
subject_id: String,
permission: ScopedPermission,
},
ExplicitConfigurationRequired {
subject_id: String,
permission: ScopedPermission,
},
}
pub fn source(evaluation: &EvaluationResult) -> Result<PermissionSource> {
let value = evaluation
.projections
.first()
.and_then(|p| p.data_json.get("permissions"))
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
serde_json::from_value(value)
.map_err(|e| Error::InvalidInput(format!("invalid Subject assignments: {e}")))
}
fn validate_assignment(host: &PermissionHost, source: &AssignmentSource) -> Result<()> {
// Validate even revoked assignments. A revocation must not hide a misspelled
// reference which would become active on a later edit.
let mut assignment = source.assignment();
assignment.revoked = false;
let resolution = host.resolve(Some(&assignment));
if resolution.projection().diagnostics.iter().any(|d| {
matches!(
d,
PermissionDiagnostic::UnknownKey(_)
| PermissionDiagnostic::UnknownNamespace(_)
| PermissionDiagnostic::UnknownSet(_)
)
}) {
return Err(Error::InvalidInput(format!(
"invalid permission references: {:?}",
resolution.projection().diagnostics
)));
}
Ok(())
}
pub fn validate_source(
host: &PermissionHost,
source: &PermissionSource,
mut require_subject: impl FnMut(&str) -> Result<()>,
) -> Result<()> {
let mut subjects = BTreeSet::new();
for item in &source.subjects {
server_api::authorization::validate_authorization_identifier(&item.subject_id)
.map_err(Error::InvalidInput)?;
if !subjects.insert(&item.subject_id) {
return Err(Error::InvalidInput("duplicate Subject assignment".into()));
}
require_subject(&item.subject_id)?;
validate_assignment(host, &item.execution)?;
validate_assignment(host, &item.delegation)?;
}
for subject in &source.remove_subjects {
server_api::authorization::validate_authorization_identifier(subject)
.map_err(Error::InvalidInput)?;
if !subjects.insert(subject) {
return Err(Error::InvalidInput(
"Subject both assigned and removed, or removed twice".into(),
));
}
require_subject(subject)?;
}
Ok(())
}
/// A patch deliberately does not replace the entire policy map. In particular a
/// schema default empty list never deletes omitted policies or reinstates defaults.
pub fn apply(
policies: &mut BTreeMap<String, AdoptedSubjectPolicy>,
source: &PermissionSource,
change_id: &str,
) {
for subject in &source.remove_subjects {
policies.remove(subject);
}
for item in &source.subjects {
match policies.get_mut(&item.subject_id) {
Some(previous) if previous.source == *item => {}
Some(previous) => {
previous.source = item.clone();
previous.change_id = change_id.to_owned();
}
None => {
policies.insert(
item.subject_id.clone(),
AdoptedSubjectPolicy {
source: item.clone(),
change_id: change_id.to_owned(),
legacy_constraints: None,
},
);
}
}
}
}
/// Convert exact legacy operations without expanding explicit children. Restrictions
/// which cannot be represented by the owning domain must be diagnosed before seal.
/// This routine does not issue a grant or install a policy.
pub fn migrate_subject(
host: &PermissionHost,
subject_id: &str,
legacy: &SubjectPermissions,
) -> Result<(AdoptedSubjectPolicy, Vec<MigrationDiagnostic>)> {
let mut diagnostics = vec![];
let mut lane = |permissions: &[ScopedPermission]| -> Result<AssignmentSource> {
let mut by_namespace: BTreeMap<NamespaceId, BTreeSet<PermissionKey>> = BTreeMap::new();
for permission in permissions {
let operation =
server_api::authorization::canonical_worker_operation(&permission.operation)
.unwrap_or(&permission.operation);
let Ok(key) = PermissionKey::parse(operation.to_owned()) else {
diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired {
subject_id: subject_id.into(),
permission: permission.clone(),
});
continue;
};
let assignment = PermissionAssignment {
grants: vec![PermissionReference::Namespace(NamespacePermissions {
namespace: key.namespace(),
keys: vec![key.clone()],
})],
exclusions: vec![],
revoked: false,
};
let resolution = host.resolve(Some(&assignment));
let exact = resolution.projection().diagnostics.is_empty()
&& resolution.projection().allowed.len() == 1
&& resolution.projection().allowed.contains_key(&key);
if !exact {
diagnostics.push(MigrationDiagnostic::ExplicitConfigurationRequired {
subject_id: subject_id.into(),
permission: permission.clone(),
});
continue;
}
by_namespace.entry(key.namespace()).or_default().insert(key);
diagnostics.push(MigrationDiagnostic::DomainConstraint {
subject_id: subject_id.into(),
permission: permission.clone(),
});
}
Ok(AssignmentSource {
namespaces: by_namespace
.into_iter()
.map(|(namespace, keys)| NamespacePermissions {
namespace,
keys: keys.into_iter().collect(),
})
.collect(),
..Default::default()
})
};
let execution = lane(&legacy.execution)?;
let delegation = lane(&legacy.delegation)?;
Ok((
AdoptedSubjectPolicy {
source: SubjectAssignmentSource {
subject_id: subject_id.into(),
execution,
delegation,
},
change_id: legacy.change_id.clone(),
legacy_constraints: Some(legacy.clone()),
},
diagnostics,
))
}
impl AdoptedSubjectPolicy {
/// Read bridge for the old scoped-operation consumer. New payload-free
/// consumers resolve `source` through the current Feature Host and also enforce
/// retained domain constraints. This bridge cannot turn a key into an all-target
/// legacy resource: an operation without a representable constraint is unavailable.
pub fn legacy_read(&self, host: &PermissionHost) -> Result<SubjectPermissions> {
let execution = host.resolve(Some(&self.source.execution.assignment()));
let delegation = host.resolve(Some(&self.source.delegation.assignment()));
let filter = |allowed: &feature_permissions::PermissionResolution,
constraints: &[ScopedPermission]|
-> Result<Vec<ScopedPermission>> {
let mut represented = BTreeSet::new();
let mut output = vec![];
for permission in constraints {
let operation =
server_api::authorization::canonical_worker_operation(&permission.operation)
.unwrap_or(&permission.operation);
if let Ok(key) = PermissionKey::parse(operation.to_owned()) {
if allowed.allowed.contains_key(&key) {
represented.insert(key);
output.push(permission.clone());
}
}
}
if allowed.allowed.keys().any(|key| !represented.contains(key)) {
return Err(Error::InvalidInput("current key policy requires the payload-free operation consumer; no legacy all-target fallback is available".into()));
}
Ok(output)
};
let empty = SubjectPermissions {
change_id: self.change_id.clone(),
execution: vec![],
delegation: vec![],
};
let retained = self.legacy_constraints.as_ref().unwrap_or(&empty);
Ok(SubjectPermissions {
change_id: self.change_id.clone(),
execution: filter(execution.projection(), &retained.execution)?,
delegation: filter(delegation.projection(), &retained.delegation)?,
})
}
}
@@ -189,24 +189,65 @@ fn project_repository_access_evaluation(
projection_digest: &str,
evaluation: &config_source::EvaluationResult,
) -> Result<RepositoryAccessProjection> {
let projection = evaluation.projections.first().ok_or_else(|| {
Error::InvalidInput("Workspace config produced no active projection".to_string())
})?;
let config: VirtualWorkspaceConfig = serde_json::from_value(projection.data_json.clone())
.map_err(|error| {
Error::InvalidInput(format!("invalid Repository access config: {error}"))
})?;
let config = repository_access_source(evaluation)?;
if !config.repository_access.is_empty() {
secrets.ensure_workspace_default_credential(workspace_id)?;
}
project_repository_access_bindings(
workspace_id,
projection_digest,
config,
|key| store.get_repository_by_key(workspace_id, key),
|id| secrets.get_credential(workspace_id, id, &[]),
|id| secrets.get_host_trust(workspace_id, id, &[]),
)
}
/// Validate against the same catalog transaction that will adopt the source.
/// This reads only public credential/trust metadata: no default key generation,
/// unsealing, filesystem writes or second connection under the writer lock.
/// Both preview and adoption use the owning projection's semantic checks.
pub(crate) fn validate_repository_access_in_transaction(
tx: &rusqlite::Transaction<'_>,
workspace_id: &str,
candidate: &EvaluatedConfigCandidate,
) -> Result<()> {
project_repository_access_bindings(
workspace_id,
&candidate.evaluation.projection_digest,
repository_access_source(&candidate.evaluation)?,
|key| crate::store::read_repository_by_key(tx, workspace_id, key),
|id| read_credential(tx, workspace_id, id),
|id| read_host_trust(tx, workspace_id, id),
)
.map(|_| ())
}
fn repository_access_source(
evaluation: &config_source::EvaluationResult,
) -> Result<VirtualWorkspaceConfig> {
let projection = evaluation.projections.first().ok_or_else(|| {
Error::InvalidInput("Workspace config produced no active projection".to_string())
})?;
serde_json::from_value(projection.data_json.clone())
.map_err(|error| Error::InvalidInput(format!("invalid Repository access config: {error}")))
}
fn project_repository_access_bindings(
workspace_id: &str,
projection_digest: &str,
config: VirtualWorkspaceConfig,
repository: impl Fn(&str) -> Result<Option<crate::store::RepositoryRecord>>,
credential: impl Fn(&str) -> Result<Option<RepositorySshCredential>>,
host_trust: impl Fn(&str) -> Result<Option<RepositorySshHostTrust>>,
) -> Result<RepositoryAccessProjection> {
let mut bindings = Vec::with_capacity(config.repository_access.len());
for (repository_key, access) in config.repository_access {
server_api::validate_repository_key(&repository_key)
.map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?;
validate_identifier("credential_id", &access.ssh.credential)?;
validate_identifier("host_trust_id", &access.ssh.host_trust)?;
let repository = store
.get_repository_by_key(workspace_id, &repository_key)?
let repository = repository(&repository_key)?
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
validate_repository_access_source(&repository_key, &repository.source)?;
if repository.source.kind != server_api::RepositorySourceKind::Ssh {
@@ -214,28 +255,24 @@ fn project_repository_access_evaluation(
"Repository `{repository_key}` is not an ssh:// Repository"
)));
}
let credential = secrets
.get_credential(workspace_id, &access.ssh.credential, &[])?
.ok_or_else(|| {
Error::InvalidInput(format!(
"unknown Repository SSH credential `{}`",
access.ssh.credential
))
})?;
let credential = credential(&access.ssh.credential)?.ok_or_else(|| {
Error::InvalidInput(format!(
"unknown Repository SSH credential `{}`",
access.ssh.credential
))
})?;
if credential.status != "active" {
return Err(Error::InvalidInput(format!(
"Repository SSH credential `{}` is not active",
access.ssh.credential
)));
}
let host_trust = secrets
.get_host_trust(workspace_id, &access.ssh.host_trust, &[])?
.ok_or_else(|| {
Error::InvalidInput(format!(
"unknown Repository SSH host trust `{}`",
access.ssh.host_trust
))
})?;
let host_trust = host_trust(&access.ssh.host_trust)?.ok_or_else(|| {
Error::InvalidInput(format!(
"unknown Repository SSH host trust `{}`",
access.ssh.host_trust
))
})?;
let (hostname, port) =
repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
|| {
+85 -41
View File
@@ -60,6 +60,8 @@ mod plugin_host;
#[path = "server_plugin_runtime.rs"]
mod plugin_runtime;
pub use plugin_delivery::InProcessPluginArtifactSource;
#[path = "server_configuration_adoption.rs"]
mod configuration_adoption;
#[path = "server_workspace_config.rs"]
mod workspace_config;
use std::collections::{BTreeMap, HashMap, HashSet};
@@ -3530,6 +3532,8 @@ fn is_workspace_config_editor_path(path: &str) -> bool {
};
resource == "config/source-tree"
|| resource.starts_with("config/source-tree/")
|| resource == "config/adoption"
|| resource.starts_with("config/adoption/")
|| resource == "settings/profiles"
|| resource.starts_with("settings/profiles/")
}
@@ -4106,7 +4110,8 @@ fn workspace_server_router(api: WorkspaceServerApi) -> Router {
.merge(generated_auth_contract_router(contract_service.clone()))
.merge(generated_workspace_catalog_contract_router(
contract_service,
));
))
.merge(configuration_adoption::routes(api.clone()));
catalog.layer(axum::middleware::from_fn_with_state(
api,
enforce_server_cookie_mutation_origin,
@@ -4636,6 +4641,9 @@ impl WorkspaceApi {
crate::profile_settings::ProfileConfigSchemaProvider,
))
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider))
.with_provider(Arc::new(
crate::policy_config::PermissionConfigSchemaProvider,
))
.with_provider(Arc::new(RuntimeConfigSchemaProvider))
.with_provider(Arc::new(RepositoryAccessConfigSchemaProvider))
.with_provider(Arc::new(skills::SkillConfigSchemaProvider))
@@ -13477,7 +13485,9 @@ impl server_api::ServerApi for ServerApiContractService {
&worker,
&source.authentication_binding_id,
)?;
recover_ticket_notifications(&api);
// The receipt certifies result/source commit, not notification acceptance.
// Do not make it wait for unrelated pending deliveries or their Runtime ACKs.
drop(enqueue_ticket_notification_recovery(&api));
Ok(receipt)
})();
result
@@ -16486,22 +16496,13 @@ async fn scoped_get_workspace_config_entry(
}
async fn scoped_commit_workspace_config_tree(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<server_api::ConfigCommitRequest>,
State(_api): State<WorkspaceApi>,
AxumPath(_path): AxumPath<ScopedWorkspacePath>,
Json(_request): Json<server_api::ConfigCommitRequest>,
) -> ApiResult<(StatusCode, Json<server_api::WorkspaceConfigTreeResponse>)> {
api.host_executors
.host()?
.run(move || {
let request = config_commit_request_from_api(request)?;
validate_workspace_scope(&api, &path.workspace_id)?;
let state = commit_workspace_config_tree(&api, &path.workspace_id, &request)?;
Ok((
StatusCode::CREATED,
Json(workspace_config_state_to_api(state)),
))
})
.await
Err(Error::WorkspaceConfigConflict(
"source-tree commits lack an editing head and request identity; use /config/adoption to preview, adopt and reconcile the owner request".into(),
).into())
}
/// Shared canonical commit and activation boundary for UI and logical config operations.
@@ -17571,7 +17572,7 @@ async fn apply_set_ticket_assignment(
// same best-effort notification completion as before offload.
Ok::<_, Error>((
assignment,
enqueue_orchestrator_assignment_attention(&notification_api),
enqueue_ticket_notification_recovery(&notification_api),
))
})
.await?
@@ -23503,7 +23504,7 @@ fn maybe_dispatch_orchestrator_turn_end(
});
}
fn enqueue_orchestrator_assignment_attention(
fn enqueue_ticket_notification_recovery(
api: &WorkspaceApi,
) -> Option<tokio::sync::oneshot::Receiver<()>> {
let api = api.clone();
@@ -23516,7 +23517,7 @@ fn enqueue_orchestrator_assignment_attention(
}) {
Ok(()) => Some(completion),
Err(error) => {
tracing::warn!(%error, "Assign notification was not admitted; input remains durable");
tracing::warn!(%error, "Ticket notification recovery was not admitted; source remains durable");
None
}
}
@@ -24198,11 +24199,27 @@ where
executor.run(move || runtime.block_on(future)).await
}
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
fn open_legacy_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|error| ApiError::from(Error::Store(error.to_string())))
}
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
let host = worker::feature::permissions::builtin_permission_host(
feature_permissions::WorkspaceId::parse(api.config.workspace_id.clone())
.map_err(|error| Error::InvalidInput(error.to_string()))?,
)
.map_err(|error| Error::Config(error.to_string()))?;
Ok(
open_legacy_subjektiv_store(api)?.with_canonical_permission_source(Arc::new(
crate::config_adoption::CanonicalConfigurationSource {
store: api.config_store.as_ref().clone(),
host,
},
)),
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubjektivWorkerAuthority {
Subject,
@@ -40388,6 +40405,7 @@ mod tests {
mod workspace_admission_tests;
mod workspace_protocol_backpressure_tests;
include!("server_workspace_config_tests.rs");
include!("server_configuration_adoption_tests.rs");
use super::*;
use axum::body::{Body, to_bytes};
use axum::http::Request;
@@ -71977,33 +71995,58 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
#[tokio::test]
async fn generated_config_source_routes_preserve_nested_entry_paths() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await;
let api = test_api(dir.path()).await;
let owner =
seed_test_workspace_owner_token(api.store.as_ref(), "nested-config-owner").await;
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api));
let app = workspace_server_router(catalog);
let source_tree_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
let initial = get_json(app.clone(), &source_tree_path).await;
let digest = initial["snapshot"]["digest"].as_str().unwrap();
let committed = request_json(
let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
let initial = get_json_authenticated(app.clone(), &adoption_path, &owner).await;
let request = json!({
"operation_id": "nested-config-adoption", "expected_head": initial["history_head"],
"expected_catalog_digest": initial["catalog_digest"], "expected_legacy_export": initial["expected_legacy_export"],
"source": {
"base_digest": initial["state"]["snapshot"]["digest"],
"changes": [{ "kind": "create", "path": "notes/readme.txt", "content_type": "text", "content": "nested entry" }],
"entrypoints": ["main.dcdl"]
}
});
let preview = request_json_authenticated(
app.clone(),
"POST",
&format!("{source_tree_path}/commit"),
Some(json!({
"base_digest": digest,
"changes": [{
"kind": "create",
"path": "notes/readme.txt",
"content_type": "text",
"content": "nested entry"
}],
"entrypoints": ["main.dcdl"]
})),
StatusCode::CREATED,
&format!("{adoption_path}/preview"),
Some(request.clone()),
&owner,
StatusCode::OK,
)
.await;
let committed_digest = committed["snapshot"]["digest"].as_str().unwrap();
let committed = request_json_authenticated(
app.clone(),
"POST",
&format!("{adoption_path}/commit"),
Some(request),
&owner,
StatusCode::OK,
)
.await;
assert_eq!(committed["state"], preview["state"]);
let committed_digest = committed["state"]["snapshot"]["digest"].as_str().unwrap();
let config_snapshot = get_json(
let config_snapshot = get_json_authenticated(
app.clone(),
&format!("{source_tree_path}/history/{committed_digest}"),
&owner,
)
.await;
assert_eq!(config_snapshot["digest"], committed_digest);
@@ -72012,9 +72055,10 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
"nested entry"
);
let entry = get_json(
let entry = get_json_authenticated(
app,
&format!("{source_tree_path}/entries/notes%2Freadme.txt"),
&owner,
)
.await;
assert_eq!(entry["path"], "notes/readme.txt");
@@ -47,33 +47,63 @@ fn source(value: &str) -> String {
async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let app = build_router(api.clone());
let token = seed_test_workspace_owner_token(api.store.as_ref(), "builtin-profile-owner").await;
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
let app = workspace_server_router(catalog);
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
let commit = format!("{tree}/commit");
let initial = get_json_authenticated(app.clone(), &tree, &token).await;
let saved = request_json_authenticated(
let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
let commit = format!("{adoption_path}/commit");
let current = get_json_authenticated(app.clone(), &adoption_path, &token).await;
let initial = current["state"].clone();
let adoption = |current: &Value, id: &str, source: Value| {
json!({
"operation_id": id, "expected_head": current["history_head"],
"expected_catalog_digest": current["catalog_digest"], "expected_legacy_export": current["expected_legacy_export"],
"source": source,
})
};
let source_request = json!({
"base_digest": current["state"]["snapshot"]["digest"],
"entrypoints": current["state"]["contract"]["entrypoints"],
"changes": [
{ "kind": "update", "path": "main.dcdl",
"expected_digest": current["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"],
"content": source(r#"import "$builtin/profiles/companion.dcdl""#) },
// A Workspace file at the matching suffix must not rescue an unknown builtin.
{ "kind": "create", "path": "profiles/private.dcdl", "content_type": "decodal", "content": PROFILE }
],
});
let save = adoption(&current, "builtin-profile-adopt", source_request);
let preview = request_json_authenticated(
app.clone(),
"POST",
&format!("{adoption_path}/preview"),
Some(save.clone()),
&token,
StatusCode::OK,
)
.await;
let receipt = request_json_authenticated(
app.clone(),
"POST",
&commit,
Some(
serde_json::to_value(request(
&api,
&source(r#"import "$builtin/profiles/companion.dcdl""#),
vec![config_source::ConfigTreeChange::Create {
// An ordinary Workspace file at the matching suffix must not rescue
// an unknown builtin import.
path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
}],
))
.unwrap(),
),
Some(save),
&token,
StatusCode::CREATED,
StatusCode::OK,
)
.await;
assert_eq!(receipt["state"], preview["state"]);
let saved = receipt["state"].clone();
assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]);
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
assert_eq!(listed, saved);
@@ -107,22 +137,23 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
"base_digest": listed["snapshot"]["digest"],
"entrypoints": listed["contract"]["entrypoints"],
});
let current = get_json_authenticated(app.clone(), &adoption_path, &token).await;
let reserved = "$builtin/profiles/companion.dcdl";
for change in [
for (index, change) in [
json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }),
json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }),
json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }),
json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }),
json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }),
json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }),
] {
].into_iter().enumerate() {
let mut body = base.clone();
body["changes"] = json!([change]);
let error = request_json_authenticated(
app.clone(),
"POST",
&commit,
Some(body),
Some(adoption(&current, &format!("reserved-builtin-{index}"), body)),
&token,
StatusCode::BAD_REQUEST,
)
@@ -148,7 +179,7 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
app.clone(),
"POST",
&commit,
Some(body),
Some(adoption(&current, "unknown-builtin-import", body)),
&token,
StatusCode::BAD_REQUEST,
)
@@ -0,0 +1,541 @@
//! Owner-only HTTP boundary for source adoption and the one-way legacy cutover.
//!
//! Every preview and adoption acquires the native Feature authority guard before
//! entering the Server transaction. Subject references are checked against current
//! metadata/deletion fences under its IMMEDIATE transaction, not a preloaded set.
//! Preview and canonical saves never seal: their nonmutating Feature transaction
//! remains held through the Server operation and rolls back when the guard drops.
//!
//! Only migration adoption seals. Seal commits Feature evidence, not Server
//! adoption; the outer connection/lifecycle guard remains held until Server commit
//! or rollback, and no Feature access follows the seal. Legacy logical writers
//! must use that same domain guard and require_unsealed. IMMEDIATE serializes gate
//! users across connections/processes; after seal releases the SQLite write lock,
//! the durable seal rejects legacy writers. This is not a distributed commit:
//! Server failure can leave a pending transfer requiring the exact original retry.
//! Runtime operations must still check current policy and Subject lifecycle.
//! Callbacks must not reenter the Feature store or its canonical source provider.
use super::*;
use crate::config_adoption::{
ConfigurationAdoptionRequest as AdoptionRequest, ConfigurationAdoptionResult as AdoptionResult,
ConfigurationCurrent, ConfigurationMigrationBaseline, ConfigurationPolicyTransfer,
request_fingerprint,
};
use crate::configuration_migration::{MigrationSeed, preview_migration};
use crate::policy_config::{AdoptedSubjectPolicy, MigrationDiagnostic};
use crate::subjektiv::{
DatabaseIdentity, PermissionTransferReceipt, SubjektivError, SubjektivStore,
};
use feature_permissions::PermissionHost;
use server_api::{
ConfigurationAdoptionApi, ConfigurationAdoptionReceipt, ConfigurationAdoptionRequest,
ConfigurationMigrationDiagnostic, ConfigurationPreviewResponse, ConfigurationReconciliation,
ConfigurationSourceResponse, ConfigurationSubjectPolicy, RepositoryApiError,
RuntimePolicyApplication, ServerRequestContext,
};
/// Mount on the Server router, under its cookie-mutation-origin middleware.
/// These routes own admission because they bypass Workspace fallback dispatch.
pub(super) fn routes(service: WorkspaceServerApi) -> Router {
let context_service = ServerApiContractService::Server(service.clone());
let service = Arc::new(service);
Router::new()
.merge(server_api::configuration_adoption_api_axum::configuration_source(service.clone()))
.merge(server_api::configuration_adoption_api_axum::configuration_preview(service.clone()))
.merge(server_api::configuration_adoption_api_axum::configuration_adopt(service.clone()))
.merge(server_api::configuration_adoption_api_axum::configuration_reconcile(service))
.layer(middleware::from_fn_with_state(
context_service,
attach_server_request_context,
))
}
async fn owner_operation<T: Send + 'static>(
service: &WorkspaceServerApi,
context: ServerRequestContext,
workspace: String,
operation: impl FnOnce(WorkspaceApi, String) -> Result<T> + Send + 'static,
) -> std::result::Result<T, RepositoryApiError> {
// These owner-only routes are mounted above Workspace fallback dispatch.
// A Runtime proof must not be ignored in favor of a shared human credential.
if context.transport_headers.iter().any(|(name, _)| {
name.eq_ignore_ascii_case(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
}) {
return Err(ApiError::from(Error::WorkspacePermissionDenied(
"Runtime source proofs cannot authorize owner configuration management".into(),
))
.into_repository_api_error());
}
let actor = subject_authorization::administration_actor(&context)
.map_err(ApiError::into_repository_api_error)?
.clone();
let result: ApiResult<T> = async {
let gate = service.admission_for_workspace(&workspace).await;
let lease = gate.admit(false).ok_or_else(|| {
Error::WorkspaceConfigConflict("Workspace deletion is draining requests".into())
})?;
crate::db_executor::with_operation_lifetime(Arc::new(lease), async {
// Authenticate against catalog ownership before resolving an API:
// API construction itself can load configuration and Feature stores.
let record = service
.store
.get_workspace(&workspace)
.await?
.ok_or(Error::WorkspaceIdMismatch)?;
if record.owner_account_id != actor.account_id {
return Err(Error::WorkspacePermissionDenied(
"configuration management requires the Workspace owner".into(),
)
.into());
}
if record.state != "active" {
return Err(
Error::WorkspaceConfigConflict("Workspace is not active".into()).into(),
);
}
let api = service
.api_for_workspace(&workspace)
.await?
.ok_or(Error::WorkspaceIdMismatch)?;
require_workspace_owner(&api, &workspace, &actor, "Configuration adoption").await?;
// The admitted lifetime survives HTTP cancellation. The synchronous
// DB job owns native Feature guards through Server commit/rollback.
service
.store
.db_executor()
.run(move || operation(api, actor.account_id))
.await
.map_err(ApiError::from)
})
.await
}
.await;
result.map_err(ApiError::into_repository_api_error)
}
fn conflict(message: &str) -> Error {
Error::WorkspaceConfigConflict(message.into())
}
fn request_from_api(request: ConfigurationAdoptionRequest) -> Result<AdoptionRequest> {
server_api::authorization::validate_authorization_identifier(&request.operation_id)
.map_err(Error::InvalidInput)?;
Ok(AdoptionRequest {
operation_id: request.operation_id,
expected_head: request.expected_head,
expected_catalog_digest: request.expected_catalog_digest,
expected_legacy_export: request.expected_legacy_export,
source: config_commit_request_from_api(request.source)?,
})
}
fn host(workspace: &str) -> Result<PermissionHost> {
let workspace =
feature_permissions::WorkspaceId::parse(workspace).map_err(Error::InvalidInput)?;
worker::feature::permissions::builtin_permission_host(workspace)
.map_err(|error| Error::Config(error.to_string()))
}
fn raw_legacy(api: &WorkspaceApi) -> Result<SubjektivStore> {
// Intentionally do not call open_subjektiv_store: the normal helper connects
// the canonical provider. Transfer preparation must retain the raw guard.
open_legacy_subjektiv_store(api).map_err(|error| error.error)
}
fn transfer_error(error: SubjektivError) -> Error {
match error {
SubjektivError::PermissionTransferConflict | SubjektivError::PermissionAuthoritySealed => {
conflict("legacy permission transfer changed or is sealed for another request")
}
error => subjektiv_store_error(error),
}
}
fn source_snapshot(api: &WorkspaceApi, account: &str) -> Result<ConfigurationCurrent> {
api.config_store
.configuration_current(&api.config.workspace_id, account)
}
fn saved_result(
api: &WorkspaceApi,
account: &str,
request: &AdoptionRequest,
) -> Result<Option<AdoptionResult>> {
// Core authenticates the current owner and compares the complete request.
api.config_store
.configuration_request_result(&api.config.workspace_id, account, request)
}
fn pending_transfer(
legacy: &SubjektivStore,
workspace: &str,
account: &str,
request: &AdoptionRequest,
) -> Result<Option<PermissionTransferReceipt>> {
let identity = legacy.database_identity().map_err(transfer_error)?;
if identity.scope_id != workspace || identity.feature_id != subjektiv::SUBJEKTIV_FEATURE_ID {
return Err(Error::WorkspaceIdMismatch);
}
// Read-only guarded lookup verifies native identity; lookup must never seal.
let receipt = legacy
.permission_transfer_receipt(&identity)
.map_err(transfer_error)?;
if let Some(receipt) = &receipt {
if receipt.snapshot.identity != identity
|| receipt.transfer_id != request_fingerprint(workspace, account, request)?
|| request.expected_legacy_export.as_deref()
!= Some(receipt.snapshot.export_digest.as_str())
{
return Err(conflict(
"sealed transfer belongs to another adoption request",
));
}
}
Ok(receipt)
}
fn baseline(seed: &MigrationSeed) -> ConfigurationMigrationBaseline<'_> {
ConfigurationMigrationBaseline {
original: &seed.original,
state: &seed.state,
policies: &seed.policies,
diagnostics: &seed.diagnostics,
export_digest: &seed.snapshot.export_digest,
}
}
struct Preparation {
host: PermissionHost,
schema: ::config_source::WorkspaceConfigSchemaBundle,
legacy: SubjektivStore,
identity: DatabaseIdentity,
seed: Option<MigrationSeed>,
}
fn prepare(api: &WorkspaceApi, account: &str, request: &AdoptionRequest) -> Result<Preparation> {
let current = source_snapshot(api, account)?;
if current.canonical && request.expected_legacy_export.is_some() {
return Err(conflict(
"legacy transfer is already adopted; reload current source",
));
}
let host = host(&api.config.workspace_id)?;
let schema = api.config_schema_registry.compose()?;
let legacy = raw_legacy(api)?;
let sealed = if current.canonical {
None
} else {
pending_transfer(&legacy, &api.config.workspace_id, account, request)?
};
let seed = if current.canonical {
None
} else {
let seed = preview_migration(
&api.config_store,
&legacy,
&api.config.workspace_id,
schema.clone(),
&host,
)?;
if sealed
.as_ref()
.is_some_and(|receipt| receipt.snapshot != seed.snapshot)
{
return Err(conflict(
"sealed transfer differs from the evaluated migration snapshot",
));
}
Some(seed)
};
// Observe identity outside the guarded Server operation. Inside the native
// gate, use only authority methods; reopening this store would reenter it.
let identity = match &seed {
Some(seed) => seed.snapshot.identity.clone(),
None => legacy.database_identity().map_err(transfer_error)?,
};
Ok(Preparation {
host,
schema,
legacy,
identity,
seed,
})
}
fn policy_to_api(policy: AdoptedSubjectPolicy) -> ConfigurationSubjectPolicy {
ConfigurationSubjectPolicy {
subject_id: policy.source.subject_id,
change_id: policy.change_id,
execution: policy.source.execution.assignment(),
delegation: policy.source.delegation.assignment(),
legacy_constraints: policy.legacy_constraints,
}
}
fn diagnostic_to_api(diagnostic: MigrationDiagnostic) -> ConfigurationMigrationDiagnostic {
match diagnostic {
MigrationDiagnostic::Converted {
subject_id,
operation,
} => ConfigurationMigrationDiagnostic::Converted {
subject_id,
operation,
},
MigrationDiagnostic::DomainConstraint {
subject_id,
permission,
} => ConfigurationMigrationDiagnostic::DomainConstraint {
subject_id,
permission,
},
MigrationDiagnostic::ExplicitConfigurationRequired {
subject_id,
permission,
} => ConfigurationMigrationDiagnostic::ExplicitConfigurationRequired {
subject_id,
permission,
},
}
}
fn receipt_to_api(result: AdoptionResult) -> ConfigurationAdoptionReceipt {
ConfigurationAdoptionReceipt {
workspace_id: result.workspace_id,
operation_id: result.operation_id,
history_head: result.history_head,
state: workspace_config_state_to_api(result.state),
source_stored: result.source_stored,
policy_committed: result.policy_committed,
runtime_application: match result.runtime_application {
crate::config_adoption::RuntimePolicyApplication::NextCurrentPolicyCheck => {
RuntimePolicyApplication::NextCurrentPolicyCheck
}
},
}
}
impl ConfigurationAdoptionApi for WorkspaceServerApi {
async fn configuration_source(
&self,
context: ServerRequestContext,
workspace_id: String,
) -> std::result::Result<ConfigurationSourceResponse, RepositoryApiError> {
owner_operation(self, context, workspace_id, |api, account| {
let current = source_snapshot(&api, &account)?;
let host = host(&api.config.workspace_id)?;
let catalog_digest = crate::config_adoption::catalog_digest(&host)?;
let (state, expected_legacy_export, policies, diagnostics) = if current.canonical {
(current.state, None, current.policies, vec![])
} else {
let legacy = raw_legacy(&api)?;
let seed = preview_migration(
&api.config_store,
&legacy,
&api.config.workspace_id,
api.config_schema_registry.compose()?,
&host,
)?;
let observed = source_snapshot(&api, &account)?;
if observed.canonical
|| observed.history_head != current.history_head
|| observed.state != seed.original
{
return Err(conflict(
"configuration changed during migration preview; reload source",
));
}
(
seed.state,
Some(seed.snapshot.export_digest),
seed.policies,
seed.diagnostics,
)
};
Ok(ConfigurationSourceResponse {
state: workspace_config_state_to_api(state),
history_head: current.history_head,
source_stored: current.canonical,
catalog: host.catalog(),
catalog_digest,
expected_legacy_export,
policies: policies.into_values().map(policy_to_api).collect(),
migration_diagnostics: diagnostics.into_iter().map(diagnostic_to_api).collect(),
})
})
.await
}
async fn configuration_preview(
&self,
context: ServerRequestContext,
workspace_id: String,
request: ConfigurationAdoptionRequest,
) -> std::result::Result<ConfigurationPreviewResponse, RepositoryApiError> {
owner_operation(self, context, workspace_id, move |api, account| {
let request = request_from_api(request)?;
// Detect operation-ID reuse even though preview itself never persists.
saved_result(&api, &account, &request)?;
let Preparation {
host,
schema,
legacy,
identity,
seed,
} = prepare(&api, &account, &request)?;
let baseline = seed.as_ref().map(baseline);
// Preview never seals. The native read-only IMMEDIATE transaction
// fences metadata updates through core validation, then rolls back.
let preview = legacy
.with_permission_authority_guard(&identity, |authority| {
Ok(api.config_store.preview_configuration_adoption(
&api.config.workspace_id,
&account,
&request,
schema,
&host,
baseline.as_ref(),
|subject| authority.require_subject(subject).map_err(transfer_error),
))
})
.map_err(transfer_error)??;
Ok(ConfigurationPreviewResponse {
expected_head: preview.expected_head,
state: workspace_config_state_to_api(preview.state),
changed_paths: preview.changed_paths,
changed_subjects: preview.changed_subjects,
})
})
.await
}
async fn configuration_adopt(
&self,
context: ServerRequestContext,
workspace_id: String,
request: ConfigurationAdoptionRequest,
) -> std::result::Result<ConfigurationAdoptionReceipt, RepositoryApiError> {
owner_operation(self, context, workspace_id, move |api, account| {
let request = request_from_api(request)?;
if let Some(result) = saved_result(&api, &account, &request)? {
return Ok(receipt_to_api(result));
}
let Preparation {
host,
schema,
legacy,
identity,
seed,
} = prepare(&api, &account, &request)?;
let baseline = seed.as_ref().map(baseline);
// Feature connection + IMMEDIATE transaction always precede Server.
// Never call store-level Feature APIs from inside this closure.
let result = legacy
.with_permission_authority_guard(&identity, |authority| {
// Keep Server failures as the inner Result. Only native gate
// acquisition/finalization failures belong to the outer lane.
Ok(api.config_store.adopt_configuration(
&api.config.workspace_id,
&account,
&request,
schema,
&host,
baseline.as_ref(),
|subject| authority.require_subject(subject).map_err(transfer_error),
|fingerprint| {
// Canonical saves must not seal. Their Feature transaction
// stays nonmutating until guard-drop rollback after core.
let seed = seed.as_ref().ok_or_else(|| {
conflict("canonical adoption must not prepare a legacy transfer")
})?;
// Core completes every reference check before this final
// Feature access. One method verifies a retained seal or
// creates the first one; the rest is in-memory work only.
let receipt = authority
.seal_permission_sources(fingerprint, &seed.snapshot.export_digest)
.map_err(transfer_error)?;
if receipt.transfer_id != fingerprint
|| receipt.snapshot != seed.snapshot
{
return Err(conflict(
"sealed transfer differs from the evaluated migration snapshot",
));
}
Ok(ConfigurationPolicyTransfer {
transfer_identity: receipt.transfer_id,
physical_identity: receipt.snapshot.identity.physical_id,
export_digest: receipt.snapshot.export_digest,
policies: seed.policies.clone(),
})
},
))
})
.map_err(transfer_error)??;
// Core and Feature guards are released before best-effort Runtime
// publication. Dispatch transfers the admitted lifetime without
// keeping the SQL lane occupied by an external Runtime response.
// Exact historical receipt replay above must not publish old state.
let publication_api = api.clone();
let state = result.state.clone();
let _ = api.host_executors.host().and_then(|executor| {
executor.dispatch(move || {
if let Ok(projection) = publication_api
.prompt_projection_cache
.resolve(&publication_api.config.workspace_id, &state)
{
let _ = publication_api
.runtime
.observe_workspace_prompt_projection((*projection).clone());
}
})
});
Ok(receipt_to_api(result))
})
.await
}
async fn configuration_reconcile(
&self,
context: ServerRequestContext,
workspace_id: String,
request: ConfigurationAdoptionRequest,
) -> std::result::Result<ConfigurationReconciliation, RepositoryApiError> {
owner_operation(self, context, workspace_id, move |api, account| {
let request = request_from_api(request)?;
if let Some(result) = saved_result(&api, &account, &request)? {
return Ok(ConfigurationReconciliation::Adopted {
receipt: receipt_to_api(result),
});
}
// Canonical operations do not use the old transfer. A missing normal
// save receipt is not evidence about the unrelated initial cutover.
if source_snapshot(&api, &account)?.canonical {
return Ok(match saved_result(&api, &account, &request)? {
Some(result) => ConfigurationReconciliation::Adopted {
receipt: receipt_to_api(result),
},
None => ConfigurationReconciliation::NotObserved,
});
}
// No draft generation, validation against today's head/catalog, seal,
// or adoption here: this is observation of the exact original input.
let legacy = raw_legacy(&api)?;
let pending = pending_transfer(&legacy, &api.config.workspace_id, &account, &request)?;
// Adoption may have committed between the first lookup and Feature read.
if let Some(result) = saved_result(&api, &account, &request)? {
return Ok(ConfigurationReconciliation::Adopted {
receipt: receipt_to_api(result),
});
}
Ok(match pending {
Some(receipt) => ConfigurationReconciliation::PendingTransfer {
transfer_identity: receipt.transfer_id,
export_digest: receipt.snapshot.export_digest,
},
None => ConfigurationReconciliation::NotObserved,
})
})
.await
}
}
File diff suppressed because it is too large Load Diff
@@ -46,6 +46,111 @@ async fn assert_result_diagnostics(
assert_eq!(checks[0]["captured"], true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn implementation_result_receipt_and_replay_do_not_wait_for_notification_acceptance() {
use std::time::Duration;
struct ReleaseOnDrop(Option<std::sync::mpsc::Sender<()>>);
impl Drop for ReleaseOnDrop {
fn drop(&mut self) {
if let Some(release) = self.0.take() {
let _ = release.send(());
}
}
}
let dir = tempfile::tempdir().unwrap();
let (api, execution, orchestrator, identity) = delivery_recording_fixture(dir.path()).await;
let ticket = delivery_ticket(&api, "Receipt is independent of notification acceptance");
delivery_assign(&api, &ticket.id, "blocked-result-delegation").await;
let coder = spawn_ticket_check_source(&api, "blocked-result-coder");
seed_worker_source_member(&api, &coder.runtime_id, &coder.worker_id);
let input = json!({"operation_key":"blocked-result", "kind":"result", "body":"Committed facts survive a blocked notification"});
let (entered, entered_rx) = tokio::sync::oneshot::channel();
let (release, release_rx) = std::sync::mpsc::channel();
let release = ReleaseOnDrop(Some(release));
let recipient = orchestrator.worker_id.clone();
execution.before_input_returns(move |worker| {
assert_eq!(worker.worker_id.to_string(), recipient);
let _ = entered.send(());
release_rx.recv_timeout(Duration::from_secs(15)).unwrap();
});
let mut report = tokio::spawn({
let api = api.clone();
let identity = identity.clone();
let coder = coder.clone();
let ticket = ticket.id.clone();
let input = input.clone();
async move { result_signed_http(&api, &identity, &coder, &ticket, &input, StatusCode::OK).await }
});
tokio::time::timeout(Duration::from_secs(5), entered_rx)
.await
.expect("notification reached the gated Runtime acceptance boundary")
.unwrap();
let receipt = tokio::time::timeout(Duration::from_secs(2), &mut report).await;
let replay = if receipt.is_ok() {
Some(
tokio::time::timeout(
Duration::from_secs(2),
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK),
)
.await,
)
} else {
None
};
api.config_store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM ticket_implementation_results",
[],
|row| row.get::<_, i64>(0)
)?,
1
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM notification_sources WHERE kind='implementation_result'",
[],
|row| row.get::<_, i64>(0)
)?,
1
);
Ok(())
})
.unwrap();
// Release before asserting the response result, including when testing the old coupled handler.
drop(release);
let cleanup = if receipt.is_err() {
Some(tokio::time::timeout(Duration::from_secs(5), report).await)
} else {
None
};
tokio::time::timeout(
Duration::from_secs(5),
api.host_executors.host().unwrap().drain(),
)
.await
.expect("owned recovery drained after release")
.unwrap();
let receipt = receipt
.expect("committed receipt must not wait for Runtime acceptance")
.unwrap();
assert_eq!(
replay
.unwrap()
.expect("replay must not wait for Runtime acceptance"),
receipt
);
assert!(cleanup.is_none());
assert_result_diagnostics(&api, &orchestrator, receipt["result_id"].as_str().unwrap()).await;
delivery_stop(&api, &coder);
delivery_stop(&api, &orchestrator);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr()
{
@@ -252,32 +252,7 @@ mod plugin_controller_tests {
crate::plugin_settings::render_value(&plugins).unwrap(),
crate::plugin_settings::render_value(&selection).unwrap()
);
let state = self
.api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let path = config_source::VirtualPath::parse("main.dcdl").unwrap();
let commit = ConfigCommitRequest {
base_digest: state.snapshot.digest.clone(),
entrypoints: state.contract.entrypoints.clone(),
changes: vec![config_source::ConfigTreeChange::Update {
path: path.clone(),
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
content,
}],
};
request(
build_router(self.api.clone()),
Some(&self.owner),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree/commit"),
serde_json::to_vec(&commit).unwrap(),
false,
StatusCode::CREATED,
)
.await;
super::plugin_tests::adopt_plugin_config(self.api.clone(), &self.owner, content).await;
}
async fn revoke(&self, installed: &server_api::PluginOperationResponse) {
@@ -1373,25 +1373,24 @@ fn assert_check_physical_initializations(
.subjektiv_registration
.open_observation()
.since(before);
let directory = f
.api
.config
.database_path
.parent()
.unwrap()
.join("feature-storage")
.join(TEST_WORKSPACE_ID)
.join("features");
let resolved = fs::canonicalize(&directory).unwrap();
let ancestors: std::collections::HashSet<_> =
directory.ancestors().chain(resolved.ancestors()).collect();
// Subject creation has already durably provisioned this physical DB. Cold
// connection opens must still initialize/read SQLite, but must not provision
// or sync new metadata ancestors. Initial publication sync is owned below
// FeatureStorage's identity boundary, not repeated per authorization check.
assert!(
before.directory_sync_attempts > 0,
"fixture must exercise initial publication I/O"
);
assert_eq!(
before.directory_sync_successes,
before.directory_sync_attempts
);
assert_eq!(observed.completed_initializations, expected, "{observed:?}");
assert_eq!(observed.physical_open_attempts, expected, "{observed:?}");
assert_eq!(observed.physical_opens, expected, "{observed:?}");
assert_eq!(
observed.directory_sync_attempts,
expected * ancestors.len() as u64,
"{observed:?}"
observed.directory_sync_attempts, 0,
"owned DB reopen must not publish directories: {observed:?}"
);
assert_eq!(
observed.directory_sync_successes,
@@ -126,30 +126,7 @@ mod plugin_remote_controller_tests {
crate::plugin_settings::render_value(&plugins).unwrap(),
crate::plugin_settings::render_value(&selection).unwrap()
);
let state = f
.api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
.unwrap()
.unwrap();
let path = config_source::VirtualPath::parse("main.dcdl").unwrap();
let commit = ConfigCommitRequest {
base_digest: state.snapshot.digest.clone(),
entrypoints: state.contract.entrypoints.clone(),
changes: vec![config_source::ConfigTreeChange::Update {
path: path.clone(),
expected_digest: state.snapshot.get(&path).unwrap().content_digest.clone(),
content,
}],
};
let (status, result) = f
.human(
reqwest::Method::POST,
&format!("{prefix}/config/source-tree/commit"),
serde_json::to_value(commit).unwrap(),
)
.await;
assert_eq!(status, StatusCode::CREATED, "{result}");
super::plugin_tests::adopt_plugin_config(f.api.clone(), &f.owner, content).await;
(installed, package.bytes)
}
@@ -77,6 +77,53 @@ async fn get(app: Router, token: Option<&str>, path: &str, expected: StatusCode)
request(app, token, "GET", path, vec![], false, expected).await
}
pub(super) async fn adopt_plugin_config(api: WorkspaceApi, owner: &str, content: String) {
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api));
let app = workspace_server_router(catalog);
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
let current = get(app.clone(), Some(owner), &prefix, StatusCode::OK).await;
let adoption = serde_json::json!({
"operation_id": uuid::Uuid::now_v7().to_string(),
"expected_head": current["history_head"],
"expected_catalog_digest": current["catalog_digest"],
"expected_legacy_export": current["expected_legacy_export"],
"source": {
"base_digest": current["state"]["snapshot"]["digest"],
"entrypoints": ["main.dcdl"],
"changes": [{"kind":"update", "path":"main.dcdl",
"expected_digest": current["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"],
"content": content}]
}
});
let preview = json(
app.clone(),
owner,
&format!("{prefix}/preview"),
adoption.clone(),
StatusCode::OK,
)
.await;
let saved = json(
app,
owner,
&format!("{prefix}/commit"),
adoption,
StatusCode::OK,
)
.await;
assert_eq!(saved["state"], preview["state"]);
}
#[tokio::test]
async fn authenticated_catalog_lifecycle_replay_cas_and_current_read_authority() {
let dir = tempfile::tempdir().unwrap();
@@ -414,12 +461,23 @@ async fn api_restart_recovers_orphan_publication_and_preserves_committed_catalog
}
#[tokio::test]
async fn workspace_owner_without_hosting_admin_configures_plugin_through_existing_config_commit() {
async fn workspace_owner_without_hosting_admin_configures_plugin_through_owner_adoption() {
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 catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
let app = workspace_server_router(catalog);
let prefix = format!("/api/w/{TEST_WORKSPACE_ID}");
let upload = request(
app.clone(),
@@ -435,7 +493,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
let state = get(
app.clone(),
Some(&owner),
&format!("{prefix}/config/source-tree"),
&format!("{prefix}/config/adoption"),
StatusCode::OK,
)
.await;
@@ -447,11 +505,15 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
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}]});
let change = serde_json::json!({
"operation_id": "config-plugin-adopt", "expected_head":state["history_head"],
"expected_catalog_digest":state["catalog_digest"], "expected_legacy_export":state["expected_legacy_export"],
"source": {"base_digest":state["state"]["snapshot"]["digest"],"entrypoints":["main.dcdl"],"changes":[{"kind":"update","path":"main.dcdl","expected_digest":state["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"],"content":source}]}
});
json(
app.clone(),
&reader,
&format!("{prefix}/config/source-tree/commit"),
&format!("{prefix}/config/adoption/commit"),
change.clone(),
StatusCode::FORBIDDEN,
)
@@ -459,7 +521,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
json(
app.clone(),
&reader,
&format!("{prefix}/config/source-tree/preview"),
&format!("{prefix}/config/adoption/preview"),
change.clone(),
StatusCode::FORBIDDEN,
)
@@ -467,7 +529,7 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
let preview = json(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/preview"),
&format!("{prefix}/config/adoption/preview"),
change.clone(),
StatusCode::OK,
)
@@ -475,26 +537,42 @@ async fn workspace_owner_without_hosting_admin_configures_plugin_through_existin
let unchanged = get(
app.clone(),
Some(&owner),
&format!("{prefix}/config/source-tree"),
&format!("{prefix}/config/adoption"),
StatusCode::OK,
)
.await;
assert_eq!(unchanged["snapshot"]["digest"], state["snapshot"]["digest"]);
assert_eq!(unchanged, state);
let saved = json(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/commit"),
&format!("{prefix}/config/adoption/commit"),
change.clone(),
StatusCode::CREATED,
StatusCode::OK,
)
.await;
assert_eq!(saved, preview);
assert_ne!(saved["snapshot"]["digest"], state["snapshot"]["digest"]);
assert_eq!(saved["state"], preview["state"]);
assert_ne!(
saved["state"]["snapshot"]["digest"],
state["state"]["snapshot"]["digest"]
);
assert_eq!(
json(
app.clone(),
&owner,
&format!("{prefix}/config/adoption/commit"),
change.clone(),
StatusCode::OK
)
.await,
saved
);
let mut stale = change;
stale["operation_id"] = "stale-plugin-adoption".into();
json(
app.clone(),
&owner,
&format!("{prefix}/config/source-tree/commit"),
change,
&format!("{prefix}/config/adoption/commit"),
stale,
StatusCode::CONFLICT,
)
.await;
@@ -1316,13 +1316,28 @@ async fn drive_and_config_preflight_keep_live_authority_owned_and_report_overloa
}
#[tokio::test(flavor = "current_thread")]
async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane() {
async fn owner_adoption_publishes_after_commit_without_holding_the_sql_lane() {
use server_api::ConfigurationAdoptionApi;
let fixture = fixture().await;
// This executor fixture supplies an authenticated owner context; full cookie
// admission belongs to configuration_adoption_http_integration.
let context = server_api::ServerRequestContext {
actor: Some(RequestActor {
user_id: "fixture-user".into(),
account_id: format!("account-{WORKSPACE}"),
handle: "fixture-user".into(),
display_name: "Fixture user".into(),
auth_method: ActorAuthMethod::BrowserSession,
}),
worker_source: None,
runtime_source: None,
origin: Some("http://localhost:5173".into()),
transport_headers: Vec::new(),
};
let current = fixture
.api
.config_store
.load_workspace_config(WORKSPACE)
.unwrap()
.server
.configuration_source(context.clone(), WORKSPACE.into())
.await
.unwrap();
let mut reply = HeldRuntimeReply::new();
install_remote_fixture(
@@ -1331,31 +1346,29 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
&format!("http://{}", reply.address),
false,
);
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut commit = Box::pin(crate::db_executor::with_operation_lifetime(
Arc::new(CountJobLifetime(leases.clone())),
scoped_commit_workspace_config_tree(
State(fixture.api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: WORKSPACE.into(),
}),
Json(server_api::ConfigCommitRequest {
base_digest: current.snapshot.digest.clone(),
entrypoints: current
.contract
.entrypoints
.iter()
.map(ToString::to_string)
.collect(),
changes: vec![server_api::ConfigTreeChange::Create {
path: "notes/owned-publication.txt".into(),
content_type: server_api::ConfigContentType::Text,
content: "committed before prompt publication".into(),
}],
}),
),
));
assert!(futures::poll!(&mut commit).is_pending());
let server = fixture.server.clone();
let request = server_api::ConfigurationAdoptionRequest {
operation_id: "owned-publication".into(),
expected_head: current.history_head.clone(),
expected_catalog_digest: current.catalog_digest.clone(),
expected_legacy_export: current.expected_legacy_export.clone(),
source: server_api::ConfigCommitRequest {
base_digest: current.state.snapshot.digest.clone(),
entrypoints: current.state.contract.entrypoints.clone(),
changes: vec![server_api::ConfigTreeChange::Create {
path: "notes/owned-publication.txt".into(),
content_type: server_api::ConfigContentType::Text,
content: "committed before prompt publication".into(),
}],
},
};
let commit = tokio::spawn(async move {
server
.configuration_adopt(context, WORKSPACE.into(), request)
.await
});
let receipt = observed(commit).await.unwrap().unwrap();
assert!(receipt.source_stored && receipt.policy_committed);
let request = observed(reply.entered.take().unwrap()).await.unwrap();
assert!(
request.starts_with("POST "),
@@ -1371,7 +1384,7 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
.unwrap()
.unwrap();
assert_ne!(
state.snapshot.digest, current.snapshot.digest,
state.snapshot.digest, current.state.snapshot.digest,
"publication must observe the committed tree"
);
assert!(
@@ -1387,12 +1400,16 @@ async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane()
"control"
);
assert!(!reply.upstream_released.load(Ordering::SeqCst));
drop(commit);
assert_eq!(leases.load(Ordering::SeqCst), 1);
let admission = fixture.server.admission_for_workspace(WORKSPACE).await;
let mut deletion = Box::pin(admission.lock_deletion());
assert!(
futures::poll!(&mut deletion).is_pending(),
"publication must retain its admitted Workspace lifetime after the receipt returns"
);
reply.finish();
observed(fixture.host_executor.drain()).await.unwrap();
observed(fixture.executor.drain()).await.unwrap();
assert_eq!(leases.load(Ordering::SeqCst), 0);
let _deletion = observed(deletion).await;
assert_eq!(
fixture
.api
@@ -619,22 +619,31 @@ pub(super) async fn commit(
{
return Err(fail(409, "authority_changed"));
}
// Preserve the shared helper's ordering and atomic CAS, but never
// run its remote Runtime publication on the sole DB worker.
project_repository_access_candidate(
&*persistence_api.store,
&persistence_api.repository_secrets,
&persistence_api.config.workspace_id,
&candidate,
)
.map_err(|_| unknown())?;
let state = persistence_api
.config_store
.commit_evaluated_workspace_config(
&persistence_api.config.workspace_id,
&candidate,
)
.map_err(|_| unknown())?;
// Legacy logical writes share the physical authority gate with
// initial cutover, in Feature -> Server order. A durable seal
// fences this writer even when the Server adoption rolled back.
let legacy = open_legacy_subjektiv_store(&persistence_api).map_err(pre_error)?;
let identity = legacy.database_identity().map_err(|_| unknown())?;
let state = legacy.with_permission_authority_guard(&identity, |authority| {
authority.require_unsealed()?;
Ok((|| {
project_repository_access_candidate(
&*persistence_api.store,
&persistence_api.repository_secrets,
&persistence_api.config.workspace_id,
&candidate,
).map_err(|_| unknown())?;
// Repository projection may already have auxiliary secret
// effects. A subsequent save error cannot promise that the
// whole operation had no durable effect, even on SQL rollback.
persistence_api.config_store.commit_evaluated_workspace_config(
&persistence_api.config.workspace_id, &candidate,
).map_err(|_| unknown())
})())
}).map_err(|error| match error {
subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"),
_ => unknown(),
})??;
let publication = persistence_api
.prompt_projection_cache
.resolve(&persistence_api.config.workspace_id, &state)
@@ -390,8 +390,10 @@ mod workspace_config_integration {
"the granted WIP path remains available"
);
// Real API-token authentication still permits the normal UI editor.
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
// Real owner API-token authentication still permits the normal editor,
// but only through the head/request-bound adoption boundary.
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "logical-editor-owner").await;
for (method, path) in &routes {
if *method == "GET" {
request_json_authenticated(
@@ -409,11 +411,52 @@ mod workspace_config_integration {
build_router(api.clone()),
"POST",
&format!("{tree}/commit"),
Some(serde_json::to_value(change).unwrap()),
Some(serde_json::to_value(&change).unwrap()),
&token,
StatusCode::CREATED,
StatusCode::CONFLICT,
)
.await;
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
let editor = workspace_server_router(catalog);
let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
let current = get_json_authenticated(editor.clone(), &adoption_path, &token).await;
let adoption = json!({
"operation_id": "logical-editor-owner-adoption",
"expected_head": current["history_head"],
"expected_catalog_digest": current["catalog_digest"],
"expected_legacy_export": current["expected_legacy_export"],
"source": {"base_digest": current["state"]["snapshot"]["digest"],
"entrypoints": current["state"]["contract"]["entrypoints"], "changes": change.changes}
});
let preview = request_json_authenticated(
editor.clone(),
"POST",
&format!("{adoption_path}/preview"),
Some(adoption.clone()),
&token,
StatusCode::OK,
)
.await;
let receipt = request_json_authenticated(
editor,
"POST",
&format!("{adoption_path}/commit"),
Some(adoption),
&token,
StatusCode::OK,
)
.await;
assert_eq!(receipt["state"], preview["state"]);
let saved = api
.config_store
.load_workspace_config(TEST_WORKSPACE_ID)
@@ -110,8 +110,13 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) {
verify_history(conn, current).unwrap();
if current >= 114 {
let plan = published_plan(conn, current).unwrap().unwrap();
assert!(plan.migrations.is_empty());
assert_eq!(plan.target_schema_version, current);
assert_eq!(plan.migrations.len(), 1);
assert_eq!(plan.migrations[0].version, 122);
assert_eq!(
plan.migrations[0].name,
crate::config_adoption::MIGRATION_NAME
);
assert_eq!(plan.target_schema_version, 122);
}
assert_eq!(rows, saved_rows(conn));
assert_eq!(shape, snapshot(conn).unwrap());
@@ -195,11 +200,30 @@ fn published_database_runs_normal_store_preparation_and_reopens_without_downgrad
let conn = Connection::open(&path).unwrap();
// Production store owner, not a separate test migration implementation.
super::super::prepare_connection(&conn).unwrap();
assert_eq!(super::super::current_schema_version(&conn).unwrap(), 122);
for (table, rows) in &expected {
if table != "__yoi_schema_migrations" {
assert_eq!(
saved_rows(&conn)
.into_iter()
.find(|(name, _)| name == table)
.unwrap()
.1,
*rows,
"{table}, from {current}"
);
}
}
let mut history = super::super::workspace_schema_migration_history(&conn).unwrap();
assert_eq!(
super::super::current_schema_version(&conn).unwrap(),
current
history.pop(),
Some((122, crate::config_adoption::MIGRATION_NAME.into()))
);
assert_eq!(saved_rows(&conn), expected);
assert_eq!(
history,
super::super::workspace_schema_migration_history(&source).unwrap()
);
verify_history(&conn, 122).unwrap();
}
}
}
@@ -290,7 +314,7 @@ fn oldest_data_bearing_schema_and_both_real_103_histories_migrate_without_relabe
let (_temp, conn) = retained_to(103, alternate);
let original = super::super::workspace_schema_migration_history(&conn).unwrap();
super::super::prepare_connection(&conn).unwrap();
verify_history(&conn, 113).unwrap();
verify_history(&conn, super::super::LATEST_SCHEMA_VERSION).unwrap();
let updated = super::super::workspace_schema_migration_history(&conn).unwrap();
assert_eq!(&updated[..original.len()], &original);
let name: String = conn
+51 -3
View File
@@ -71,6 +71,31 @@ fn invalid(message: impl std::fmt::Display) -> Error {
/// Every other row, gap, mixed family, unknown suffix and missing object fails.
pub(super) fn verify_history(conn: &Connection, current: i64) -> Result<()> {
let rows = super::workspace_schema_migration_history(conn)?;
verify_history_records(conn, current, &rows)
}
fn verify_history_records(conn: &Connection, current: i64, rows: &[(i64, String)]) -> Result<()> {
if current == 122 {
verify_configuration_shape(conn)?;
if rows == [(122, super::SCHEMA_BASELINE_NAME.to_owned())] {
require_shape(conn, Stage::Base)?;
return verify_103_features(conn, current, rows);
}
if rows.last().map(|(version, name)| (*version, name.as_str()))
!= Some((122, crate::config_adoption::MIGRATION_NAME))
{
return Err(invalid("unrecognized configuration adoption receipt"));
}
let prefix = &rows[..rows.len() - 1];
let prior = prefix
.last()
.map(|row| row.0)
.ok_or_else(|| invalid("missing configuration predecessor"))?;
if !(113..=121).contains(&prior) {
return Err(invalid("invalid configuration predecessor"));
}
return verify_history_records(conn, prior, prefix);
}
let Some((baseline, name)) = rows.first() else {
return Err(invalid("missing migration receipts"));
};
@@ -191,11 +216,34 @@ pub(super) fn published_plan(
verify_history(conn, current)?;
Ok(Some(super::WorkspaceSchemaMigrationPlan {
current_schema_version: current,
target_schema_version: current,
migrations: vec![],
target_schema_version: 122,
migrations: if current < 122 {
vec![super::WorkspaceSchemaMigrationStep {
version: 122,
name: crate::config_adoption::MIGRATION_NAME.to_owned(),
}]
} else {
vec![]
},
}))
}
fn verify_configuration_shape(conn: &Connection) -> Result<()> {
let expected_db = Connection::open_in_memory()?;
expected_db.execute_batch(content_history_store::SCHEMA)?;
expected_db.execute_batch(crate::config_adoption::SCHEMA)?;
let expected = snapshot(&expected_db)?;
let tables: BTreeSet<_> = expected.values().map(|object| object.1.clone()).collect();
let actual: Shape = snapshot(conn)?
.into_iter()
.filter(|(_, object)| tables.contains(&object.1))
.collect();
if actual != expected {
return Err(invalid("configuration adoption receipt/shape mismatch"));
}
Ok(())
}
// sqlite_schema programs, not table/column presence alone. SQL literal bytes,
// CHECK/FK clauses, indexes and trigger programs remain part of the contract.
type Shape = BTreeMap<String, (String, String, Vec<String>)>;
@@ -233,7 +281,7 @@ fn reference_base() -> Result<Connection> {
merge_request::migrate(&conn).map_err(invalid)?;
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
let tx = conn.unchecked_transaction()?;
super::create_latest_workspace_schema(&tx)?;
super::create_workspace_schema_through_v113(&tx)?;
tx.commit()?;
Ok(conn)
}
@@ -41,6 +41,113 @@ fn legacy_subject(source: &Path, destination: &Path, attribution: &str) -> Strin
subject.id
}
#[test]
fn reserved_deletion_reuses_owned_storage_but_never_adopts_legacy_or_recreates_missing() {
use crate::workspace_deletion::WorkspaceDeletionStore;
for owned in [false, true] {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let catalog = catalog(&path);
let scope = FeatureStorage::for_server_database(&path)
.unwrap()
.scope("storage-workspace")
.unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
let feature_path = temp
.path()
.join("feature-storage/storage-workspace/features/subjektiv.sqlite");
let identity = if owned {
Some(scope.open(&registration).unwrap().identity().clone())
} else {
legacy_subject(
&temp.path().join("source"),
&feature_path,
"storage-workspace",
);
None
};
catalog.with_conn(|conn| {
conn.execute_batch("INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at)
VALUES('retained-workspace','storage-owner','Retain','active','1','1');")?;
Ok(())
}).unwrap();
let preflight = catalog
.workspace_deletion_preflight("storage-owner", "storage-workspace")
.unwrap();
catalog
.reserve_workspace_deletion(
"storage-owner",
"storage-workspace",
&server_api::WorkspaceDeletionRequest {
operation_id: "delete-storage".into(),
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: preflight.display_name,
},
)
.unwrap();
let result = catalog.adopt_workspace_feature_storage(&scope, &registration, "store_scope");
if let Some(identity) = identity {
result.unwrap();
assert_eq!(
scope
.open_existing(&registration, &identity)
.unwrap()
.identity(),
&identity
);
std::fs::remove_file(&feature_path).unwrap();
assert!(
catalog
.adopt_workspace_feature_storage(&scope, &registration, "store_scope")
.is_err()
);
assert!(!feature_path.exists());
} else {
assert!(result.is_err());
let raw = Connection::open(&feature_path).unwrap();
let identity_count: i64 = raw
.query_row(
"SELECT COUNT(*) FROM sqlite_schema WHERE name='__yoi_storage_identity'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(identity_count, 0, "deletion is not new adoption authority");
}
}
}
#[test]
fn deleting_without_reservation_cannot_reuse_storage() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let catalog = catalog(&path);
let scope = FeatureStorage::for_server_database(&path)
.unwrap()
.scope("storage-workspace")
.unwrap();
let registration = SubjektivStore::register(&scope).unwrap();
let identity = scope.open(&registration).unwrap().identity().clone();
catalog
.with_conn(|conn| {
conn.execute("UPDATE workspaces SET state='deleting'", [])?;
Ok(())
})
.unwrap();
assert!(
catalog
.adopt_workspace_feature_storage(&scope, &registration, "store_scope")
.is_err()
);
assert_eq!(
scope
.open_existing(&registration, &identity)
.unwrap()
.identity(),
&identity
);
}
#[test]
fn catalog_authorized_legacy_adoption_reopens_and_preserves_domain_data() {
let temp = tempfile::tempdir().unwrap();
+79 -20
View File
@@ -132,7 +132,7 @@ const OLDEST_SCHEMA_VERSION: i64 = 50;
#[path = "storage_formats.rs"]
mod storage_formats;
const LATEST_SCHEMA_VERSION: i64 = 113;
const LATEST_SCHEMA_VERSION: i64 = 122;
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";
@@ -505,6 +505,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Ticket work-decomposition parent links",
apply: migrate_ticket_parents_v112_to_v113,
},
Migration {
version: 122,
name: crate::config_adoption::MIGRATION_NAME,
apply: migrate_configuration_adoption_to_v122,
},
];
// Both schema-103 identities shipped. Keep the alternate implementation and accept
@@ -2993,10 +2998,25 @@ impl SqliteWorkspaceStore {
}
};
self.with_immediate_transaction(|tx| {
let row: Option<(String,String)> = tx.query_row(
"SELECT owner_account_id,created_at FROM workspaces WHERE workspace_id=?1 AND state='active'",[storage.scope_id()],
|row| Ok((row.get(0)?,row.get(1)?))).optional()?;
let (owner,created) = row.ok_or_else(|| Error::Store("Workspace storage requires a current catalog owner".into()))?;
let row: Option<(String,String,String)> = tx.query_row(
"SELECT owner_account_id,created_at,state FROM workspaces WHERE workspace_id=?1",[storage.scope_id()],
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?))).optional()?;
let (owner,created,state) = row.ok_or_else(|| Error::Store("Workspace storage requires a current catalog owner".into()))?;
if state == "deleting" {
// A retained deletion consumer can validate completed ownership,
// but cannot authorize new legacy adoption or replacement storage.
let reserved: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workspace_deletion_operations WHERE workspace_id=?1 AND owner_account_id=?2 AND state!='succeeded')",
params![storage.scope_id(),owner], |row| row.get(0))?;
if !reserved {
return Err(Error::Store("Workspace storage requires a retained deletion reservation".into()));
}
storage.verify_owned_if_present(registration)?;
return Ok(());
}
if state != "active" {
return Err(Error::Store("Workspace storage adoption requires an active catalog owner".into()));
}
let evidence = format!("catalog:{}:{owner}:{created}",storage.scope_id());
let request = format!("legacy-storage:{}:{}",storage.scope_id(),registration.feature_id());
storage.adopt_legacy_if_present(registration,&request,&evidence,|feature_tx| {
@@ -6566,19 +6586,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
workspace_id: &str,
repository_key: &str,
) -> Result<Option<RepositoryRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref,
source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories
WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![workspace_id, repository_key],
read_repository_record,
)
.optional()
.map_err(Error::from)
})
self.with_conn(|conn| read_repository_by_key(conn, workspace_id, repository_key))
}
fn list_repositories(&self, workspace_id: &str) -> Result<Vec<RepositoryRecord>> {
@@ -12205,6 +12213,24 @@ fn validate_repository_record_identity(record: &RepositoryRecord) -> Result<()>
Ok(())
}
pub(crate) fn read_repository_by_key(
conn: &Connection,
workspace_id: &str,
repository_key: &str,
) -> Result<Option<RepositoryRecord>> {
conn.query_row(
r#"SELECT workspace_id, repository_id, repository_key, kind, provider,
source_kind, source_uri, default_ref,
source_fingerprint, observed_status, observed_at, created_at, updated_at
FROM repositories
WHERE workspace_id = ?1 AND repository_key = ?2"#,
params![workspace_id, repository_key],
read_repository_record,
)
.optional()
.map_err(Error::from)
}
fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<RepositoryRecord> {
let source_kind_value = row.get::<_, String>(5)?;
let source_kind = server_api::RepositorySourceKind::parse(&source_kind_value)
@@ -16069,6 +16095,25 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_configuration_adoption_to_v122(conn: &Connection) -> Result<()> {
let current = current_schema_version(conn)?;
if !(113..=121).contains(&current) {
return Err(Error::Store(
"Configuration adoption migration requires a recognized schema 113..121".into(),
));
}
storage_formats::verify_history(conn, current)?;
let tx = conn.unchecked_transaction()?;
tx.execute_batch(content_history_store::SCHEMA)?;
tx.execute_batch(crate::config_adoption::SCHEMA)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(122,?1)",
[crate::config_adoption::MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
}
fn migrate_ticket_parents_v112_to_v113(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 112 {
return Err(Error::Store(
@@ -16165,6 +16210,13 @@ fn migrate_plugin_receipts_v108_to_v109(conn: &Connection) -> Result<()> {
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_through_v113(conn)?;
conn.execute_batch(content_history_store::SCHEMA)?;
conn.execute_batch(crate::config_adoption::SCHEMA)?;
Ok(())
}
fn create_workspace_schema_through_v113(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
assignment_delivery::install_post_inputs(conn)?;
@@ -17083,7 +17135,7 @@ fn workspace_schema_migration_history(conn: &Connection) -> Result<Vec<(i64, Str
}
fn verify_schema_history(conn: &Connection, current: i64) -> Result<()> {
if current > LATEST_SCHEMA_VERSION {
if current > 113 {
return storage_formats::verify_history(conn, current);
}
let rows = workspace_schema_migration_history(conn)?;
@@ -18878,9 +18930,16 @@ mod tests {
#[test]
fn current_schema_accepts_every_retained_canonical_provenance() {
for baseline_version in OLDEST_SCHEMA_VERSION..=LATEST_SCHEMA_VERSION {
for baseline_version in
(OLDEST_SCHEMA_VERSION..=113).chain(std::iter::once(LATEST_SCHEMA_VERSION))
{
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
ticket::migrate_sqlite_ticket_schema(&conn).unwrap();
merge_request::migrate(&conn).unwrap();
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")
.unwrap();
create_latest_workspace_schema(&conn).unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)",
params![baseline_version, SCHEMA_BASELINE_NAME],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
#!/usr/bin/env -S deno run -A
/** 実Web/実WASMを合成HTTPで操作する。認証・保存の証拠はServer suiteが所有する。 */
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join, resolve } from "@std/path";
import { createHash } from "node:crypto";
import { chromium, type Page } from "playwright";
import { sha256File } from "../src/artifacts.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts";
import init, {
apply_changes,
compose_schema_bundle,
set_snapshot,
} from "../../../web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js";
import type {
ConfigTreeChange,
ConfigTreeSnapshot,
WorkspaceConfigSchemaBundle,
WorkspaceConfigTreeResponse,
} from "../../../web/workspace/src/lib/workspace/config-source/types.ts";
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
const buildRoot = resolve(Deno.args[0] ?? join(root, "web/workspace/build"));
const runId = Deno.args[1];
const before = Deno.args[2] === "before";
if (!runId || !/^[a-zA-Z0-9._-]+$/.test(runId)) throw new Error("supply unique safe run id");
const output = join(root, "target/web-ux/t838", runId);
await Deno.mkdir(dirname(output), { recursive: true });
await Deno.mkdir(output);
await init({
module_or_path: await Deno.readFile(
join(
root,
"web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm",
),
),
});
const hash = (s: string) => `sha256:${createHash("sha256").update(s).digest("hex")}`;
const schema = compose_schema_bundle([]) as WorkspaceConfigSchemaBundle;
const content =
"{\n permissions = { subjects = []; remove_subjects = []; };\n} as WorkspaceConfigSchema\n";
const treeHash = createHash("sha256").update("yoi-config-tree-v1\0").update(
"main.dcdl\0text/x-decodal\0",
).update(content).update("\0");
const initial: WorkspaceConfigTreeResponse = {
snapshot: {
digest: `sha256:${treeHash.digest("hex")}`,
entries: {
"main.dcdl": {
path: "main.dcdl",
content_type: "decodal",
content,
content_digest: hash(content),
},
},
},
contract: {
contract_version: 2,
decodal_version: "0.4.0",
schema_version: 1,
entrypoints: ["main.dcdl"],
import_policy_version: 1,
schema_bundle: schema,
fingerprint: hash(JSON.stringify([2, "0.4.0", 1, ["main.dcdl"], 1, schema.fingerprint])),
},
projection_digest: hash("synthetic-empty-policy-projection"),
};
const port = await freePort();
const base = `http://127.0.0.1:${port}`;
const owned = await startOwnedProcesses(
[{
id: "configuration-synthetic-http",
command: Deno.execPath(),
cwd: root,
args: [
"run",
"--allow-net",
"--allow-read",
join(root, "tools/web-ux/browser-tests/workspace_home_fixture_server.ts"),
String(port),
buildRoot,
],
readyUrl: `${base}/health`,
}],
import.meta.url,
join(output, "processes"),
[],
);
const source = await new Deno.Command("git", { args: ["rev-parse", "HEAD"], cwd: root }).output();
const manifest = {
evidence: "actual-Web-and-WASM-with-synthetic-HTTP-NOT-real-Backend",
before,
source: new TextDecoder().decode(source.stdout).trim(),
buildRoot,
buildIndexSha256: await sha256File(join(buildRoot, "index.html")),
visualVerdict: "UNREVIEWED",
captures: [] as unknown[],
failures: [] as string[],
};
const browser = await chromium.launch({ headless: true });
const documents = new WeakMap<Page, number>();
async function capture(page: Page, id: string, errors: unknown[], expectedErrors: string[] = []) {
if (id.endsWith("-unknown")) {
await page.locator(".config-source-conflict").scrollIntoViewIfNeeded();
} else if (id.endsWith("-late-receipt")) {
await page.locator(".config-source-status").scrollIntoViewIfNeeded();
}
const image = join(output, `${id}.png`);
await page.screenshot({ path: image });
const accessibilitySnapshot = `${id}.aria.txt`;
await Deno.writeTextFile(
join(output, accessibilitySnapshot),
await page.locator("body").ariaSnapshot(),
);
manifest.captures.push({
id,
route: new URL(page.url()).pathname,
documentStatus: documents.get(page) ?? null,
viewport: page.viewportSize(),
image,
imageSha256: await sha256File(image),
accessibilitySnapshot,
visibleAlerts: await page.getByRole("alert").allTextContents(),
errors: [...errors],
expectedErrors,
});
await assertNoPageWideOverflow(page);
}
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
await context.route("**/api/auth/whoami", (route) =>
route.fulfill({
json: {
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
user_id: "fixture-user",
account_id: "fixture-owner",
handle: "reviewer",
display_name: "Configuration review",
auth_method: "browser_session",
},
},
}));
await context.route(
"**/api/hosting/access",
(route) => route.fulfill({ json: { is_admin: false } }),
);
await context.route("**/api/w/*/workspace", async (route) => {
const response = await route.fetch();
const data = await response.json();
data.permissions.delete_subjects = !route.request().url().includes("home-member");
await route.fulfill({ json: data });
});
await context.route("**/*.wasm", async (route) => {
const response = await route.fetch();
await route.fulfill({
response,
headers: { ...response.headers(), "content-type": "application/wasm" },
});
});
const page = await context.newPage();
const errors: { kind: string; message: string }[] = [];
page.on("response", (r) => {
if (r.request().isNavigationRequest()) documents.set(page, r.status());
if (r.status() >= 400) {
errors.push({ kind: "http", message: `${r.status()} ${new URL(r.url()).pathname}` });
}
});
page.on("pageerror", (e) => errors.push({ kind: "page", message: e.message }));
page.on("console", (e) => {
if (e.type() === "error" || e.type() === "warning") {
errors.push({ kind: "console", message: e.text() });
}
});
page.on(
"requestfailed",
(r) =>
errors.push({
kind: "request",
message: `${r.method()} ${new URL(r.url()).pathname}: ${r.failure()?.errorText}`,
}),
);
let state = structuredClone(initial);
let request: unknown = null;
let savedReceipt: unknown = null;
let head = "head:observed";
const prefix = `${colorScheme}-${width}`;
await page.route("**/api/w/*/config/**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (path.includes("/home-member/")) {
return await route.fulfill({ status: 403, body: "Workspace owner required" });
}
if (path.endsWith("/source-tree")) return await route.fulfill({ json: state });
if (path.endsWith("/adoption")) {
return await route.fulfill({
json: {
state,
history_head: head,
source_stored: true,
catalog: { workspace: "home-owner", namespaces: [] },
catalog_digest: hash("catalog"),
expected_legacy_export: null,
policies: [],
migration_diagnostics: [],
},
});
}
const input = route.request().postDataJSON();
if (path.endsWith("/preview")) {
set_snapshot(state.snapshot);
const candidate = apply_changes(
input.source.changes as ConfigTreeChange[],
) as ConfigTreeSnapshot;
return await route.fulfill({
json: {
expected_head: head,
state: { ...state, snapshot: candidate },
changed_paths: ["module.dcdl"],
changed_subjects: [],
},
});
}
if (path.endsWith("/commit")) {
request = input;
set_snapshot(state.snapshot);
state = {
...state,
snapshot: apply_changes(input.source.changes) as ConfigTreeSnapshot,
};
head = "head:adopted";
savedReceipt = {
workspace_id: "home-owner",
operation_id: input.operation_id,
history_head: head,
state,
source_stored: true,
policy_committed: true,
runtime_application: "next_current_policy_check",
};
return await route.abort("failed");
}
if (path.endsWith("/reconcile")) {
assertEquals(input, request);
return await route.fulfill({ json: { status: "not_observed" } });
}
throw new Error(`unexpected config request: ${path}`);
});
await page.goto(`${base}/w/home-owner/settings/configuration`);
await page.locator(".cm-content[contenteditable=true]").waitFor();
await page.locator(".config-source-shell").scrollIntoViewIfNeeded();
await capture(page, `${prefix}-ready`, errors);
assertEquals(errors, []);
if (!before) {
await page.getByRole("button", { name: "Create local source" }).click();
const commit = page.getByRole("button", { name: "Commit", exact: true });
await commit.click();
await page.getByRole("button", { name: "Reconcile adoption" }).waitFor();
await page.getByText(/not observed does not mean failed/).first().waitFor();
await capture(page, `${prefix}-unknown`, errors, [
"Intentional failed POST commit response",
]);
// 後続draftを編集してから古いreceiptを返し、実editorでも消えないことを確認する。
const editor = page.locator(".cm-content");
await editor.fill("{ later = true; }\n");
await page.unroute("**/api/w/*/config/**");
await page.route("**/api/w/*/config/adoption/reconcile", async (route) => {
assertEquals(route.request().postDataJSON(), request);
await route.fulfill({ json: { status: "adopted", receipt: savedReceipt } });
});
await page.getByRole("button", { name: "Reconcile adoption" }).click();
await page.getByText(/Later local edits remain unsaved/).waitFor();
assert((await editor.innerText()).includes("later"));
await capture(page, `${prefix}-late-receipt`, errors, [
"Intentional failed POST commit response",
]);
}
await page.route(
"**/api/w/home-member/config/**",
(route) => route.fulfill({ status: 403, body: "Workspace owner required" }),
);
await page.goto(`${base}/w/home-member/settings/configuration`);
await page.getByRole("alert").filter({ hasText: "Unable to load workspace configuration" })
.waitFor();
assertEquals(await page.locator(".cm-content").count(), 0);
await capture(page, `${prefix}-non-owner`, errors, [
"HTTP403 owner rejection",
...(!before ? ["Intentional failed POST commit response"] : []),
]);
} catch (error) {
const page = context.pages()[0];
if (page) {
await page.screenshot({ path: join(output, `failure-${colorScheme}-${width}.png`) });
await Deno.writeTextFile(
join(output, `failure-${colorScheme}-${width}.txt`),
await page.locator("body").innerText(),
);
}
throw error;
} finally {
await context.close();
}
}
}
} catch (e) {
manifest.failures.push(String(e));
throw e;
} finally {
await browser.close();
await stopOwnedProcesses(owned);
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
}
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts test/config-source/adoption-api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
@@ -0,0 +1,31 @@
// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.
// Do not edit manually.
import type { ConfigCommitRequest, WorkspaceConfigTreeResponse } from "./legacy-server-api.ts";
import type { ScopedPermission, SubjectPermissions } from "./memory-api.ts";
import type { PermissionAssignment, PermissionCatalog } from "./feature-permissions.generated.ts";
export type ConfigurationAdoptionRequest = { operation_id: string, expected_head: string | null, expected_catalog_digest: string, expected_legacy_export: string | null, source: ConfigCommitRequest, };
export type RuntimePolicyApplication = "next_current_policy_check";
export type ConfigurationAdoptionReceipt = { workspace_id: string, operation_id: string, history_head: string, state: WorkspaceConfigTreeResponse, source_stored: boolean, policy_committed: boolean, runtime_application: RuntimePolicyApplication, };
export type ConfigurationSubjectPolicy = { subject_id: string, change_id: string, execution: PermissionAssignment, delegation: PermissionAssignment,
/**
* Exact retained domain restrictions, not editable payloads on permission keys.
*/
legacy_constraints: SubjectPermissions | null, };
export type ConfigurationMigrationDiagnostic = { "kind": "converted", subject_id: string, operation: string, } | { "kind": "domain_constraint", subject_id: string, permission: ScopedPermission, } | { "kind": "explicit_configuration_required", subject_id: string, permission: ScopedPermission, };
export type ConfigurationSourceResponse = {
/**
* Before the first adoption this is an editable, evaluated migration draft.
* source_stored distinguishes it from the unchanged stored source.
*/
state: WorkspaceConfigTreeResponse, history_head: string | null, source_stored: boolean, catalog: PermissionCatalog, catalog_digest: string, expected_legacy_export: string | null, policies: Array<ConfigurationSubjectPolicy>, migration_diagnostics: Array<ConfigurationMigrationDiagnostic>, };
export type ConfigurationPreviewResponse = { expected_head: string | null, state: WorkspaceConfigTreeResponse, changed_paths: Array<string>, changed_subjects: Array<string>, };
export type ConfigurationReconciliation = { "status": "adopted", receipt: ConfigurationAdoptionReceipt, } | { "status": "pending_transfer", transfer_identity: string, export_digest: string, } | { "status": "not_observed" };
@@ -3,6 +3,7 @@
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import ConfigSourceEditor from "./ConfigSourceEditor.svelte";
import type { WorkspaceConfigTreeResponse } from "./types.ts";
const toolchain = vi.hoisted(() => ({
construct: vi.fn(),
@@ -30,7 +31,7 @@ vi.mock("./toolchain.ts", () => ({
}));
const schemaSource = "schema body\n".repeat(6000);
const tree = {
const tree: WorkspaceConfigTreeResponse = {
snapshot: {
digest: "sha256:tree",
entries: {
@@ -64,7 +65,27 @@ const tree = {
projection_digest: "sha256:projection",
};
const fetcher = vi.fn();
const response = () => new Response(JSON.stringify(tree));
const sourceResponse = (state = tree) => ({
state,
history_head: "head:observed",
source_stored: true,
catalog: { workspace: "w", namespaces: [] },
catalog_digest: "sha256:catalog",
expected_legacy_export: "sha256:legacy",
policies: [],
migration_diagnostics: [],
});
const response = () => new Response(JSON.stringify(sourceResponse()));
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body));
const previewResponse = (state = tree, expected_head: string | null = "head:observed") => jsonResponse({
expected_head, state, changed_paths: ["module.dcdl"], changed_subjects: [],
});
function receipt(operation_id: string, state = tree, history_head = "head:adopted") {
return {
workspace_id: "w", operation_id, history_head, state,
source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check",
};
}
beforeEach(() => {
vi.resetAllMocks();
@@ -129,7 +150,7 @@ test("loading is not an empty source tree; editing waits for schema installation
finishSetup();
await expectReady(view);
expect(fetcher).toHaveBeenCalledWith(
"/api/w/w%2Fone/config/source-tree",
"/api/w/w%2Fone/config/adoption",
expect.anything(),
);
});
@@ -140,7 +161,7 @@ test.each(["http", "invalid"])(
fetcher.mockResolvedValueOnce(
failure === "http"
? new Response("Backend unavailable", { status: 503 })
: new Response(JSON.stringify({ ...tree, unexpected: true })),
: new Response(JSON.stringify({ ...sourceResponse(), unexpected: true })),
);
const view = renderEditor();
await waitFor(() =>
@@ -190,10 +211,10 @@ test.each(["construct", "setSnapshot"] as const)(
test("a successfully loaded empty tree still offers source creation", async () => {
fetcher.mockResolvedValueOnce(
new Response(JSON.stringify({
new Response(JSON.stringify(sourceResponse({
...tree,
snapshot: { ...tree.snapshot, entries: {} },
})),
}))),
);
const view = renderEditor();
await waitFor(() =>
@@ -328,49 +349,189 @@ test("diagnostics from another tree digest are not displayed", async () => {
expect(view.container.textContent).not.toContain("Wrong tree diagnostic");
});
test.each([409, 422])("failed commit %s preserves the local draft and authoritative base digest for retry", async (status) => {
const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" };
const candidate = {
digest: "sha256:local-draft",
entries: {
...tree.snapshot.entries,
"module.dcdl": {
path: change.path,
content_type: change.content_type,
content: change.content,
content_digest: "sha256:module",
},
const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" } as const;
const candidate = {
digest: "sha256:local-draft",
entries: {
...tree.snapshot.entries,
"module.dcdl": {
path: change.path, content_type: change.content_type, content: change.content,
content_digest: "sha256:module",
},
};
},
};
const candidateState = { ...tree, snapshot: candidate };
async function prepareDraft() {
toolchain.applyChanges.mockResolvedValue(candidate);
toolchain.changesBetween.mockResolvedValue([change]);
toolchain.format.mockImplementation(async (source: string) => source);
const view = renderEditor();
await expectReady(view);
await fireEvent.click(view.getByRole("button", { name: "Create local source" }));
return view;
}
const requestAt = (index: number) => JSON.parse(fetcher.mock.calls[index][1].body);
function commitReceiptResponse(state: WorkspaceConfigTreeResponse = candidateState, historyHead = "head:adopted") {
return async (_input: string, init: RequestInit) =>
jsonResponse(receipt(JSON.parse(String(init.body)).operation_id, state, historyHead));
}
test.each([409, 422])("preview rejection %s preserves the draft and observed editing metadata", async (status) => {
const view = await prepareDraft();
const commit = view.getByRole("button", { name: "Commit" });
expect(commit).toBeInstanceOf(HTMLButtonElement);
fetcher.mockResolvedValueOnce(new Response(
status === 409 ? "base content digest mismatch" : "candidate evaluation failed",
{ status },
status === 409 ? "editing head mismatch" : "candidate evaluation failed", { status },
));
await fireEvent.click(commit);
await waitFor(() => expect(view.container.textContent).toContain(
status === 409 ? "base content digest mismatch" : "candidate evaluation failed",
status === 409 ? "editing head mismatch" : "candidate evaluation failed",
));
const expectedRequest = {
base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"],
};
expect(JSON.parse(fetcher.mock.calls[1][1].body)).toEqual(expectedRequest);
const first = requestAt(1);
expect(first).toEqual({
operation_id: expect.any(String), expected_head: "head:observed",
expected_catalog_digest: "sha256:catalog", expected_legacy_export: "sha256:legacy",
source: { base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"] },
});
expect(fetcher.mock.calls[1][0]).toBe("/api/w/w/config/adoption/preview");
expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
expect(commit).toHaveProperty("disabled", false);
expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
if (status === 409) expect(view.getByRole("alert").textContent).toContain("Discard local candidate");
fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ ...tree, snapshot: candidate })));
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
fetcher.mockImplementationOnce(commitReceiptResponse());
await fireEvent.click(commit);
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
expect(JSON.parse(fetcher.mock.calls[2][1].body)).toEqual(expectedRequest);
const second = requestAt(2);
expect(second).toEqual({ ...first, operation_id: expect.any(String) });
expect(second.operation_id).not.toBe(first.operation_id);
expect(requestAt(3)).toEqual(second);
expect(toolchain.setSnapshot).toHaveBeenLastCalledWith(candidate, tree.contract.schema_bundle);
expect(commit).toHaveProperty("disabled", true);
});
test("initial editable migration draft can be adopted without manually changing a file", async () => {
fetcher.mockResolvedValueOnce(jsonResponse({
...sourceResponse(), history_head: null, source_stored: false,
}));
toolchain.changesBetween.mockResolvedValue([]);
const view = renderEditor();
await expectReady(view);
const commit = view.getByRole("button", { name: "Commit" });
expect(commit).toHaveProperty("disabled", false);
fetcher.mockResolvedValueOnce(previewResponse(tree, null));
fetcher.mockImplementationOnce(commitReceiptResponse(tree));
await fireEvent.click(commit);
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
expect(requestAt(1).expected_head).toBeNull();
expect(requestAt(1).source.changes).toEqual([]);
expect(requestAt(2)).toEqual(requestAt(1));
expect(commit).toHaveProperty("disabled", true);
});
test("commit rejection does not refresh the head or discard local source", async () => {
const view = await prepareDraft();
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
fetcher.mockResolvedValueOnce(new Response("editing head mismatch", { status: 409 }));
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Discard local candidate"));
expect(fetcher).toHaveBeenCalledTimes(3);
expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull();
expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
fetcher.mockImplementationOnce(commitReceiptResponse());
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
expect(requestAt(3).expected_head).toBe(requestAt(1).expected_head);
expect(requestAt(3).operation_id).not.toBe(requestAt(1).operation_id);
});
test.each(["not_observed", "pending_transfer"])("lost commit followed by %s retains the exact request for explicit resume", async (status) => {
const view = await prepareDraft();
const unknown = status === "not_observed" ? { status } : {
status, transfer_identity: "transfer:one", export_digest: "sha256:legacy",
};
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
fetcher.mockRejectedValueOnce(new TypeError("response lost"));
fetcher.mockResolvedValueOnce(jsonResponse(unknown));
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
await waitFor(() => expect(view.container.textContent).toContain(
status === "not_observed" ? "not observed does not mean failed" : "transfer pending",
));
expect(fetcher).toHaveBeenCalledTimes(4);
const request = requestAt(1);
expect(requestAt(2)).toEqual(request);
expect(requestAt(3)).toEqual(request);
expect(fetcher.mock.calls[3][0]).toBe("/api/w/w/config/adoption/reconcile");
expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
expect(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
fetcher.mockResolvedValueOnce(jsonResponse(unknown));
fetcher.mockImplementationOnce(commitReceiptResponse());
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
expect(fetcher).toHaveBeenCalledTimes(6);
expect(requestAt(4)).toEqual(request);
expect(requestAt(5)).toEqual(request);
expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull();
});
test("adopted reconciliation updates head and clears the legacy export for the next explicit adoption", async () => {
const view = await prepareDraft();
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
fetcher.mockResolvedValueOnce(new Response("invalid receipt"));
fetcher.mockImplementationOnce(async (_input: string, init: RequestInit) => jsonResponse({
status: "adopted", receipt: receipt(JSON.parse(String(init.body)).operation_id, candidateState),
}));
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree"));
const first = requestAt(2);
expect(requestAt(3)).toEqual(first);
// A local deletion is another explicit adoption, not a retry of the lost reply.
const deletion = { kind: "delete", path: "module.dcdl", expected_digest: "sha256:module" };
toolchain.applyChanges.mockResolvedValue(tree.snapshot);
toolchain.changesBetween.mockResolvedValue([deletion]);
const remove = view.getByRole("button", { name: "Delete" });
await waitFor(() => expect(remove).toHaveProperty("disabled", false));
await fireEvent.click(remove);
await waitFor(() => expect(view.container.textContent).toContain("Delete staged locally"));
fetcher.mockResolvedValueOnce(previewResponse(tree, "head:adopted"));
fetcher.mockImplementationOnce(commitReceiptResponse(tree, "head:second"));
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(6));
const second = requestAt(4);
expect(second.expected_head).toBe("head:adopted");
expect(second.expected_legacy_export).toBeNull();
expect(second.expected_catalog_digest).toBe(first.expected_catalog_digest);
expect(second.operation_id).not.toBe(first.operation_id);
expect(second.source.base_digest).toBe(candidate.digest);
});
test("edited draft requires reconciliation and cannot replace or resume an unknown request", async () => {
const view = await prepareDraft();
fetcher.mockResolvedValueOnce(previewResponse(candidateState));
fetcher.mockRejectedValueOnce(new TypeError("response lost"));
fetcher.mockResolvedValueOnce(jsonResponse({ status: "not_observed" }));
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
await waitFor(() => expect(view.container.textContent).toContain("not observed does not mean failed"));
const original = requestAt(2);
toolchain.format.mockResolvedValue("{ changed: true }\n");
await fireEvent.click(view.getByRole("button", { name: "Format" }));
await waitFor(() => expect(view.container.querySelector(".cm-content")?.textContent).toContain("changed"));
fetcher.mockResolvedValueOnce(jsonResponse({ status: "not_observed" }));
await fireEvent.click(view.getByRole("button", { name: "Commit" }));
await waitFor(() => expect(view.container.textContent).toContain("Draft changed; reconcile the original request"));
expect(fetcher).toHaveBeenCalledTimes(5);
expect(requestAt(4)).toEqual(original);
expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
fetcher.mockResolvedValueOnce(jsonResponse({ status: "adopted", receipt: receipt(original.operation_id, candidateState) }));
await fireEvent.click(view.getByRole("button", { name: "Reconcile adoption" }));
await waitFor(() => expect(view.container.textContent).toContain("Later local edits remain unsaved"));
expect(view.container.querySelector(".cm-content")?.textContent).toContain("changed");
expect(requestAt(5)).toEqual(original);
expect(view.queryByRole("button", { name: "Reconcile adoption" })).toBeNull();
});
@@ -1,15 +1,22 @@
<script lang="ts">
// commitの条件は保存したbase_digestと各entryのexpected_digest。時刻や要求順では
// 編集元の内容を特定できないため、成功時だけ返されたsnapshotを新しいbaseにする。
// Server拒否時はdraftを残し、reload/reapplyを明示的な判断にする。
// 内容digestとは別に、GETで観測した履歴headと移行条件を編集元として保持する。
// 応答喪失時は依頼全体を固定し、照合で採用を確認するまで別IDへ切り替えない。
// 診断は非abortかつ解析対象tree digest一致の場合だけ表示する。content条件と
// 非同期requestの有効性は別に検査し、遅い解析を現在のsourceの診断として出さない。
import { onMount } from "svelte";
import DecodalSourceEditor from "#lib/workspace/settings/DecodalSourceEditor.svelte";
import {
commitConfigTree,
ConfigSourceApiError,
fetchConfigTree,
previewConfigTree,
reconcileConfigTree,
} from "./api.ts";
import type { ConfigurationEditingState } from "./api.ts";
import type {
ConfigurationAdoptionReceipt,
ConfigurationAdoptionRequest,
} from "#lib/generated/configuration-adoption.generated.ts";
import { ConfigSourceToolchain } from "./toolchain.ts";
import type {
ConfigDiagnostic,
@@ -31,6 +38,16 @@
let busy = $state(false);
let workingChanges = $state<ConfigTreeChange[]>([]);
let baseDigest = $state("");
let editingMetadata = $state.raw<ConfigurationEditingState | null>(null);
let editingWorkspaceId = $state("");
// 結果不明の依頼はeditor instanceのメモリ内だけに保持する。画面離脱や
// browser reloadを跨ぐ復元は保証しない。Serverのreceipt永続化とは別の限界で、
// その場合も結果不明を失敗とせず、元の依頼全体を使った照合が必要になる。
let pendingAdoption = $state.raw<{
workspaceId: string;
request: ConfigurationAdoptionRequest;
draftSignature: string;
} | null>(null);
let renamePath = $state("");
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let conflict = $state(false);
@@ -73,7 +90,7 @@
void analyzer.analyze(path, value).then((result) => {
if (!request.signal.aborted) diagnostics = result.filter((diagnostic) => diagnostic.tree_digest === digest);
}).catch((error) => {
if (!request.signal.aborted) status = `Analyze failed: ${String(error)}`;
if (!request.signal.aborted && !pendingAdoption && !busy) status = `Analyze failed: ${String(error)}`;
});
}, 250);
return () => {
@@ -83,20 +100,28 @@
});
async function reload() {
if (pendingAdoption) {
status = "Adoption outcome unknown. Reconcile the retained request before reloading.";
return;
}
loadState = "loading";
status = "Loading source tree…";
analysisReady = false;
analysisRequest?.abort();
const requestedWorkspace = workspaceId;
try {
const remote = await fetchConfigTree(workspaceId);
if (!mounted) return;
const observed = await fetchConfigTree(requestedWorkspace);
const remote = observed.state;
if (!mounted || workspaceId !== requestedWorkspace) return;
toolchain?.close();
toolchain = null;
toolchain = new ConfigSourceToolchain();
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
if (!mounted) return;
if (!mounted || workspaceId !== requestedWorkspace) return;
// Publish the new baseline only after both fetch and toolchain setup succeed.
treeState = remote;
editingMetadata = observed;
editingWorkspaceId = requestedWorkspace;
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
}
@@ -204,52 +229,174 @@
function recordCommitError(error: unknown) {
const message = String(error);
conflict = message.includes("conflict") || message.includes("base content digest mismatch");
conflict = error instanceof ConfigSourceApiError && error.status === 409;
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
}
async function commit() {
if (!treeState) return;
function draftSignature(): string {
const entries = new Map(Object.entries(treeState?.snapshot.entries ?? {}).map(
([path, entry]) => [path, [entry.content_type, entry.content]],
));
if (selectedPath) entries.set(selectedPath, [selected?.content_type ?? "decodal", source]);
return JSON.stringify([...entries].sort(([left], [right]) => left.localeCompare(right)));
}
async function applyReceipt(receipt: ConfigurationAdoptionReceipt) {
const edited = pendingAdoption !== null && draftSignature() !== pendingAdoption.draftSignature;
const local = edited && treeState ? $state.snapshot(treeState.snapshot) : null;
const localSource = source;
// receipt確定後のtoolchain障害は採用失敗ではない。headと確定状態を先に更新する。
treeState = receipt.state;
editingMetadata = {
state: receipt.state,
history_head: receipt.history_head,
catalog_digest: editingMetadata!.catalog_digest,
expected_legacy_export: null,
source_stored: true,
};
baseSnapshot = $state.snapshot(receipt.state.snapshot);
baseDigest = receipt.state.snapshot.digest;
pendingAdoption = null;
workingChanges = [];
conflict = false;
analysisReady = false;
diagnostics = [];
if (local) treeState = { ...receipt.state, snapshot: local };
if (!edited && !treeState.snapshot.entries[selectedPath]) {
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
}
source = edited ? localSource : treeState.snapshot.entries[selectedPath]?.content ?? "";
try {
if (local && toolchain) workingChanges = await toolchain.changesBetween(baseSnapshot, local);
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
analysisReady = true;
status = edited
? "Adoption confirmed. Later local edits remain unsaved; Commit to adopt them separately."
: `Committed formatted source tree · ${receipt.state.snapshot.digest.slice(0, 20)}…`;
} catch (error) {
status = `Adoption confirmed, but local editor setup failed: ${String(error)}`;
}
}
async function reconcilePending(): Promise<"adopted" | "unresolved" | "unavailable"> {
const pending = pendingAdoption;
if (!pending) return "adopted";
try {
const result = await reconcileConfigTree(pending.workspaceId, pending.request);
if (result.status === "adopted") {
await applyReceipt(result.receipt);
return "adopted";
}
status = result.status === "pending_transfer"
? "Adoption outcome unknown: transfer pending. Commit resumes the same request; reconcile before adopting edits."
: "Adoption outcome unknown: not observed does not mean failed. Commit resumes the same request; reconcile before adopting edits.";
return "unresolved";
} catch (error) {
status = `Adoption outcome unknown. Retained request must be reconciled: ${String(error)}`;
return "unavailable";
}
}
async function submitPending(wasUnknown: boolean) {
const pending = pendingAdoption;
if (!pending) return;
let receipt: ConfigurationAdoptionReceipt;
try {
receipt = await commitConfigTree(pending.workspaceId, pending.request);
} catch (error) {
// 明確な初回拒否と、既に結果不明の依頼に対する拒否は区別する。
if (!wasUnknown && error instanceof ConfigSourceApiError &&
[400, 401, 403, 404, 409, 422].includes(error.status)) {
pendingAdoption = null;
recordCommitError(error);
} else {
status = `Adoption outcome unknown: ${String(error)}`;
await reconcilePending();
}
return;
}
await applyReceipt(receipt);
}
async function reconcile() {
if (busy) return;
busy = true;
try {
await reconcilePending();
} finally {
busy = false;
}
}
async function commit() {
if (!treeState || !editingMetadata || busy || !analysisReady) return;
busy = true;
try {
if (pendingAdoption) {
const edited = draftSignature() !== pendingAdoption.draftSignature;
const outcome = await reconcilePending();
if (outcome !== "unresolved") return;
if (edited) {
status = "Adoption outcome unknown. Draft changed; reconcile the original request before adopting edits.";
return;
}
// 明示的な次のCommitだけが、保存済みの同一依頼を再送できる。
await submitPending(true);
return;
}
if (editingWorkspaceId !== workspaceId) {
status = "Workspace changed. Reload the authoritative source tree before adopting edits.";
return;
}
const adoptionWorkspaceId = editingWorkspaceId;
await formatWorkingSources();
if (workingChanges.length === 0) {
if (workingChanges.length === 0 && editingMetadata.source_stored) {
status = "No working changes to commit.";
return;
}
treeState = await commitConfigTree(workspaceId, {
base_digest: baseDigest,
changes: workingChanges,
entrypoints: entrypoints(),
});
workingChanges = [];
conflict = false;
baseSnapshot = $state.snapshot(treeState.snapshot);
baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
source = treeState.snapshot.entries[selectedPath]?.content ?? "";
diagnostics = [];
status = `Committed formatted source tree · ${treeState.snapshot.digest.slice(0, 20)}…`;
const request: ConfigurationAdoptionRequest = {
operation_id: crypto.randomUUID(),
expected_head: editingMetadata.history_head,
expected_catalog_digest: editingMetadata.catalog_digest,
expected_legacy_export: editingMetadata.expected_legacy_export,
source: {
base_digest: baseDigest,
changes: $state.snapshot(workingChanges),
entrypoints: entrypoints(),
},
};
const signature = draftSignature();
await previewConfigTree(adoptionWorkspaceId, request);
pendingAdoption = { workspaceId: adoptionWorkspaceId, request, draftSignature: signature };
await submitPending(false);
} catch (error) {
recordCommitError(error);
if (pendingAdoption) status = `Adoption outcome unknown: ${String(error)}`;
else recordCommitError(error);
} finally {
busy = false;
}
}
async function discardAndReload() {
if (busy) return;
await reload();
}
async function reloadAndReapply() {
if (!toolchain || !treeState) return;
const localChanges = [...workingChanges];
const remote = await fetchConfigTree(workspaceId);
baseSnapshot = structuredClone(remote.snapshot);
baseDigest = remote.snapshot.digest;
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
if (!toolchain || !treeState || busy || pendingAdoption) return;
busy = true;
const requestedWorkspace = workspaceId;
try {
await stageCurrent();
const localChanges = $state.snapshot(workingChanges);
const observed = await fetchConfigTree(requestedWorkspace);
const remote = observed.state;
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
const candidate = await toolchain.applyChanges(localChanges);
baseSnapshot = structuredClone(remote.snapshot);
baseDigest = remote.snapshot.digest;
editingMetadata = observed;
editingWorkspaceId = requestedWorkspace;
workingChanges = localChanges;
treeState = { ...remote, snapshot: candidate };
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
@@ -258,7 +405,14 @@
status = "Local changes reapplied to the latest source tree. Commit to persist them.";
} catch (error) {
conflict = true;
try {
await toolchain.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
} catch {
analysisReady = false;
}
status = `Local changes conflict with the latest source tree: ${String(error)}. Discard local changes or resolve against a fresh reload.`;
} finally {
busy = false;
}
}
@@ -331,6 +485,7 @@
<button
type="button"
class:active={path === selectedPath}
disabled={busy}
onclick={() => select(path)}
>
<span>{path}</span>
@@ -340,8 +495,8 @@
</nav>
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
<label for="new-config-path">New path</label>
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" />
<button type="submit">Create local source</button>
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" disabled={busy} />
<button type="submit" disabled={busy}>Create local source</button>
</form>
</aside>
@@ -355,7 +510,7 @@
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button class="primary" type="button" onclick={commit} disabled={!dirty || busy}>Commit</button>
<button class="primary" type="button" onclick={commit} disabled={(!dirty && !pendingAdoption && editingMetadata?.source_stored !== false) || busy || !analysisReady}>Commit</button>
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button>
</div>
</header>
@@ -369,10 +524,16 @@
: Promise.resolve(null)}
/>
<p class="config-source-status" aria-live="polite">{status}</p>
{#if conflict}
{#if pendingAdoption}
<div class="config-source-conflict" role="status">
<span>Adoption outcome unknown. Commit resumes the unchanged request; edited drafts require reconciliation first.</span>
<button type="button" onclick={reconcile} disabled={busy}>Reconcile adoption</button>
</div>
{/if}
{#if conflict && !pendingAdoption}
<div class="config-source-conflict" role="alert">
<button type="button" onclick={discardAndReload}>Discard local candidate and reload</button>
<button type="button" onclick={reloadAndReapply}>Reload and reapply local candidate</button>
<button type="button" onclick={discardAndReload} disabled={busy}>Discard local candidate and reload</button>
<button type="button" onclick={reloadAndReapply} disabled={busy}>Reload and reapply local candidate</button>
</div>
{/if}
{#if diagnostics.length > 0}
@@ -4,7 +4,6 @@
* Browserの解析成功はServerのcommit受理を代替しない。
*/
import type {
ConfigCommitRequest,
ConfigContentType,
ConfigEntry,
ConfigProjectionValidator,
@@ -15,6 +14,20 @@ import type {
WorkspaceConfigTreeResponse,
} from "./types.ts";
import type {
ConfigurationAdoptionReceipt,
ConfigurationAdoptionRequest,
ConfigurationPreviewResponse,
ConfigurationReconciliation,
ConfigurationSourceResponse,
} from "#lib/generated/configuration-adoption.generated.ts";
// 未使用のpolicy/catalogを認可判定へ転用しない。editorが使う観測値だけを型へ変換する。
export type ConfigurationEditingState = Pick<
ConfigurationSourceResponse,
"state" | "history_head" | "catalog_digest" | "expected_legacy_export" | "source_stored"
>;
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// Schema bodies are documents, not short metadata. Keep them within the
// response budget; readJson also bounds the combined bundle and contributions.
@@ -277,15 +290,138 @@ export function parseWorkspaceConfigTreeResponse(
};
}
function adoptionUrl(workspaceId: string): string {
return `/api/w/${encodeURIComponent(workspaceId)}/config/adoption`;
}
function nullableDigest(value: unknown): string | null {
return value === null ? null : boundedString(value, MAX_DIGEST_BYTES);
}
// 非表示のpolicy/catalogも大きさと深さを制限するが、その内部を型付け・再解釈しない。
function checkOpaqueData(value: unknown, depth = 0, budget = { nodes: 65536 }): void {
if (depth > 16 || --budget.nodes < 0) throw schemaError();
if (value === null || typeof value === "boolean") return;
if (typeof value === "string") {
boundedString(value);
return;
}
if (typeof value === "number" && Number.isFinite(value)) return;
if (Array.isArray(value)) {
for (const child of boundedArray(value)) checkOpaqueData(child, depth + 1, budget);
return;
}
const entries = Object.entries(objectValue(value));
if (entries.length > MAX_COLLECTION_ITEMS) throw schemaError();
for (const [key, child] of entries) {
boundedString(key);
checkOpaqueData(child, depth + 1, budget);
}
}
export function parseConfigurationSourceResponse(value: unknown): ConfigurationEditingState {
const item = record(value, [
"state", "history_head", "source_stored", "catalog", "catalog_digest",
"expected_legacy_export", "policies", "migration_diagnostics",
]);
if (typeof item.source_stored !== "boolean") throw schemaError();
objectValue(item.catalog);
boundedArray(item.policies);
boundedArray(item.migration_diagnostics);
checkOpaqueData([item.catalog, item.policies, item.migration_diagnostics]);
return {
state: parseWorkspaceConfigTreeResponse(item.state),
history_head: nullableDigest(item.history_head),
source_stored: item.source_stored,
catalog_digest: boundedString(item.catalog_digest, MAX_DIGEST_BYTES),
expected_legacy_export: nullableDigest(item.expected_legacy_export),
};
}
export function parseConfigurationPreviewResponse(value: unknown): ConfigurationPreviewResponse {
const item = record(value, ["expected_head", "state", "changed_paths", "changed_subjects"]);
return {
expected_head: nullableDigest(item.expected_head),
state: parseWorkspaceConfigTreeResponse(item.state),
changed_paths: boundedArray(item.changed_paths).map((path) => boundedString(path, MAX_PATH_BYTES)),
changed_subjects: boundedArray(item.changed_subjects).map((subject) => boundedString(subject)),
};
}
export function parseConfigurationAdoptionReceipt(value: unknown): ConfigurationAdoptionReceipt {
const item = record(value, [
"workspace_id", "operation_id", "history_head", "state", "source_stored",
"policy_committed", "runtime_application",
]);
if (item.source_stored !== true || item.policy_committed !== true ||
item.runtime_application !== "next_current_policy_check") throw schemaError();
return {
workspace_id: boundedString(item.workspace_id),
operation_id: boundedString(item.operation_id),
history_head: boundedString(item.history_head, MAX_DIGEST_BYTES),
state: parseWorkspaceConfigTreeResponse(item.state),
source_stored: true,
policy_committed: true,
runtime_application: "next_current_policy_check",
};
}
export function parseConfigurationReconciliation(value: unknown): ConfigurationReconciliation {
const status = objectValue(value).status;
if (status === "adopted") {
const item = record(value, ["status", "receipt"]);
return { status, receipt: parseConfigurationAdoptionReceipt(item.receipt) };
}
if (status === "pending_transfer") {
const item = record(value, ["status", "transfer_identity", "export_digest"]);
return {
status,
transfer_identity: boundedString(item.transfer_identity),
export_digest: boundedString(item.export_digest, MAX_DIGEST_BYTES),
};
}
if (status === "not_observed") {
record(value, ["status"]);
return { status };
}
throw schemaError();
}
function matchingReceipt(
receipt: ConfigurationAdoptionReceipt,
workspaceId: string,
request: ConfigurationAdoptionRequest,
): ConfigurationAdoptionReceipt {
if (receipt.workspace_id !== workspaceId || receipt.operation_id !== request.operation_id) throw schemaError();
return receipt;
}
async function postAdoption<T>(
workspaceId: string,
action: "preview" | "commit" | "reconcile",
request: ConfigurationAdoptionRequest,
parser: Parser<T>,
fetcher: typeof fetch,
): Promise<T> {
return await readJson(
await fetcher(`${adoptionUrl(workspaceId)}/${action}`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify(request),
}),
parser,
);
}
export async function fetchConfigTree(
workspaceId: string,
fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> {
): Promise<ConfigurationEditingState> {
return await readJson(
await fetcher(sourceTreeUrl(workspaceId), {
await fetcher(adoptionUrl(workspaceId), {
headers: { accept: "application/json" },
}),
parseWorkspaceConfigTreeResponse,
parseConfigurationSourceResponse,
);
}
@@ -318,30 +454,32 @@ export async function fetchConfigHistory(
export async function previewConfigTree(
workspaceId: string,
request: ConfigCommitRequest,
request: ConfigurationAdoptionRequest,
fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> {
return await readJson(
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
parseWorkspaceConfigTreeResponse,
);
): Promise<ConfigurationPreviewResponse> {
const preview = await postAdoption(workspaceId, "preview", request, parseConfigurationPreviewResponse, fetcher);
if (preview.expected_head !== request.expected_head) throw schemaError();
return preview;
}
export async function commitConfigTree(
workspaceId: string,
request: ConfigCommitRequest,
request: ConfigurationAdoptionRequest,
fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> {
return await readJson(
await fetcher(`${sourceTreeUrl(workspaceId)}/commit`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
parseWorkspaceConfigTreeResponse,
): Promise<ConfigurationAdoptionReceipt> {
return matchingReceipt(
await postAdoption(workspaceId, "commit", request, parseConfigurationAdoptionReceipt, fetcher),
workspaceId,
request,
);
}
export async function reconcileConfigTree(
workspaceId: string,
request: ConfigurationAdoptionRequest,
fetcher: typeof fetch = fetch,
): Promise<ConfigurationReconciliation> {
const result = await postAdoption(workspaceId, "reconcile", request, parseConfigurationReconciliation, fetcher);
if (result.status === "adopted") matchingReceipt(result.receipt, workspaceId, request);
return result;
}
@@ -0,0 +1,143 @@
/// <reference lib="deno.ns" />
import { assertEquals, assertRejects, assertThrows } from "jsr:@std/assert";
import {
commitConfigTree,
ConfigSourceApiError,
fetchConfigTree,
parseConfigurationAdoptionReceipt,
parseConfigurationReconciliation,
parseConfigurationSourceResponse,
previewConfigTree,
reconcileConfigTree,
} from "../../src/lib/workspace/config-source/api.ts";
import type { ConfigurationAdoptionRequest } from "#lib/generated/configuration-adoption.generated.ts";
import type { WorkspaceConfigTreeResponse } from "../../src/lib/workspace/config-source/types.ts";
const state: WorkspaceConfigTreeResponse = {
snapshot: { digest: "sha256:content-A", entries: {} },
contract: {
contract_version: 1, decodal_version: "0.4.0", schema_version: 1,
entrypoints: ["main.dcdl"], import_policy_version: 1,
schema_bundle: { contributions: [], source: "builtin", fingerprint: "sha256:schema" },
fingerprint: "sha256:contract",
},
projection_digest: "sha256:projection",
};
const source = {
state, history_head: null, source_stored: false,
catalog: { workspace: "w/one", namespaces: [] },
catalog_digest: "sha256:catalog", expected_legacy_export: "sha256:legacy",
policies: [], migration_diagnostics: [],
};
const request: ConfigurationAdoptionRequest = {
operation_id: "operation:one", expected_head: "head:A-after-B",
expected_catalog_digest: source.catalog_digest,
expected_legacy_export: source.expected_legacy_export,
source: { base_digest: state.snapshot.digest, changes: [], entrypoints: ["main.dcdl"] },
};
const receipt = {
workspace_id: "w/one", operation_id: request.operation_id, history_head: "head:adopted",
state, source_stored: true, policy_committed: true, runtime_application: "next_current_policy_check",
};
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
const replies = (...bodies: unknown[]) =>
(() => Promise.resolve(json(bodies.shift()))) as typeof fetch;
Deno.test("adoption source parser retains nullable observed metadata separately from content identity", () => {
const parsed = parseConfigurationSourceResponse(source);
assertEquals(parsed.state, state);
assertEquals(parsed.history_head, null);
assertEquals(parsed.source_stored, false);
assertEquals(parsed.expected_legacy_export, "sha256:legacy");
// A -> B -> A can retain content-A while having a different editing head.
assertEquals(parseConfigurationSourceResponse({ ...source, history_head: "head:A-after-B" }).history_head, request.expected_head);
});
Deno.test("adoption source parser rejects missing metadata, unknown envelope fields and unbounded opaque collections", () => {
const { history_head: _head, ...missingHead } = source;
const { expected_legacy_export: _export, ...missingExport } = source;
for (const invalid of [
state, missingHead, missingExport,
{ ...source, unexpected: true }, { ...source, history_head: 1 },
{ ...source, catalog_digest: "x".repeat(257) },
{ ...source, expected_legacy_export: false }, { ...source, source_stored: null },
{ ...source, policies: {} }, { ...source, migration_diagnostics: Array(257).fill({}) },
{ ...source, catalog: { namespaces: Array(257).fill({}) } },
{ ...source, state: { ...state, snapshot: { ...state.snapshot, unexpected: true } } },
]) assertThrows(() => parseConfigurationSourceResponse(invalid), ConfigSourceApiError);
});
Deno.test("adoption transport posts the full unchanged request to owner adoption routes, including reconcile", async () => {
const calls: Array<{ url: string; body?: BodyInit | null }> = [];
const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
calls.push({ url, body: init?.body });
const body = url.endsWith("/preview")
? { expected_head: request.expected_head, state, changed_paths: [], changed_subjects: [] }
: url.endsWith("/commit") ? receipt
: url.endsWith("/reconcile") ? { status: "adopted", receipt }
: source;
return Promise.resolve(json(body));
}) as typeof fetch;
await fetchConfigTree("w/one", fetcher);
await previewConfigTree("w/one", request, fetcher);
await commitConfigTree("w/one", request, fetcher);
await reconcileConfigTree("w/one", request, fetcher);
assertEquals(calls.map(({ url }) => url), [
"/api/w/w%2Fone/config/adoption", "/api/w/w%2Fone/config/adoption/preview",
"/api/w/w%2Fone/config/adoption/commit", "/api/w/w%2Fone/config/adoption/reconcile",
]);
for (const call of calls.slice(1)) assertEquals(JSON.parse(String(call.body)), request);
});
Deno.test("preview does not refresh an editing head from an unexpected response", async () => {
await assertRejects(() => previewConfigTree("w/one", request, replies({
expected_head: "head:new", state, changed_paths: [], changed_subjects: [],
})), ConfigSourceApiError, "invalid response");
assertEquals(request.expected_head, "head:A-after-B");
});
Deno.test("receipt requires committed source and policy and matches the requested workspace and operation", async () => {
for (const invalid of [
{ ...receipt, source_stored: false }, { ...receipt, policy_committed: false },
{ ...receipt, runtime_application: "already_running" },
{ ...receipt, history_head: null }, { ...receipt, extra: true },
]) assertThrows(() => parseConfigurationAdoptionReceipt(invalid), ConfigSourceApiError);
for (const invalid of [
{ ...receipt, workspace_id: "other" }, { ...receipt, operation_id: "other" },
]) {
await assertRejects(() => commitConfigTree("w/one", request, replies(invalid)), ConfigSourceApiError);
await assertRejects(() => reconcileConfigTree("w/one", request, replies({ status: "adopted", receipt: invalid })), ConfigSourceApiError);
}
});
Deno.test("not_observed and pending_transfer remain distinct unknown evidence, never a failed-save receipt", () => {
for (const evidence of [
{ status: "not_observed" },
{ status: "pending_transfer", transfer_identity: "transfer:one", export_digest: "sha256:legacy" },
{ status: "adopted", receipt },
]) assertEquals<unknown>(parseConfigurationReconciliation(evidence), evidence);
for (const invalid of [
{ status: "failed" }, { status: "not_observed", receipt },
{ status: "pending_transfer", transfer_identity: "transfer:one" },
{ status: "adopted" },
]) assertThrows(() => parseConfigurationReconciliation(invalid), ConfigSourceApiError);
});
Deno.test("lost or invalid responses do not retry or change request identity inside the adapter", async () => {
for (const failure of ["network", "malformed", "oversized", "server"]) {
let calls = 0;
const fetcher = (() => {
calls++;
if (failure === "network") return Promise.reject(new TypeError("response lost"));
return Promise.resolve(failure === "malformed" ? new Response("not JSON")
: failure === "oversized" ? new Response("{}", { headers: { "content-length": String(8 * 1024 * 1024 + 1) } })
: new Response("outcome unavailable", { status: 500 }));
}) as typeof fetch;
await assertRejects(() => commitConfigTree("w/one", request, fetcher));
assertEquals(calls, 1);
assertEquals(request.operation_id, "operation:one");
assertEquals(request.expected_head, "head:A-after-B");
}
});
+153 -63
View File
@@ -8,13 +8,24 @@ import {
} from "jsr:@std/assert";
import {
commitConfigTree,
type ConfigurationEditingState,
ConfigSourceApiError,
fetchConfigEntry,
fetchConfigHistory,
fetchConfigTree,
parseWorkspaceConfigTreeResponse,
previewConfigTree,
reconcileConfigTree,
} from "../../src/lib/workspace/config-source/api.ts";
import type {
ConfigurationAdoptionReceipt,
ConfigurationAdoptionRequest,
ConfigurationPreviewResponse,
} from "../../src/lib/generated/configuration-adoption.generated.ts";
import type {
ConfigEntry,
WorkspaceConfigTreeResponse,
} from "../../src/lib/workspace/config-source/types.ts";
function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -23,7 +34,7 @@ function response(body: unknown, status = 200): Response {
});
}
const entry = {
const entry: ConfigEntry = {
path: "profiles/main.dcdl",
content_type: "decodal",
content: "profile = {}",
@@ -33,7 +44,7 @@ const snapshot = {
digest: "sha256:tree",
entries: { "profiles/main.dcdl": entry },
};
const tree = {
const tree: WorkspaceConfigTreeResponse = {
snapshot,
contract: {
contract_version: 1,
@@ -51,38 +62,119 @@ const tree = {
projection_digest: "sha256:projection",
};
Deno.test("config source API commits directly through the workspace scope", async () => {
function editingState(
state: WorkspaceConfigTreeResponse = tree,
): ConfigurationEditingState {
return {
state,
history_head: "head:observed",
catalog_digest: "sha256:catalog",
expected_legacy_export: "sha256:legacy",
source_stored: false,
};
}
function sourceResponse(state: unknown = tree) {
return {
...editingState(),
state,
catalog: {},
policies: [],
migration_diagnostics: [],
};
}
function adoptionRequest(
source: ConfigurationAdoptionRequest["source"] = {
base_digest: snapshot.digest,
changes: [],
entrypoints: ["main.dcdl"],
},
): ConfigurationAdoptionRequest {
const observed = editingState();
return {
operation_id: "operation:one",
expected_head: observed.history_head,
expected_catalog_digest: observed.catalog_digest,
expected_legacy_export: observed.expected_legacy_export,
source,
};
}
function adoptionReceipt(
workspaceId: string,
request: ConfigurationAdoptionRequest,
state: WorkspaceConfigTreeResponse = tree,
): ConfigurationAdoptionReceipt {
return {
workspace_id: workspaceId,
operation_id: request.operation_id,
history_head: "head:adopted",
state,
source_stored: true,
policy_committed: true,
runtime_application: "next_current_policy_check",
};
}
Deno.test("config source API loads adoption metadata and posts the full request while retaining history and entry routes", async () => {
const request = adoptionRequest({
base_digest: snapshot.digest,
changes: [{
kind: "update",
path: entry.path,
expected_digest: entry.content_digest,
content: "profile = { changed = true }",
}],
entrypoints: ["main.dcdl"],
});
const receipt = adoptionReceipt("w/one", request);
const preview: ConfigurationPreviewResponse = {
expected_head: request.expected_head,
state: tree,
changed_paths: [entry.path],
changed_subjects: ["subject:one"],
};
const calls: Array<{ url: string; init?: RequestInit }> = [];
const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
calls.push({ url, init });
if (url.includes("/entries/")) return Promise.resolve(response(entry));
if (url.includes("/history/")) return Promise.resolve(response(snapshot));
return Promise.resolve(response(tree));
if (url.endsWith("/preview")) return Promise.resolve(response(preview));
if (url.endsWith("/commit")) return Promise.resolve(response(receipt));
if (url.endsWith("/reconcile")) {
return Promise.resolve(response({ status: "adopted", receipt }));
}
return Promise.resolve(response(sourceResponse()));
}) as typeof fetch;
await fetchConfigTree("w/one", fetcher);
await fetchConfigHistory("w/one", "sha256:tree", fetcher);
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher);
await commitConfigTree("w/one", {
base_digest: "sha256:base",
changes: [],
entrypoints: [],
}, fetcher);
assertEquals(await fetchConfigTree("w/one", fetcher), editingState());
assertEquals(await fetchConfigHistory("w/one", "sha256:tree", fetcher), snapshot);
assertEquals(await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher), entry);
assertEquals(await previewConfigTree("w/one", request, fetcher), preview);
assertEquals(await commitConfigTree("w/one", request, fetcher), receipt);
assertEquals(await reconcileConfigTree("w/one", request, fetcher), {
status: "adopted",
receipt,
});
assertEquals<unknown>(calls.map((call) => call.url), [
"/api/w/w%2Fone/config/source-tree",
assertEquals(calls.map((call) => call.url), [
"/api/w/w%2Fone/config/adoption",
"/api/w/w%2Fone/config/source-tree/history/sha256%3Atree",
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
"/api/w/w%2Fone/config/source-tree/commit",
"/api/w/w%2Fone/config/adoption/preview",
"/api/w/w%2Fone/config/adoption/commit",
"/api/w/w%2Fone/config/adoption/reconcile",
]);
assertEquals<unknown>(calls[3].init?.method, "POST");
assert(
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'),
);
assertEquals(JSON.parse(String(calls[3].init?.body)), {
base_digest: "sha256:base", changes: [], entrypoints: [],
});
for (const call of calls.slice(3)) {
assertEquals(call.init?.method, "POST");
assertEquals(
new Headers(call.init?.headers).get("content-type"),
"application/json",
);
assertEquals(JSON.parse(String(call.init?.body)), request);
}
});
function withSchema(source: unknown, contributionSource: unknown = "builtin") {
@@ -111,15 +203,19 @@ for (const field of ["bundle", "contribution"] as const) {
const body = field === "bundle"
? withSchema(source)
: withSchema("builtin", source);
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body);
const request = adoptionRequest();
const receipt = { ...adoptionReceipt("w", request), state: body };
const fetcher = ((input: string | URL | Request) =>
Promise.resolve(response(
String(input).endsWith("/commit") ? receipt : sourceResponse(body),
))) as typeof fetch;
assertEquals<unknown>(await fetchConfigTree("w", fetcher), {
...editingState(),
state: body,
});
assertEquals<unknown>(
await commitConfigTree("w", {
base_digest: snapshot.digest,
changes: [],
entrypoints: ["main.dcdl"],
}, fetcher),
body,
await commitConfigTree("w", request, fetcher),
receipt,
);
});
@@ -159,7 +255,7 @@ Deno.test("config source API retains metadata and total response limits", async
const fetcher = (() =>
Promise.resolve(
new Response(
JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))),
JSON.stringify(sourceResponse(withSchema("x".repeat(8 * 1024 * 1024)))),
{ headers },
),
)) as typeof fetch;
@@ -172,9 +268,10 @@ Deno.test("config source API retains metadata and total response limits", async
});
Deno.test("config source API rejects unknown response fields", async () => {
const fetcher =
(() =>
Promise.resolve(response({ ...tree, unexpected: true }))) as typeof fetch;
const fetcher = (() =>
Promise.resolve(response(
sourceResponse({ ...tree, unexpected: true }),
))) as typeof fetch;
await assertRejects(
() => fetchConfigTree("w", fetcher),
ConfigSourceApiError,
@@ -190,7 +287,7 @@ Deno.test("config source API rejects mismatched entry map paths", async () => {
entries: { "profiles/other.dcdl": entry },
},
};
const fetcher = (() => Promise.resolve(response(invalid))) as typeof fetch;
const fetcher = (() => Promise.resolve(response(sourceResponse(invalid)))) as typeof fetch;
await assertRejects(
() => fetchConfigTree("w", fetcher),
ConfigSourceApiError,
@@ -240,11 +337,11 @@ Deno.test("config source API does not materialize imported builtins in the works
},
},
};
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
const fetcher = (() => Promise.resolve(response(sourceResponse(body)))) as typeof fetch;
const result = await fetchConfigTree("w", fetcher);
assertEquals(Object.keys(result.snapshot.entries), ["profiles/main.dcdl"]);
assertEquals(Object.keys(result.state.snapshot.entries), ["profiles/main.dcdl"]);
assertEquals(
result.snapshot.entries["profiles/main.dcdl"].content,
result.state.snapshot.entries["profiles/main.dcdl"].content,
body.snapshot.entries["profiles/main.dcdl"].content,
);
});
@@ -269,16 +366,12 @@ Deno.test("config source API preserves unknown read-only builtin diagnostics wit
}) as typeof fetch;
const error = await assertRejects(
() =>
commitConfigTree("w", {
base_digest: "sha256:tree",
changes: [],
entrypoints: ["main.dcdl"],
}, fetcher),
commitConfigTree("w", adoptionRequest(), fetcher),
ConfigSourceApiError,
diagnostic.message,
);
assert(String(error).includes("main.dcdl"));
assertEquals(calls, ["/api/w/w/config/source-tree/commit"]);
assertEquals(calls, ["/api/w/w/config/adoption/commit"]);
});
Deno.test("config source API surfaces bounded failed evaluation details", async () => {
@@ -286,17 +379,12 @@ Deno.test("config source API surfaces bounded failed evaluation details", async
Promise.resolve(
new Response("structured diagnostics", { status: 422 }),
)) as typeof fetch;
let message = "";
try {
await commitConfigTree("w", {
base_digest: "sha256:base",
changes: [],
entrypoints: [],
}, fetcher);
} catch (error) {
message = String(error);
}
assert(message.includes("structured diagnostics"));
const error = await assertRejects(
() => commitConfigTree("w", adoptionRequest(), fetcher),
ConfigSourceApiError,
"structured diagnostics",
);
assertEquals(error.status, 422);
});
Deno.test("config source API carries bounded optional authoring schemas without changing validation source", () => {
@@ -337,11 +425,13 @@ Deno.test("config source API carries bounded optional authoring schemas without
}
});
Deno.test("config source API previews a candidate without calling commit", async () => {
const request = {
base_digest: "sha256:base",
changes: [],
entrypoints: ["main.dcdl"],
Deno.test("config source API previews adoption change scope without calling commit", async () => {
const request = adoptionRequest();
const preview: ConfigurationPreviewResponse = {
expected_head: request.expected_head,
state: tree,
changed_paths: [entry.path],
changed_subjects: ["subject:one"],
};
const calls: string[] = [];
const result = await previewConfigTree(
@@ -351,9 +441,9 @@ Deno.test("config source API previews a candidate without calling commit", async
calls.push(String(input));
assertEquals(init?.method, "POST");
assertEquals(JSON.parse(String(init?.body)), request);
return Promise.resolve(response(tree));
return Promise.resolve(response(preview));
}) as typeof fetch,
);
assertEquals(calls, ["/api/w/w%2Fone/config/source-tree/preview"]);
assertEquals<unknown>(result, tree);
assertEquals(calls, ["/api/w/w%2Fone/config/adoption/preview"]);
assertEquals(result, preview);
});
@@ -137,7 +137,7 @@ Deno.test("main entrypoint always enables the fixed schema wrapper", async () =>
);
});
Deno.test("commit formats every working Decodal source without a preview roundtrip", async () => {
Deno.test("config adoption formats changed sources and validates a request bound to observed editing metadata", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
@@ -154,16 +154,59 @@ Deno.test("commit formats every working Decodal source without a preview roundtr
"all changed Decodal entries should be formatted rather than only the selected source",
);
assert(
source.includes("await formatWorkingSources();") &&
source.includes("entrypoints: entrypoints()") &&
source.includes("Committed formatted source tree"),
"commit should format first and send the working changes directly to Backend authority",
source.includes("expected_head: editingMetadata.history_head") &&
source.includes("expected_catalog_digest: editingMetadata.catalog_digest") &&
source.includes("expected_legacy_export: editingMetadata.expected_legacy_export") &&
source.includes("base_digest: baseDigest") &&
source.includes("entrypoints: entrypoints()"),
"adoption should carry the observed history head and migration conditions separately from the source digest",
);
const commit = source.slice(
source.indexOf("async function commit()"),
source.indexOf("async function discardAndReload()"),
);
const formatted = commit.indexOf("await formatWorkingSources()");
const validated = commit.indexOf("await previewConfigTree(");
const submitted = commit.indexOf("await submitPending(false)");
assert(
formatted >= 0 && validated > formatted && submitted > validated,
"a new adoption should format and validate with Server authority before submission; local analysis alone is insufficient",
);
});
Deno.test("config editor wires retained unknown requests to an explicit reconciliation action", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
import.meta.url,
),
);
assert(
source.includes("reconcileConfigTree(pending.workspaceId, pending.request)") &&
source.includes("commitConfigTree(pending.workspaceId, pending.request)"),
"reconciliation and explicit resubmission should use the retained workspace and complete request, not a new operation identity",
);
const reconciliation = source.slice(
source.indexOf("async function reconcilePending("),
source.indexOf("async function submitPending("),
);
assert(
!source.includes("previewConfigTree") &&
!source.includes("requestCandidatePreview") &&
!source.includes("toolchain_fingerprint"),
"normal commit must not depend on a separate preview or client-echoed toolchain fingerprint",
reconciliation.includes('result.status === "adopted"') &&
reconciliation.includes("applyReceipt(result.receipt)") &&
!reconciliation.includes("pendingAdoption = null"),
"unknown evidence must leave the request retained; only an adopted receipt can be applied",
);
const action = source.slice(
source.indexOf("async function reconcile()"),
source.indexOf("async function commit()"),
);
assert(
source.includes("onclick={reconcile}") &&
action.includes("await reconcilePending()") &&
!action.includes("commitConfigTree(") &&
!action.includes("submitPending("),
"the explicit reconciliation control must look up the retained outcome without submitting an adoption",
);
});