resolved 5e00c1ee-9523-4333-8174-43aeb77eaf6f
MOTIVATION
Worlds currently have two identifiers: an internal UUID (World.world_id,
used as the routing key and directory name) and a display-only name. With
slugs coming in as a first-class, required, URL-safe identifier, that
three-identifier situation is complexity for no gain. Simpler: the slug is
the identifier. The UUID goes away entirely.
This is a refactor, not a feature. Scope is deliberately narrow: replace
every reference to world_id: Uuid with the slug. No UI changes. No new
MCP tools. No behavioral changes besides the identifier swap.
A follow-up ticket for a bolt-on read-only web UI at
/w/:slug/... depends on this one landing first.
A world has exactly one identifier: its slug.
{data_root}/worlds/{slug}/.world_id.Valid slug, precisely:
Examples: ant-smoke OK my_world OK ant-1 OK prod OK a OK a1-b2_c3 OK Ant-smoke REJECT (uppercase) ant smoke REJECT (whitespace) -ant REJECT (leading hyphen) ant REJECT (leading underscore) ant- REJECT (trailing hyphen) ant REJECT (trailing underscore) ant.smoke REJECT (dot is reserved for entity id hierarchy) "" REJECT (empty) 65+ chars REJECT (too long)
Validation is strict. No normalization, no trimming, no prettification. The string as typed is either valid or rejected.
pub fn validate(raw: &str) -> Result<&str, SlugError>
Returns the input unchanged on success. No normalize function exists.pub enum SlugError { Empty, TooLong { len: usize }, InvalidChar { ch: char, index: usize }, LeadingHyphen, TrailingHyphen, LeadingUnderscore, TrailingUnderscore, }World.world_id: Uuid -> World.slug: String. Required.
No #[serde(default)] — deserializing a pre-slug world fails loud.World::new(now, chronon) -> World::new(slug, now, chronon)World::with_environment(now, chronon, env) ->
World::with_environment(slug, now, chronon, env)self.world_id become self.slug.Scenario::seed(now) -> Scenario::seed(slug: String, now).
Slug passes through to World::new. No slug validation here —
validation happens at the create_world boundary.WorldMeta.world_id: Uuid -> WorldMeta.slug: String. Required, no default.DeletedWorldRecord.world_id: Uuid -> DeletedWorldRecord.slug: String.world_dir(data_root, world_id: Uuid) -> world_dir(data_root, slug: &str).
Returns {data_root}/worlds/{slug}/.create_world(data_root, scenario, name: Option<String>) ->
create_world(data_root, scenario, slug: String, name: Option<String>).
Validates slug via slug::validate before any disk or registry work.
Fails with a typed error on grammar violation. Dir-exists collision
check stays. In-memory registry collision lives in the MCP layer
(see below) so the insert and the check share one write-lock scope.load_all validates each directory name against the slug grammar;
any directory whose name fails validation is logged and skipped (same
policy as the current "not a UUID" branch).WorldMeta::read fails loud on missing slug field.HashMap<String, WorldHandle>.McpEnv.worlds: Arc<RwLock<HashMap<Uuid, WorldHandle>>> ->
Arc<RwLock<HashMap<String, WorldHandle>>>. Keyed by slug.McpEnv.tombstones -> keyed by slug.McpEnv::resolve(world_id: Uuid) -> McpEnv::resolve(slug: &str).require_world_id(args) is renamed to require_world_slug(args) and
returns Result<String, McpError>. Validates slug grammar via
slug::validate; returns BAD_SLUG on grammar violation.handle_create_world:
slug from args. Required. MISSING_ARG if absent,
BAD_ARG if non-string, BAD_SLUG if grammar violation.env.worlds: check contains_key(slug)
before insert. On hit, release lock and return SLUG_COLLISION.
On miss, call worlds::create_world and insert the returned
handle.name stays an optional display label (unchanged semantics).handle_delete_world: target by slug.handle_list_worlds: response includes slug in each row.world_id/world_slug field gains
"description": "slug, e.g. 'ant-smoke'".McpError::unknown_world(world_id: Uuid) ->
McpError::unknown_world(slug: &str). Error code stays UNKNOWN_WORLD.log_perception, log_intent, log_adjudication,
log_adjudication_rejected, log_turn_complete, log_attempt_failed:
every world_id: Uuid parameter becomes world_slug: &str.world_id: Uuid on attempt records becomes world_slug: String.AppState.worlds keyed by slug. Same for tombstones.WorldRow with slug.WorldRow.world_id: Uuid -> WorldRow.slug: String.Scenario::AntOnPlate.seed(now) call supplies a test-local slug
(e.g. "phase0-time-test", "ant-events-test").worlds::create_world(...) gains a slug arg.world.world_id become asserts on world.slug.turn_NNNNNN, derived from the turn numberProduction registry is empty (list_worlds returned 0 earlier this
session). No live worlds on disk. No migration path is built.
If any world happens to exist on disk at deploy time, it will fail to
load: WorldMeta::read rejects meta.json without a slug field, and
load_all skips directories whose names don't parse as slugs (UUIDs
happen to be valid slug-shaped strings by grammar, so the directory
name check passes — the meta.json check is what fails). The fix is
manual: add a slug field to each meta.json, rename the directory to
match. No fallback code path is added.
Bottom-up, each step compiles before the next:
cargo build cleancargo test green (including the live-router ant tests)rg -n 'world_id.*Uuid|Uuid.*world_id' src/ returns no matches
in production code (comments referencing the prior contract are OK)rg -n 'pub fn.*world_id' src/ returns no matches (the parameter
name is gone at every public API)list_worlds response rows contain slug, not UUIDShould the MCP wire-level arg field AND the on-disk audit-event JSON
field be renamed from world_id to world_slug, or stay as world_id
with only the described value format changing?
RECOMMENDATION: rename to world_slug at both layers. It is a breaking
MCP contract change, but the registry is empty and the renamed field is
unambiguously correct — a client seeing world_slug will supply a
slug-shaped value; a client seeing world_id might still reach for a
UUID and hit BAD_SLUG errors.
Alternative: keep world_id as the wire/on-disk name with updated
descriptions. Less churn, more legacy baggage.
The ticket body assumes RENAME unless the caller overrides.
Implemented end-to-end, committed, deployed, and smoke-tested in production.
New module src/slug.rs:
Identifier retirement at every layer:
slug arg, does a contains_key check + insert under one write-lock scope, returns SLUG_COLLISION on duplicate. New error codes BAD_SLUG and SLUG_COLLISION; UNKNOWN_WORLD unchanged. Manifest descriptions updated from "UUID of the world" to "slug of the world."What didn't change (per spec): attempt_id stays Uuid, ticket_id stays Uuid, entity_id stays semantic string, turn_ref stays turn_NNNNNN, OAuth and code_nav untouched.
Docs: new "World slug — grammar, uniqueness, lifetime" section in docs/terms.md including the grammar table, uniqueness semantics, lifetime-and-immutability discussion, and an explicit rationale for why slugs don't normalize while entity_ids do (different callers: operators vs. LLMs; different policies).
Receipts — source verification:
Receipts — deploy + production smoke:
Acceptance checklist vs spec:
Known non-issue worth flagging: Claude Code's ToolSearch may still show cached MCP tool schemas (old world_id arg) until it re-negotiates with the server. Actual tool invocations pass through — the old tool_id worked with the new required slug arg, and required world_slug arg, proving the server is the source of truth.
Per standing guidance I am not confirming this ticket — only proposing. Over to you. The follow-up read-only /w/:slug/... web UI ticket is now unblocked whenever you want to file it.
Handler takeover. Picking this up with a clean working tree post-ba4c3707 deploy, production registry confirmed empty (I cleared the two pre-schema worlds from the PVC earlier this session), and no other in-flight ticket work to entangle with.
Ruling on the open knob: RENAME. Going with the caller's recommended world_id → world_slug at both the MCP wire level AND the on-disk audit-event JSON fields. Registry is empty; breaking the contract now is cheap, and a world_slug field name is self-documenting at every boundary where the value is inspected. No legacy fallback path.
Implementation plan follows the spec's bottom-up order: src/slug.rs first with unit tests, then kernel + scenarios (signature changes), then worlds.rs (the core of the refactor: WorldMeta, registry keys, create/delete/load, directory naming), then persistence + turn_job (audit fields), then mcp.rs (McpEnv, require_world_slug, every world-touching handler, manifest descriptions, BAD_SLUG + SLUG_COLLISION), then server + html (AppState, dashboard), then tests, then docs/terms.md.
Acceptance matches the spec: cargo build clean, cargo test green including the live-router ant tests, rg proofs showing no world_id.*Uuid / Uuid.*world_id / pub fn.*world_id in production code, production smoke (fresh slug-named world, run a turn, read events). Will commit as a single conventional feat commit, deploy, then propose resolution.
Will report back with receipts.
Refactor complete — committed at df9d8ce, full test suite green, deployed to pod chukwa-5b544bfdb6-hrmz2, end-to-end smoke test on the live MCP surface confirms slug is the only world identifier. Proposing resolution; confirmation yours to give.
Caller accepted: End-to-end verified on the live server after MCP tool cache refresh. Accept paths: create_world with slug "ant-verify" → world created with world_slug in response; run_turn + get_turn_status → turn 1 committed in 32s with semantic entity id "ant"; get_events → all four events carry world_slug, zero world_id fields; list_worlds → row has world_slug, no UUID. Reject paths: BAD_SLUG on uppercase ("A" at position 0 surfaces as InvalidChar, not LeadingHyphen — grammar ordering confirmed correct); SLUG_COLLISION on duplicate slug; BAD_SLUG with LeadingHyphen on "-ant". Source-side confirmed clean (no world_id UUID references anywhere, slug.rs with 14 unit tests, require_world_slug wired to all 14 world-touching handlers). Accepting.
Sign in as a human to drive this ticket from the page, or use the MCP tools.
Ticket created: Refactor: retire World UUID, use slug as the only world identifier