Publish campaigns only after atomic initialization

This commit is contained in:
Meta Vens 2026-08-18 23:26:20 +02:00
commit 90e7ca38a2
4 changed files with 201 additions and 31 deletions

View file

@ -61,6 +61,18 @@ pub struct WorldKernel {
}
impl WorldKernel {
pub fn initialize_campaign(
store: &CampaignStore,
command: WorldCommand,
) -> Result<CommandResult, KernelError> {
if !matches!(command, WorldCommand::CreateCampaign { .. }) {
return Err(KernelError::Invalid(
"campaign initialization accepts only CreateCampaign".into(),
));
}
execute(store, &mut BTreeMap::new(), command)
}
pub fn start(store: CampaignStore) -> Self {
let (tx, mut rx) = mpsc::channel::<Request>(64);
tokio::spawn(async move {

View file

@ -728,26 +728,22 @@ async fn approve_preview(
None => return StatusCode::UNAUTHORIZED.into_response(),
};
let _live = LiveTurnGuard::enter(&state).await;
let mut previews = state.compile_previews.lock().await;
let previews = state.compile_previews.lock().await;
let Some(owned) = previews.get(&preview_id).cloned() else {
return (StatusCode::NOT_FOUND, "preview missing or already consumed").into_response();
};
if owned.session_hash != session {
return StatusCode::FORBIDDEN.into_response();
}
previews.remove(&preview_id);
drop(previews);
let preview = owned.value;
let model_receipts = owned.model_receipts;
let campaign_id = preview.campaign.id;
match state
.registry
.create(preview.campaign, preview.evidence_receipts, model_receipts)
.await
{
match create_or_recover_preview_campaign(&state, &preview, model_receipts).await {
Ok(runtime) => match select_campaign(&state, &session, campaign_id).await {
Ok(()) => match load_campaign(&runtime.store) {
Ok(campaign) => {
state.compile_previews.lock().await.remove(&preview_id);
if let Err(error) = refresh_mesh(&state).await {
tracing::warn!(%error, "campaign approval CultMesh publication failed");
}
@ -767,6 +763,44 @@ async fn approve_preview(
}
}
async fn create_or_recover_preview_campaign(
state: &AppState,
preview: &WorldCompilePreview,
model_receipts: Vec<ghostlight_dungeon::model::ModelStageReceipt>,
) -> anyhow::Result<CampaignRuntime> {
let campaign_id = preview.campaign.id;
let runtime = match state.registry.runtime(campaign_id).await {
Ok(runtime) => runtime,
Err(_) => match state
.registry
.create(
preview.campaign.clone(),
preview.evidence_receipts.clone(),
model_receipts,
)
.await
{
Ok(runtime) => runtime,
Err(create_error) => state
.registry
.runtime(campaign_id)
.await
.map_err(|_| create_error)?,
},
};
let persisted_seed = runtime
.store
.load::<Campaign>("campaign_seed.v1", &campaign_id.to_string())?
.ok_or_else(|| anyhow::anyhow!("published campaign has no approved seed"))?
.1;
if persisted_seed != preview.campaign {
return Err(anyhow::anyhow!(
"published campaign id belongs to a different approval preview"
));
}
Ok(runtime)
}
#[derive(Deserialize)]
struct DestinationRequest {
origin_location_id: String,
@ -2570,6 +2604,66 @@ mod tests {
);
}
#[tokio::test]
async fn failed_campaign_approval_keeps_its_retryable_preview() {
let dir = tempfile::tempdir().unwrap();
let state = empty_app_state(dir.path());
let session = "valid-session";
let session_hash = secret_hash(session);
state
.auth
.lock()
.await
.state
.session_hashes
.insert(session_hash.clone());
let mut campaign = seed("Invalid preview");
campaign
.actors
.get_mut(&campaign.player_actor_id)
.unwrap()
.location_id = "missing".into();
let preview_id = "preview".to_owned();
state.compile_previews.lock().await.insert(
preview_id.clone(),
OwnedPreview {
session_hash,
value: WorldCompilePreview {
schema: "ghostlight.world_compile_preview.v1".into(),
title: "Invalid preview".into(),
campaign,
evidence_receipts: vec![],
evidence_coverage: vec![],
gaps: vec![],
branch_assumptions: vec![],
requires_approval: true,
},
model_receipts: vec![],
},
);
let app = app_router(state.clone(), dir.path().join("web"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/api/compiler/approve/{preview_id}"))
.header(header::COOKIE, format!("ghostlight_session={session}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(
state
.compile_previews
.lock()
.await
.contains_key(&preview_id)
);
assert!(state.registry.list().await.is_empty());
}
#[tokio::test]
async fn live_turn_cancels_background_inference_and_excludes_its_commit_gate() {
let dir = tempfile::tempdir().unwrap();

View file

@ -1,6 +1,7 @@
use crate::domain::{
Campaign, GestaltMaterializationReceipt, ResolutionControlReceipt, ResolutionWaveCommit,
StrategicTickReceipt, VaultEvidenceReceipt, VaultManifest, WorldCommitReceipt,
Campaign, CampaignLifecycleReceipt, GestaltMaterializationReceipt, ResolutionControlReceipt,
ResolutionWaveCommit, StrategicTickReceipt, VaultEvidenceReceipt, VaultManifest,
WorldCommitReceipt,
};
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
@ -123,6 +124,19 @@ impl CampaignStore {
candidate,
)?);
}
rows.push(envelope(
"campaign_lifecycle_receipt.v1",
"ghostlight.campaign_lifecycle_receipt.v1",
&format!("{}:create", campaign.id),
&CampaignLifecycleReceipt {
schema: "ghostlight.campaign_lifecycle_receipt.v1".into(),
campaign_id: campaign.id,
operation: "create".into(),
parent_campaign_id: None,
parent_revision: None,
created_at: Utc::now(),
},
)?);
if !self.inner.compare_and_swap_batch(&[], rows)? {
return Err(anyhow!("campaign store is not empty"));
}

View file

@ -121,29 +121,41 @@ impl CampaignRegistry {
return Err(anyhow!("campaign already exists"));
}
let directory = self.root.join(campaign.id.to_string());
fs::create_dir(&directory)?;
let store = CampaignStore::open(directory.join("campaign.cc"))?;
if directory.exists() {
return Err(anyhow!("campaign directory already exists"));
}
let staging = self
.root
.join(format!(".creating-{}-{}", campaign.id, Uuid::new_v4()));
fs::create_dir(&staging)?;
let prepared = (|| -> Result<()> {
let store = CampaignStore::open(staging.join("campaign.cc"))?;
WorldKernel::initialize_campaign(
&store,
crate::domain::WorldCommand::CreateCampaign {
campaign: campaign.clone(),
evidence_receipts: evidence,
model_stage_receipts: model_receipts,
},
)
.map_err(|error| anyhow!(error.to_string()))?;
drop(store);
fs::rename(&staging, &directory)?;
Ok(())
})();
if let Err(error) = prepared {
cleanup_staging_directory(&self.root, &staging);
return Err(error);
}
let store = match CampaignStore::open(directory.join("campaign.cc")) {
Ok(store) => store,
Err(error) => {
let _ = fs::rename(&directory, &staging);
cleanup_staging_directory(&self.root, &staging);
return Err(error);
}
};
let kernel = WorldKernel::start(store.clone());
kernel
.command(crate::domain::WorldCommand::CreateCampaign {
campaign: campaign.clone(),
evidence_receipts: evidence,
model_stage_receipts: model_receipts,
})
.await?;
store.insert(
"campaign_lifecycle_receipt.v1",
"ghostlight.campaign_lifecycle_receipt.v1",
&format!("{}:create", campaign.id),
&CampaignLifecycleReceipt {
schema: "ghostlight.campaign_lifecycle_receipt.v1".into(),
campaign_id: campaign.id,
operation: "create".into(),
parent_campaign_id: None,
parent_revision: None,
created_at: Utc::now(),
},
)?;
let runtime = CampaignRuntime { store, kernel };
self.runtimes
.write()
@ -262,6 +274,17 @@ impl CampaignRegistry {
}
}
fn cleanup_staging_directory(root: &Path, staging: &Path) {
let safe = staging.parent() == Some(root)
&& staging
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(".creating-"));
if safe && staging.exists() {
let _ = fs::remove_dir_all(staging);
}
}
fn validated_branch_name(name: String) -> Result<String> {
let name = name.trim();
if name.is_empty() || name.chars().count() > 80 || name.chars().any(char::is_control) {
@ -445,6 +468,33 @@ mod tests {
assert_eq!(forked.name, "A clean branch");
}
#[tokio::test]
async fn failed_creation_never_enters_the_discoverable_campaign_namespace() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("campaigns");
let registry = CampaignRegistry::new(&root).unwrap();
let mut invalid = seed("Invalid");
invalid
.actors
.get_mut(&invalid.player_actor_id)
.unwrap()
.location_id = "missing".into();
assert!(registry.create(invalid, vec![], vec![]).await.is_err());
assert!(registry.list().await.is_empty());
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
let valid = seed("Valid");
let runtime = registry
.create(valid.clone(), vec![], vec![])
.await
.unwrap();
assert_eq!(
runtime.store.keys("campaign_lifecycle_receipt.v1").unwrap(),
vec![format!("{}:create", valid.id)]
);
}
#[tokio::test]
async fn load_existing_migrates_flat_campaign_without_advancing_world_revision() {
let dir = tempfile::tempdir().unwrap();