resolved 5a8af099-ac41-40bd-9898-a28f7726233f
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.
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:
scenario_slug: string. Must match the filename basename (minus
.json). Must pass Slug::new() (grammar: length 1..=64,
[a-z0-9_-], edges [a-z0-9]). Validated at load time; failure
is a boot-time panic with a specific message.description: string, human prose explaining the scenario's
purpose. No length cap. Displayed on the detail page.chronon_seconds: integer. Matches kernel::World::chronon_seconds.environment: string. Initial world environment prose.entities: array of entity objects. Shape is serde-compatible
with kernel::Entity — kind is either "prop" or
{ "agent": { "goal": "...", "memory": "..." } }.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.
/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.
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.
pub use scenarios::{Scenario, ScenarioCatalog};
(adds ScenarioCatalog to the existing export)
Two call sites change:
handle_list_scenarios (~line 1591): Scenario::all() becomes
ScenarioCatalog::global().all(). The existing code iterates
scenarios to build a JSON list with id, description, and
worlds-using counts. Replace s.id() with
s.scenario_slug.as_str() in that loop. Replace s.description()
with &s.description.
handle_create_world (~line 1623): Scenario::from_id(scenario_id)
becomes ScenarioCatalog::global().get(scenario_id). The
UNKNOWN_SCENARIO error code/message is unchanged.
Signature:
pub fn create_world(
data_root: &Path,
scenario: &Scenario, // was: scenario: Scenario
slug: Slug,
name: Option<String>,
) -> io::Result<WorldHandle>
Inside:
scenario.seed(slug.as_str().to_string(), Utc::now()) — same
call shape, just through the struct-method instead of enum-method.name.unwrap_or_else(|| format!("{} #{}", scenario.id(), slug))
becomes name.unwrap_or_else(|| format!("{} #{}", scenario.scenario_slug, slug)).scenario: scenario.id().to_string() on WorldMeta becomes
scenario: scenario.scenario_slug.as_str().to_string().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.
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.
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.
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.
w_session, w_turn, w_entity each construct a PageContext.
Every world_slug: slug.clone() becomes world_slug: Some(slug.clone()).
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.
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.
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.
#[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,
);
}
Everything AntOnPlate- and LockedVendingRoom-specific goes away:
DEFAULT_CHRONON_SECONDS constant (was AntOnPlate-only; the
new files carry chronon_seconds per-scenario)Existing #[cfg(test)] tests that can be preserved with the new
shape SHOULD be preserved, with minor renames. Specifically the
round-trip tests:
all_scenarios_round_trip_through_id becomes
all_scenarios_round_trip_through_slug and iterates
ScenarioCatalog::global().all() asserting that
get(scenario_slug) == Some(&scenario) for each.unknown_id_returns_none becomes
unknown_slug_returns_none:
assert!(ScenarioCatalog::global().get("not_a_scenario").is_none());ids_are_stable_snake_case becomes
slugs_are_stable_snake_case and asserts the two current
slug strings against known values (ant_on_plate,
locked_vending_room).ant_on_plate_seed_has_one_agent_and_three_props stays under
the same name; uses the catalog lookup instead of the enum
variant.locked_vending_room_seed_has_one_agent_and_five_slots stays
under the same name; uses the catalog lookup instead of the
enum variant.seed_threads_caller_supplied_slug stays; uses catalog.cargo build clean with the new include_dir dep.cargo test --lib green. All moved/renamed tests pass.cargo test --test ant_scenario green (live-router).cargo test --test phase0 green.rg 'Scenario::AntOnPlate' src/ tests/ returns zero matches.rg 'Scenario::LockedVendingRoom' src/ tests/ returns zero matches.rg 'DEFAULT_CHRONON_SECONDS' src/ returns zero matches.Live smoke (deployed):
GET /scenarios/ant_on_plate returns 200 HTML. Body shows
scenario_slug, description, chronon_seconds, environment,
four entities (ant, crumb, sugar_grain, sesame_seed) with
their states, goals, memories.?format=json on the same URL returns the raw serialized
scenario struct. scenario_slug field is present; id field
is NOT present.GET /scenarios/locked_vending_room returns 200 HTML. Body
shows the six entities (subject + five slots). The
DIRECTOR-ONLY INFORMATION sections display as regular text
— this is correct; the detail page is for human operators and
is separate from the simulation-time perception filtering.GET /scenarios/nonexistent returns 404 with a listing of
valid scenario slugs as links.GET /scenarios/Bad-Slug returns 404 via the BAD_SLUG path
(grammar rejection) — or however the 404 handler renders it.GET /w/vending-room-1 HTML contains
<a href="/scenarios/locked_vending_room">locked_vending_room</a>
on the scenario field.GET /w/vending-room-1 breadcrumb still works (session page
breadcrumb has dashboard link).GET /scenarios/locked_vending_room breadcrumb has just a
dashboard link (no session, no prev/next).ant-verify and vending-room-1 continue to
render, run turns, show the correct scenario name in their
payloads.create_world(scenario: "locked_vending_room", slug: "temp-smoke"),
that world's /w/temp-smoke page renders, the scenario field
is clickable, and the world can then be deleted cleanly.Slug::new + filename-match.
Malformed files fail loud at boot with a specific panic message.list_scenarios MCP tool output shape. It still returns the
same id, description, worlds_using fields externally —
the id field continues to be populated from the scenario's
slug string, even though internally the struct field is now
scenario_slug. The wire format of that tool is a separate
concern and could be renamed in a future ticket.meta.json migration. The scenario field there is still
a plain string and continues to work — the catalog recognizes
the same strings the old enum did.PageContext rewrites beyond the world_slug: Option<String>
widening and the new PageType::Scenario variant. The struct
stays otherwise identical; the turn / entity_id /
chain_range fields stay as they are.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.
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.
feat/scenarios-json merged to main)d716bf8 feat(scenarios): load from filesystem JSON; add detail page and inline linking617f307 build: include scenarios/ in container build contextb9d0a7f brings the feature branch onto main.HEAD of main is now b9d0a7f. Deployed to pod chukwa-848cf75955-54v66.
New:
scenarios/ant_on_plate.json — ported Scenario::AntOnPlate byte-identically (environment, 4 entities: ant, crumb, sugar_grain, sesame_seed, chronon 300).scenarios/locked_vending_room.json — ported Scenario::LockedVendingRoom byte-identically (environment, 6 entities: subject + slot_a..slot_e, chronon 43200). Vending-slot prompt-engineering prose preserved verbatim — no paraphrase, no whitespace normalization.src/linking.rs already existed; added LinkTarget::ScenarioBySlug rule + href_for None guards for world-scoped targets when world_slug is None.Rewritten:
src/scenarios.rs: Scenario enum gone; replaced with ScenarioCatalog + Scenario struct loaded via include_dir!("$CARGO_MANIFEST_DIR/scenarios") at first access through a OnceLock. Every file must have a .json stem that passes Slug::new, valid UTF-8, parse as ScenarioFile, and its scenario_slug must equal the filename stem — any violation panics at catalog-load time with a specific message. Tests renamed per spec (all_scenarios_round_trip_through_slug, unknown_slug_returns_none, slugs_are_stable_snake_case, plus the seed-shape tests preserved under the same names using catalog lookup). DEFAULT_CHRONON_SECONDS constant deleted (chronon is per-scenario now).Migrated:
src/lib.rs: exports ScenarioCatalog in addition to Scenario.src/mcp.rs: handle_list_scenarios uses ScenarioCatalog::global().all(), s.scenario_slug.as_str(), &s.description. handle_create_world uses ScenarioCatalog::global().get(id). Wire shape unchanged (external id, description, worlds_using fields preserved).src/worlds.rs: create_world signature takes &Scenario, seeds via catalog, WorldMeta.scenario stores scenario.scenario_slug.as_str().to_string(). All 6 test call sites updated.src/views.rs: added build_scenario_payload with BAD_SLUG / UNKNOWN_SCENARIO error routing; existing WorldMeta test fixture migrated.tests/ant_scenario.rs: uses catalog lookup; ScenarioCatalog added to public imports.src/render.rs: PageContext.world_slug widened to Option<String>; PageType::Scenario variant added; render_breadcrumbs arm for Scenario emits just [("/dashboard", "dashboard")]; Session/Turn/Entity arms unwrap with expect("world context requires world_slug") — safe because those callers always pass Some.src/server.rs: w_session / w_turn / w_entity pass Some(slug.clone()) for world_slug; new scenario_detail handler with Query<FormatParam> for HTML/JSON, 404 routes through html::scenario_not_found on UNKNOWN_SCENARIO or BAD_SLUG; route .route("/scenarios/:scenario_slug", get(scenario_detail)) placed after /w/:slug/... routes.src/html.rs: scenario_not_found(bad_slug, known_slugs) modeled on world_not_found — same structure, links to /scenarios/{slug}.Cargo.toml: include_dir = "0.7" added.Containerfile: COPY scenarios ./scenarios added to builder stage, and COPY scenarios /app/repo/scenarios added to runtime stage so the code-navigator can read scenario files. Without this, include_dir! panics at compile time because the repo scenarios/ tree isn't in the build context — caught this in the first deploy, landed as a follow-up commit.cargo test --lib: 329 passed; 0 failedcargo test --test phase0: 14 passed; 0 failedcargo test --test ant_scenario: 4 passed; 0 failed (live LLM, ~235s)rg 'Scenario::AntOnPlate' src/ tests/ → 0 matchesrg 'Scenario::LockedVendingRoom' src/ tests/ → 0 matchesrg 'DEFAULT_CHRONON_SECONDS' src/ → 0 matchesGET /healthz → 200GET /scenarios/ant_on_plate → 200 HTML. Title chukwa — scenario ant_on_plate. Shows scenario_slug, description, chronon_seconds, environment, 4 entities with states/goals/memories.GET /scenarios/ant_on_plate?format=json → JSON. scenario_slug: "ant_on_plate" present; id field NOT present (confirmed via Python check: id present: False). chronon_seconds: 300, entities array with [ant, crumb, sugar_grain, sesame_seed].GET /scenarios/locked_vending_room → 200 HTML. Six entities (subject + five slots). Director-only prose displays as regular text (matches spec: detail page is for operators, separate from simulation-time perception filtering).GET /scenarios/locked_vending_room?format=json → JSON. scenario_slug: "locked_vending_room", id present: False, chronon_seconds: 43200, entities [subject, slot_a..slot_e].GET /scenarios/nonexistent → 404 with listing: <a href="/scenarios/ant_on_plate">ant_on_plate</a>, <a href="/scenarios/locked_vending_room">locked_vending_room</a>.GET /scenarios/Bad-Slug → 404 (BAD_SLUG path through html::scenario_not_found).GET /w/vending-room-1 → HTML contains <a href="/scenarios/locked_vending_room"> on the scenario field. Linking rule fires via the new LinkTarget::ScenarioBySlug.<nav class="links"><a href="/dashboard">dashboard</a></nav> — exactly the spec shape (no session, no prev/next)./w/vending-room-1 200, /w/vending-room-1/turn/0 200, /w/vending-room-1/entity/subject 200, /w/ant-verify 200, /dashboard 200. Entity-scoped /w/ant-verify/entity/ant still emits world-scoped anchors (href="/w/ant-verify/entity/ant") — the world-bound linking rules still fire when world_slug is Some.mcp__list_scenarios returns both scenarios with worlds_using: 1 each, confirming catalog lookup runs against existing meta.json strings (backward compat held — no migration needed, per spec).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.
Slug::new + filename-match (spec: malformed files fail loud at boot).list_scenarios wire format unchanged externally (id field still populated, from scenario_slug).meta.json migration needed.PageContext touched only as specified: world_slug: Option<String> widening + PageType::Scenario variant.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.
Picked up. Spec is prescriptive end-to-end (file format, catalog, caller migrations, PageContext widening, linking rule, detail-page handler, 404 page, test renames). Will branch from main (cd9ef9e) in a worktree, implement, run cargo build + cargo test + cargo test --test ant_scenario + cargo test --test phase0, merge to main, deploy, and smoke the live acceptance list (200 HTML + JSON on both scenarios, 404 on nonexistent/bad-slug, scenario field linked on /w/vending-room-1). Will post back with proposed resolution once all acceptance items pass live. Flagging on the OPEN KNOBS note: if I hit a genuine spec ambiguity I'll comment on the ticket rather than guess.
Shipped per spec. See proposed_resolution for verification transcript.
Caller accepted: Audited independently. Main commit d716bf8 touches exactly the 13 spec'd files (+426/-153); Containerfile follow-up 617f307 legitimately needed because include_dir!($CARGO_MANIFEST_DIR/scenarios) requires the tree in the build context (2 COPY lines, builder + runtime for code-nav). Both scenario JSON files exist with prose byte-identical to the old enum arms — all five vending slot strings verified to contain "DIRECTOR-ONLY" + "physically identical", slot_e has "manufacturing defect" + "always be zero". scenarios.rs fully rewritten to Scenario struct + ScenarioCatalog + include_dir + OnceLock; zero AntOnPlate/LockedVendingRoom/DEFAULT_CHRONON_SECONDS references remain. linking.rs has ScenarioBySlug target + KeyPath(["scenario"]) rule + href_for /scenarios/{value} + static_key_for inclusion + dedicated test. render.rs has world_slug: Option and PageType::Scenario. server.rs has /scenarios/:scenario_slug route and scenario_detail handler. Handler flagged the Containerfile deviation honestly rather than hiding it. Live smoke per receipts covers the full acceptance list including the 404 paths and the scenario anchor on /w/vending-room-1. Handler-side inability to mint a temp-smoke world is a client-side schema caching quirk, not a defect; unit-test coverage of create path is green. Accepting.
Sign in as a human to drive this ticket from the page, or use the MCP tools.
Ticket created: Scenarios as filesystem JSON + detail page + inline linking