Sign in to edit tickets from this page.

← all tickets · home

Scenarios as filesystem JSON + detail page + inline linking

resolved 5a8af099-ac41-40bd-9898-a28f7726233f

created_at
2026-04-24
updated_at
2026-04-24
code_context
scenarios/*.json (new), src/scenarios.rs, src/lib.rs, src/mcp.rs, src/worlds.rs, src/views.rs, src/server.rs, src/html.rs, src/linking.rs, src/render.rs, tests/ant_scenario.rs, Cargo.toml
priority
P2
ticket_type
feature
depends_on
d3c5fe99
resolved_at
2026-04-24
resolution
accepted

Body

MOTIVATION

Scenarios live today as a compiled Scenario enum in src/scenarios.rs with all prose (environment strings, entity states, agent goals) embedded in seed() match arms. Adding a scenario is an enum-variant + match-arm + test-addition operation. There is no way to view a scenario's full definition through the UI: the session page at /w/:slug shows scenario: locked_vending_room as plain text, no link target.

After this ticket, scenarios live as one JSON file per scenario under /scenarios/, loaded at compile time via the include_dir crate and accessed through a runtime catalog. A new route /scenarios/:scenario_slug renders each scenario through the universal renderer. The session page's scenario field becomes a link. The pattern is symmetrical with how turns and entities are rendered.

Depends on the just-resolved ticket d3c5fe99 (Slug newtype). The Slug type is the gate that validates scenario_slug at load time.

================================================================ FILE FORMAT

Each scenario lives at /scenarios/{scenario_slug}.json:

{
  "scenario_slug": "locked_vending_room",
  "description": "One subject locked in a small windowless room...",
  "chronon_seconds": 43200,
  "environment": "A small windowless concrete room...",
  "entities": [
    {
      "id": "subject",
      "name": "The subject",
      "state": "standing in the room. Last meal: 6 hours ago...",
      "kind": { "agent": { "goal": "survive until...", "memory": "" } }
    },
    {
      "id": "slot_a",
      "name": "Vending slot A",
      "state": "The slot behind button A...",
      "kind": "prop"
    }
  ]
}

Fields:

No id field at the root. No name field. The scenario_slug IS the identifier. The detail page title synthesizes a display form via snake_to_sentence(scenario_slug) — the same helper render.rs already uses for labels.

Entity id fields inside entities[] stay as-is. Those are entity semantic IDs governed by entity_id.rs, NOT slugs. They allow . (for nested names like plate.crumb) that slugs reject. Two different grammars on purpose.

================================================================ TWO FILES TO CREATE UNDER /scenarios/

/scenarios/ant_on_plate.json — port the current Scenario::AntOnPlate match arm verbatim. Description becomes the current description() return value. Environment, chronon_seconds, and the four entities (ant, crumb, sugar_grain, sesame_seed) come directly from the match-arm source.

/scenarios/locked_vending_room.json — port the current Scenario::LockedVendingRoom match arm verbatim. Description, environment, 43200 chronon, and the six entities (subject, slot_a, slot_b, slot_c, slot_d, slot_e) come directly.

Every prose string in both files must be BYTE-IDENTICAL to the strings currently in src/scenarios.rs. This is critical: the vending-machine slot prose is doing prompt-engineering work and edits can shift behavior. Do NOT paraphrase, normalize, or "clean up" any whitespace. Copy exactly.

================================================================ LOADING

New Cargo dep: include_dir = "0.7". Add to Cargo.toml.

src/scenarios.rs is rewritten:

use chrono::{DateTime, Utc};
use include_dir::{include_dir, Dir};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::OnceLock;

use crate::kernel::{Entity, World};
use crate::slug::Slug;

/// Embedded at compile time. CARGO_MANIFEST_DIR points at the
/// repo root; `scenarios` is sibling to `src/`.
static SCENARIO_FILES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/scenarios");

/// Intermediate struct for JSON deserialization. `scenario_slug`
/// is a plain string on disk; converted to `Slug` at load time.
#[derive(Deserialize)]
struct ScenarioFile {
    scenario_slug: String,
    description: String,
    chronon_seconds: i64,
    environment: String,
    entities: Vec<Entity>,
}

/// Loaded scenario. `scenario_slug` is typed as `Slug` — if you
/// have a `&Scenario` it has passed grammar validation by
/// construction.
#[derive(Serialize, Clone)]
pub struct Scenario {
    #[serde(serialize_with = "serialize_slug_as_string")]
    pub scenario_slug: Slug,
    pub description: String,
    pub chronon_seconds: i64,
    pub environment: String,
    pub entities: Vec<Entity>,
}

fn serialize_slug_as_string<S: serde::Serializer>(
    slug: &Slug,
    s: S,
) -> Result<S::Ok, S::Error> {
    s.serialize_str(slug.as_str())
}

impl Scenario {
    /// Seed a fresh world from this scenario's template. Slug is
    /// the caller's world slug (NOT the scenario slug); scenarios
    /// don't own the world identity.
    pub fn seed(&self, world_slug: String, now: DateTime<Utc>) -> World {
        let mut world = World::with_environment(
            world_slug,
            now,
            self.chronon_seconds,
            &self.environment,
        );
        for entity in &self.entities {
            world.add_entity(entity.clone())
                .expect("scenario entities are validated at load time");
        }
        world
    }
}

/// Runtime catalog of all scenarios embedded into the binary.
/// Lazy-loaded once on first access via `OnceLock`.
pub struct ScenarioCatalog {
    by_slug: HashMap<String, Scenario>,
}

static CATALOG: OnceLock<ScenarioCatalog> = OnceLock::new();

impl ScenarioCatalog {
    pub fn global() -> &'static ScenarioCatalog {
        CATALOG.get_or_init(Self::load)
    }

    fn load() -> ScenarioCatalog {
        let mut by_slug: HashMap<String, Scenario> = HashMap::new();
        for file in SCENARIO_FILES.files() {
            let path = file.path();
            let stem = path
                .file_stem()
                .and_then(|s| s.to_str())
                .unwrap_or_else(|| {
                    panic!("scenario file without a .json stem: {:?}", path)
                });
            // Validate the filename basename against the slug
            // grammar. Fails loud if a file has a bad name.
            let filename_slug = Slug::new(stem).unwrap_or_else(|e| {
                panic!("scenario filename {:?} is not a valid slug: {}", path, e)
            });

            let raw = file.contents_utf8().unwrap_or_else(|| {
                panic!("scenario file {:?} is not valid UTF-8", path)
            });
            let parsed: ScenarioFile = serde_json::from_str(raw)
                .unwrap_or_else(|e| {
                    panic!("scenario file {:?} failed to parse: {}", path, e)
                });

            // Validate the scenario_slug field and cross-check
            // against the filename.
            let scenario_slug = Slug::new(parsed.scenario_slug.clone())
                .unwrap_or_else(|e| {
                    panic!(
                        "scenario file {:?} has invalid scenario_slug {:?}: {}",
                        path, parsed.scenario_slug, e,
                    )
                });
            if scenario_slug != filename_slug {
                panic!(
                    "scenario file {:?} has scenario_slug {:?} that does not match filename stem {:?}",
                    path, parsed.scenario_slug, stem,
                );
            }

            let s = Scenario {
                scenario_slug,
                description: parsed.description,
                chronon_seconds: parsed.chronon_seconds,
                environment: parsed.environment,
                entities: parsed.entities,
            };
            by_slug.insert(stem.to_string(), s);
        }
        ScenarioCatalog { by_slug }
    }

    pub fn get(&self, slug: &str) -> Option<&Scenario> {
        self.by_slug.get(slug)
    }

    pub fn all(&self) -> impl Iterator<Item = &Scenario> + '_ {
        self.by_slug.values()
    }

    pub fn known_slugs(&self) -> Vec<String> {
        let mut s: Vec<String> = self.by_slug.keys().cloned().collect();
        s.sort();
        s
    }
}

The old Scenario enum is entirely removed. Scenario::all(), Scenario::from_id(), Scenario::AntOnPlate.id() etc. all go away and are replaced by catalog lookups.

================================================================ CALLER MIGRATION

src/lib.rs

pub use scenarios::{Scenario, ScenarioCatalog};

(adds ScenarioCatalog to the existing export)

src/mcp.rs

Two call sites change:

src/worlds.rs::create_world

Signature:

pub fn create_world(
    data_root: &Path,
    scenario: &Scenario,          // was: scenario: Scenario
    slug: Slug,
    name: Option<String>,
) -> io::Result<WorldHandle>

Inside:

src/worlds.rs tests

All 6 create_world call sites update their scenario argument from Scenario::AntOnPlate to ScenarioCatalog::global().get("ant_on_plate").unwrap(). The slug argument stays as the Slug::new(...) form from d3c5fe99.

src/views.rs

The existing test fixture at line ~371 does scenario: Scenario::AntOnPlate.id().to_string() inside WorldMeta construction. Replace with scenario: ScenarioCatalog::global().get("ant_on_plate").unwrap().scenario_slug.as_str().to_string(). Or hoist to a local:

let scenario_name = ScenarioCatalog::global()
    .get("ant_on_plate")
    .unwrap()
    .scenario_slug
    .as_str()
    .to_string();

Either is fine — prefer clarity over golf.

New function: build_scenario_payload:

pub fn build_scenario_payload(env: &McpEnv, slug: &str) -> Result<Value, McpError> {
    // Validate slug grammar first so a bad shape returns BAD_SLUG,
    // not UNKNOWN_SCENARIO.
    crate::slug::Slug::new(slug).map_err(|e| {
        McpError::new("BAD_SLUG", e.to_string())
    })?;

    let scenario = crate::scenarios::ScenarioCatalog::global()
        .get(slug)
        .ok_or_else(|| {
            McpError::new("UNKNOWN_SCENARIO", format!("no scenario '{}'", slug))
        })?;

    Ok(serde_json::to_value(scenario).expect("Scenario serializes"))
}

_env parameter stays for API consistency with the other builders, even though this builder doesn't read worlds/tickets data. Mark env with let _ = env; or just _env: &McpEnv to suppress the unused warning.

tests/ant_scenario.rs

Scenario::AntOnPlate.seed("ant-scenario-run".into(), ...) becomes ScenarioCatalog::global().get("ant_on_plate").unwrap().seed("ant-scenario-run".into(), ...). The public re-exports via use chukwa::... need ScenarioCatalog added.

================================================================ PAGECONTEXT WIDENING

src/render.rs

Change PageContext.world_slug type:

pub struct PageContext {
    pub page_type: PageType,
    pub world_slug: Option<String>,   // was: String
    pub turn: Option<u64>,
    pub entity_id: Option<String>,
    pub chain_range: Option<(u64, u64)>,
}

Add PageType::Scenario variant:

pub enum PageType {
    Session,
    Turn,
    Entity,
    Scenario,
}

Update render_breadcrumbs to cover the new variant:

PageType::Scenario => {
    links.push(("/dashboard".to_string(), "dashboard".to_string()));
}

For the existing Session/Turn/Entity arms, every access to context.world_slug needs to unwrap. These arms are only exercised by handlers that always pass Some(slug), so .as_ref().expect("world context requires world_slug") is correct. The slug local in those arms becomes &String from the unwrapped Option.

src/server.rs (existing handlers)

w_session, w_turn, w_entity each construct a PageContext. Every world_slug: slug.clone() becomes world_slug: Some(slug.clone()).

src/server.rs (new handler)

async fn scenario_detail(
    State(state): State<Arc<AppState>>,
    Path(slug): Path<String>,
    Query(q): Query<FormatParam>,
) -> Response {
    let env = view_env(&state);
    match views::build_scenario_payload(&env, &slug) {
        Ok(payload) => {
            let title = format!("chukwa — {}", slug);
            let ctx = render::PageContext {
                page_type: render::PageType::Scenario,
                world_slug: None,
                turn: None,
                entity_id: None,
                chain_range: None,
            };
            render_view(&title, payload, &q, Some(&ctx))
        }
        Err(err) if err.code == "UNKNOWN_SCENARIO" || err.code == "BAD_SLUG" => {
            let slugs = scenarios::ScenarioCatalog::global().known_slugs();
            (
                StatusCode::NOT_FOUND,
                Html(html::scenario_not_found(&slug, &slugs)),
            ).into_response()
        }
        Err(err) => render_view_error(err),
    }
}

Add the route:

.route("/scenarios/:scenario_slug", get(scenario_detail))

placed right after the existing /w/:slug/... routes for readability.

src/html.rs

New function:

pub fn scenario_not_found(bad_slug: &str, known_slugs: &[String]) -> String {
    // Follows the shape of world_not_found: header, "not found"
    // message, bulleted list of known scenarios linking to
    // /scenarios/{slug}. Reuse the same CSS class patterns.
}

Model it on world_not_found — same structure, different labels and links. The function returns a complete HTML document (same pattern as world_not_found), or returns just a body snippet that gets wrapped by the caller — match whatever the existing world_not_found does.

================================================================ LINKING RULE

src/linking.rs

Add LinkTarget::ScenarioBySlug variant:

enum LinkTarget {
    EntityInWorld,
    TurnInWorld,
    SessionForSlug,
    ScenarioBySlug,    // NEW: /scenarios/{value}
}

Add rule to the static RULES table:

LinkRule::KeyPath {
    path: &[PathSeg::Key("scenario")],
    target: LinkTarget::ScenarioBySlug,
},

Add "scenario" to the static_key_for match:

fn static_key_for(k: &str) -> &'static str {
    match k {
        "entity_id" => "entity_id",
        "entities_touched" => "entities_touched",
        "entities" => "entities",
        "scenario" => "scenario",      // NEW
        _ => "",
    }
}

Update href_for to handle ScenarioBySlug:

LinkTarget::ScenarioBySlug => Some(format!("/scenarios/{}", value)),

ScenarioBySlug does NOT use context.world_slug — it builds the href purely from the matched value. Safe when world_slug is None.

For the other three targets (EntityInWorld, TurnInWorld, SessionForSlug) that DO use world_slug: update href_for so they return None when context.world_slug is None. On the scenario detail page (which passes world_slug: None) those rules just don't fire.

New test in linking.rs::tests

#[test]
fn scenario_slug_is_wrapped_as_anchor() {
    let payload = json!({ "scenario": "locked_vending_room" });
    let body = "scenario: locked_vending_room";
    // Scenario links don't need a world_slug in context.
    let ctx = PageContext {
        page_type: PageType::Session,
        world_slug: Some("any-world".to_string()),
        turn: None,
        entity_id: None,
        chain_range: None,
    };
    let out = apply_rules(&payload, body, &ctx);
    assert!(
        out.contains(r#"<a href="/scenarios/locked_vending_room">locked_vending_room</a>"#),
        "expected scenario anchor, got: {}",
        out,
    );
}

#[test]
fn entity_rules_skip_when_world_slug_is_none() {
    // On the scenario detail page, entity_id appears in payload
    // but there's no world_slug context. Entity links should NOT
    // be produced.
    let payload = json!({ "entity_id": "ant" });
    let body = "entity_id: ant";
    let ctx = PageContext {
        page_type: PageType::Scenario,
        world_slug: None,
        turn: None,
        entity_id: None,
        chain_range: None,
    };
    let out = apply_rules(&payload, body, &ctx);
    assert!(
        !out.contains("<a href=\"/w/"),
        "entity link was produced despite world_slug = None: {}",
        out,
    );
}

================================================================ DELETE FROM src/scenarios.rs

Everything AntOnPlate- and LockedVendingRoom-specific goes away:

Existing #[cfg(test)] tests that can be preserved with the new shape SHOULD be preserved, with minor renames. Specifically the round-trip tests:

================================================================ ACCEPTANCE

Live smoke (deployed):

================================================================ EXPLICITLY OUT OF SCOPE

================================================================ OPEN KNOBS

None. The spec is prescriptive on every file name, field, test rename, and call-site update. If a question arises during implementation, leave a comment on the ticket rather than guessing.

Proposed resolution

Scenarios now live as JSON files on disk and render through a dedicated detail page. Pattern is symmetrical with how turns and entities are rendered.

Shipped commits (branch feat/scenarios-json merged to main)

HEAD of main is now b9d0a7f. Deployed to pod chukwa-848cf75955-54v66.

Files delivered (per spec)

New:

Rewritten:

Migrated:

Test verification (branch before merge, re-verified on main)

Grep verification

Live smoke (deployed, chukwa.benac.dev)

Fresh-world create path

The two existing worlds (vending-room-1, ant-verify) were seeded via the old enum path and continue to render under the new catalog path — that exercises the lookup-by-stored-slug. Did not mint a new temp-smoke world via the live MCP because my in-process MCP tool schema for create_world is cached without the slug parameter, and the server correctly rejects without it. The create_world code path is unit-tested in worlds::tests (6 call sites using ScenarioCatalog::global().get("ant_on_plate").unwrap()), all green.

Scope discipline

One deviation worth flagging

Containerfile was not in the ticket's listed files, but include_dir! requires the tree to exist in the build context. Added two COPY scenarios lines (builder + runtime) in commit 617f307. This is a build-side plumbing follow-up, not a scope expansion — the ticket can't ship without it.

Per standing guidance I am not confirming — only proposing.

History (4 events)

Sign in as a human to drive this ticket from the page, or use the MCP tools.