Sign in to edit tickets from this page.

← all tickets · home

Eliminate scenario_slug / scenario_name divergence: one identifier, enforced everywhere

rejected 415be57e-e143-4ed0-9d97-f34dc5aeb5c6

created_at
2026-04-27
updated_at
2026-04-27
priority
P3
ticket_type
bug
labels
substrate, mcp, ergonomics
resolved_at
2026-04-27
resolution
rejected

Body

HOLD — DO NOT PICK UP UNTIL HUMAN AUTHORIZATION.

Sits in pending until the human operator (johnb) posts a comment authorizing work to begin. If a handler reaches this ticket before that authorization, post one acknowledgment comment confirming you've read this hold instruction and that you are waiting for the human's go-ahead, then stop. Do not branch, do not read code, do not draft a plan. The human will return to either authorize, defer, or rewrite.

This ticket replaces the cancelled ee470925-23e0-4c16-97bb-7aeafcd2dfae. The previous framing proposed three candidates that all preserved or hid the divergence between scenario_slug and scenario_name. The correct move is to eliminate the divergence — that is what this ticket specifies.

Goal

A scenario has exactly one human-readable identifier. The substrate enforces that identifier's grammar at the boundary; downstream code never has to ask "is this a slug or a name?" Divergence between manifest field and name binding becomes unrepresentable.

Background

The substrate today carries two distinct identifiers for the same concept:

These can — and currently do — diverge. For the world first-meeting, the scenario manifest carries scenario_slug: midnight_library (with underscore), while the scenario_names table has the binding midnight-library (with hyphen). Both refer to the same scenario hash 9e525e2adfb0ed0d9f6cbe95f52f192e30836c020b1e9526822c3f32ed0de4d0.

When a world is created, its scenario_label is derived from the manifest's scenario_slug:

// src/world_store/postgres.rs
let scenario_label = scenario.scenario_slug.as_str().to_string();

So the world's MCP output reads "scenario": "midnight_library" — but get_scenario({ name: "midnight_library" }) returns UNKNOWN_SCENARIO, because the binding in the names table is midnight-library. The output the user just read implies the lookup should work; it doesn't.

The divergence isn't load-bearing. There's no operational reason for a scenario's manifest slug to differ from its name binding, and no callsite in the substrate's actual computation depends on the two being independent. The slug is part of the content hash; the name binding is a mutable label table. If we collapse them, nothing important is lost.

Architectural commitments

These define the work; they're not negotiable without rewriting the spec.

One identifier, one grammar. A scenario has exactly one human-readable name. Whatever string the manifest carries as its slug is the name; the scenario_names table either goes away entirely or becomes a degenerate table whose rows must equal the manifest's slug for the bound hash. (Recommend the former unless the names table earns its keep some other way.)

Grammar is enforced at the boundary. The chosen grammar (underscore OR hyphen, not both — see "Open question" below) is enforced at every entry point: scenario assembly, name binding (if retained), MCP tool inputs. A grammar violation returns a structured error with a clear message; it does not silently coerce. The Label docs already say "no normalization on input"; this is a substrate-wide application of that principle.

No silent fallbacks anywhere. No name-to-slug fallback. No underscore-to-hyphen normalization. No "try it the other way." If a caller asks for a name that doesn't exist exactly, the lookup fails. The point is to prevent the substrate from teaching callers that divergence is OK.

Existing data migrates explicitly. Production currently has two scenarios; one (midnight_library) has a divergent name binding (midnight-library), one (moth_and_flame) has no name binding at all. Both get reconciled as part of this ticket — either by rewriting the binding to match the slug, or by deleting the binding table entry, depending on the chosen design.

The open design question

The handler picks one of these and documents the choice in proposed_resolution:

Underscore. Matches the existing slug convention in the codebase (scenario_slug, cognition_profile, perceive_system). Lower blast radius — most existing data already uses it. Idiomatic for Rust identifiers.

Hyphen. More URL-friendly. Matches the existing Label doc examples (midnight-library). Idiomatic for slugs in web contexts. Already the convention used in some name bindings (midnight-library) and in world slugs (single-moth, first-meeting).

This is genuinely the only open choice. Once it's picked, the rest of the spec follows mechanically: pick the grammar, enforce it everywhere, migrate existing data to match, delete the divergence-enabling code paths.

A third option worth considering, which I lean toward: allow both underscore and hyphen at the grammar level (as the Label grammar already does), but require that the slug-of-record and any name binding for the same scenario be byte-identical. That is: divergence between two strings for the same scenario is forbidden, even if both strings are valid grammar. Worlds get bonus consistency without forcing a global rename of every existing identifier. The handler should evaluate whether this is better than picking one or the other; if so, document it as the chosen approach.

What needs to change

I'm naming the visible surfaces; the handler reads the code and decides on exact mechanics:

Substrate. The scenario_names table either goes away (preferred, if the manifest slug is the canonical name) or gains a constraint that bound names equal the manifest slug. The grammar enforcement lives wherever scenario assembly accepts the slug field and wherever name binding (if retained) accepts the name field. Migration 0004_eliminate_scenario_name_divergence.sql (or whatever the next migration number is) handles the existing-data reconciliation.

MCP surface. set_scenario_name either goes away (if the names table goes away) or rejects any name not equal to the manifest's slug. unset_scenario_name follows the same pattern. get_scenario continues to support { name }, { hash }, and after this ticket, { name } is unambiguous because the name is the slug. The MCP world output's scenario and scenario_label fields stay shaped the same — they continue to surface the slug, which is now also the name.

Graph browser. The /scenarios/name/:name route works, the Scenario reference rules in the catalog work, the world detail page's scenario link resolves. Nothing about the structural linker changes — but verify the catalog contract test still passes, and verify the live graph browser smoke (the 11-route operator traversal from ticket 04d1b392) still walks cleanly.

Tests. Round-trip tests on every lookup mode, in both store impls (memory + postgres). Grammar enforcement tests: every entry point rejects invalid grammar with a clean error. Migration tests: production-shaped existing data reconciles correctly without losing anything important. Negative tests: divergent inputs (underscore-version + hyphen-version of the same conceptual name) result in either both representing the same scenario, or one being rejected at boundary, depending on the chosen grammar approach.

Approach

Single phase, probably. The fix is bounded. The discipline matches the substrate-tickets-this-week pattern:

Acceptance

  1. The chosen grammar approach (underscore-only / hyphen-only / both-grammars-but-no-divergence) is named in the proposed_resolution, with the trade-off documented and rejected alternatives briefly addressed.
  2. The substrate enforces the chosen grammar at every entry point. Grammar violations return a structured error. No silent coercion.
  3. The scenario_names table either goes away or has a constraint preventing divergence from the manifest slug. The choice is documented.
  4. Existing production data is reconciled by a migration. Both scenarios in production (midnight_library / 9e525e2… and moth_and_flame / e087d3a…) end up with consistent name and slug. No data loss.
  5. Round-trip test coverage. For each scenario in the test fixture (memory + postgres impls), every supported lookup mode succeeds with the canonical name; invalid grammar fails with a named error.
  6. Graph browser consistency. The catalog contract test from 04d1b392 Phase J still passes. A fresh authenticated walk of the 11-route operator path completes cleanly. The world first-meeting's detail page surfaces a scenario link that resolves.
  7. Live verification. Starting from get_world({ slug: "first-meeting" }), an operator can navigate to the underlying scenario via get_scenario({ name: <surfaced-name> }) in exactly one additional MCP call, no trial-and-error.
  8. No new fallback magic. Any place the substrate previously relied on (or could have relied on) name-to-slug fallback or grammar normalization, that path is gone or explicitly absent. Searching the post-ticket codebase for normalization helpers, fallback logic, or "try the other way" patterns returns clean.

Out of scope

Sequencing

Independent of the open work on multi-agent turn execution (4601f21a, 2dc48e22). Doesn't block them; isn't blocked by them. Picks up whenever convenient.

Related

History (2 events)

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