fix: preserve uncertain config effects and exercise owner adoption fixtures

Select identifier-free startup summaries from T-840 1d8c205b, without adopting that whole branch or its review status. Keep physical identity guards, migration rollback assertions and old-writer fencing intact.
This commit is contained in:
2026-11-04 20:48:18 +09:00
parent eb64644c9b
commit 8613f2df7d
8 changed files with 172 additions and 38 deletions
+2 -2
View File
@@ -2347,8 +2347,8 @@ impl RuntimeHttpRestError {
RuntimeError::WorkerExecutionRejected { result, .. } => startup_transfer(result), RuntimeError::WorkerExecutionRejected { result, .. } => startup_transfer(result),
_ => None, _ => None,
}; };
let message = if startup.is_some() { let message = if let Some(startup) = &startup {
"Worker startup failed".to_owned() worker::startup_failure::internal_startup_failure_summary(&startup.primary).to_owned()
} else { } else {
error.to_string() error.to_string()
}; };
+44
View File
@@ -120,6 +120,20 @@ pub fn reproject_internal_startup_failure(
StartupFailureAttachment::from_projected(&projected) StartupFailureAttachment::from_projected(&projected)
} }
/// Fixed, identifier-free summary for an already authenticated internal caller.
/// Keep the required operation actionable without exposing the source's Display.
/// This neither projects a Browser cause nor chooses an HTTP status.
pub fn internal_startup_failure_summary(failure: &StartupFailureAttachment) -> &'static str {
if failure
.project_for::<InternalTransfer, protocol::session_failure::SessionRegistrationCause>(&())
.is_ok()
{
"Worker startup failed while registering Session (session.record)"
} else {
"Worker startup failed"
}
}
/// 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,
@@ -249,6 +263,36 @@ mod tests {
WorkerStoreError, WorkerStoreError,
}; };
#[test]
fn internal_summary_retains_only_attested_required_operation() {
use protocol::session_failure::{SessionPrincipal, SessionRegistrationCause};
let session = protocol::session_failure::session_registration_attachment(
SessionRegistrationCause::denied(SessionPrincipal::Subject),
)
.unwrap();
assert_eq!(
internal_startup_failure_summary(&session),
"Worker startup failed while registering Session (session.record)"
);
let runtime = runtime_startup_attachment(RuntimeStartupCause {
stage: StartupStage::Feature,
reason: RuntimeStartupReason::FeatureFailed,
})
.unwrap();
assert_eq!(
internal_startup_failure_summary(&runtime),
"Worker startup failed"
);
let corrupt = serde_json::from_value(serde_json::json!(
"session.record denied TEST_ONLY_CREDENTIAL /private/path raw_http_body"
))
.unwrap();
assert_eq!(
internal_startup_failure_summary(&corrupt),
"Worker startup failed"
);
}
struct LostSaveResponse(FsWorkerStore); struct LostSaveResponse(FsWorkerStore);
impl WorkerMetadataStore for LostSaveResponse { impl WorkerMetadataStore for LostSaveResponse {
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> { fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
+5 -2
View File
@@ -6772,8 +6772,11 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
), ),
}; };
if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error { if let EmbeddedRuntimeError::WorkerExecutionRejected { result, .. } = error {
if result.startup_failure.is_some() { if let Some(primary) = &result.startup_failure {
projection.message = "Embedded Runtime Worker startup failed".into(); projection.message = format!(
"Embedded Runtime: {}",
worker::startup_failure::internal_startup_failure_summary(primary)
);
} }
projection.startup = result.startup_failure.as_ref().and_then(|primary| { projection.startup = result.startup_failure.as_ref().and_then(|primary| {
internal_startup_transfer(runtime_api::WorkerStartupFailure { internal_startup_transfer(runtime_api::WorkerStartupFailure {
@@ -11,7 +11,9 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap(); let fresh = SqliteWorkspaceStore::open_for_test(dir.path().join("fresh.db")).unwrap();
let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap(); let canonical = fresh.with_conn(|c| Ok(catalog_schema(c))).unwrap();
for version in [50, 105] { for version in [50, 105] {
let path = dir.path().join(format!("v{version}.db")); let root = dir.path().join(format!("v{version}"));
std::fs::create_dir(&root).unwrap();
let path = root.join("workspace.db");
if version == 50 { if version == 50 {
tests::prepare_schema_v50(&path, Some("workspace-a")); tests::prepare_schema_v50(&path, Some("workspace-a"));
} else { } else {
@@ -30,7 +32,7 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
// Plugin catalog remains migration 106 even when startup applies later steps. // Plugin catalog remains migration 106 even when startup applies later steps.
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(), assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]); vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
assert_eq!(after.last().unwrap(), &(113, "Ticket work-decomposition parent links".into())); assert_eq!(after.last().unwrap(), &(122, crate::config_adoption::MIGRATION_NAME.into()));
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION); assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins"); assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0); assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
@@ -83,6 +85,7 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
)); ));
expected.push((112, "Ticket Service outbox and fenced consumers".into())); expected.push((112, "Ticket Service outbox and fenced consumers".into()));
expected.push((113, "Ticket work-decomposition parent links".into())); expected.push((113, "Ticket work-decomposition parent links".into()));
expected.push((122, crate::config_adoption::MIGRATION_NAME.into()));
assert_eq!(workspace_schema_migration_history(c)?, expected); assert_eq!(workspace_schema_migration_history(c)?, expected);
crate::plugin_catalog::verify_with_data(c)?; crate::plugin_catalog::verify_with_data(c)?;
Ok(()) Ok(())
@@ -101,7 +104,13 @@ fn plugin_catalog_startup_rejects_drifted_reference_fence() {
}) })
.unwrap(); .unwrap();
drop(store); drop(store);
assert!( match SqliteWorkspaceStore::open_for_test(path) {
matches!(SqliteWorkspaceStore::open_for_test(path),Err(Error::Store(message)) if message.contains("Plugin catalog storage schema has drifted")) Err(Error::Store(message)) => assert!(
); message.contains("Server storage format: physical Base mismatch")
&& message.contains("plugin_reference_live"),
"{message}"
),
Err(error) => panic!("unexpected rejection: {error}"),
Ok(_) => panic!("startup accepted a missing reference fence"),
}
} }
@@ -41,6 +41,7 @@ fn plugin_plan_migration_marker_failure_preserves_catalog_then_retries_and_match
)); ));
expected.push((112, "Ticket Service outbox and fenced consumers".into())); expected.push((112, "Ticket Service outbox and fenced consumers".into()));
expected.push((113, "Ticket work-decomposition parent links".into())); expected.push((113, "Ticket work-decomposition parent links".into()));
expected.push((122, crate::config_adoption::MIGRATION_NAME.into()));
assert_eq!(workspace_schema_migration_history(conn)?, expected); assert_eq!(workspace_schema_migration_history(conn)?, expected);
subject_assignment_migration::verify(conn)?; subject_assignment_migration::verify(conn)?;
crate::plugin_plan::verify_plan_schema(conn)?; crate::plugin_plan::verify_plan_schema(conn)?;
@@ -47,33 +47,63 @@ fn source(value: &str) -> String {
async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() { async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await; let api = test_api(dir.path()).await;
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); let token = seed_test_workspace_owner_token(api.store.as_ref(), "builtin-profile-owner").await;
let app = build_router(api.clone()); let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
let app = workspace_server_router(catalog);
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree"); let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
let commit = format!("{tree}/commit"); let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
let initial = get_json_authenticated(app.clone(), &tree, &token).await; let commit = format!("{adoption_path}/commit");
let saved = request_json_authenticated( let current = get_json_authenticated(app.clone(), &adoption_path, &token).await;
let initial = current["state"].clone();
let adoption = |current: &Value, id: &str, source: Value| {
json!({
"operation_id": id, "expected_head": current["history_head"],
"expected_catalog_digest": current["catalog_digest"], "expected_legacy_export": current["expected_legacy_export"],
"source": source,
})
};
let source_request = json!({
"base_digest": current["state"]["snapshot"]["digest"],
"entrypoints": current["state"]["contract"]["entrypoints"],
"changes": [
{ "kind": "update", "path": "main.dcdl",
"expected_digest": current["state"]["snapshot"]["entries"]["main.dcdl"]["content_digest"],
"content": source(r#"import "$builtin/profiles/companion.dcdl""#) },
// A Workspace file at the matching suffix must not rescue an unknown builtin.
{ "kind": "create", "path": "profiles/private.dcdl", "content_type": "decodal", "content": PROFILE }
],
});
let save = adoption(&current, "builtin-profile-adopt", source_request);
let preview = request_json_authenticated(
app.clone(),
"POST",
&format!("{adoption_path}/preview"),
Some(save.clone()),
&token,
StatusCode::OK,
)
.await;
let receipt = request_json_authenticated(
app.clone(), app.clone(),
"POST", "POST",
&commit, &commit,
Some( Some(save),
serde_json::to_value(request(
&api,
&source(r#"import "$builtin/profiles/companion.dcdl""#),
vec![config_source::ConfigTreeChange::Create {
// An ordinary Workspace file at the matching suffix must not rescue
// an unknown builtin import.
path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(),
content_type: config_source::ConfigContentType::Decodal,
content: PROFILE.into(),
}],
))
.unwrap(),
),
&token, &token,
StatusCode::CREATED, StatusCode::OK,
) )
.await; .await;
assert_eq!(receipt["state"], preview["state"]);
let saved = receipt["state"].clone();
assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]); assert_ne!(saved["snapshot"]["digest"], initial["snapshot"]["digest"]);
let listed = get_json_authenticated(app.clone(), &tree, &token).await; let listed = get_json_authenticated(app.clone(), &tree, &token).await;
assert_eq!(listed, saved); assert_eq!(listed, saved);
@@ -107,22 +137,23 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
"base_digest": listed["snapshot"]["digest"], "base_digest": listed["snapshot"]["digest"],
"entrypoints": listed["contract"]["entrypoints"], "entrypoints": listed["contract"]["entrypoints"],
}); });
let current = get_json_authenticated(app.clone(), &adoption_path, &token).await;
let reserved = "$builtin/profiles/companion.dcdl"; let reserved = "$builtin/profiles/companion.dcdl";
for change in [ for (index, change) in [
json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }), json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }),
json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }), json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }),
json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }), json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }),
json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }), json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }),
json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }), json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }),
json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }), json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }),
] { ].into_iter().enumerate() {
let mut body = base.clone(); let mut body = base.clone();
body["changes"] = json!([change]); body["changes"] = json!([change]);
let error = request_json_authenticated( let error = request_json_authenticated(
app.clone(), app.clone(),
"POST", "POST",
&commit, &commit,
Some(body), Some(adoption(&current, &format!("reserved-builtin-{index}"), body)),
&token, &token,
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
) )
@@ -148,7 +179,7 @@ async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_ba
app.clone(), app.clone(),
"POST", "POST",
&commit, &commit,
Some(body), Some(adoption(&current, "unknown-builtin-import", body)),
&token, &token,
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
) )
@@ -633,9 +633,12 @@ pub(super) async fn commit(
&persistence_api.config.workspace_id, &persistence_api.config.workspace_id,
&candidate, &candidate,
).map_err(|_| unknown())?; ).map_err(|_| unknown())?;
// Repository projection may already have auxiliary secret
// effects. A subsequent save error cannot promise that the
// whole operation had no durable effect, even on SQL rollback.
persistence_api.config_store.commit_evaluated_workspace_config( persistence_api.config_store.commit_evaluated_workspace_config(
&persistence_api.config.workspace_id, &candidate, &persistence_api.config.workspace_id, &candidate,
).map_err(pre_error) ).map_err(|_| unknown())
})()) })())
}).map_err(|error| match error { }).map_err(|error| match error {
subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"), subjektiv::SubjektivError::PermissionAuthoritySealed => fail(409, "owner_adoption_required"),
@@ -390,8 +390,10 @@ mod workspace_config_integration {
"the granted WIP path remains available" "the granted WIP path remains available"
); );
// Real API-token authentication still permits the normal UI editor. // Real owner API-token authentication still permits the normal editor,
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); // but only through the head/request-bound adoption boundary.
let token =
seed_test_workspace_owner_token(api.store.as_ref(), "logical-editor-owner").await;
for (method, path) in &routes { for (method, path) in &routes {
if *method == "GET" { if *method == "GET" {
request_json_authenticated( request_json_authenticated(
@@ -409,11 +411,52 @@ mod workspace_config_integration {
build_router(api.clone()), build_router(api.clone()),
"POST", "POST",
&format!("{tree}/commit"), &format!("{tree}/commit"),
Some(serde_json::to_value(change).unwrap()), Some(serde_json::to_value(&change).unwrap()),
&token, &token,
StatusCode::CREATED, StatusCode::CONFLICT,
) )
.await; .await;
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
let editor = workspace_server_router(catalog);
let adoption_path = format!("/api/w/{TEST_WORKSPACE_ID}/config/adoption");
let current = get_json_authenticated(editor.clone(), &adoption_path, &token).await;
let adoption = json!({
"operation_id": "logical-editor-owner-adoption",
"expected_head": current["history_head"],
"expected_catalog_digest": current["catalog_digest"],
"expected_legacy_export": current["expected_legacy_export"],
"source": {"base_digest": current["state"]["snapshot"]["digest"],
"entrypoints": current["state"]["contract"]["entrypoints"], "changes": change.changes}
});
let preview = request_json_authenticated(
editor.clone(),
"POST",
&format!("{adoption_path}/preview"),
Some(adoption.clone()),
&token,
StatusCode::OK,
)
.await;
let receipt = request_json_authenticated(
editor,
"POST",
&format!("{adoption_path}/commit"),
Some(adoption),
&token,
StatusCode::OK,
)
.await;
assert_eq!(receipt["state"], preview["state"]);
let saved = api let saved = api
.config_store .config_store
.load_workspace_config(TEST_WORKSPACE_ID) .load_workspace_config(TEST_WORKSPACE_ID)