chore: retain pinned configuration and Session prerequisites

Preserve the already-applied dependency tree from T838 source 9f8aad79a3, including its T840 prerequisites. This is dependency attribution for the incomplete T841 checkpoint, not adoption of later dependency changes, inherited approval, integration, or a target-branch update.
This commit is contained in:
2026-11-04 20:33:09 +09:00
parent acbaaaa1d6
commit c6756bba2e
54 changed files with 10460 additions and 282 deletions
Generated
+1
View File
@@ -7579,6 +7579,7 @@ dependencies = [
"content-history", "content-history",
"content-history-adapters", "content-history-adapters",
"content-history-store", "content-history-store",
"feature-permissions",
"feature-storage", "feature-storage",
"flate2", "flate2",
"flow", "flow",
+42
View File
@@ -3,6 +3,48 @@
//! Causes contain only fixed enum values. Stored or transferred attachments are //! Causes contain only fixed enum values. Stored or transferred attachments are
//! observations, never permission to disclose: Browser/Tool projections require //! observations, never permission to disclose: Browser/Tool projections require
//! the caller's current visibility decision and fail closed without it. //! the caller's current visibility decision and fail closed without it.
//!
//! # Projection boundary
//!
//! The same closed cause is mapped explicitly into the shared error-contract codec.
//! The transport boundary does not convert an internal Session denial into an HTTP
//! verdict about a Browser operator. Public consumers must resolve current viewer
//! authority before projecting; a saved attachment cannot carry that authority.
//!
//! | Contract fact | Internal transfer / storage | Authorized Browser / Tool |
//! | --- | --- | --- |
//! | Domain, reason, principal, required operation | Typed cause | Same cause, never inferred from prose |
//! | Explanation | Fixed local template | Fixed local template, not received text |
//! | Operation context | Allowlisted operation/stage pairs | Same allowlist, no operation references |
//! | Subject, Session, Worker IDs; credentials; paths; raw HTTP body | Not representable in this cause | Not representable in this cause |
//! | Resource ownership, persistence, secondary cleanup | Separate startup result facts | Not disclosure/retry authority |
//!
//! A denied current visibility decision fails projection instead of substituting
//! an invented cause. Unknown codec versions and corrupt attachments stay distinct
//! from known causes. Missing legacy attachments do not certify legacy diagnostics.
//!
//! # Failure investigation
//!
//! A known denial identifies the operation and principal class, not an instruction
//! to grant rights. Check the current Subject execution policy and the authenticated
//! Runtime/Worker binding at the Session-record authorization boundary. Compare
//! existing Session attribution and saved Worker metadata before deciding whether
//! any state repair is needed. Operator authority and Subject execution policy are
//! different decisions; neither can be derived from an internal HTTP status alone.
//!
//! Transport and unknown outcomes do not establish non-execution. Reconcile the
//! existing Session attribution, Worker aggregate and Runtime operation result;
//! a lost response may coexist with a committed write. A conflict invalidates the
//! old metadata expectation. Do not retry the startup, create another Worker, or
//! enlarge permissions solely because a failure was displayed. Inspect primary,
//! secondary, persistence and resource facts independently: rollback failure must
//! not replace the refusal, and persistence uncertainty must not be called saved.
//!
//! The real authorization/factory fixture is owned by workspace-server's normal
//! `session_registration_connected` tests. Its signed Session callback revokes the
//! real policy at a synchronization point; a real retention pin forces independent
//! cleanup failure. Leaf transport/projection tests do not substitute for that
//! product connection, and the fixture claims neither a process crash nor restore.
use error_contract::{ use error_contract::{
Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity, Browser, Category, ContractError, Disclosure, FailureCause, FailureFacts, FailureIdentity,
+16
View File
@@ -105,10 +105,25 @@ pub struct RuntimeApiError {
status: u16, status: u16,
} }
/// Transient, allowlisted startup observations copied from the execution owner.
/// This is not an operation result, lifecycle record, or permission to retry.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerStartupFailure {
pub primary: StartupFailureAttachment,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secondary: Vec<StartupSecondaryFailure>,
pub resources: Option<StartupResourceFacts>,
pub persistence: Option<StartupPersistenceOutcome>,
pub io_kind: Option<StartupIoKind>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RuntimeApiErrorDetail { pub struct RuntimeApiErrorDetail {
pub code: String, pub code: String,
pub message: String, pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup: Option<WorkerStartupFailure>,
} }
impl RuntimeApiError { impl RuntimeApiError {
@@ -117,6 +132,7 @@ impl RuntimeApiError {
error: RuntimeApiErrorDetail { error: RuntimeApiErrorDetail {
code: code.into(), code: code.into(),
message: message.into(), message: message.into(),
startup: None,
}, },
status, status,
} }
+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 - [`ServerApi`] — HTTP operations and transport metadata
- [`ServerRequestContext`] — Request context passed to Server authentication adapters - [`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 - [`RequestActor`] — Human identity and authentication method
- [`WorkerRestoreRequest`] / [`WorkerRestoreResult`] — Observation-based Restore intent and result - [`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, ScopedPermission, SubjectPermissions }} from \"./legacy-server-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 use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization; pub mod authorization;
mod chat; mod chat;
pub mod configuration_adoption;
pub mod feature_permissions; pub mod feature_permissions;
pub use chat::*; pub use chat::*;
pub use configuration_adoption::*;
mod subject_body; mod subject_body;
pub use subject_body::*; pub use subject_body::*;
mod chat_transport; 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 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,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])?; 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) tx.execute("INSERT INTO subject_deleted_sessions(subject_id,session_id)
SELECT subject_id,session_id FROM subject_session_attributions WHERE subject_id=?1 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 UNION SELECT subject_id,source_session_id FROM journals WHERE subject_id=?1
@@ -401,6 +406,12 @@ impl SubjektivStore {
[subject], [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 { for table in DIRECT {
tx.execute( tx.execute(
&format!("DELETE FROM {table} WHERE subject_id=?1"), &format!("DELETE FROM {table} WHERE subject_id=?1"),
+13 -1
View File
@@ -8,6 +8,10 @@ pub use deletion::{DeletionSubjectJob, SubjectDeletion};
pub mod journal; pub mod journal;
mod legacy_migrations; mod legacy_migrations;
pub mod permissions; pub mod permissions;
pub use permissions::{
CanonicalPermissionSource, DatabaseIdentity, PermissionSourceExport, PermissionTransferReceipt,
PermissionTransferSnapshot,
};
mod schema; mod schema;
mod subject_creation; mod subject_creation;
use legacy_migrations::{ use legacy_migrations::{
@@ -95,6 +99,10 @@ pub enum SubjektivError {
SubjectPermissionsConflict(String), SubjectPermissionsConflict(String),
#[error("permission operation `{0}` was reused with different input")] #[error("permission operation `{0}` was reused with different input")]
PermissionOperationConflict(String), 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}")] #[error("authorization denied: {0}")]
AuthorizationDenied(String), AuthorizationDenied(String),
#[error("Worker execution grant binding conflicts for `{0}`")] #[error("Worker execution grant binding conflicts for `{0}`")]
@@ -689,6 +697,7 @@ pub const REGISTRATION: FeatureRegistration =
#[derive(Clone)] #[derive(Clone)]
pub struct SubjektivStore { pub struct SubjektivStore {
database: FeatureDatabase, database: FeatureDatabase,
canonical_permission_source: Option<std::sync::Arc<dyn permissions::CanonicalPermissionSource>>,
} }
impl SubjektivStore { impl SubjektivStore {
@@ -746,7 +755,10 @@ impl SubjektivStore {
} }
} }
})?; })?;
Ok(Self { database }) Ok(Self {
database,
canonical_permission_source: None,
})
} }
pub fn scope_id(&self) -> &str { pub fn scope_id(&self) -> &str {
+30 -13
View File
@@ -12,6 +12,12 @@ use server_api::authorization::{
}; };
use sha2::{Digest, Sha256}; 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 const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000;
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { 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, /// Unset sources are `None` (deny), including migrated Subjects. This is an admin read,
/// not an effective-authorization decision; retired Subjects retain their source evidence. /// not an effective-authorization decision; retired Subjects retain their source evidence.
pub fn subject_permissions(&self, subject_id: &str) -> Result<Option<SubjectPermissions>> { 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)?; 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 /// Management replacement rejects new unsupported operation/resource pairs while
/// retaining exact legacy entries in their original execution/delegation lane. /// retaining exact legacy entries in their original execution/delegation lane.
/// Validation shares the existing CAS/receipt transaction so a stale editor cannot /// 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( pub fn set_managed_subject_permissions(
&self, &self,
subject_id: &str, subject_id: &str,
@@ -71,6 +78,7 @@ impl SubjektivStore {
.map(|byte| format!("{byte:02x}")) .map(|byte| format!("{byte:02x}"))
.collect(); .collect();
self.database.try_transaction(|tx| { self.database.try_transaction(|tx| {
transfer::require_unsealed(tx)?;
require_subject(tx, subject_id)?; require_subject(tx, subject_id)?;
let receipt: Option<(String, String)> = tx.query_row( let receipt: Option<(String, String)> = tx.query_row(
"SELECT request_fingerprint, change_id FROM subject_permission_receipts WHERE operation_id=?1", "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)?; 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"))?; .ok_or_else(|| denied("Subject permissions are unset"))?;
if source.change_id != grant.source_change_id { if source.change_id != grant.source_change_id {
return Err(denied("Subject permission source is no longer current")); 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.permissions)?;
require_covered(&origin.redelegation, &grant.redelegation)?; 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)?])?; params![grant.runtime_id, grant.worker_id, grant.subject_id, grant.source_change_id, serde_json::to_string(grant)?])?;
Ok(()) Ok(())
}) })
@@ -164,9 +177,12 @@ impl SubjektivStore {
validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?; validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?;
self.database.try_with_connection(|connection| { self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?; require_subject_in_connection(connection, subject_id)?;
let mut statement = connection.prepare( let sql = if transfer::has_table(connection, "canonical_worker_execution_grants")? {
"SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2", "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 rows = statement.query(params![subject_id, MAX_SUBJECT_WORKER_EXECUTION_GRANTS + 1])?;
let mut grants = Vec::new(); let mut grants = Vec::new();
while let Some(row) = rows.next()? { while let Some(row) = rows.next()? {
@@ -216,12 +232,13 @@ fn grant_in_connection(
// Absence must never become non-Subject fallback authority after purge. // Absence must never become non-Subject fallback authority after purge.
deletion::require_live_worker(connection, runtime, worker)?; 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 let raw: Option<String> = connection
.query_row( .query_row(sql, params![runtime, worker], |row| row.get(0))
"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),
)
.optional()?; .optional()?;
raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)) raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into))
.transpose() .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); drop(db);
scope.shutdown().unwrap(); 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)], applied: &[(u32, String)],
) -> feature_storage::Result<()> { ) -> feature_storage::Result<()> {
let format = recognize(applied)?; 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)?; let actual = domain_shape(tx)?;
if actual != expected { if actual != expected {
let difference = actual let difference = actual
@@ -115,9 +124,17 @@ fn domain_shape(connection: &Connection) -> feature_storage::Result<Vec<SchemaOb
Ok(shape) Ok(shape)
} }
#[cfg(test)]
fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>> { 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. // 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 { let index = match format {
Format::Common(count) => count, Format::Common(count) => count,
Format::Permission18 => 18, Format::Permission18 => 18,
@@ -127,7 +144,7 @@ fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>>
Format::Consolidation20 => 22, Format::Consolidation20 => 22,
Format::Fixed20 => 23, Format::Fixed20 => 23,
Format::Cancellation20 => 24, Format::Cancellation20 => 24,
}; } + if transfer { 25 } else { 0 };
if let Some(shape) = SHAPES[index].get() { if let Some(shape) = SHAPES[index].get() {
return Ok(shape.clone()); return Ok(shape.clone());
} }
@@ -184,11 +201,66 @@ fn reference_shape(format: Format) -> feature_storage::Result<Vec<SchemaObject>>
tx.execute_batch(CONSOLIDATION_SQL)?; tx.execute_batch(CONSOLIDATION_SQL)?;
} }
} }
if transfer {
install_transfer_shape(&tx, format == Format::Permission18)?;
}
let shape = domain_shape(&tx)?; let shape = domain_shape(&tx)?;
let _ = SHAPES[index].set(shape.clone()); let _ = SHAPES[index].set(shape.clone());
Ok(shape) 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] = [ const PERMISSION_FENCE_TABLES: [&str; 4] = [
"subject_permissions", "subject_permissions",
"subject_permission_changes", "subject_permission_changes",
@@ -196,7 +268,9 @@ const PERMISSION_FENCE_TABLES: [&str; 4] = [
"worker_execution_grants", "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. // 9edebfa5936f2411adb6affffc793d3e93cc21bd:crates/subjektiv/src/permissions/authority.rs.
// Journal/consolidation SQL is from 7b39a44ca698c474287e5511f674f2e8baae4c03: // Journal/consolidation SQL is from 7b39a44ca698c474287e5511f674f2e8baae4c03:
// crates/subjektiv/src/{consolidation_write_bindings.sql,journal/final_scope.sql, // 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. //! Before sealing, new Subjects receive their self-scoped source in the creation transaction.
//! This path is never called by migration, launch, restore or permission reads. Receipts //! After sealing, creation retains enrollment evidence only; current reads never revive defaults.
//! retain only the issued identity and input fingerprint across erasure, preventing replay //! 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. //! from recreating an erased Subject or restoring a revoked policy.
use super::*; use super::*;
use server_api::authorization::{SubjectPermissions, subject_conversation_permissions}; use server_api::authorization::{SubjectPermissions, subject_conversation_permissions};
@@ -18,6 +19,25 @@ pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
} }
impl SubjektivStore { 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( pub(super) fn create_subject_request(
&self, &self,
role: SubjectRole, role: SubjectRole,
@@ -60,10 +80,20 @@ impl SubjektivStore {
execution: subject_conversation_permissions(self.scope_id(), &record.id), execution: subject_conversation_permissions(self.scope_id(), &record.id),
delegation: vec![], delegation: vec![],
}; };
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)", let sealed = permissions::transfer::is_sealed(tx)?;
params![record.id,source.change_id,serde_json::to_string(&source)?])?; if !sealed {
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?; tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
if let Some(operation) = operation_id { 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])?; tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?;
} }
Ok(record) Ok(record)
+38 -1
View File
@@ -542,6 +542,8 @@ pub struct RuntimeHttpErrorResponse {
pub struct RuntimeHttpErrorDetail { pub struct RuntimeHttpErrorDetail {
pub code: String, pub code: String,
pub message: String, pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup: Option<runtime_api::WorkerStartupFailure>,
} }
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
@@ -2325,6 +2327,7 @@ struct RuntimeHttpRestError {
status: StatusCode, status: StatusCode,
code: String, code: String,
message: String, message: String,
startup: Option<runtime_api::WorkerStartupFailure>,
} }
impl RuntimeHttpRestError { impl RuntimeHttpRestError {
@@ -2333,13 +2336,28 @@ impl RuntimeHttpRestError {
status, status,
code: code.into(), code: code.into(),
message: message.into(), message: message.into(),
startup: None,
} }
} }
fn runtime(error: RuntimeError) -> Self { fn runtime(error: RuntimeError) -> Self {
let status = status_for_runtime_error(&error); let status = status_for_runtime_error(&error);
let code = code_for_runtime_error(&error); let code = code_for_runtime_error(&error);
Self::new(status, code, error.to_string()) let startup = match &error {
RuntimeError::WorkerExecutionRejected { result, .. } => startup_transfer(result),
_ => None,
};
let message = if startup.is_some() {
"Worker startup failed".to_owned()
} else {
error.to_string()
};
Self {
status,
code,
message,
startup,
}
} }
fn working_directory_join(_error: tokio::task::JoinError) -> Self { fn working_directory_join(_error: tokio::task::JoinError) -> Self {
@@ -2375,12 +2393,31 @@ impl IntoResponse for RuntimeHttpRestError {
error: RuntimeHttpErrorDetail { error: RuntimeHttpErrorDetail {
code: self.code, code: self.code,
message: self.message, message: self.message,
startup: self.startup,
}, },
}; };
(self.status, Json(body)).into_response() (self.status, Json(body)).into_response()
} }
} }
/// Explicit transfer mapping, not another execution/lifecycle result. HTTP request
/// authentication has already established this internal Runtime caller's scope.
fn startup_transfer(
result: &crate::execution::WorkerExecutionResult,
) -> Option<runtime_api::WorkerStartupFailure> {
let primary = worker::startup_failure::reproject_internal_startup_failure(
result.startup_failure.as_ref()?,
)
.ok()?;
Some(runtime_api::WorkerStartupFailure {
primary,
secondary: result.secondary_failures.clone(),
resources: result.startup_resources,
persistence: result.startup_failure_persistence,
io_kind: result.startup_io_kind,
})
}
fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
match error { match error {
RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => { RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => {
@@ -224,7 +224,10 @@ where
} }
fn api_error(error: RuntimeHttpRestError) -> runtime_api::RuntimeApiError { fn api_error(error: RuntimeHttpRestError) -> runtime_api::RuntimeApiError {
runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message) let mut response =
runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message);
response.error.startup = error.startup;
response
} }
impl runtime_api::RuntimeApi for RuntimeManagementApi { impl runtime_api::RuntimeApi for RuntimeManagementApi {
@@ -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)] #[cfg(test)]
mod tests; mod tests;
#[path = "builtin/permission_catalog.rs"]
mod builtin_catalog;
pub use builtin_catalog::builtin_permission_host;
+14
View File
@@ -106,6 +106,20 @@ pub fn runtime_startup_attachment(
StartupFailureAttachment::from_projected(&projected) StartupFailureAttachment::from_projected(&projected)
} }
/// The internal HTTP/Host transfer admits only implemented startup domains.
/// Unknown/corrupt envelopes cannot be blindly forwarded or classified from text.
pub fn reproject_internal_startup_failure(
failure: &StartupFailureAttachment,
) -> Result<StartupFailureAttachment, error_contract::ContractError> {
if let Ok(projected) = failure
.project_for::<InternalTransfer, protocol::session_failure::SessionRegistrationCause>(&())
{
return StartupFailureAttachment::from_projected(&projected);
}
let projected = failure.project_for::<InternalTransfer, RuntimeStartupCause>(&())?;
StartupFailureAttachment::from_projected(&projected)
}
/// Dispatch by the domain attested in process, not by parsing attachment fields. /// Dispatch by the domain attested in process, not by parsing attachment fields.
pub(crate) fn persist_binding_startup_failure<S: session_store::WorkerMetadataStore>( pub(crate) fn persist_binding_startup_failure<S: session_store::WorkerMetadataStore>(
store: &S, store: &S,
+4
View File
@@ -897,6 +897,10 @@ pub(crate) fn test_subject_authorization_response(
}) })
} }
#[cfg(test)]
#[path = "subjektiv_session_registration_tests.rs"]
mod session_registration_tests;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -0,0 +1,159 @@
//! The real authorization/factory fixture lives in workspace-server. This leaf
//! exercises only WorkspaceClient transfer failures and response validation; it
//! must not substitute simulated policy decisions for that connected fixture.
use super::*;
use crate::{WorkspaceClientError, WorkspaceResponse};
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause as Cause};
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug)]
struct Client {
reply: Reply,
calls: AtomicUsize,
}
#[derive(Debug)]
enum Reply {
Response(u16, String),
Unavailable,
Request,
}
impl WorkspaceClient for Client {
fn kind(&self) -> &str {
"registration-transfer-test"
}
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.calls.fetch_add(1, Ordering::SeqCst);
assert_eq!(request.method, WorkspaceRequestMethod::Post);
assert_eq!(request.path, "/api/w/workspace/subjektiv/sessions");
match &self.reply {
Reply::Response(status, body) => Ok(WorkspaceResponse {
status: *status,
body: body.clone(),
}),
Reply::Unavailable => Err(WorkspaceClientError::Unavailable(
"TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
)),
Reply::Request => Err(WorkspaceClientError::Request(
"TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
)),
}
}
}
fn client(reply: Reply) -> Client {
Client {
reply,
calls: AtomicUsize::new(0),
}
}
fn failure_body(cause: Cause) -> String {
let mut error = RepositoryApiError::new(
403,
"ignored",
"TEST_ONLY_CREDENTIAL /private/path raw_http_body",
vec![],
);
error.failure =
Some(protocol::session_failure::session_registration_attachment(cause).unwrap());
serde_json::to_string(&error).unwrap()
}
fn assert_failure(client: Client, expected: Cause) -> SubjektivHostError {
let error = record_backend_session(&client, "session", true).unwrap_err();
assert_eq!(
client.calls.load(Ordering::SeqCst),
1,
"classification grants no replay"
);
assert_eq!(error.session_registration_cause(), expected);
let attachment = protocol::session_failure::session_registration_attachment(
error.session_registration_cause(),
)
.unwrap();
let encoded = serde_json::to_string(&attachment).unwrap();
for secret in ["TEST_ONLY_CREDENTIAL", "/private/path", "raw_http_body"] {
assert!(!encoded.contains(secret));
assert!(!error.to_string().contains(secret));
}
error
}
#[test]
fn session_registration_http_reuses_typed_causes_not_status_or_prose() {
for cause in [
Cause::denied(SessionPrincipal::Operator),
Cause::denied(SessionPrincipal::Subject),
Cause::attribution_missing(),
Cause::binding_mismatch(),
Cause::outcome_unknown(),
] {
for status in [403, 409, 500] {
assert_failure(client(Reply::Response(status, failure_body(cause))), cause);
}
}
// A legacy 403 without a domain attestation is not a certified denial.
let body = serde_json::to_string(&RepositoryApiError::new(
403,
"permission_denied",
"session.record denied",
vec![],
))
.unwrap();
assert_failure(client(Reply::Response(403, body)), Cause::outcome_unknown());
}
#[test]
fn session_registration_transport_and_unknown_keep_error_sources_without_disclosure_or_retry() {
for (reply, cause) in [
(Reply::Unavailable, Cause::transport()),
(Reply::Request, Cause::outcome_unknown()),
] {
let error = assert_failure(client(reply), cause);
assert!(
std::error::Error::source(&error)
.unwrap()
.downcast_ref::<WorkspaceClientError>()
.is_some()
);
}
let legacy =
SubjektivHostError::PermissionDenied("session.record denied for secret subject".into());
assert_eq!(
legacy.session_registration_cause(),
Cause::outcome_unknown()
);
}
#[test]
fn session_registration_rejects_malformed_incomplete_and_mismatched_responses() {
for (status, body) in [
(403, "not json"),
(200, "not json"),
(200, "{}"),
(200, r#"{"session_id":"session","subject_id":""}"#),
(
200,
r#"{"session_id":"other-session","subject_id":"subject"}"#,
),
] {
assert_failure(
client(Reply::Response(status, body.into())),
Cause::invalid_response(),
);
}
let allowed = client(Reply::Response(
200,
r#"{"session_id":"session","subject_id":"subject"}"#.into(),
));
let output = record_backend_session(&allowed, "session", true).unwrap();
assert_eq!(output.session_id, "session");
assert_eq!(output.subject_id, "subject");
assert_eq!(allowed.calls.load(Ordering::SeqCst), 1);
}
+4 -3
View File
@@ -21,6 +21,10 @@ axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures.workspace = true futures.workspace = true
config-source.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 feature-storage.workspace = true
workspace-drive.workspace = true workspace-drive.workspace = true
subjektiv.workspace = true subjektiv.workspace = true
@@ -66,9 +70,6 @@ webauthn-rs = { workspace = true, features = ["conditional-ui"] }
webauthn-rs-proto = "0.5.5" webauthn-rs-proto = "0.5.5"
[dev-dependencies] [dev-dependencies]
content-history.workspace = true
content-history-store.workspace = true
content-history-adapters.workspace = true
rusqlite = { workspace = true, features = ["hooks"] } rusqlite = { workspace = true, features = ["hooks"] }
feature-storage = { workspace = true, features = ["test-support"] } feature-storage = { workspace = true, features = ["test-support"] }
native-tls = "0.2" native-tls = "0.2"
+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 - [`hosts`] / [`worker_source`] — Runtime delegation and request source verification
- [`backend_job`] — Immutable input, attempt, and structured result boundaries - [`backend_job`] — Immutable input, attempt, and structured result boundaries
- [`workspace_catalog`] — Workspace creation and replay contracts - [`workspace_catalog`] — Workspace creation and replay contracts
- [`config_adoption`] / [`policy_config`] — Owner source adoption, cutover boundaries and payload-free Subject assignments
@@ -0,0 +1,544 @@
//! 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)?;
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, schema_bundle: WorkspaceConfigSchemaBundle,
) -> Result<WorkspaceConfigState> { ) -> Result<WorkspaceConfigState> {
let desired_schema = schema_bundle.clone(); let desired_schema = schema_bundle.clone();
let (state, requires_toolchain_refresh, base_identity) = self.with_conn_mut(|conn| { let (state, requires_toolchain_refresh, base_identity, canonical) =
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; self.with_conn_mut(|conn| {
let workspace_exists: bool = tx.query_row( let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", let workspace_exists: bool = tx.query_row(
[workspace_id], "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
|row| row.get(0), [workspace_id],
)?; |row| row.get(0),
if !workspace_exists { )?;
return Err(Error::WorkspaceIdMismatch); if !workspace_exists {
} return Err(Error::WorkspaceIdMismatch);
let stored_decodal_version = tx }
let stored_decodal_version = tx
.query_row( .query_row(
"SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1", "SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1",
[workspace_id], [workspace_id],
|row| row.get::<_, String>(0), |row| row.get::<_, String>(0),
) )
.optional()?; .optional()?;
let requires_toolchain_refresh = stored_decodal_version let requires_toolchain_refresh = stored_decodal_version
.as_deref() .as_deref()
.is_some_and(|version| version != DECODAL_VERSION); .is_some_and(|version| version != DECODAL_VERSION);
let state = match load_state(&tx, workspace_id)? { let state = match load_state(&tx, workspace_id)? {
Some(state) => state, Some(state) => state,
None => { None => {
let mut state = initial_state_with_schema(schema_bundle.clone())?; let mut state = initial_state_with_schema(schema_bundle.clone())?;
if has_plugin_provider(&schema_bundle) { if has_plugin_provider(&schema_bundle) {
let (contract, evaluation, _) = let (contract, evaluation, _) =
crate::plugin_settings::evaluate_catalog_config( crate::plugin_settings::evaluate_catalog_config(
&tx, &tx,
workspace_id, workspace_id,
&state.snapshot, &state.snapshot,
schema_bundle.clone(), schema_bundle.clone(),
)?; )?;
state.contract = contract; state.contract = contract;
state.projection_digest = evaluation.projection_digest; 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)?;
} let canonical = crate::config_adoption::canonical(&tx, workspace_id)?;
}; tx.commit()?;
let base_identity = load_evaluation_identity(&tx, workspace_id)?; Ok((state, requires_toolchain_refresh, base_identity, canonical))
tx.commit()?; })?;
Ok((state, requires_toolchain_refresh, base_identity)) // 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( let desired_schema = crate::plugin_settings::materialization_schema(
desired_schema, desired_schema,
&state.contract.schema_bundle, &state.contract.schema_bundle,
@@ -340,6 +347,9 @@ impl SqliteWorkspaceStore {
) -> Result<WorkspaceConfigState> { ) -> Result<WorkspaceConfigState> {
self.with_conn_mut(|conn| { self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; 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( let workspace_exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
[workspace_id], [workspace_id],
@@ -490,7 +500,7 @@ fn format_candidate_sources(
} }
// Read persisted provenance, not load_state's upgraded in-memory contract. // Read persisted provenance, not load_state's upgraded in-memory contract.
fn load_evaluation_identity( pub(crate) fn load_evaluation_identity(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
workspace_id: &str, workspace_id: &str,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
@@ -509,7 +519,7 @@ fn has_plugin_provider(bundle: &WorkspaceConfigSchemaBundle) -> bool {
.any(|provider| provider.provider_id == "builtin:plugins") .any(|provider| provider.provider_id == "builtin:plugins")
} }
fn evaluate_candidate_in_transaction( pub(crate) fn evaluate_candidate_in_transaction(
tx: &rusqlite::Transaction<'_>, tx: &rusqlite::Transaction<'_>,
workspace_id: &str, workspace_id: &str,
current: WorkspaceConfigState, 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( pub(crate) fn load_state(
conn: &rusqlite::Connection, conn: &rusqlite::Connection,
workspace_id: &str, workspace_id: &str,
@@ -792,7 +824,7 @@ fn config_not_materialized() -> Error {
Error::RegistryInconsistency("workspace config tree is not materialized".to_string()) 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()] { if entrypoints == [main_config_path()] {
Ok(()) Ok(())
} else { } 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
+114 -5
View File
@@ -117,6 +117,9 @@ where
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeDiagnostic { pub struct RuntimeDiagnostic {
/// Internal startup observations; public consumers must separately authorize projection.
#[serde(skip)]
pub startup: Option<runtime_api::WorkerStartupFailure>,
pub code: String, pub code: String,
pub severity: HostDiagnosticSeverity, pub severity: HostDiagnosticSeverity,
pub message: String, pub message: String,
@@ -6649,7 +6652,7 @@ fn sanitize_embedded_execution_detail(summary: &str, message: &str) -> String {
} }
fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic { fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnostic {
match error { let mut projection = match error {
EmbeddedRuntimeError::RestoreAdmissionBusy { .. } => diagnostic( EmbeddedRuntimeError::RestoreAdmissionBusy { .. } => diagnostic(
"restore_admission_busy", "restore_admission_busy",
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
@@ -6767,7 +6770,22 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
HostDiagnosticSeverity::Error, HostDiagnosticSeverity::Error,
"Embedded Runtime state is unavailable".to_string(), "Embedded Runtime state is unavailable".to_string(),
), ),
};
if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error {
if result.startup_failure.is_some() {
projection.message = "Embedded Runtime Worker startup failed".into();
}
projection.startup = result.startup_failure.as_ref().and_then(|primary| {
internal_startup_transfer(runtime_api::WorkerStartupFailure {
primary: primary.clone(),
secondary: result.secondary_failures.clone(),
resources: result.startup_resources,
persistence: result.startup_failure_persistence,
io_kind: result.startup_io_kind,
})
});
} }
projection
} }
fn host_id_for_embedded_workspace(workspace_id: &str) -> String { fn host_id_for_embedded_workspace(workspace_id: &str) -> String {
@@ -6846,6 +6864,7 @@ fn runtime_api_diagnostic(
) -> RuntimeDiagnostic { ) -> RuntimeDiagnostic {
match error { match error {
runtime_api::client_support::ClientError::Public { status, error } => { runtime_api::client_support::ClientError::Public { status, error } => {
let startup = error.error.startup.and_then(internal_startup_transfer);
let remote_code = error.error.code; let remote_code = error.error.code;
let message = sanitize_remote_runtime_message(&remote_code, &error.error.message); let message = sanitize_remote_runtime_message(&remote_code, &error.error.message);
let (code, severity) = match status { let (code, severity) = match status {
@@ -6862,13 +6881,15 @@ fn runtime_api_diagnostic(
}, },
), ),
}; };
diagnostic( let mut projection = diagnostic(
code, code,
severity, severity,
format!( format!(
"Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {message}" "Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {message}"
), ),
) );
projection.startup = startup;
projection
} }
runtime_api::client_support::ClientError::Failure(failure) => { runtime_api::client_support::ClientError::Failure(failure) => {
let code = match failure { let code = match failure {
@@ -6945,11 +6966,25 @@ fn remote_http_status_diagnostic(
} }
_ => (remote_code, HostDiagnosticSeverity::Warning), _ => (remote_code, HostDiagnosticSeverity::Warning),
}; };
diagnostic( let mut projection = diagnostic(
code, code,
severity, severity,
format!("Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {remote_message}"), format!("Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {remote_message}"),
) );
projection.startup = error
.and_then(|error| error.error.startup)
.and_then(internal_startup_transfer);
projection
}
/// Treat transferred details as untrusted observations and reproject through the
/// same domain allowlist; HTTP status and text never supply a missing cause.
fn internal_startup_transfer(
mut startup: runtime_api::WorkerStartupFailure,
) -> Option<runtime_api::WorkerStartupFailure> {
startup.primary =
worker::startup_failure::reproject_internal_startup_failure(&startup.primary).ok()?;
Some(startup)
} }
fn diagnostic( fn diagnostic(
@@ -6958,6 +6993,7 @@ fn diagnostic(
message: impl Into<String>, message: impl Into<String>,
) -> RuntimeDiagnostic { ) -> RuntimeDiagnostic {
RuntimeDiagnostic { RuntimeDiagnostic {
startup: None,
code: code.into(), code: code.into(),
severity, severity,
message: message.into(), message: message.into(),
@@ -7145,6 +7181,79 @@ mod tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
#[test]
fn session_registration_embedded_and_remote_host_projection_is_internal_only() {
use protocol::session_failure::{
SessionPrincipal, SessionRegistrationCause, session_registration_attachment,
};
use worker_runtime::execution::{
WorkerExecutionOperation, WorkerExecutionOutcome, WorkerExecutionResult,
};
let mut result = WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn);
result.outcome = WorkerExecutionOutcome::Errored;
result.startup_failure = Some(
session_registration_attachment(SessionRegistrationCause::denied(
SessionPrincipal::Subject,
))
.unwrap(),
);
result.startup_failure_persistence =
Some(protocol::startup_failure::StartupPersistenceOutcome::OutcomeUnknown);
result.secondary_failures.push(
protocol::startup_failure::StartupSecondaryFailure::Persistence {
phase: protocol::startup_failure::StartupFailurePhase::MetadataSave,
outcome: protocol::startup_failure::StartupPersistenceOutcome::OutcomeUnknown,
},
);
let error = EmbeddedRuntimeError::WorkerExecutionRejected {
worker_id: EmbeddedWorkerId::now_v7(),
operation: result.operation,
outcome: result.outcome,
message: "TEST_ONLY_CREDENTIAL /private/path raw_http_body".into(),
result,
};
let embedded = embedded_runtime_diagnostic(&error);
let mut wire = runtime_api::RuntimeApiError::new(
500,
"worker_execution_rejected",
"unused remote prose",
);
wire.error.startup = embedded.startup.clone();
let received = serde_json::from_str(&serde_json::to_string(&wire).unwrap()).unwrap();
let remote = runtime_api_diagnostic(
"remote",
runtime_api::client_support::ClientError::Public {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error: received,
},
);
assert!(embedded.startup.is_some());
assert_eq!(embedded.startup, remote.startup);
let public = serde_json::to_value(&remote).unwrap();
assert!(
public.get("startup").is_none(),
"no blind public/storage attachment forwarding"
);
for secret in ["TEST_ONLY_CREDENTIAL", "/private/path", "raw_http_body"] {
assert!(!serde_json::to_string(&embedded).unwrap().contains(secret));
}
let legacy = runtime_api_diagnostic(
"remote",
runtime_api::client_support::ClientError::Public {
status: reqwest::StatusCode::FORBIDDEN,
error: runtime_api::RuntimeApiError::new(403, "forbidden", "session.record denied"),
},
);
assert!(
legacy.startup.is_none(),
"HTTP status and prose cannot attest a Session cause"
);
let mut corrupt = embedded.startup.unwrap();
corrupt.primary = serde_json::from_value(json!("not an error-contract envelope")).unwrap();
assert!(internal_startup_transfer(corrupt).is_none());
}
#[tokio::test] #[tokio::test]
async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() { async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() {
use crate::db_executor::{DbExecutor, DbExecutorLimits}; use crate::db_executor::{DbExecutor, DbExecutorLimits};
+5
View File
@@ -5,7 +5,11 @@ pub mod authority;
pub mod backend_job; pub mod backend_job;
pub mod companion; pub mod companion;
pub mod config; pub mod config;
pub mod config_adoption;
pub mod config_source; pub mod config_source;
mod configuration_migration;
#[cfg(test)]
mod configuration_transfer_tests;
#[cfg(test)] #[cfg(test)]
mod content_history_tests; mod content_history_tests;
pub mod db_executor; pub mod db_executor;
@@ -24,6 +28,7 @@ pub mod plugin_plan;
pub mod plugin_receipt; pub mod plugin_receipt;
pub mod plugin_service; pub mod plugin_service;
pub mod plugin_settings; pub mod plugin_settings;
pub mod policy_config;
pub mod profile_settings; pub mod profile_settings;
pub mod prompt_settings; pub mod prompt_settings;
pub mod records; pub mod records;
@@ -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)?,
})
}
}
+83 -17
View File
@@ -60,6 +60,8 @@ mod plugin_host;
#[path = "server_plugin_runtime.rs"] #[path = "server_plugin_runtime.rs"]
mod plugin_runtime; mod plugin_runtime;
pub use plugin_delivery::InProcessPluginArtifactSource; pub use plugin_delivery::InProcessPluginArtifactSource;
#[path = "server_configuration_adoption.rs"]
mod configuration_adoption;
#[path = "server_workspace_config.rs"] #[path = "server_workspace_config.rs"]
mod workspace_config; mod workspace_config;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
@@ -3530,6 +3532,8 @@ fn is_workspace_config_editor_path(path: &str) -> bool {
}; };
resource == "config/source-tree" resource == "config/source-tree"
|| resource.starts_with("config/source-tree/") || resource.starts_with("config/source-tree/")
|| resource == "config/adoption"
|| resource.starts_with("config/adoption/")
|| resource == "settings/profiles" || resource == "settings/profiles"
|| resource.starts_with("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_auth_contract_router(contract_service.clone()))
.merge(generated_workspace_catalog_contract_router( .merge(generated_workspace_catalog_contract_router(
contract_service, contract_service,
)); ))
.merge(configuration_adoption::routes(api.clone()));
catalog.layer(axum::middleware::from_fn_with_state( catalog.layer(axum::middleware::from_fn_with_state(
api, api,
enforce_server_cookie_mutation_origin, enforce_server_cookie_mutation_origin,
@@ -4636,6 +4641,9 @@ impl WorkspaceApi {
crate::profile_settings::ProfileConfigSchemaProvider, crate::profile_settings::ProfileConfigSchemaProvider,
)) ))
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider)) .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(RuntimeConfigSchemaProvider))
.with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider))
.with_provider(Arc::new(skills::SkillConfigSchemaProvider)) .with_provider(Arc::new(skills::SkillConfigSchemaProvider))
@@ -8612,6 +8620,7 @@ fn worker_input_result_to_api(
// distinguishable without collapsing it into a definite refusal or replaying input. // distinguishable without collapsing it into a definite refusal or replaying input.
if result.disposition == WorkerInputDisposition::Unknown { if result.disposition == WorkerInputDisposition::Unknown {
result.diagnostics.push(RuntimeDiagnostic { result.diagnostics.push(RuntimeDiagnostic {
startup: None,
code: "worker_input_outcome_unknown".into(), code: "worker_input_outcome_unknown".into(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "Worker input delivery outcome is unknown; inspect the current session before retrying".into(), message: "Worker input delivery outcome is unknown; inspect the current session before retrying".into(),
@@ -16485,22 +16494,13 @@ async fn scoped_get_workspace_config_entry(
} }
async fn scoped_commit_workspace_config_tree( async fn scoped_commit_workspace_config_tree(
State(api): State<WorkspaceApi>, State(_api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>, AxumPath(_path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<server_api::ConfigCommitRequest>, Json(_request): Json<server_api::ConfigCommitRequest>,
) -> ApiResult<(StatusCode, Json<server_api::WorkspaceConfigTreeResponse>)> { ) -> ApiResult<(StatusCode, Json<server_api::WorkspaceConfigTreeResponse>)> {
api.host_executors Err(Error::WorkspaceConfigConflict(
.host()? "source-tree commits lack an editing head and request identity; use /config/adoption to preview, adopt and reconcile the owner request".into(),
.run(move || { ).into())
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
} }
/// Shared canonical commit and activation boundary for UI and logical config operations. /// Shared canonical commit and activation boundary for UI and logical config operations.
@@ -24197,11 +24197,27 @@ where
executor.run(move || runtime.block_on(future)).await 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) crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|error| ApiError::from(Error::Store(error.to_string()))) .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)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubjektivWorkerAuthority { enum SubjektivWorkerAuthority {
Subject, Subject,
@@ -27020,6 +27036,7 @@ fn skill_api_error(error: skills::SkillError) -> ApiError {
message: format!("unknown Skill `{name}`"), message: format!("unknown Skill `{name}`"),
}, },
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "skill_not_found".to_string(), code: "skill_not_found".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: format!("unknown Skill `{name}`"), message: format!("unknown Skill `{name}`"),
@@ -30426,6 +30443,7 @@ fn prepare_workspace_workdir_create(
.to_string(), .to_string(),
), ),
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "default_runtime_not_configured".to_string(), code: "default_runtime_not_configured".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "Workspace default Runtime is not configured".to_string(), message: "Workspace default Runtime is not configured".to_string(),
@@ -30580,6 +30598,7 @@ async fn create_workspace_working_directory(
return Err(ApiError::with_diagnostics( return Err(ApiError::with_diagnostics(
Error::UnknownRuntime(reserved.resolved_runtime_id), Error::UnknownRuntime(reserved.resolved_runtime_id),
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "runtime_unavailable".to_string(), code: "runtime_unavailable".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "Selected Runtime is not available".to_string(), message: "Selected Runtime is not available".to_string(),
@@ -30603,6 +30622,7 @@ async fn create_workspace_working_directory(
message: "Selected Runtime is not connected".to_string(), message: "Selected Runtime is not connected".to_string(),
}, },
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "runtime_unavailable".to_string(), code: "runtime_unavailable".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "Selected Runtime is not available".to_string(), message: "Selected Runtime is not available".to_string(),
@@ -30659,6 +30679,7 @@ async fn create_workspace_working_directory(
return Err(ApiError::with_diagnostics( return Err(ApiError::with_diagnostics(
error.into_error(), error.into_error(),
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: failure_code, code: failure_code,
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "Selected Runtime could not inspect the reserved Workdir".to_string(), message: "Selected Runtime could not inspect the reserved Workdir".to_string(),
@@ -30697,6 +30718,7 @@ async fn create_workspace_working_directory(
return Err(ApiError::with_diagnostics( return Err(ApiError::with_diagnostics(
error.into_error(), error.into_error(),
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: failure_code, code: failure_code,
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "Selected Runtime rejected Workdir creation".to_string(), message: "Selected Runtime rejected Workdir creation".to_string(),
@@ -30736,6 +30758,7 @@ async fn create_workspace_working_directory(
diagnostics.insert( diagnostics.insert(
0, 0,
RuntimeDiagnostic { RuntimeDiagnostic {
startup: None,
code: failure_code.clone(), code: failure_code.clone(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "Runtime did not create the reserved Workdir".to_string(), message: "Runtime did not create the reserved Workdir".to_string(),
@@ -31490,6 +31513,7 @@ fn build_runtime_cleanup_plan(
let mut diagnostics = error.diagnostics; let mut diagnostics = error.diagnostics;
if diagnostics.is_empty() { if diagnostics.is_empty() {
diagnostics.push(RuntimeDiagnostic { diagnostics.push(RuntimeDiagnostic {
startup: None,
code: "workspace_cleanup_runtime_observation_unavailable".to_string(), code: "workspace_cleanup_runtime_observation_unavailable".to_string(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: sanitize_backend_error(&error.error.to_string()), message: sanitize_backend_error(&error.error.to_string()),
@@ -35599,6 +35623,7 @@ fn reject_workdir_for_embedded_runtime(runtime_id: &str, has_workdir: bool) -> A
message: "The embedded Runtime does not accept working directories".to_string(), message: "The embedded Runtime does not accept working directories".to_string(),
}, },
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "embedded_worker_workdir_unsupported".to_string(), code: "embedded_worker_workdir_unsupported".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "Choose a non-embedded Runtime for workspace-file Workers; embedded Workers are no-workdir Workspace-API workers.".to_string(), message: "Choose a non-embedded Runtime for workspace-file Workers; embedded Workers are no-workdir Workspace-API workers.".to_string(),
@@ -35689,6 +35714,7 @@ fn finalize_worker_spawn_stage<T>(
.map(|assignment| assignment.operation_id.as_str()) .map(|assignment| assignment.operation_id.as_str())
.unwrap_or("none"); .unwrap_or("none");
let mut diagnostics = vec![RuntimeDiagnostic { let mut diagnostics = vec![RuntimeDiagnostic {
startup: None,
code: format!("worker_spawn_finalize_{}_failed", stage.as_str()), code: format!("worker_spawn_finalize_{}_failed", stage.as_str()),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: format!( message: format!(
@@ -35704,6 +35730,7 @@ fn finalize_worker_spawn_stage<T>(
diagnostics.extend(compensation_errors); diagnostics.extend(compensation_errors);
if !compensation_failed { if !compensation_failed {
diagnostics.push(RuntimeDiagnostic { diagnostics.push(RuntimeDiagnostic {
startup: None,
code: "worker_spawn_compensated".to_string(), code: "worker_spawn_compensated".to_string(),
severity: HostDiagnosticSeverity::Info, severity: HostDiagnosticSeverity::Info,
message: format!( message: format!(
@@ -36039,6 +36066,7 @@ fn lifecycle_failure_detail(
fn spawn_compensation_diagnostic(code: &str, message: String) -> RuntimeDiagnostic { fn spawn_compensation_diagnostic(code: &str, message: String) -> RuntimeDiagnostic {
RuntimeDiagnostic { RuntimeDiagnostic {
startup: None,
code: code.to_string(), code: code.to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message, message,
@@ -37044,6 +37072,7 @@ fn project_workspace_worker_with_attachments(
summary.state = "unavailable".to_string(); summary.state = "unavailable".to_string();
summary.worker_state = None; summary.worker_state = None;
summary.diagnostics.push(RuntimeDiagnostic { summary.diagnostics.push(RuntimeDiagnostic {
startup: None,
code: "worker_singleton_fenced".to_string(), code: "worker_singleton_fenced".to_string(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "This historical singleton generation is fenced by Backend ownership." message: "This historical singleton generation is fenced by Backend ownership."
@@ -37749,6 +37778,7 @@ fn working_directory_summaries(
} }
if let Some(grant_id) = legacy_external_workdir_grant_id(api, &record)? { if let Some(grant_id) = legacy_external_workdir_grant_id(api, &record)? {
diagnostics.push(RuntimeDiagnostic { diagnostics.push(RuntimeDiagnostic {
startup: None,
code: "external_workdir_legacy_permissions".to_string(), code: "external_workdir_legacy_permissions".to_string(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: format!( message: format!(
@@ -37955,6 +37985,7 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> InternalWorker
}, },
workdir_attachments: Vec::new(), workdir_attachments: Vec::new(),
diagnostics: vec![RuntimeDiagnostic { diagnostics: vec![RuntimeDiagnostic {
startup: None,
code: "backend_worker_missing".to_string(), code: "backend_worker_missing".to_string(),
severity: HostDiagnosticSeverity::Info, severity: HostDiagnosticSeverity::Info,
message: message:
@@ -37999,6 +38030,7 @@ fn worker_summary_from_projection(
Vec::new() Vec::new()
} else { } else {
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "worker_observation_unavailable".to_string(), code: "worker_observation_unavailable".to_string(),
severity: HostDiagnosticSeverity::Info, severity: HostDiagnosticSeverity::Info,
message: "The Worker remains in the Backend catalog, but its Runtime observation is currently unavailable.".to_string(), message: "The Worker remains in the Backend catalog, but its Runtime observation is currently unavailable.".to_string(),
@@ -38645,6 +38677,7 @@ fn validate_working_directory_claims_for_browser(
message: "working directory relative_cwd must stay inside the Runtime-owned working directory".to_string(), message: "working directory relative_cwd must stay inside the Runtime-owned working directory".to_string(),
}, },
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_relative_cwd_invalid".to_string(), code: "working_directory_relative_cwd_invalid".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "relative_cwd must be a relative path without parent traversal".to_string(), message: "relative_cwd must be a relative path without parent traversal".to_string(),
@@ -39239,6 +39272,7 @@ fn settings_diagnostic(
message: impl Into<String>, message: impl Into<String>,
) -> RuntimeDiagnostic { ) -> RuntimeDiagnostic {
RuntimeDiagnostic { RuntimeDiagnostic {
startup: None,
code: code.into(), code: code.into(),
severity, severity,
message: message.into(), message: message.into(),
@@ -39260,6 +39294,7 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
message: message.clone(), message: message.clone(),
}, },
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "repository_not_configured".to_string(), code: "repository_not_configured".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message, message,
@@ -39277,6 +39312,7 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
message: message.clone(), message: message.clone(),
}, },
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "repository_provider_unsupported".to_string(), code: "repository_provider_unsupported".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message, message,
@@ -39292,6 +39328,7 @@ fn repository_lookup<T>(result: std::result::Result<T, RepositoryLookupError>) -
message: message.clone(), message: message.clone(),
}, },
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "repository_evidence_invalid".to_string(), code: "repository_evidence_invalid".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message, message,
@@ -39452,6 +39489,7 @@ fn workspace_id_mismatch_error() -> ApiError {
ApiError::with_diagnostics( ApiError::with_diagnostics(
Error::WorkspaceIdMismatch, Error::WorkspaceIdMismatch,
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "workspace_id_mismatch".to_string(), code: "workspace_id_mismatch".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message, message,
@@ -39561,6 +39599,7 @@ impl ApiErrorLog {
.diagnostics .diagnostics
.iter() .iter()
.map(|diagnostic| RuntimeDiagnostic { .map(|diagnostic| RuntimeDiagnostic {
startup: None,
code: diagnostic.code.clone(), code: diagnostic.code.clone(),
severity: match diagnostic.severity { severity: match diagnostic.severity {
server_api::DiagnosticSeverity::Info => HostDiagnosticSeverity::Info, server_api::DiagnosticSeverity::Info => HostDiagnosticSeverity::Info,
@@ -39598,6 +39637,7 @@ impl From<Error> for ApiError {
Error::DbCapacityExceeded Error::DbCapacityExceeded
| Error::DbExecutorUnavailable | Error::DbExecutorUnavailable
| Error::DbOperationOutcomeUnknown => vec![RuntimeDiagnostic { | Error::DbOperationOutcomeUnknown => vec![RuntimeDiagnostic {
startup: None,
code: match &error { code: match &error {
Error::DbCapacityExceeded => "db_capacity_exceeded", Error::DbCapacityExceeded => "db_capacity_exceeded",
Error::DbExecutorUnavailable => "db_executor_unavailable", Error::DbExecutorUnavailable => "db_executor_unavailable",
@@ -39608,21 +39648,25 @@ impl From<Error> for ApiError {
message: error.to_string(), message: error.to_string(),
}], }],
Error::WorkdirSessionOpenFailed { transport, .. } => vec![RuntimeDiagnostic { Error::WorkdirSessionOpenFailed { transport, .. } => vec![RuntimeDiagnostic {
startup: None,
code: "workdir_session_open_failed".to_string(), code: "workdir_session_open_failed".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: transport.message.clone(), message: transport.message.clone(),
}], }],
Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic { Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic {
startup: None,
code: code.clone(), code: code.clone(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: sanitize_backend_error(message), message: sanitize_backend_error(message),
}], }],
Error::WorkspaceSigningIdentity { code, message } => vec![RuntimeDiagnostic { Error::WorkspaceSigningIdentity { code, message } => vec![RuntimeDiagnostic {
startup: None,
code: code.clone(), code: code.clone(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: sanitize_backend_error(message), message: sanitize_backend_error(message),
}], }],
Error::Ticket(ticket_error) => vec![RuntimeDiagnostic { Error::Ticket(ticket_error) => vec![RuntimeDiagnostic {
startup: None,
code: match ticket_error { code: match ticket_error {
ticket::TicketError::NotFound(_) => "ticket_not_found", ticket::TicketError::NotFound(_) => "ticket_not_found",
ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous", ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous",
@@ -39669,6 +39713,7 @@ impl From<Error> for ApiError {
subjektiv_diagnostic_code(message) subjektiv_diagnostic_code(message)
.map(|code| { .map(|code| {
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: code.to_string(), code: code.to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: sanitize_backend_error(message), message: sanitize_backend_error(message),
@@ -40358,6 +40403,7 @@ mod tests {
mod workspace_admission_tests; mod workspace_admission_tests;
mod workspace_protocol_backpressure_tests; mod workspace_protocol_backpressure_tests;
include!("server_workspace_config_tests.rs"); include!("server_workspace_config_tests.rs");
include!("server_configuration_adoption_tests.rs");
use super::*; use super::*;
use axum::body::{Body, to_bytes}; use axum::body::{Body, to_bytes};
use axum::http::Request; use axum::http::Request;
@@ -43074,6 +43120,7 @@ mod tests {
worker: None, worker: None,
acceptance_evidence: Vec::new(), acceptance_evidence: Vec::new(),
diagnostics: vec![RuntimeDiagnostic { diagnostics: vec![RuntimeDiagnostic {
startup: None,
code: "fixture_runtime_rejected".to_string(), code: "fixture_runtime_rejected".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "fixture rejected Worker create".to_string(), message: "fixture rejected Worker create".to_string(),
@@ -44981,6 +45028,7 @@ mod tests {
kind: "ticket_backend_error".to_string(), kind: "ticket_backend_error".to_string(),
message: "sqlite error: FOREIGN KEY constraint failed".to_string(), message: "sqlite error: FOREIGN KEY constraint failed".to_string(),
diagnostics: vec![RuntimeDiagnostic { diagnostics: vec![RuntimeDiagnostic {
startup: None,
code: "ticket_backend_error".to_string(), code: "ticket_backend_error".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "FOREIGN KEY constraint failed".to_string(), message: "FOREIGN KEY constraint failed".to_string(),
@@ -51633,6 +51681,7 @@ mod tests {
#[test] #[test]
fn unrecoverable_pending_workspace_restore_is_typed_for_replacement() { fn unrecoverable_pending_workspace_restore_is_typed_for_replacement() {
let diagnostics = [RuntimeDiagnostic { let diagnostics = [RuntimeDiagnostic {
startup: None,
code: "embedded_worker_execution_rejected".to_string(), code: "embedded_worker_execution_rejected".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "Restore Errored: pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config".to_string(), message: "Restore Errored: pending Workspace Worker restore requires operation-owned launch material; generic restore must not reconstruct it from current Workspace config".to_string(),
@@ -51644,6 +51693,7 @@ mod tests {
#[test] #[test]
fn workdir_runtime_miss_uses_exact_typed_code() { fn workdir_runtime_miss_uses_exact_typed_code() {
let typed_not_found = [RuntimeDiagnostic { let typed_not_found = [RuntimeDiagnostic {
startup: None,
code: "working_directory_not_found".to_string(), code: "working_directory_not_found".to_string(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "missing".to_string(), message: "missing".to_string(),
@@ -51655,6 +51705,7 @@ mod tests {
assert!(workdir_runtime_miss_is_not_found(&typed_not_found)); assert!(workdir_runtime_miss_is_not_found(&typed_not_found));
let unrelated = [RuntimeDiagnostic { let unrelated = [RuntimeDiagnostic {
startup: None,
code: "some_other_not_found".to_string(), code: "some_other_not_found".to_string(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "not a typed workdir miss".to_string(), message: "not a typed workdir miss".to_string(),
@@ -63358,6 +63409,7 @@ mod tests {
&api, &api,
deleted, deleted,
&[RuntimeDiagnostic { &[RuntimeDiagnostic {
startup: None,
code: "working_directory_not_found".to_string(), code: "working_directory_not_found".to_string(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "missing".to_string(), message: "missing".to_string(),
@@ -63384,6 +63436,7 @@ mod tests {
&api, &api,
unknown, unknown,
&[RuntimeDiagnostic { &[RuntimeDiagnostic {
startup: None,
code: "runtime_unavailable".to_string(), code: "runtime_unavailable".to_string(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "temporarily unavailable".to_string(), message: "temporarily unavailable".to_string(),
@@ -65080,6 +65133,7 @@ mod tests {
InternalWorkerOperationState::Rejected, InternalWorkerOperationState::Rejected,
None, None,
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_not_found".into(), code: "working_directory_not_found".into(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "authoritatively absent".into(), message: "authoritatively absent".into(),
@@ -65131,6 +65185,7 @@ mod tests {
InternalWorkerOperationState::Rejected, InternalWorkerOperationState::Rejected,
None, None,
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_cleanup_resource_busy".into(), code: "working_directory_cleanup_resource_busy".into(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "busy".into(), message: "busy".into(),
@@ -65360,6 +65415,7 @@ mod tests {
let (missing_operation, _) = reserve_removal_fixture(&api, "provider-not-found"); let (missing_operation, _) = reserve_removal_fixture(&api, "provider-not-found");
let not_found = RuntimeDiagnostic { let not_found = RuntimeDiagnostic {
startup: None,
code: "working_directory_not_found".to_string(), code: "working_directory_not_found".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "missing".to_string(), message: "missing".to_string(),
@@ -65395,6 +65451,7 @@ mod tests {
InternalWorkerOperationState::Accepted, InternalWorkerOperationState::Accepted,
None, None,
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_provider_timeout".to_string(), code: "working_directory_provider_timeout".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "timeout".to_string(), message: "timeout".to_string(),
@@ -65511,6 +65568,7 @@ mod tests {
InternalWorkerOperationState::Unsupported, InternalWorkerOperationState::Unsupported,
None, None,
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_unsupported".to_string(), code: "working_directory_unsupported".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "unsupported".to_string(), message: "unsupported".to_string(),
@@ -65571,6 +65629,7 @@ mod tests {
InternalWorkerOperationState::Rejected, InternalWorkerOperationState::Rejected,
None, None,
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: code.into(), code: code.into(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "secret /private/path".into(), message: "secret /private/path".into(),
@@ -65602,6 +65661,7 @@ mod tests {
InternalWorkerOperationState::Rejected, InternalWorkerOperationState::Rejected,
None, None,
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_cleanup_mount_present".into(), code: "working_directory_cleanup_mount_present".into(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "/private/mount credentials must not escape".into(), message: "/private/mount credentials must not escape".into(),
@@ -65752,6 +65812,7 @@ mod tests {
InternalWorkerOperationState::Rejected, InternalWorkerOperationState::Rejected,
None, None,
vec![RuntimeDiagnostic { vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_cleanup_permission_denied".into(), code: "working_directory_cleanup_permission_denied".into(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "permission denied /private/path".into(), message: "permission denied /private/path".into(),
@@ -65938,6 +65999,7 @@ mod tests {
state: InternalWorkerOperationState::Rejected, state: InternalWorkerOperationState::Rejected,
working_directory: None, working_directory: None,
diagnostics: vec![RuntimeDiagnostic { diagnostics: vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_not_found".to_string(), code: "working_directory_not_found".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "missing".to_string(), message: "missing".to_string(),
@@ -65949,6 +66011,7 @@ mod tests {
state: InternalWorkerOperationState::Rejected, state: InternalWorkerOperationState::Rejected,
working_directory: None, working_directory: None,
diagnostics: vec![RuntimeDiagnostic { diagnostics: vec![RuntimeDiagnostic {
startup: None,
code: "working_directory_provider_timeout".to_string(), code: "working_directory_provider_timeout".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "timeout".to_string(), message: "timeout".to_string(),
@@ -67641,11 +67704,13 @@ mod tests {
fn provider_rejection_classification_prefers_stable_error_diagnostic() { fn provider_rejection_classification_prefers_stable_error_diagnostic() {
let diagnostics = vec![ let diagnostics = vec![
RuntimeDiagnostic { RuntimeDiagnostic {
startup: None,
code: "runtime_capacity_warning".to_string(), code: "runtime_capacity_warning".to_string(),
severity: HostDiagnosticSeverity::Warning, severity: HostDiagnosticSeverity::Warning,
message: "capacity is low".to_string(), message: "capacity is low".to_string(),
}, },
RuntimeDiagnostic { RuntimeDiagnostic {
startup: None,
code: "runtime_capacity_unavailable".to_string(), code: "runtime_capacity_unavailable".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "capacity is exhausted".to_string(), message: "capacity is exhausted".to_string(),
@@ -67705,6 +67770,7 @@ mod tests {
}) })
.unwrap(); .unwrap();
let diagnostics = vec![RuntimeDiagnostic { let diagnostics = vec![RuntimeDiagnostic {
startup: None,
code: "runtime_capacity_unavailable".to_string(), code: "runtime_capacity_unavailable".to_string(),
severity: HostDiagnosticSeverity::Error, severity: HostDiagnosticSeverity::Error,
message: "capacity is exhausted".to_string(), message: "capacity is exhausted".to_string(),
@@ -0,0 +1,458 @@
//! Focused T840 connection: real Subject policy -> signed Session POST -> real
//! ProfileRuntimeWorkerFactory startup -> RuntimeHTTP/Host diagnostic -> saved
//! Worker metadata. Only the unused external model is replaced. No restore,
//! operation replay, singleton-release, or Browser rendering contract is asserted.
use super::*;
use protocol::session_failure::{
SessionPrincipal, SessionRegistrationCause, session_registration_attachment,
};
use session_store::WorkerMetadataStore as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use worker_runtime::worker_backend::RuntimeWorkerFactory as _;
#[derive(Clone, Default)]
struct UnusedModel(Arc<AtomicUsize>);
#[async_trait::async_trait]
impl agen::llm_client::LlmClient for UnusedModel {
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_: agen::llm_client::Request,
) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError>
{
self.0.fetch_add(1, Ordering::SeqCst);
panic!("Session registration does not require a model turn")
}
}
struct RegistrationFixture {
connected: ConnectedWorkerHarness,
subject: String,
callbacks: Arc<Mutex<Vec<(StatusCode, Value)>>>,
model: UnusedModel,
}
impl RegistrationFixture {
async fn new(revoke_on_registration: bool) -> Self {
// Armed only after real Subject creation with its normal default policy.
// The callback is a deterministic synchronization point, not a fake Host
// rejection: the original signed request and real handler remain intact.
let revoke = Arc::new(Mutex::new(None::<(WorkspaceApi, String, PathBuf)>));
let hook = revoke.clone();
let callbacks = Arc::new(Mutex::new(Vec::new()));
let observed = callbacks.clone();
let model = UnusedModel::default();
let connected = ConnectedWorkerHarness::start_with_layer(model.clone(), move |router| {
router.layer(axum::middleware::from_fn(
move |request: axum::extract::Request, next: axum::middleware::Next| {
let hook = hook.clone();
let observed = observed.clone();
async move {
if request.method() != axum::http::Method::POST
|| request.uri().path()
!= format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions")
{
return next.run(request).await;
}
assert!(request.headers().contains_key(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER
));
let armed = hook.lock().unwrap().take();
let pin_root = armed.as_ref().map(|(_, _, root)| root.clone());
if let Some((api, subject, _)) = armed {
let store = open_subjektiv_store(&api).unwrap();
let policy = store.subject_permissions(&subject).unwrap().unwrap();
assert!(
policy
.execution
.iter()
.any(|p| p.operation == "session.record")
);
store
.set_subject_permissions(
&subject,
&SubjectPermissionsUpdate {
operation_id: "session-registration-revoke-at-callback"
.into(),
expected_change_id: Some(policy.change_id),
execution: policy
.execution
.into_iter()
.filter(|p| p.operation != "session.record")
.collect(),
delegation: policy.delegation,
},
)
.unwrap();
}
let (parts, body) = request.into_parts();
let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap();
let input: Value = serde_json::from_slice(&bytes).unwrap();
if let Some(root) = pin_root {
let request: server_api::SubjektivRecordSessionRequest = serde_json::from_slice(&bytes).unwrap();
let session = request.session_id.parse().unwrap();
let retention = worker_runtime::retention::runtime_session_retention(&root);
retention.pin(session, session_store::retention::SessionRetentionPin(uuid::Uuid::now_v7()), || {
let entries = std::fs::read_dir(root.join("workers"))?;
for entry in entries {
let entry = entry?;
let name = format!("worker-runtime-{}", entry.file_name().to_string_lossy());
let store = session_store::WorkerAggregateStore::open_read_only(&entry.path(), &name)
.map_err(|error| session_store::StoreError::Io(std::io::Error::other(error)))?;
if store.read_by_name(&name).unwrap().and_then(|m| m.active).is_some_and(|a| a.session_id == session) { return Ok(true); }
}
Ok(false)
}).unwrap();
}
let response = next
.run(Request::from_parts(parts, Body::from(bytes)))
.await;
observed.lock().unwrap().push((response.status(), input));
response
}
},
))
})
.await;
let subject = open_subjektiv_store(&connected.api)
.unwrap()
.create_subject(
crate::subjektiv::SubjectRole::new("session-registration-connected").unwrap(),
)
.unwrap()
.id;
let initial = open_subjektiv_store(&connected.api)
.unwrap()
.subject_permissions(&subject)
.unwrap()
.unwrap();
assert!(
initial
.execution
.iter()
.any(|p| p.operation == "session.record")
);
if revoke_on_registration {
*revoke.lock().unwrap() = Some((
connected.api.clone(),
subject.clone(),
connected.runtime_root.clone(),
));
}
Self {
connected,
subject,
callbacks,
model,
}
}
fn request(&self) -> CreateWorkspaceWorkerRequest {
CreateWorkspaceWorkerRequest {
runtime_id: CONNECTED_RUNTIME.into(),
display_name: "Connected Session registration".into(),
singleton_key: None,
profile: Some("builtin:companion".into()),
ticket_assignment: None,
initial_submit: vec![],
workdir_attachments: vec![],
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
subject_id: self.subject.clone(),
}),
},
control_operation_id: None,
execution_grant: None,
}
}
fn assert_callback(&self, expected: StatusCode) -> String {
let callbacks = self.callbacks.lock().unwrap();
assert!(!callbacks.is_empty(), "real signed startup callback");
assert!(callbacks.iter().all(|(status, body)| *status == expected
&& body["session_id"] == callbacks[0].1["session_id"]));
let (status, input) = &callbacks[0];
assert_eq!(*status, expected);
assert_eq!(input["create_if_missing"], true);
assert_eq!(self.model.0.load(Ordering::SeqCst), 0);
input["session_id"].as_str().unwrap().to_owned()
}
}
// This deliberately inspects only persisted serde shape, not rendered error text
// or an error-contract dependency. The expected value is a typed live attachment.
fn contains_saved_value(saved: &Value, expected: &Value) -> bool {
saved == expected
|| match saved {
Value::Object(fields) => fields
.values()
.any(|value| contains_saved_value(value, expected)),
Value::Array(values) => values
.iter()
.any(|value| contains_saved_value(value, expected)),
_ => false,
}
}
fn read_metadata(root: &std::path::Path, worker: &str) -> Value {
// Reopen the canonical metadata store, not the separate Runtime catalog.
let name = format!("worker-runtime-{worker}");
let store = session_store::WorkerAggregateStore::open_read_only(
&root.join("workers").join(worker),
&name,
)
.unwrap();
let metadata = store.read_by_name(&name).unwrap().unwrap();
assert!(matches!(
metadata.decode_startup_failure::<SessionRegistrationCause>(),
session_store::worker_metadata::StartupFailureRead::Present(_)
));
serde_json::to_value(metadata).unwrap()
}
#[tokio::test]
async fn session_registration_real_judgments_separate_operator_subject_and_attribution() {
// This normal boundary fixture uses the real router, Runtime signature,
// authority store and attribution store; its Runtime does not run a model.
let f = Fixture::new().await;
let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions");
let missing_session = session_store::new_session_id().to_string();
let request = json!({"session_id": missing_session, "create_if_missing": false});
let (status, body) = response(
&f.api,
human_request(&f.owner, "POST", &path, Some(request.clone())),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_registration_cause(
body,
SessionRegistrationCause::denied(SessionPrincipal::Operator),
);
let signed = |request: &Value| {
runtime_source_request(
&f.identity,
Some(&f.source.worker_id),
"POST",
&path,
serde_json::to_vec(request).unwrap(),
)
};
let (status, body) = response(&f.api, signed(&request)).await;
assert_eq!(status, StatusCode::CONFLICT);
assert_registration_cause(body, SessionRegistrationCause::attribution_missing());
let store = open_subjektiv_store(&f.api).unwrap();
let foreign_session = session_store::new_session_id().to_string();
store
.record_session_attribution(
crate::subjektiv::SubjectSessionAttribution::new(
&f.subject,
&f.source.runtime_id,
"foreign-worker",
&foreign_session,
)
.unwrap(),
)
.unwrap();
let (status, body) = response(
&f.api,
signed(&json!({"session_id":foreign_session, "create_if_missing":false})),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_registration_cause(body, SessionRegistrationCause::binding_mismatch());
// The fixture removes its bootstrap policy only after the real setup POST.
let (status, body) = response(
&f.api,
signed(&json!({"session_id":missing_session, "create_if_missing":true})),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_registration_cause(
body,
SessionRegistrationCause::denied(SessionPrincipal::Subject),
);
assert!(
store
.session_attribution(&missing_session)
.unwrap()
.is_none()
);
}
fn assert_registration_cause(body: Value, cause: SessionRegistrationCause) {
let error: server_api::RepositoryApiError = serde_json::from_value(body).unwrap();
assert_eq!(
error.failure,
Some(session_registration_attachment(cause).unwrap())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
async fn real_session_record_denial_survives_runtime_host_and_metadata_reopen() {
let fixture = RegistrationFixture::new(true).await;
// The real create preflight sees normal defaults. Revocation happens only
// after the actual factory has issued its signed Session-registration POST.
let error = scoped_create_workspace_worker(
State(fixture.connected.api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.into(),
}),
HeaderMap::new(),
Json(fixture.request()),
)
.await
.unwrap_err();
fixture.assert_callback(StatusCode::FORBIDDEN);
let store = open_subjektiv_store(&fixture.connected.api).unwrap();
assert!(
store
.subject_session_attributions(&fixture.subject)
.unwrap()
.is_empty()
);
assert!(
!store
.subject_permissions(&fixture.subject)
.unwrap()
.unwrap()
.execution
.iter()
.any(|p| p.operation == "session.record")
);
let failures = fixture.connected.runtime_startup_failures.lock().unwrap();
assert_eq!(
failures.len(),
1,
"actual RuntimeHTTP startup error envelope"
);
let wire = &failures[0];
let expected_primary = session_registration_attachment(SessionRegistrationCause::denied(
SessionPrincipal::Subject,
))
.unwrap();
assert_eq!(wire.primary, expected_primary);
assert_eq!(
wire.persistence,
Some(protocol::startup_failure::StartupPersistenceOutcome::Persisted)
);
let host = error
.diagnostics
.iter()
.filter_map(|d| d.startup.as_ref())
.find(|startup| startup.primary == wire.primary)
.expect("RemoteWorkerRuntime must preserve the typed startup primary in Host diagnostics");
assert_eq!(host.primary, wire.primary);
assert!(
host.secondary.iter().any(|failure| matches!(
failure,
protocol::startup_failure::StartupSecondaryFailure::Persistence {
phase: protocol::startup_failure::StartupFailurePhase::RuntimeRecordDelete,
..
}
)),
"a real Session retention pin rejects outer Runtime deletion without hiding the primary"
);
let expected = serde_json::to_value(&wire.primary).unwrap();
assert!(
expected.is_string(),
"the shared attachment contains a codec envelope"
);
// A fresh fixture has exactly one attempted Worker. Discover its canonical
// saved aggregate, without making any assumptions about retained ownership
// or public catalog visibility after a failed create.
let workers = std::fs::read_dir(fixture.connected.runtime_root.join("workers"))
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect::<Vec<_>>();
assert_eq!(
workers.len(),
1,
"factory attempt must have real saved metadata"
);
let saved = read_metadata(&fixture.connected.runtime_root, &workers[0]);
assert!(
contains_saved_value(&saved, &expected),
"saved metadata must retain the original typed Session registration denial"
);
let reopened = read_metadata(&fixture.connected.runtime_root, &workers[0]);
assert_eq!(
reopened, saved,
"read-only reopen, not a startup operation retry"
);
assert!(contains_saved_value(&reopened, &expected));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
async fn permitted_session_registration_reaches_real_factory_and_saved_confirmation() {
let fixture = RegistrationFixture::new(false).await;
let initial = open_subjektiv_store(&fixture.connected.api)
.unwrap()
.subject_permissions(&fixture.subject)
.unwrap()
.unwrap();
let Json(created) = scoped_create_workspace_worker(
State(fixture.connected.api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.into(),
}),
HeaderMap::new(),
Json(fixture.request()),
)
.await
.unwrap();
let session_id = fixture.assert_callback(StatusCode::OK);
let store = open_subjektiv_store(&fixture.connected.api).unwrap();
assert_eq!(
store
.subject_permissions(&fixture.subject)
.unwrap()
.unwrap(),
initial
);
let attributions = store
.subject_session_attributions(&fixture.subject)
.unwrap();
assert_eq!(attributions.len(), 1);
assert_eq!(attributions[0].session_id, session_id);
assert_eq!(attributions[0].worker_id, created.worker_id);
assert_eq!(attributions[0].runtime_id, CONNECTED_RUNTIME);
assert!(
fixture
.connected
.runtime_startup_failures
.lock()
.unwrap()
.is_empty()
);
let name = format!("worker-runtime-{}", created.worker_id);
let metadata = session_store::WorkerAggregateStore::open_read_only(
&fixture
.connected
.runtime_root
.join("workers")
.join(&created.worker_id),
&name,
)
.unwrap();
let saved = metadata.read_by_name(&name).unwrap().unwrap();
assert!(matches!(
saved.subjektiv_session_attribution,
Some(session_store::SubjektivSessionAttributionState::Confirmed { .. })
));
let source = RuntimeWorkerRef::new(created.runtime_id, created.worker_id);
fixture
.connected
.factory
.retained_session_snapshot(&worker_runtime::identity::WorkerRef::try_from(&source).unwrap())
.expect("real factory retained the permitted Worker Session");
}
@@ -9,6 +9,8 @@ use super::*;
mod chat_delegation_connected; mod chat_delegation_connected;
#[path = "chat_tests.rs"] #[path = "chat_tests.rs"]
mod chat_tests; mod chat_tests;
#[path = "session_registration_connected_tests.rs"]
mod session_registration_connected;
#[path = "subject_body_cleanup_connected_tests.rs"] #[path = "subject_body_cleanup_connected_tests.rs"]
mod subject_body_cleanup_connected; mod subject_body_cleanup_connected;
#[path = "subject_body_connected_tests.rs"] #[path = "subject_body_connected_tests.rs"]
@@ -2737,6 +2739,7 @@ pub(super) struct ConnectedWorkerHarness {
materializer: worker_runtime::working_directory::RuntimeGitMaterializer, materializer: worker_runtime::working_directory::RuntimeGitMaterializer,
authorizations: Arc<Mutex<Vec<(Value, StatusCode)>>>, authorizations: Arc<Mutex<Vec<(Value, StatusCode)>>>,
runtime_errors: Arc<Mutex<Vec<String>>>, runtime_errors: Arc<Mutex<Vec<String>>>,
runtime_startup_failures: Arc<Mutex<Vec<runtime_api::WorkerStartupFailure>>>,
_backend_server: AuthorizationCallbackServer, _backend_server: AuthorizationCallbackServer,
_runtime_server: ConnectedRuntimeServer, _runtime_server: ConnectedRuntimeServer,
_dir: tempfile::TempDir, _dir: tempfile::TempDir,
@@ -2942,6 +2945,8 @@ impl ConnectedWorkerHarness {
.unwrap(); .unwrap();
let runtime_errors = Arc::new(Mutex::new(Vec::<String>::new())); let runtime_errors = Arc::new(Mutex::new(Vec::<String>::new()));
let captured_errors = runtime_errors.clone(); let captured_errors = runtime_errors.clone();
let runtime_startup_failures = Arc::new(Mutex::new(Vec::new()));
let captured_startup_failures = runtime_startup_failures.clone();
let runtime_server = ConnectedRuntimeServer::start( let runtime_server = ConnectedRuntimeServer::start(
runtime_listener, runtime_listener,
worker_runtime::http_server::runtime_http_router_with_workspace_auth( worker_runtime::http_server::runtime_http_router_with_workspace_auth(
@@ -2955,6 +2960,7 @@ impl ConnectedWorkerHarness {
.layer(axum::middleware::from_fn( .layer(axum::middleware::from_fn(
move |request: axum::extract::Request, next: axum::middleware::Next| { move |request: axum::extract::Request, next: axum::middleware::Next| {
let errors = captured_errors.clone(); let errors = captured_errors.clone();
let startup_failures = captured_startup_failures.clone();
async move { async move {
let response = next.run(request).await; let response = next.run(request).await;
if !response.status().is_client_error() if !response.status().is_client_error()
@@ -2965,6 +2971,13 @@ impl ConnectedWorkerHarness {
let (parts, body) = response.into_parts(); let (parts, body) = response.into_parts();
let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap(); let bytes = to_bytes(body, 2 * 1024 * 1024).await.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap(); let body: Value = serde_json::from_slice(&bytes).unwrap();
if let Some(startup) = body["error"].get("startup").filter(|v| !v.is_null())
{
startup_failures.lock().unwrap().push(
serde_json::from_value(startup.clone())
.expect("RuntimeHTTP structured startup failure"),
);
}
errors.lock().unwrap().push( errors.lock().unwrap().push(
body["error"]["message"] body["error"]["message"]
.as_str() .as_str()
@@ -3045,6 +3058,7 @@ impl ConnectedWorkerHarness {
materializer, materializer,
authorizations, authorizations,
runtime_errors, runtime_errors,
runtime_startup_failures,
_backend_server: backend_server, _backend_server: backend_server,
_runtime_server: runtime_server, _runtime_server: runtime_server,
_dir: dir, _dir: dir,
@@ -0,0 +1,523 @@
//! 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)??;
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
}
}
@@ -0,0 +1,945 @@
// Included inside server::tests to reuse its catalog, identity and storage fixtures.
mod configuration_adoption_http_integration {
//! Ordinary Server library integration tests: real catalog router (including
//! cookie-origin middleware), owner/session lookup, builtin permission catalog,
//! migration, guarded Feature storage and Server SQLite adoption. Only unused
//! Runtime execution is the existing deterministic fixture. Router oneshot is
//! the HTTP boundary; these are not TCP-loss, OS-crash or store-fault tests.
//! The separate configuration_transfer/config_adoption suites own store faults.
use super::*;
use server_api::authorization::{
PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate,
};
use server_api::{
ConfigCommitRequest as SourceRequest, ConfigTreeChange as Change,
ConfigurationAdoptionReceipt as Receipt, ConfigurationAdoptionRequest as Adoption,
ConfigurationPreviewResponse as Preview, ConfigurationReconciliation as Reconciliation,
ConfigurationSourceResponse as Source, RuntimePolicyApplication,
};
const OWNER_SUFFIX: &str = "adoption-http-owner";
const COOKIE_TOKEN: &str = "adoption-http-browser-session";
const MARKER: &str = "notes/adoption-http.txt";
struct Fixture {
api: WorkspaceApi,
app: Router,
owner: String,
foreign: String,
owner_account: String,
cookie: String,
origin: String,
subject: String,
legacy: crate::subjektiv::SubjektivStore,
legacy_update: SubjectPermissionsUpdate,
legacy_policy: SubjectPermissions,
runtime_identity: RuntimeIdentityMaterial,
_dir: tempfile::TempDir,
}
impl Fixture {
async fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let mut api = test_api(dir.path()).await;
let owner_account = api
.store
.get_workspace(TEST_WORKSPACE_ID)
.await
.unwrap()
.unwrap()
.owner_account_id;
let owner = seed_test_workspace_owner_token(api.store.as_ref(), OWNER_SUFFIX).await;
let foreign = seed_test_api_token(api.store.as_ref(), "adoption-http-nonowner");
api.store
.create_browser_session(&BrowserSessionRecord {
token_hash: crate::auth::token_hash(COOKIE_TOKEN),
session_id: "adoption-http-session-id".into(),
user_id: format!("user-{OWNER_SUFFIX}"),
created_at: "2026-01-01T00:00:00Z".into(),
expires_at: "2099-01-01T00:00:00Z".into(),
revoked_at: None,
})
.unwrap();
let AuthConfig::Passkey {
origin,
cookie_name,
..
} = &api.config.auth;
let origin = origin.clone();
let cookie = format!("{cookie_name}={COOKIE_TOKEN}");
// Open the real raw Feature store before adoption, retaining a handle
// to verify that cutover fences already-open legacy writers too.
let legacy = open_legacy_subjektiv_store(&api).unwrap();
let subject = legacy
.create_subject(crate::subjektiv::SubjectRole::new("adoption-http").unwrap())
.unwrap()
.id;
let legacy_update = SubjectPermissionsUpdate {
operation_id: "adoption-http-existing-policy".into(),
expected_change_id: legacy
.subject_permissions(&subject)
.unwrap()
.map(|policy| policy.change_id),
execution: vec![workspace_permission("ticket.query")],
delegation: vec![workspace_permission("ticket.create")],
};
let legacy_policy = legacy
.set_subject_permissions(&subject, &legacy_update)
.unwrap();
let runtime_identity =
RuntimeIdentityMaterial::generate("adoption-http-runtime").unwrap();
configure_runtime_request_auth(&mut api, &runtime_identity, "adoption-http-runtime");
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
// Reuse the fixture API, not a second Runtime with the same store lease.
// Catalog ownership and credential resolution remain production code.
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);
Self {
api,
app,
owner,
foreign,
owner_account,
cookie,
origin,
subject,
legacy,
legacy_update,
legacy_policy,
runtime_identity,
_dir: dir,
}
}
fn path(suffix: &str) -> String {
format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption{suffix}")
}
fn bearer(
&self,
token: &str,
method: &str,
suffix: &str,
body: Option<Value>,
) -> Request<Body> {
Request::builder()
.method(method)
.uri(Self::path(suffix))
.header("authorization", format!("Bearer {token}"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
body.map(|body| body.to_string()).unwrap_or_default(),
))
.unwrap()
}
fn browser(
&self,
method: &str,
suffix: &str,
body: Option<Value>,
origin: Option<&str>,
) -> Request<Body> {
let mut request = Request::builder()
.method(method)
.uri(Self::path(suffix))
.header(axum::http::header::COOKIE, &self.cookie)
.header(CONTENT_TYPE, "application/json");
if let Some(origin) = origin {
request = request.header(ORIGIN, origin);
}
request
.body(Body::from(
body.map(|body| body.to_string()).unwrap_or_default(),
))
.unwrap()
}
async fn call(&self, request: Request<Body>) -> (StatusCode, Value) {
tokio::time::timeout(std::time::Duration::from_secs(15), async {
let response = self.app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024)
.await
.unwrap();
let body = serde_json::from_slice(&bytes).unwrap_or_else(|_| {
panic!(
"expected JSON response ({status}): {}",
String::from_utf8_lossy(&bytes)
)
});
(status, body)
})
.await
.expect("bounded adoption HTTP request")
}
async fn owner_call(
&self,
suffix: &str,
request: &Adoption,
expected: StatusCode,
) -> Value {
let (status, body) = self
.call(self.bearer(
&self.owner,
"POST",
suffix,
Some(serde_json::to_value(request).unwrap()),
))
.await;
assert_eq!(status, expected, "{suffix}: {body}");
body
}
async fn source(&self) -> Source {
let (status, body) = self.call(self.bearer(&self.owner, "GET", "", None)).await;
assert_eq!(status, StatusCode::OK, "{body}");
serde_json::from_value(body).unwrap()
}
async fn commit(&self, request: &Adoption) -> Receipt {
serde_json::from_value(self.owner_call("/commit", request, StatusCode::OK).await)
.unwrap()
}
async fn adopt_draft(&self, id: &str) -> Receipt {
let draft = self.source().await;
self.commit(&request(&draft, id, vec![])).await
}
// Re-read durable authorities, not cached HTTP responses. The raw export
// API is guarded by FeatureStorage and checks its physical identity.
fn durable(&self) -> Value {
let current = self
.api
.config_store
.configuration_current(TEST_WORKSPACE_ID, &self.owner_account)
.unwrap();
let identity = self.legacy.database_identity().unwrap();
let receipts: Vec<(String, String, String)> = self.api.config_store.with_conn(|conn| {
let mut query = conn.prepare(
"SELECT operation_id,request_fingerprint,result_json FROM workspace_configuration_receipts WHERE workspace_id=?1 ORDER BY operation_id",
)?;
let rows = query.query_map([TEST_WORKSPACE_ID], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}).unwrap();
json!({
"state": current.state,
"history_head": current.history_head,
"canonical": current.canonical,
"policies": current.policies,
"receipts": receipts,
"legacy_export": self.legacy.preview_permission_transfer(&identity).unwrap(),
"legacy_receipt": self.legacy.permission_transfer_receipt(&identity).unwrap(),
})
}
fn assert_legacy_writable(&self) {
// Exact replay is accepted by an unsealed writer, but rejected after
// cutover even on an already-open handle. It does not create a change.
assert_eq!(
self.legacy
.set_subject_permissions(&self.subject, &self.legacy_update)
.unwrap(),
self.legacy_policy
);
}
fn assert_legacy_sealed(&self) {
assert!(matches!(
self.legacy
.set_subject_permissions(&self.subject, &self.legacy_update),
Err(crate::subjektiv::SubjektivError::PermissionAuthoritySealed),
));
}
}
fn core_request(request: &Adoption) -> crate::config_adoption::ConfigurationAdoptionRequest {
crate::config_adoption::ConfigurationAdoptionRequest {
operation_id: request.operation_id.clone(),
expected_head: request.expected_head.clone(),
expected_catalog_digest: request.expected_catalog_digest.clone(),
expected_legacy_export: request.expected_legacy_export.clone(),
source: config_commit_request_from_api(request.source.clone()).unwrap(),
}
}
fn assignment(operation: &str) -> feature_permissions::PermissionAssignment {
feature_permissions::PermissionAssignment {
grants: vec![feature_permissions::PermissionReference::Namespace(
feature_permissions::NamespacePermissions {
namespace: feature_permissions::NamespaceId::parse("ticket").unwrap(),
keys: vec![feature_permissions::PermissionKey::parse(operation).unwrap()],
},
)],
exclusions: vec![],
revoked: false,
}
}
fn workspace_permission(operation: &str) -> ScopedPermission {
ScopedPermission {
operation: operation.into(),
resource: PermissionResource::Workspace {
workspace_id: TEST_WORKSPACE_ID.into(),
},
}
}
fn request(source: &Source, id: &str, changes: Vec<Change>) -> Adoption {
Adoption {
operation_id: id.into(),
expected_head: source.history_head.clone(),
expected_catalog_digest: source.catalog_digest.clone(),
expected_legacy_export: source.expected_legacy_export.clone(),
source: SourceRequest {
base_digest: source.state.snapshot.digest.clone(),
changes,
entrypoints: source.state.contract.entrypoints.clone(),
},
}
}
fn update(source: &Source, path: &str, content: &str) -> Change {
Change::Update {
path: path.into(),
expected_digest: source.state.snapshot.entries[path].content_digest.clone(),
content: content.into(),
}
}
fn create_marker(content: &str) -> Change {
Change::Create {
path: MARKER.into(),
content_type: server_api::ConfigContentType::Text,
content: content.into(),
}
}
fn permission_path(source: &Source) -> String {
source
.state
.snapshot
.entries
.keys()
.find(|path| path.starts_with("permissions/migrated") && path.ends_with(".dcdl"))
.expect("migration generated editable permission source")
.clone()
}
fn permission_source(subject: &str, execution: &str, delegation: &str) -> String {
format!(
r#"{{
subjects = [{{
subject_id = {subject:?};
execution = {{ namespaces = [{{ namespace = "ticket"; keys = [{execution:?}]; }}]; }};
delegation = {{ namespaces = [{{ namespace = "ticket"; keys = [{delegation:?}]; }}]; }};
}}];
remove_subjects = [];
}}
"#
)
}
#[tokio::test]
async fn owner_http_edits_migration_draft_and_adopts_source_policy_head_and_receipt() {
let fixture = Fixture::new().await;
let before = fixture.durable();
let draft = fixture.source().await;
assert!(!draft.source_stored);
assert_eq!(draft.history_head, None);
assert!(draft.expected_legacy_export.is_some());
assert_ne!(
draft.state.snapshot.digest,
before["state"]["snapshot"]["digest"]
);
assert_eq!(draft.catalog.workspace.as_str(), TEST_WORKSPACE_ID);
assert!(draft.catalog.namespaces.iter().any(|namespace| {
namespace
.declaration
.permissions
.iter()
.any(|permission| permission.key.as_str() == "ticket.query")
}));
assert_eq!(draft.policies.len(), 1);
assert_eq!(draft.policies[0].change_id, fixture.legacy_policy.change_id);
assert_eq!(
fixture.durable(),
before,
"GET is a read, not a save or seal"
);
fixture.assert_legacy_writable();
let path = permission_path(&draft);
let edited = format!(
"{}\n# Owner edited the migration draft before first adoption.\n",
draft.state.snapshot.entries[&path].content
);
let adoption = request(
&draft,
"edited-migration-draft",
vec![update(&draft, &path, &edited)],
);
let preview: Preview = serde_json::from_value(
fixture
.owner_call("/preview", &adoption, StatusCode::OK)
.await,
)
.unwrap();
assert_eq!(preview.expected_head, draft.history_head);
assert!(preview.changed_paths.contains(&path));
assert!(
preview.state.snapshot.entries[&path]
.content
.contains("# Owner edited the migration draft before first adoption.")
);
assert_eq!(
fixture.durable(),
before,
"preview must not seal or persist the draft"
);
fixture.assert_legacy_writable();
// Exercise the real Browser session and configured Origin on commit.
let (status, wire) = fixture
.call(fixture.browser(
"POST",
"/commit",
Some(serde_json::to_value(&adoption).unwrap()),
Some(&fixture.origin),
))
.await;
assert_eq!(status, StatusCode::OK, "{wire}");
assert_eq!(wire["runtime_application"], "next_current_policy_check");
let receipt: Receipt = serde_json::from_value(wire).unwrap();
assert_eq!(
receipt.runtime_application,
RuntimePolicyApplication::NextCurrentPolicyCheck
);
assert!(receipt.source_stored && receipt.policy_committed);
assert_eq!(receipt.workspace_id, TEST_WORKSPACE_ID);
assert_eq!(receipt.operation_id, adoption.operation_id);
assert_eq!(receipt.state, preview.state);
let stored = fixture.source().await;
assert!(stored.source_stored);
assert_eq!(stored.expected_legacy_export, None);
assert_eq!(stored.state, receipt.state);
assert_eq!(
stored.history_head.as_deref(),
Some(receipt.history_head.as_str())
);
assert_eq!(
stored.policies, draft.policies,
"source-only edit preserves policy and change IDs"
);
let current = fixture
.api
.config_store
.configuration_current(TEST_WORKSPACE_ID, &fixture.owner_account)
.unwrap();
assert!(current.canonical);
assert_eq!(workspace_config_state_to_api(current.state), receipt.state);
assert_eq!(
open_subjektiv_store(&fixture.api)
.unwrap()
.subject_permissions(&fixture.subject)
.unwrap(),
Some(fixture.legacy_policy.clone())
);
let identity = fixture.legacy.database_identity().unwrap();
let transfer = fixture
.legacy
.permission_transfer_receipt(&identity)
.unwrap()
.unwrap();
assert_eq!(
Some(transfer.snapshot.export_digest),
draft.expected_legacy_export
);
assert_eq!(transfer.snapshot.identity, identity);
let saved = fixture
.api
.config_store
.configuration_request_result(
TEST_WORKSPACE_ID,
&fixture.owner_account,
&core_request(&adoption),
)
.unwrap()
.unwrap();
assert_eq!(saved.history_head, receipt.history_head);
assert_eq!(workspace_config_state_to_api(saved.state), receipt.state);
assert_eq!(
saved.runtime_application,
crate::config_adoption::RuntimePolicyApplication::NextCurrentPolicyCheck
);
assert_eq!(
transfer.transfer_id,
crate::config_adoption::request_fingerprint(
TEST_WORKSPACE_ID,
&fixture.owner_account,
&core_request(&adoption),
)
.unwrap()
);
fixture.assert_legacy_sealed();
// Then edit actual builtin operation assignments through the same routes.
let changed = permission_source(&fixture.subject, "ticket.create", "ticket.query");
let policy_edit = request(
&stored,
"owner-policy-edit",
vec![update(&stored, &path, &changed)],
);
let preview: Preview = serde_json::from_value(
fixture
.owner_call("/preview", &policy_edit, StatusCode::OK)
.await,
)
.unwrap();
assert_eq!(preview.changed_subjects, vec![fixture.subject.clone()]);
let next = fixture.commit(&policy_edit).await;
assert_eq!(next.state, preview.state);
assert_ne!(next.history_head, receipt.history_head);
let stored = fixture.source().await;
assert_eq!(stored.state, next.state);
let policy = stored
.policies
.iter()
.find(|policy| policy.subject_id == fixture.subject)
.unwrap();
assert_eq!(policy.execution, assignment("ticket.create"));
assert_eq!(policy.delegation, assignment("ticket.query"));
assert_ne!(policy.change_id, fixture.legacy_policy.change_id);
let adopted = fixture
.api
.config_store
.adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject)
.unwrap()
.unwrap();
assert_eq!(adopted.source.execution.assignment(), policy.execution);
assert_eq!(adopted.source.delegation.assignment(), policy.delegation);
assert_eq!(adopted.change_id, policy.change_id);
// New payload-free keys cannot fabricate broader legacy resource scopes.
assert_eq!(
policy.legacy_constraints,
Some(fixture.legacy_policy.clone())
);
fixture.assert_legacy_sealed();
}
#[tokio::test]
async fn competing_http_requests_reject_stale_head_even_after_content_returns_to_a() {
let fixture = Fixture::new().await;
fixture.adopt_draft("aba-migrate").await;
let initial = fixture.source().await;
fixture
.commit(&request(&initial, "aba-a", vec![create_marker("A")]))
.await;
let a = fixture.source().await;
// Two independent requests are built from the same observed head.
let stale = request(&a, "aba-losing-editor", vec![update(&a, MARKER, "loser")]);
let b = request(&a, "aba-b", vec![update(&a, MARKER, "B")]);
fixture.commit(&b).await;
let before = fixture.durable();
fixture
.owner_call("/commit", &stale, StatusCode::CONFLICT)
.await;
assert_eq!(fixture.durable(), before);
let b = fixture.source().await;
fixture
.commit(&request(&b, "aba-return-a", vec![update(&b, MARKER, "A")]))
.await;
let returned = fixture.source().await;
assert_eq!(
returned.state.snapshot, a.state.snapshot,
"the content really returned to A"
);
assert_ne!(
returned.history_head, a.history_head,
"editing history did not return to A"
);
let before = fixture.durable();
fixture
.owner_call("/preview", &stale, StatusCode::CONFLICT)
.await;
fixture
.owner_call("/commit", &stale, StatusCode::CONFLICT)
.await;
assert_eq!(
fixture.durable(),
before,
"ABA rejection leaves source, policy and receipts intact"
);
let reconciled: Reconciliation = serde_json::from_value(
fixture
.owner_call("/reconcile", &stale, StatusCode::OK)
.await,
)
.unwrap();
assert_eq!(reconciled, Reconciliation::NotObserved);
assert_eq!(fixture.durable(), before);
fixture.assert_legacy_sealed();
}
#[tokio::test]
async fn full_request_reconcile_recovers_received_commit_result_without_reapplying_old_policy()
{
let fixture = Fixture::new().await;
let draft = fixture.source().await;
let adoption = request(&draft, "response-lost-after-commit", vec![]);
let before = fixture.durable();
let missing: Reconciliation = serde_json::from_value(
fixture
.owner_call("/reconcile", &adoption, StatusCode::OK)
.await,
)
.unwrap();
assert_eq!(missing, Reconciliation::NotObserved);
assert_eq!(fixture.durable(), before);
fixture.assert_legacy_writable();
// Receive a committed HTTP result, then model the consumer losing it.
// Reconciliation uses the retained original request, not result-derived input.
let received_result = fixture
.owner_call("/commit", &adoption, StatusCode::OK)
.await;
let after_commit = fixture.durable();
let reconciled: Reconciliation = serde_json::from_value(
fixture
.owner_call("/reconcile", &adoption, StatusCode::OK)
.await,
)
.unwrap();
let expected: Receipt = serde_json::from_value(received_result).unwrap();
assert_eq!(
reconciled,
Reconciliation::Adopted {
receipt: expected.clone()
}
);
assert_eq!(fixture.commit(&adoption).await, expected);
assert_eq!(
fixture.durable(),
after_commit,
"reconcile and exact retry are not new adoptions"
);
let current = fixture.source().await;
let path = permission_path(&current);
// Explicit removal makes old-receipt resurrection observable.
let removal = format!(
"{{ subjects = []; remove_subjects = [{:?}]; }}\n",
fixture.subject
);
fixture
.commit(&request(
&current,
"later-policy-removal",
vec![update(&current, &path, &removal)],
))
.await;
assert!(
fixture
.api
.config_store
.adopted_subject_policy(TEST_WORKSPACE_ID, &fixture.subject)
.unwrap()
.is_none()
);
let latest = fixture.durable();
let reconciled: Reconciliation = serde_json::from_value(
fixture
.owner_call("/reconcile", &adoption, StatusCode::OK)
.await,
)
.unwrap();
assert_eq!(
reconciled,
Reconciliation::Adopted {
receipt: expected.clone()
}
);
assert_eq!(fixture.commit(&adoption).await, expected);
assert_eq!(
fixture.durable(),
latest,
"historical receipt must not resurrect removed policy or head"
);
assert!(
open_subjektiv_store(&fixture.api)
.unwrap()
.subject_permissions(&fixture.subject)
.unwrap()
.is_none()
);
fixture.assert_legacy_sealed();
}
#[tokio::test]
async fn reconcile_rejects_same_operation_id_with_any_changed_request_fingerprint() {
let fixture = Fixture::new().await;
let draft = fixture.source().await;
let adoption = request(&draft, "fingerprint-migration", vec![]);
let receipt = fixture.commit(&adoption).await;
let before = fixture.durable();
let mut variants = Vec::new();
let mut changed = adoption.clone();
changed.expected_head = Some(receipt.history_head.clone());
variants.push(("head", changed));
let mut changed = adoption.clone();
changed.expected_catalog_digest.push_str("-different");
variants.push(("catalog", changed));
let mut changed = adoption.clone();
changed.expected_legacy_export = None;
variants.push(("legacy export", changed));
let mut changed = adoption.clone();
changed.source.base_digest.push_str("-different");
variants.push(("base content", changed));
let mut changed = adoption.clone();
changed
.source
.changes
.push(create_marker("not the same request"));
variants.push(("changes", changed));
let mut changed = adoption.clone();
changed.source.entrypoints.push("other.dcdl".into());
variants.push(("entrypoints", changed));
for (field, changed) in variants {
fixture
.owner_call("/reconcile", &changed, StatusCode::CONFLICT)
.await;
fixture
.owner_call("/commit", &changed, StatusCode::CONFLICT)
.await;
assert_eq!(
fixture.durable(),
before,
"changed {field} must not adopt or modify a receipt"
);
}
let exact: Reconciliation = serde_json::from_value(
fixture
.owner_call("/reconcile", &adoption, StatusCode::OK)
.await,
)
.unwrap();
assert_eq!(exact, Reconciliation::Adopted { receipt });
assert_eq!(fixture.durable(), before);
}
#[tokio::test]
async fn http_owner_and_cookie_origin_denials_leave_all_authorities_and_legacy_writer_intact() {
let fixture = Fixture::new().await;
for canonical in [false, true] {
let source = fixture.source().await;
assert_eq!(source.source_stored, canonical);
let change = if canonical {
update(&source, MARKER, "second authorized value")
} else {
create_marker("first authorized value")
};
let adoption = request(
&source,
&format!("denied-cutover-{canonical}"),
vec![change],
);
let wire = serde_json::to_value(&adoption).unwrap();
let before = fixture.durable();
for (method, suffix) in [
("GET", ""),
("POST", "/preview"),
("POST", "/commit"),
("POST", "/reconcile"),
] {
let body = (method == "POST").then(|| wire.clone());
let anonymous = Request::builder()
.method(method)
.uri(Fixture::path(suffix))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
body.as_ref().map(Value::to_string).unwrap_or_default(),
))
.unwrap();
let runtime = runtime_source_request(
&fixture.runtime_identity,
None,
method,
&Fixture::path(suffix),
body.as_ref()
.map(|value| serde_json::to_vec(value).unwrap())
.unwrap_or_default(),
);
let mut runtime_with_owner = runtime_source_request(
&fixture.runtime_identity,
None,
method,
&Fixture::path(suffix),
body.as_ref()
.map(|value| serde_json::to_vec(value).unwrap())
.unwrap_or_default(),
);
runtime_with_owner.headers_mut().insert(
axum::http::header::AUTHORIZATION,
format!("Bearer {}", fixture.owner).parse().unwrap(),
);
for (principal, request) in [
(
"nonowner",
fixture.bearer(&fixture.foreign, method, suffix, body.clone()),
),
("unauthenticated", anonymous),
("runtime proof", runtime),
// A valid owner credential must not hide the Runtime source.
("runtime proof plus owner bearer", runtime_with_owner),
] {
let (status, response) = fixture.call(request).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{principal} {method} {suffix}: {response}"
);
assert_eq!(fixture.durable(), before, "{principal} {suffix}");
if canonical {
fixture.assert_legacy_sealed();
} else {
fixture.assert_legacy_writable();
}
}
if method == "POST" {
for origin in [None, Some("https://attacker.invalid")] {
let (status, response) = fixture
.call(fixture.browser(method, suffix, body.clone(), origin))
.await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"cookie {origin:?} {suffix}: {response}"
);
assert_eq!(fixture.durable(), before);
if canonical {
fixture.assert_legacy_sealed();
} else {
fixture.assert_legacy_writable();
}
}
}
}
// A Browser GET does not require a mutation Origin, and the same
// valid adoption remains possible after every denied request.
let (status, response) = fixture.call(fixture.browser("GET", "", None, None)).await;
assert_eq!(status, StatusCode::OK, "{response}");
assert_eq!(serde_json::from_value::<Source>(response).unwrap(), source);
fixture.commit(&adoption).await;
fixture.assert_legacy_sealed();
}
}
#[tokio::test]
async fn invalid_http_source_and_old_source_tree_writer_cannot_partially_adopt() {
let fixture = Fixture::new().await;
let draft = fixture.source().await;
let path = permission_path(&draft);
let before = fixture.durable();
let invalid = [
("syntax", "{ subjects = [".to_owned()),
(
"unknown-key",
permission_source(
&fixture.subject,
"ticket.not_a_builtin_operation",
"ticket.query",
),
),
(
"missing-subject",
permission_source(
"nonexistent-adoption-subject",
"ticket.create",
"ticket.query",
),
),
(
"unknown-set",
format!(
"{{ subjects = [{{ subject_id = {:?}; execution = {{ sets = [\"unknown-adoption-set\"]; }}; delegation = {{}}; }}]; remove_subjects = []; }}",
fixture.subject
),
),
];
for (name, content) in invalid {
let adoption = request(
&draft,
&format!("invalid-{name}"),
vec![update(&draft, &path, &content)],
);
for suffix in ["/preview", "/commit"] {
let (status, response) = fixture
.call(fixture.bearer(
&fixture.owner,
"POST",
suffix,
Some(serde_json::to_value(&adoption).unwrap()),
))
.await;
assert!(
status.is_client_error(),
"{name} {suffix}: {status} {response}"
);
assert_eq!(
fixture.durable(),
before,
"invalid {name} leaves no source, policy, head, receipt or seal"
);
fixture.assert_legacy_writable();
}
}
for canonical in [false, true] {
if canonical {
fixture.adopt_draft("old-writer-cutover").await;
}
let current = fixture.source().await;
let old = SourceRequest {
base_digest: current.state.snapshot.digest,
changes: vec![create_marker("old writer must not persist")],
entrypoints: current.state.contract.entrypoints,
};
let before = fixture.durable();
let response = Request::builder()
.method("POST")
.uri(format!(
"/api/w/{TEST_WORKSPACE_ID}/config/source-tree/commit"
))
.header("authorization", format!("Bearer {}", fixture.owner))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&old).unwrap()))
.unwrap();
let (status, response) = fixture.call(response).await;
assert_eq!(
status,
StatusCode::CONFLICT,
"old writer canonical={canonical}: {response}"
);
assert_eq!(fixture.durable(), before);
if canonical {
fixture.assert_legacy_sealed();
} else {
fixture.assert_legacy_writable();
}
}
}
}
@@ -619,22 +619,28 @@ pub(super) async fn commit(
{ {
return Err(fail(409, "authority_changed")); return Err(fail(409, "authority_changed"));
} }
// Preserve the shared helper's ordering and atomic CAS, but never // Legacy logical writes share the physical authority gate with
// run its remote Runtime publication on the sole DB worker. // initial cutover, in Feature -> Server order. A durable seal
project_repository_access_candidate( // fences this writer even when the Server adoption rolled back.
&*persistence_api.store, let legacy = open_legacy_subjektiv_store(&persistence_api).map_err(pre_error)?;
&persistence_api.repository_secrets, let identity = legacy.database_identity().map_err(|_| unknown())?;
&persistence_api.config.workspace_id, let state = legacy.with_permission_authority_guard(&identity, |authority| {
&candidate, authority.require_unsealed()?;
) Ok((|| {
.map_err(|_| unknown())?; project_repository_access_candidate(
let state = persistence_api &*persistence_api.store,
.config_store &persistence_api.repository_secrets,
.commit_evaluated_workspace_config( &persistence_api.config.workspace_id,
&persistence_api.config.workspace_id, &candidate,
&candidate, ).map_err(|_| unknown())?;
) persistence_api.config_store.commit_evaluated_workspace_config(
.map_err(|_| unknown())?; &persistence_api.config.workspace_id, &candidate,
).map_err(pre_error)
})())
}).map_err(|error| match error {
subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"),
_ => unknown(),
})??;
let publication = persistence_api let publication = persistence_api
.prompt_projection_cache .prompt_projection_cache
.resolve(&persistence_api.config.workspace_id, &state) .resolve(&persistence_api.config.workspace_id, &state)
@@ -110,8 +110,13 @@ fn assert_accepted_without_domain_changes(conn: &Connection, current: i64) {
verify_history(conn, current).unwrap(); verify_history(conn, current).unwrap();
if current >= 114 { if current >= 114 {
let plan = published_plan(conn, current).unwrap().unwrap(); let plan = published_plan(conn, current).unwrap().unwrap();
assert!(plan.migrations.is_empty()); assert_eq!(plan.migrations.len(), 1);
assert_eq!(plan.target_schema_version, current); 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!(rows, saved_rows(conn));
assert_eq!(shape, snapshot(conn).unwrap()); 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(); let conn = Connection::open(&path).unwrap();
// Production store owner, not a separate test migration implementation. // Production store owner, not a separate test migration implementation.
super::super::prepare_connection(&conn).unwrap(); 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!( assert_eq!(
super::super::current_schema_version(&conn).unwrap(), history.pop(),
current 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 (_temp, conn) = retained_to(103, alternate);
let original = super::super::workspace_schema_migration_history(&conn).unwrap(); let original = super::super::workspace_schema_migration_history(&conn).unwrap();
super::super::prepare_connection(&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(); let updated = super::super::workspace_schema_migration_history(&conn).unwrap();
assert_eq!(&updated[..original.len()], &original); assert_eq!(&updated[..original.len()], &original);
let name: String = conn 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. /// Every other row, gap, mixed family, unknown suffix and missing object fails.
pub(super) fn verify_history(conn: &Connection, current: i64) -> Result<()> { pub(super) fn verify_history(conn: &Connection, current: i64) -> Result<()> {
let rows = super::workspace_schema_migration_history(conn)?; 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 { let Some((baseline, name)) = rows.first() else {
return Err(invalid("missing migration receipts")); return Err(invalid("missing migration receipts"));
}; };
@@ -191,11 +216,34 @@ pub(super) fn published_plan(
verify_history(conn, current)?; verify_history(conn, current)?;
Ok(Some(super::WorkspaceSchemaMigrationPlan { Ok(Some(super::WorkspaceSchemaMigrationPlan {
current_schema_version: current, current_schema_version: current,
target_schema_version: current, target_schema_version: 122,
migrations: vec![], 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, // sqlite_schema programs, not table/column presence alone. SQL literal bytes,
// CHECK/FK clauses, indexes and trigger programs remain part of the contract. // CHECK/FK clauses, indexes and trigger programs remain part of the contract.
type Shape = BTreeMap<String, (String, String, Vec<String>)>; type Shape = BTreeMap<String, (String, String, Vec<String>)>;
@@ -233,7 +281,7 @@ fn reference_base() -> Result<Connection> {
merge_request::migrate(&conn).map_err(invalid)?; merge_request::migrate(&conn).map_err(invalid)?;
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
super::create_latest_workspace_schema(&tx)?; super::create_workspace_schema_through_v113(&tx)?;
tx.commit()?; tx.commit()?;
Ok(conn) Ok(conn)
} }
+41 -3
View File
@@ -132,7 +132,7 @@ const OLDEST_SCHEMA_VERSION: i64 = 50;
#[path = "storage_formats.rs"] #[path = "storage_formats.rs"]
mod storage_formats; 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_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -505,6 +505,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Ticket work-decomposition parent links", name: "Ticket work-decomposition parent links",
apply: migrate_ticket_parents_v112_to_v113, 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 // Both schema-103 identities shipped. Keep the alternate implementation and accept
@@ -16069,6 +16074,25 @@ fn migrate_plugin_services_v111_to_v112(conn: &Connection) -> Result<()> {
Ok(()) 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<()> { fn migrate_ticket_parents_v112_to_v113(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 112 { if current_schema_version(conn)? != 112 {
return Err(Error::Store( return Err(Error::Store(
@@ -16165,6 +16189,13 @@ fn migrate_plugin_receipts_v108_to_v109(conn: &Connection) -> Result<()> {
} }
fn create_latest_workspace_schema(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)?; create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?; assignment_delivery::install(conn)?;
assignment_delivery::install_post_inputs(conn)?; assignment_delivery::install_post_inputs(conn)?;
@@ -17083,7 +17114,7 @@ fn workspace_schema_migration_history(conn: &Connection) -> Result<Vec<(i64, Str
} }
fn verify_schema_history(conn: &Connection, current: i64) -> Result<()> { fn verify_schema_history(conn: &Connection, current: i64) -> Result<()> {
if current > LATEST_SCHEMA_VERSION { if current > 113 {
return storage_formats::verify_history(conn, current); return storage_formats::verify_history(conn, current);
} }
let rows = workspace_schema_migration_history(conn)?; let rows = workspace_schema_migration_history(conn)?;
@@ -18878,9 +18909,16 @@ mod tests {
#[test] #[test]
fn current_schema_accepts_every_retained_canonical_provenance() { 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(); let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).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( conn.execute(
"INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)", "INSERT INTO __yoi_schema_migrations(version, name) VALUES (?1, ?2)",
params![baseline_version, SCHEMA_BASELINE_NAME], params![baseline_version, SCHEMA_BASELINE_NAME],
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -420,6 +420,17 @@
"error": { "error": {
"type": "string" "type": "string"
}, },
"failure": {
"anyOf": [
{
"$ref": "#/components/schemas/StartupFailureAttachment"
},
{
"type": "null"
}
],
"description": "An origin-projected error-contract attachment, not raw HTTP error text.\nReceiving it does not authorize redisclosure to a different audience."
},
"message": { "message": {
"type": "string" "type": "string"
} }
@@ -449,6 +460,10 @@
], ],
"type": "object" "type": "object"
}, },
"StartupFailureAttachment": {
"description": "A bounded, projected envelope at the Worker/Runtime or metadata boundary.\nNo constructor accepts Display text, arbitrary JSON fields, or an unprojected error.",
"type": "string"
},
"ValidatePermissionsRequest": { "ValidatePermissionsRequest": {
"additionalProperties": false, "additionalProperties": false,
"description": "The management boundary uses exactly the same assignment as text/storage.\nWorkspace comes from authenticated routing, not a repeated permission field.", "description": "The management boundary uses exactly the same assignment as text/storage.\nWorkspace comes from authenticated routing, not a repeated permission field.",
@@ -481,7 +496,7 @@
"info": { "info": {
"title": "Feature permission declaration contract", "title": "Feature permission declaration contract",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:3c85df5880ec5b52d693ba559f02534609f0667d7f95841f6eb58a36823824cb" "x-yoi-source-digest": "sha256:9a4f2ec1ab277c73bf8c69b2adef050dbae26cff5430ff2bd1201371feef5a38"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev", "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", "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", "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 src/lib/workspace/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: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: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", "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,30 @@
// Generated by `cargo run -p server-api --features typescript --example generate_configuration_adoption_types`.
// Do not edit manually.
import type { ConfigCommitRequest, WorkspaceConfigTreeResponse, ScopedPermission, SubjectPermissions } from "./legacy-server-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 { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import ConfigSourceEditor from "./ConfigSourceEditor.svelte"; import ConfigSourceEditor from "./ConfigSourceEditor.svelte";
import type { WorkspaceConfigTreeResponse } from "./types.ts";
const toolchain = vi.hoisted(() => ({ const toolchain = vi.hoisted(() => ({
construct: vi.fn(), construct: vi.fn(),
@@ -30,7 +31,7 @@ vi.mock("./toolchain.ts", () => ({
})); }));
const schemaSource = "schema body\n".repeat(6000); const schemaSource = "schema body\n".repeat(6000);
const tree = { const tree: WorkspaceConfigTreeResponse = {
snapshot: { snapshot: {
digest: "sha256:tree", digest: "sha256:tree",
entries: { entries: {
@@ -64,7 +65,27 @@ const tree = {
projection_digest: "sha256:projection", projection_digest: "sha256:projection",
}; };
const fetcher = vi.fn(); 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(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
@@ -129,7 +150,7 @@ test("loading is not an empty source tree; editing waits for schema installation
finishSetup(); finishSetup();
await expectReady(view); await expectReady(view);
expect(fetcher).toHaveBeenCalledWith( expect(fetcher).toHaveBeenCalledWith(
"/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/adoption",
expect.anything(), expect.anything(),
); );
}); });
@@ -140,7 +161,7 @@ test.each(["http", "invalid"])(
fetcher.mockResolvedValueOnce( fetcher.mockResolvedValueOnce(
failure === "http" failure === "http"
? new Response("Backend unavailable", { status: 503 }) ? new Response("Backend unavailable", { status: 503 })
: new Response(JSON.stringify({ ...tree, unexpected: true })), : new Response(JSON.stringify({ ...sourceResponse(), unexpected: true })),
); );
const view = renderEditor(); const view = renderEditor();
await waitFor(() => await waitFor(() =>
@@ -190,10 +211,10 @@ test.each(["construct", "setSnapshot"] as const)(
test("a successfully loaded empty tree still offers source creation", async () => { test("a successfully loaded empty tree still offers source creation", async () => {
fetcher.mockResolvedValueOnce( fetcher.mockResolvedValueOnce(
new Response(JSON.stringify({ new Response(JSON.stringify(sourceResponse({
...tree, ...tree,
snapshot: { ...tree.snapshot, entries: {} }, snapshot: { ...tree.snapshot, entries: {} },
})), }))),
); );
const view = renderEditor(); const view = renderEditor();
await waitFor(() => await waitFor(() =>
@@ -328,49 +349,186 @@ test("diagnostics from another tree digest are not displayed", async () => {
expect(view.container.textContent).not.toContain("Wrong tree diagnostic"); 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" } as const;
const change = { kind: "create", path: "module.dcdl", content_type: "decodal", content: "{}\n" }; const candidate = {
const candidate = { digest: "sha256:local-draft",
digest: "sha256:local-draft", entries: {
entries: { ...tree.snapshot.entries,
...tree.snapshot.entries, "module.dcdl": {
"module.dcdl": { path: change.path, content_type: change.content_type, content: change.content,
path: change.path, content_digest: "sha256:module",
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.applyChanges.mockResolvedValue(candidate);
toolchain.changesBetween.mockResolvedValue([change]); toolchain.changesBetween.mockResolvedValue([change]);
toolchain.format.mockImplementation(async (source: string) => source); toolchain.format.mockImplementation(async (source: string) => source);
const view = renderEditor(); const view = renderEditor();
await expectReady(view); await expectReady(view);
await fireEvent.click(view.getByRole("button", { name: "Create local source" })); 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 = 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" }); const commit = view.getByRole("button", { name: "Commit" });
expect(commit).toBeInstanceOf(HTMLButtonElement);
fetcher.mockResolvedValueOnce(new Response( fetcher.mockResolvedValueOnce(new Response(
status === 409 ? "base content digest mismatch" : "candidate evaluation failed", status === 409 ? "editing head mismatch" : "candidate evaluation failed", { status },
{ status },
)); ));
await fireEvent.click(commit); await fireEvent.click(commit);
await waitFor(() => expect(view.container.textContent).toContain( 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 = { const first = requestAt(1);
base_digest: tree.snapshot.digest, changes: [change], entrypoints: ["main.dcdl"], expect(first).toEqual({
}; operation_id: expect.any(String), expected_head: "head:observed",
expect(JSON.parse(fetcher.mock.calls[1][1].body)).toEqual(expectedRequest); 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(view.container.querySelector(".cm-content")?.textContent).toContain("{}");
expect(commit).toHaveProperty("disabled", false); expect(commit).toHaveProperty("disabled", false);
expect(toolchain.setSnapshot).toHaveBeenCalledOnce(); expect(toolchain.setSnapshot).toHaveBeenCalledOnce();
if (status === 409) expect(view.getByRole("alert").textContent).toContain("Discard local candidate"); 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 fireEvent.click(commit);
await waitFor(() => expect(view.container.textContent).toContain("Committed formatted source tree")); 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(toolchain.setSnapshot).toHaveBeenLastCalledWith(candidate, tree.contract.schema_bundle);
expect(commit).toHaveProperty("disabled", true); 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]);
await fireEvent.click(view.getByRole("button", { name: "Delete" }));
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"> <script lang="ts">
// commitの条件は保存したbase_digestと各entryのexpected_digest。時刻や要求順では // 内容digestとは別に、GETで観測した履歴headと移行条件を編集元として保持する。
// 編集元の内容を特定できないため、成功時だけ返されたsnapshotを新しいbaseにする。 // 応答喪失時は依頼全体を固定し、照合で採用を確認するまで別IDへ切り替えない。
// Server拒否時はdraftを残し、reload/reapplyを明示的な判断にする。
// 診断は非abortかつ解析対象tree digest一致の場合だけ表示する。content条件と // 診断は非abortかつ解析対象tree digest一致の場合だけ表示する。content条件と
// 非同期requestの有効性は別に検査し、遅い解析を現在のsourceの診断として出さない。 // 非同期requestの有効性は別に検査し、遅い解析を現在のsourceの診断として出さない。
import { onMount } from "svelte"; import { onMount } from "svelte";
import DecodalSourceEditor from "#lib/workspace/settings/DecodalSourceEditor.svelte"; import DecodalSourceEditor from "#lib/workspace/settings/DecodalSourceEditor.svelte";
import { import {
commitConfigTree, commitConfigTree,
ConfigSourceApiError,
fetchConfigTree, fetchConfigTree,
previewConfigTree,
reconcileConfigTree,
} from "./api.ts"; } 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 { ConfigSourceToolchain } from "./toolchain.ts";
import type { import type {
ConfigDiagnostic, ConfigDiagnostic,
@@ -31,6 +38,13 @@
let busy = $state(false); let busy = $state(false);
let workingChanges = $state<ConfigTreeChange[]>([]); let workingChanges = $state<ConfigTreeChange[]>([]);
let baseDigest = $state(""); let baseDigest = $state("");
let editingMetadata = $state.raw<ConfigurationEditingState | null>(null);
let editingWorkspaceId = $state("");
let pendingAdoption = $state.raw<{
workspaceId: string;
request: ConfigurationAdoptionRequest;
draftSignature: string;
} | null>(null);
let renamePath = $state(""); let renamePath = $state("");
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null); let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
let conflict = $state(false); let conflict = $state(false);
@@ -73,7 +87,7 @@
void analyzer.analyze(path, value).then((result) => { void analyzer.analyze(path, value).then((result) => {
if (!request.signal.aborted) diagnostics = result.filter((diagnostic) => diagnostic.tree_digest === digest); if (!request.signal.aborted) diagnostics = result.filter((diagnostic) => diagnostic.tree_digest === digest);
}).catch((error) => { }).catch((error) => {
if (!request.signal.aborted) status = `Analyze failed: ${String(error)}`; if (!request.signal.aborted && !pendingAdoption && !busy) status = `Analyze failed: ${String(error)}`;
}); });
}, 250); }, 250);
return () => { return () => {
@@ -83,20 +97,28 @@
}); });
async function reload() { async function reload() {
if (pendingAdoption) {
status = "Adoption outcome unknown. Reconcile the retained request before reloading.";
return;
}
loadState = "loading"; loadState = "loading";
status = "Loading source tree…"; status = "Loading source tree…";
analysisReady = false; analysisReady = false;
analysisRequest?.abort(); analysisRequest?.abort();
const requestedWorkspace = workspaceId;
try { try {
const remote = await fetchConfigTree(workspaceId); const observed = await fetchConfigTree(requestedWorkspace);
if (!mounted) return; const remote = observed.state;
if (!mounted || workspaceId !== requestedWorkspace) return;
toolchain?.close(); toolchain?.close();
toolchain = null; toolchain = null;
toolchain = new ConfigSourceToolchain(); toolchain = new ConfigSourceToolchain();
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle); 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. // Publish the new baseline only after both fetch and toolchain setup succeed.
treeState = remote; treeState = remote;
editingMetadata = observed;
editingWorkspaceId = requestedWorkspace;
if (!selectedPath || !treeState.snapshot.entries[selectedPath]) { if (!selectedPath || !treeState.snapshot.entries[selectedPath]) {
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
} }
@@ -204,52 +226,174 @@
function recordCommitError(error: unknown) { function recordCommitError(error: unknown) {
const message = String(error); 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; status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
} }
async function commit() { function draftSignature(): string {
if (!treeState) return; 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; busy = true;
try { 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(); await formatWorkingSources();
if (workingChanges.length === 0) { if (workingChanges.length === 0 && editingMetadata.source_stored) {
status = "No working changes to commit."; status = "No working changes to commit.";
return; return;
} }
treeState = await commitConfigTree(workspaceId, { const request: ConfigurationAdoptionRequest = {
base_digest: baseDigest, operation_id: crypto.randomUUID(),
changes: workingChanges, expected_head: editingMetadata.history_head,
entrypoints: entrypoints(), expected_catalog_digest: editingMetadata.catalog_digest,
}); expected_legacy_export: editingMetadata.expected_legacy_export,
workingChanges = []; source: {
conflict = false; base_digest: baseDigest,
baseSnapshot = $state.snapshot(treeState.snapshot); changes: $state.snapshot(workingChanges),
baseDigest = treeState.snapshot.digest; entrypoints: entrypoints(),
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); },
source = treeState.snapshot.entries[selectedPath]?.content ?? ""; };
diagnostics = []; const signature = draftSignature();
status = `Committed formatted source tree · ${treeState.snapshot.digest.slice(0, 20)}…`; await previewConfigTree(adoptionWorkspaceId, request);
pendingAdoption = { workspaceId: adoptionWorkspaceId, request, draftSignature: signature };
await submitPending(false);
} catch (error) { } catch (error) {
recordCommitError(error); if (pendingAdoption) status = `Adoption outcome unknown: ${String(error)}`;
else recordCommitError(error);
} finally { } finally {
busy = false; busy = false;
} }
} }
async function discardAndReload() { async function discardAndReload() {
if (busy) return;
await reload(); await reload();
} }
async function reloadAndReapply() { async function reloadAndReapply() {
if (!toolchain || !treeState) return; if (!toolchain || !treeState || busy || pendingAdoption) return;
const localChanges = [...workingChanges]; busy = true;
const remote = await fetchConfigTree(workspaceId); const requestedWorkspace = workspaceId;
baseSnapshot = structuredClone(remote.snapshot);
baseDigest = remote.snapshot.digest;
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
try { 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); const candidate = await toolchain.applyChanges(localChanges);
baseSnapshot = structuredClone(remote.snapshot);
baseDigest = remote.snapshot.digest;
editingMetadata = observed;
editingWorkspaceId = requestedWorkspace;
workingChanges = localChanges; workingChanges = localChanges;
treeState = { ...remote, snapshot: candidate }; treeState = { ...remote, snapshot: candidate };
selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? ""; selectedPath = candidate.entries[selectedPath] ? selectedPath : Object.keys(candidate.entries).toSorted()[0] ?? "";
@@ -258,7 +402,14 @@
status = "Local changes reapplied to the latest source tree. Commit to persist them."; status = "Local changes reapplied to the latest source tree. Commit to persist them.";
} catch (error) { } catch (error) {
conflict = true; 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.`; 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 +482,7 @@
<button <button
type="button" type="button"
class:active={path === selectedPath} class:active={path === selectedPath}
disabled={busy}
onclick={() => select(path)} onclick={() => select(path)}
> >
<span>{path}</span> <span>{path}</span>
@@ -340,8 +492,8 @@
</nav> </nav>
<form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}> <form class="config-source-create" onsubmit={(event) => { event.preventDefault(); createEntry(); }}>
<label for="new-config-path">New path</label> <label for="new-config-path">New path</label>
<input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" /> <input id="new-config-path" bind:value={newPath} placeholder="module.dcdl" disabled={busy} />
<button type="submit">Create local source</button> <button type="submit" disabled={busy}>Create local source</button>
</form> </form>
</aside> </aside>
@@ -355,7 +507,7 @@
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} /> <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={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</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> <button class="danger" type="button" onclick={deleteEntry} disabled={!selected || mainSelected || busy}>Delete</button>
</div> </div>
</header> </header>
@@ -369,10 +521,16 @@
: Promise.resolve(null)} : Promise.resolve(null)}
/> />
<p class="config-source-status" aria-live="polite">{status}</p> <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"> <div class="config-source-conflict" role="alert">
<button type="button" onclick={discardAndReload}>Discard local candidate and reload</button> <button type="button" onclick={discardAndReload} disabled={busy}>Discard local candidate and reload</button>
<button type="button" onclick={reloadAndReapply}>Reload and reapply local candidate</button> <button type="button" onclick={reloadAndReapply} disabled={busy}>Reload and reapply local candidate</button>
</div> </div>
{/if} {/if}
{#if diagnostics.length > 0} {#if diagnostics.length > 0}
@@ -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 "./api.ts";
import type { ConfigurationAdoptionRequest } from "#lib/generated/configuration-adoption.generated.ts";
import type { WorkspaceConfigTreeResponse } from "./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(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");
}
});
@@ -4,7 +4,6 @@
* Browserの解析成功はServerのcommit受理を代替しない。 * Browserの解析成功はServerのcommit受理を代替しない。
*/ */
import type { import type {
ConfigCommitRequest,
ConfigContentType, ConfigContentType,
ConfigEntry, ConfigEntry,
ConfigProjectionValidator, ConfigProjectionValidator,
@@ -15,6 +14,20 @@ import type {
WorkspaceConfigTreeResponse, WorkspaceConfigTreeResponse,
} from "./types.ts"; } 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; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
// Schema bodies are documents, not short metadata. Keep them within the // Schema bodies are documents, not short metadata. Keep them within the
// response budget; readJson also bounds the combined bundle and contributions. // 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( export async function fetchConfigTree(
workspaceId: string, workspaceId: string,
fetcher: typeof fetch = fetch, fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> { ): Promise<ConfigurationEditingState> {
return await readJson( return await readJson(
await fetcher(sourceTreeUrl(workspaceId), { await fetcher(adoptionUrl(workspaceId), {
headers: { accept: "application/json" }, headers: { accept: "application/json" },
}), }),
parseWorkspaceConfigTreeResponse, parseConfigurationSourceResponse,
); );
} }
@@ -318,30 +454,32 @@ export async function fetchConfigHistory(
export async function previewConfigTree( export async function previewConfigTree(
workspaceId: string, workspaceId: string,
request: ConfigCommitRequest, request: ConfigurationAdoptionRequest,
fetcher: typeof fetch = fetch, fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> { ): Promise<ConfigurationPreviewResponse> {
return await readJson( const preview = await postAdoption(workspaceId, "preview", request, parseConfigurationPreviewResponse, fetcher);
await fetcher(`${sourceTreeUrl(workspaceId)}/preview`, { if (preview.expected_head !== request.expected_head) throw schemaError();
method: "POST", return preview;
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
}),
parseWorkspaceConfigTreeResponse,
);
} }
export async function commitConfigTree( export async function commitConfigTree(
workspaceId: string, workspaceId: string,
request: ConfigCommitRequest, request: ConfigurationAdoptionRequest,
fetcher: typeof fetch = fetch, fetcher: typeof fetch = fetch,
): Promise<WorkspaceConfigTreeResponse> { ): Promise<ConfigurationAdoptionReceipt> {
return await readJson( return matchingReceipt(
await fetcher(`${sourceTreeUrl(workspaceId)}/commit`, { await postAdoption(workspaceId, "commit", request, parseConfigurationAdoptionReceipt, fetcher),
method: "POST", workspaceId,
headers: { "content-type": "application/json" }, request,
body: JSON.stringify(request),
}),
parseWorkspaceConfigTreeResponse,
); );
} }
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;
}
+153 -63
View File
@@ -8,13 +8,24 @@ import {
} from "jsr:@std/assert"; } from "jsr:@std/assert";
import { import {
commitConfigTree, commitConfigTree,
type ConfigurationEditingState,
ConfigSourceApiError, ConfigSourceApiError,
fetchConfigEntry, fetchConfigEntry,
fetchConfigHistory, fetchConfigHistory,
fetchConfigTree, fetchConfigTree,
parseWorkspaceConfigTreeResponse, parseWorkspaceConfigTreeResponse,
previewConfigTree, previewConfigTree,
reconcileConfigTree,
} from "../../src/lib/workspace/config-source/api.ts"; } 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 { function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { 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", path: "profiles/main.dcdl",
content_type: "decodal", content_type: "decodal",
content: "profile = {}", content: "profile = {}",
@@ -33,7 +44,7 @@ const snapshot = {
digest: "sha256:tree", digest: "sha256:tree",
entries: { "profiles/main.dcdl": entry }, entries: { "profiles/main.dcdl": entry },
}; };
const tree = { const tree: WorkspaceConfigTreeResponse = {
snapshot, snapshot,
contract: { contract: {
contract_version: 1, contract_version: 1,
@@ -51,38 +62,119 @@ const tree = {
projection_digest: "sha256:projection", 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 calls: Array<{ url: string; init?: RequestInit }> = [];
const fetcher = ((input: string | URL | Request, init?: RequestInit) => { const fetcher = ((input: string | URL | Request, init?: RequestInit) => {
const url = String(input); const url = String(input);
calls.push({ url, init }); calls.push({ url, init });
if (url.includes("/entries/")) return Promise.resolve(response(entry)); if (url.includes("/entries/")) return Promise.resolve(response(entry));
if (url.includes("/history/")) return Promise.resolve(response(snapshot)); 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; }) as typeof fetch;
await fetchConfigTree("w/one", fetcher); assertEquals(await fetchConfigTree("w/one", fetcher), editingState());
await fetchConfigHistory("w/one", "sha256:tree", fetcher); assertEquals(await fetchConfigHistory("w/one", "sha256:tree", fetcher), snapshot);
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher); assertEquals(await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher), entry);
await commitConfigTree("w/one", { assertEquals(await previewConfigTree("w/one", request, fetcher), preview);
base_digest: "sha256:base", assertEquals(await commitConfigTree("w/one", request, fetcher), receipt);
changes: [], assertEquals(await reconcileConfigTree("w/one", request, fetcher), {
entrypoints: [], status: "adopted",
}, fetcher); receipt,
});
assertEquals<unknown>(calls.map((call) => call.url), [ assertEquals(calls.map((call) => call.url), [
"/api/w/w%2Fone/config/source-tree", "/api/w/w%2Fone/config/adoption",
"/api/w/w%2Fone/config/source-tree/history/sha256%3Atree", "/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/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"); for (const call of calls.slice(3)) {
assert( assertEquals(call.init?.method, "POST");
String(calls[3].init?.body).includes('"base_digest":"sha256:base"'), assertEquals(
); new Headers(call.init?.headers).get("content-type"),
assertEquals(JSON.parse(String(calls[3].init?.body)), { "application/json",
base_digest: "sha256:base", changes: [], entrypoints: [], );
}); assertEquals(JSON.parse(String(call.init?.body)), request);
}
}); });
function withSchema(source: unknown, contributionSource: unknown = "builtin") { function withSchema(source: unknown, contributionSource: unknown = "builtin") {
@@ -111,15 +203,19 @@ for (const field of ["bundle", "contribution"] as const) {
const body = field === "bundle" const body = field === "bundle"
? withSchema(source) ? withSchema(source)
: withSchema("builtin", source); : withSchema("builtin", source);
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch; const request = adoptionRequest();
assertEquals<unknown>(await fetchConfigTree("w", fetcher), body); 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>( assertEquals<unknown>(
await commitConfigTree("w", { await commitConfigTree("w", request, fetcher),
base_digest: snapshot.digest, receipt,
changes: [],
entrypoints: ["main.dcdl"],
}, fetcher),
body,
); );
}); });
@@ -159,7 +255,7 @@ Deno.test("config source API retains metadata and total response limits", async
const fetcher = (() => const fetcher = (() =>
Promise.resolve( Promise.resolve(
new Response( new Response(
JSON.stringify(withSchema("x".repeat(8 * 1024 * 1024))), JSON.stringify(sourceResponse(withSchema("x".repeat(8 * 1024 * 1024)))),
{ headers }, { headers },
), ),
)) as typeof fetch; )) 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 () => { Deno.test("config source API rejects unknown response fields", async () => {
const fetcher = const fetcher = (() =>
(() => Promise.resolve(response(
Promise.resolve(response({ ...tree, unexpected: true }))) as typeof fetch; sourceResponse({ ...tree, unexpected: true }),
))) as typeof fetch;
await assertRejects( await assertRejects(
() => fetchConfigTree("w", fetcher), () => fetchConfigTree("w", fetcher),
ConfigSourceApiError, ConfigSourceApiError,
@@ -190,7 +287,7 @@ Deno.test("config source API rejects mismatched entry map paths", async () => {
entries: { "profiles/other.dcdl": entry }, 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( await assertRejects(
() => fetchConfigTree("w", fetcher), () => fetchConfigTree("w", fetcher),
ConfigSourceApiError, 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); 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( assertEquals(
result.snapshot.entries["profiles/main.dcdl"].content, result.state.snapshot.entries["profiles/main.dcdl"].content,
body.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; }) as typeof fetch;
const error = await assertRejects( const error = await assertRejects(
() => () =>
commitConfigTree("w", { commitConfigTree("w", adoptionRequest(), fetcher),
base_digest: "sha256:tree",
changes: [],
entrypoints: ["main.dcdl"],
}, fetcher),
ConfigSourceApiError, ConfigSourceApiError,
diagnostic.message, diagnostic.message,
); );
assert(String(error).includes("main.dcdl")); 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 () => { 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( Promise.resolve(
new Response("structured diagnostics", { status: 422 }), new Response("structured diagnostics", { status: 422 }),
)) as typeof fetch; )) as typeof fetch;
let message = ""; const error = await assertRejects(
try { () => commitConfigTree("w", adoptionRequest(), fetcher),
await commitConfigTree("w", { ConfigSourceApiError,
base_digest: "sha256:base", "structured diagnostics",
changes: [], );
entrypoints: [], assertEquals(error.status, 422);
}, fetcher);
} catch (error) {
message = String(error);
}
assert(message.includes("structured diagnostics"));
}); });
Deno.test("config source API carries bounded optional authoring schemas without changing validation source", () => { 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 () => { Deno.test("config source API previews adoption change scope without calling commit", async () => {
const request = { const request = adoptionRequest();
base_digest: "sha256:base", const preview: ConfigurationPreviewResponse = {
changes: [], expected_head: request.expected_head,
entrypoints: ["main.dcdl"], state: tree,
changed_paths: [entry.path],
changed_subjects: ["subject:one"],
}; };
const calls: string[] = []; const calls: string[] = [];
const result = await previewConfigTree( const result = await previewConfigTree(
@@ -351,9 +441,9 @@ Deno.test("config source API previews a candidate without calling commit", async
calls.push(String(input)); calls.push(String(input));
assertEquals(init?.method, "POST"); assertEquals(init?.method, "POST");
assertEquals(JSON.parse(String(init?.body)), request); assertEquals(JSON.parse(String(init?.body)), request);
return Promise.resolve(response(tree)); return Promise.resolve(response(preview));
}) as typeof fetch, }) as typeof fetch,
); );
assertEquals(calls, ["/api/w/w%2Fone/config/source-tree/preview"]); assertEquals(calls, ["/api/w/w%2Fone/config/adoption/preview"]);
assertEquals<unknown>(result, tree); 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( const source = await Deno.readTextFile(
new URL( new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte", "../../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", "all changed Decodal entries should be formatted rather than only the selected source",
); );
assert( assert(
source.includes("await formatWorkingSources();") && source.includes("expected_head: editingMetadata.history_head") &&
source.includes("entrypoints: entrypoints()") && source.includes("expected_catalog_digest: editingMetadata.catalog_digest") &&
source.includes("Committed formatted source tree"), source.includes("expected_legacy_export: editingMetadata.expected_legacy_export") &&
"commit should format first and send the working changes directly to Backend authority", 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( assert(
!source.includes("previewConfigTree") && reconciliation.includes('result.status === "adopted"') &&
!source.includes("requestCandidatePreview") && reconciliation.includes("applyReceipt(result.receipt)") &&
!source.includes("toolchain_fingerprint"), !reconciliation.includes("pendingAdoption = null"),
"normal commit must not depend on a separate preview or client-echoed toolchain fingerprint", "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",
); );
}); });