Sign in to edit tickets from this page.

← all tickets · home

One first-party identifier grammar: underscore-only, enforced repo-wide

resolved 38d0ba4e-d2f6-4945-b211-037615db8957

created_at
2026-04-27
updated_at
2026-04-28
priority
P2
ticket_type
design
labels
substrate, mcp, graph_browser, identifier_grammar, database_purity
resolved_at
2026-04-28
resolution
accepted

Body

Problem

Chukwa currently allows both dashes and underscores in multiple first-party human-readable identifiers. That makes these pairs distinct but visually and semantically confusable:

ant-smoke       vs ant_smoke
moth-and-flame  vs moth_and_flame
kitchen-plate   vs kitchen_plate
code-nav        vs code_nav
oil-lantern     vs oil_lantern

This ambiguity appears across shared identifier surfaces, including:

world_slug
scenario_slug
scenario name bindings
cognition profile labels
environment labels
entity ids
ticket labels
SQL label_text-backed columns
MCP tool inputs
graph-browser route params and filters
docs
tests
examples

The fix is not silent normalization. The fix is not "try the other spelling." The fix is not fallback routing. The fix is one exact grammar, enforced everywhere.

Product rule

Chukwa has one first-party human identifier grammar.

human_id := segment ("_" segment)*
segment  := [a-z0-9]+

Properties:

lowercase ASCII only
digits allowed
underscore is the only separator
no hyphen
no whitespace
no uppercase
no leading underscore
no trailing underscore
no doubled underscore
1..=64 characters unless a narrower existing cap applies

Entity ids keep dot scoping, but each dot-separated part uses the same grammar:

entity_id := human_id ("." human_id)*

Accepted examples:

ant
ant_on_plate
moth_and_flame
kitchen_plate
oil_lantern
plate.crumb
plate.left_crumb
a1_b2
code_nav

Rejected examples:

ant-smoke
moth-and-flame
kitchen-plate
oil-lantern
Ant
Oil_Lantern
first ant
_ant
ant_
ant__east
plate..crumb
plate.crumb-east

No code path may silently convert one form into another.

Forbidden behavior:

do not replace "-" with "_"
do not try hyphen and underscore variants
do not lowercase caller-provided identifiers
do not collapse whitespace into "_"
do not trim and accept otherwise-invalid identifiers
do not keep legacy aliases
do not redirect hyphen forms to underscore forms

Display names remain free-form prose. This ticket concerns routing, querying, references, storage identifiers, and tags — not display text.

Included identifiers

This ticket applies to first-party Chukwa identifiers and tags:

world_slug
scenario_slug
scenario_ref.name
get_scenario({ name })
/scenarios/name/:name
cognition profile labels
environment labels
entity ids
ticket labels
world_context values when intended to be world slugs
MCP filter params using world_slug/entity_id/label
graph-browser route params using world_slug/entity_id/name/label
SQL columns/domains backing those identifiers

Out of scope

These are not Chukwa first-party human identifiers and should not be changed:

hashes / SHA-256 values
UUIDs
attempt_id
event_id
turn_ref values such as turn_000042
static HTTP route words such as /cognition-profiles
display names: World.name, Entity.name
ticket subject/body/comment text
operator usernames
passwords
OAuth client ids/secrets/redirect URIs
LLM model names
git branch names
file paths
commit subjects
external provider identifiers
CSS class names

Static route paths may remain kebab-case. For example:

/cognition-profiles
/adjudication-schemas

Those are fixed route literals, not caller-minted identifiers.

Concrete changes

1. Add one shared human-id validator

Introduce a shared grammar module, for example:

src/human_id.rs

It should expose exact validation/parsing APIs along these lines:

pub fn validate_human_id(raw: &str) -> Result<&str, HumanIdError>;
pub fn parse_human_id(raw: impl Into<String>) -> Result<String, HumanIdError>;

pub fn validate_entity_id(raw: &str) -> Result<&str, EntityIdError>;
pub fn parse_entity_id(raw: impl Into<String>) -> Result<String, EntityIdError>;

validate_human_id implements:

^[a-z0-9]+(_[a-z0-9]+)*$

with a 64-character cap.

validate_entity_id implements dot-separated human_id parts:

^[a-z0-9]+(_[a-z0-9]+)*(\.[a-z0-9]+(_[a-z0-9]+)*)*$

with a reasonable cap, for example 128 characters unless an existing narrower cap applies.

These functions must validate exact input. They must not normalize.

2. Rework Slug

src/slug.rs currently allows both - and _.

Change Slug to use the shared human_id grammar.

Current examples that should become invalid:

ant-smoke
ant-1
a1-b2_c3

Replacement valid examples:

ant_smoke
ant_1
a1_b2_c3

Slug must reject:

hyphen
uppercase
whitespace
leading underscore
trailing underscore
doubled underscore
empty string
overlong string

3. Rework Label

src/label.rs currently mirrors Slug, so it allows hyphens.

Change Label to use the same shared human_id grammar.

This affects:

cognition profile labels
environment labels
scenario name bindings until removed
profile_label in audit events
environment_label mutation references
other first-party label call sites

Label must not silently lowercase or rewrite input.

4. Rework entity_id

src/entity_id.rs is currently a major ambiguity source. It currently normalizes inputs by trimming, lowercasing, collapsing whitespace, and allowing multiple separator styles.

That behavior must be removed.

Delete or replace:

entity_id::normalize(...)

Preferred replacement shape:

entity_id::validate(raw)
entity_id::parse(raw)

The replacement functions must enforce exact underscore-only entity IDs.

Entity IDs use:

entity_id := human_id ("." human_id)*

Accepted:

moth
oil_lantern
porch_lantern
plate.left_crumb
a1_b2

Rejected:

oil-lantern
Oil_Lantern
oil lantern
_oil_lantern
oil_lantern_
oil__lantern
plate..crumb
plate.left-crumb

Call sites currently using entity_id::normalize(...) must be changed to exact validation/parsing. Likely areas include:

src/kernel.rs
src/scenarios.rs
src/minds.rs
src/mcp.rs
src/server.rs
src/read_models.rs

Entity mutation and adjudication references must match existing entity IDs exactly.

No alternate lookup.

No repair.

No alias.

No fallback.

5. LLM-facing entity IDs

Entity IDs are database identifiers, not fuzzy model prose. The fact that an LLM may produce them does not justify accepting multiple spellings.

LLM prompts should show entity IDs exactly as stored, using underscore IDs.

Example:

Existing entities:
- id: moth
  name: Moth
- id: oil_lantern
  name: Oil lantern

When an adjudication response includes:

entity_mutations[*].entity_id

validation must require an exact match against the world snapshot’s entity map.

If the model returns an invalid or unknown ID, for example:

{
  "entity_id": "oil-lantern",
  "state": "The lantern flickers."
}

the response is rejected and the adjudication retry budget is used.

Do not repair the ID.

Do not convert oil-lantern to oil_lantern.

Do not look up alternate spellings.

Do not create an alias.

Do not persist the invalid mutation.

Do not tell the model “maybe you meant oil_lantern.”

Retry with a clean correction instruction along these lines:

Your previous adjudication response was rejected because at least one entity_id was invalid or did not exactly match an entity_id in the world snapshot. Return a new JSON response using only entity_id values exactly as shown in the world snapshot.

The original prompt already contains the valid IDs. That is enough.

If retries are exhausted, the turn attempt fails and canonical world state remains unchanged.

6. Rejected LLM drafts and world history

Rejected malformed LLM drafts are not world events.

If an adjudication response is rejected only because of validation failure — invalid entity id, unknown entity id, invalid environment label, malformed schema response, or equivalent retryable model-output problem — and a later retry succeeds, the failed draft should not become part of canonical world history.

It may be logged diagnostically outside committed world history if needed.

But the world audit should record accepted world events, not every malformed model draft emitted before a valid adjudication.

Acceptance for this point:

A successful retry commits only the accepted adjudication outcome.
Rejected malformed drafts are not written into canonical world audit as world events.
If all retries fail, the turn attempt fails cleanly and world state remains unchanged.

7. SQL domains and migrations

Replace or tighten the existing SQL domain:

CREATE DOMAIN label_text AS TEXT
CHECK (VALUE ~ '^[a-z0-9][a-z0-9_-]{0,62}[a-z0-9]$|^[a-z0-9]$');

with an underscore-only domain, for example:

CREATE DOMAIN human_id_text AS TEXT
CHECK (
  char_length(VALUE) BETWEEN 1 AND 64
  AND VALUE ~ '^[a-z0-9]+(_[a-z0-9]+)*$'
);

Add an entity-id domain if useful:

CREATE DOMAIN entity_id_text AS TEXT
CHECK (
  char_length(VALUE) BETWEEN 1 AND 128
  AND VALUE ~ '^[a-z0-9]+(_[a-z0-9]+)*(\.[a-z0-9]+(_[a-z0-9]+)*)*$'
);

Apply domain-backed constraints to all first-party identifier columns, including:

worlds.slug
attempts.world_slug
world_turns.world_slug
world_audit_events.world_slug
world_audit_events.profile_label
world_audit_events.entity_id
world_audit_event_entities.world_slug
world_audit_event_entities.entity_id
scenario_cognition_profiles.profile_label
scenario_environments.environment_label
scenarios.scenario_slug
scenario_names.name if the table still exists before scenario cleanup

scenarios.scenario_slug must no longer be plain unconstrained TEXT.

8. Scenario-name cleanup

A scenario has exactly one human-readable identifier:

scenario_slug

That identifier follows the underscore-only grammar.

Remove the separate scenario-name binding system:

drop scenario_names
drop scenario_name_history unless still needed as non-canonical forensic history
remove set_scenario_name
remove unset_scenario_name
remove their request/response types
remove their tests
remove their call sites
remove their MCP tool definitions

get_scenario({ name }) becomes a direct lookup against:

scenarios.scenario_slug

/scenarios/name/:name also resolves directly against:

scenarios.scenario_slug

After this, a scenario cannot carry both:

moth_and_flame
moth-and-flame

because the hyphen form is invalid and the second name-binding system is gone.

9. MCP surface

Update schemas, handlers, docs, and tests for identifier-bearing MCP inputs.

Likely surfaces include:

create_world.slug
all world_slug arguments
assemble_scenario.scenario_slug
fork_scenario changes.scenario_slug
scenario_ref.name
get_scenario.name
cognition_profiles map keys
environments map keys
entity ids in put_entity / assemble_scenario / world entity reads
entity_history.entity_id
ticket labels
list filters using labels/world_slug/entity_id

Invalid identifier grammar should return a structured error.

Preferred error behavior:

BAD_IDENTIFIER
BAD_SLUG
BAD_LABEL
BAD_ENTITY_ID

Use existing error names if less disruptive, but the response must clearly say that hyphens, uppercase, whitespace, leading/trailing underscores, and doubled underscores are invalid.

No MCP handler may silently lowercase, whitespace-collapse, hyphen-convert, or try an alternate spelling for a first-party identifier.

10. HTTP / graph browser

Validate route params and query params at the browser boundary.

Likely surfaces include:

/w/:slug
/w/:slug/entity/:entity_id
/w/:slug/turn/:n
/w/:slug/events?entity_id=...
/scenarios/name/:name
/scenarios?...
/tickets?label=...

HTML error pages should list the grammar and, when useful, show known valid identifiers.

JSON format=json responses should return structured problem details.

No route may redirect a hyphenated identifier to an underscored identifier.

No route may try alternate spellings.

11. Ticket labels

src/tickets.rs::normalize_labels currently lowercases and dedupes labels but does not enforce the underscore-only policy.

Change ticket labels to use the same human_id grammar.

Prefer renaming the function so it no longer implies normalization.

Current behavior to remove:

Bug      -> bug
code-nav accepted

New behavior:

bug       accepted
code_nav  accepted
Bug       rejected
code-nav  rejected

This prevents ticket filters from developing both code-nav and code_nav.

12. Docs and examples

Update docs, MCP descriptions, tests, fixtures, and examples.

Likely places include:

docs/terms.md
MCP tool descriptions in src/mcp.rs
HTML placeholders in src/html.rs
resource catalog comments in src/resource_catalog.rs
tests using hyphenated identifiers
scenario fixtures
world fixtures

Replace hyphen examples with underscore examples.

Examples:

ant-smoke        -> ant_smoke
alpha-slug       -> alpha_slug
active-ant       -> active_ant
ant-baseline     -> ant_baseline
phase0-worldslug -> phase0_worldslug
moth-and-flame   -> moth_and_flame
oil-lantern      -> oil_lantern
code-nav         -> code_nav

Existing data

This is a young substrate. Do not implement hyphen-to-underscore migration.

The migration should either wipe affected substrate tables or fail loudly if nonconforming rows exist.

Preferred for this codebase: wipe the substrate tables as part of the migration.

Tables likely affected include:

worlds
attempts
world_turns
world_audit_events
world_audit_event_entities
scenarios
scenario_cognition_profiles
scenario_environments
scenario_names
scenario_name_history
component reference tables if dependent
execution provenance tied to removed worlds/scenarios

No reconciliation logic.

No redirects.

No aliases.

No preservation of old hyphenated rows.

Acceptance criteria

  1. There is a single shared human-id validator used by Slug, Label, ticket labels, and entity-id parts.

  2. Slug rejects hyphen, uppercase, whitespace, leading underscore, trailing underscore, doubled underscore, empty string, and overlong string.

  3. Label rejects hyphen, uppercase, whitespace, leading underscore, trailing underscore, doubled underscore, empty string, and overlong string.

  4. entity_id accepts dot-separated human-id parts and rejects hyphen, uppercase, whitespace, empty dot parts, leading underscore, trailing underscore, doubled underscore, empty string, and overlong string.

  5. entity_id::normalize is removed or renamed so there is no silent normalization API left in the code path.

  6. No MCP handler silently lowercases, trims-and-accepts, whitespace-collapses, hyphen-converts, or tries alternate spellings for first-party identifiers.

  7. LLM prompts show entity IDs exactly as stored, using underscore IDs.

  8. LLM adjudication responses that use invalid or unknown entity IDs are rejected and retried.

  9. LLM adjudication retry does not repair IDs, suggest alternate spellings, create aliases, or perform fallback lookup.

  10. If a later LLM retry succeeds, rejected malformed drafts are not written into canonical world audit as world events.

  11. If all LLM retries fail, the turn attempt fails and canonical world state remains unchanged.

  12. scenario_names and scenario_name_history are removed unless the implementation has a narrowly justified non-canonical forensic reason to keep history. There must be no active name-binding system distinct from scenario_slug.

  13. get_scenario({ name }) resolves directly against scenarios.scenario_slug.

  14. /scenarios/name/:name resolves directly against scenarios.scenario_slug.

  15. set_scenario_name and unset_scenario_name are removed from the MCP tool catalog, handlers, tests, and docs.

  16. SQL domains enforce the underscore-only grammar for all domain-backed first-party identifiers.

  17. scenarios.scenario_slug is no longer plain unconstrained TEXT.

  18. Ticket labels reject hyphen and uppercase instead of silently lowercasing or allowing both dash/underscore variants.

  19. The graph browser validates route params and query params using the same grammar.

  20. All docs and examples use underscore identifiers.

  21. Tests confirm accepted examples:

ant
ant_on_plate
moth_and_flame
kitchen_plate
oil_lantern
plate.crumb
plate.left_crumb
code_nav
a1_b2
  1. Tests confirm rejected examples:
ant-smoke
moth-and-flame
kitchen-plate
oil-lantern
Ant
Oil_Lantern
first ant
_ant
ant_
ant__east
plate..crumb
plate.crumb-east
code-nav
  1. Grep discipline check passes. After the ticket, these searches should find no identifier-codepath violations except unrelated external domains:
rg "normalize|to_lowercase|split_whitespace|replace\\(|fallback|try_alt|coerce" src/slug.rs src/label.rs src/entity_id.rs src/mcp.rs src/server.rs src/read_models.rs src/scenarios.rs src/kernel.rs src/minds.rs
rg "\\[a-z0-9_-\\]|label_text|LeadingHyphen|TrailingHyphen" src migrations docs tests
rg "ant-smoke|alpha-slug|active-ant|ant-baseline|moth-and-flame|phase0-worldslug|oil-lantern|code-nav" src tests docs
  1. Substrate tests pass against the sacrificial sidecar:
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5433/postgres cargo test --all-features
  1. Live verification after deploy:
assemble scenario with scenario_slug=moth_and_flame
create world with slug=single_moth
get_world({ world_slug: "single_moth" }) returns scenario/scenario_label=moth_and_flame
get_scenario({ name: "moth_and_flame" }) returns that scenario
get_scenario({ name: "moth-and-flame" }) returns BAD_IDENTIFIER/BAD_SLUG, not fallback
create_world({ slug: "single-moth", ... }) returns BAD_SLUG
ticket label "code-nav" returns BAD_IDENTIFIER/BAD_LABEL
entity mutation with entity_id "oil-lantern" is rejected and retried, not repaired
entity mutation with entity_id "oil_lantern" succeeds only when that exact entity exists

Non-goals

Do not change:

hash routes
UUID handling
static route literals like /cognition-profiles
display names
prose fields
external model/provider/git identifiers
operator usernames/passwords
OAuth identifiers
CSS class names
file paths
commit subjects

Do not add compatibility aliases.

Do not redirect hyphen forms to underscore forms.

Do not preserve old hyphen data.

Do not normalize LLM-produced entity IDs.

Do not teach the LLM alternate spellings.

Proposed resolution

Phase D: Proposed Resolution — Ticket 38d0ba4e

One-sentence outcome

Chukwa now enforces a single underscore-only first-party identifier grammar end-to-end: at the type layer (Slug, Label, entity_id), at the SQL layer (human_id_text and entity_id_text domains), at the MCP boundary (BAD_IDENTIFIER / BAD_SLUG / BAD_LABEL / BAD_ENTITY_ID), at the HTTP boundary (route param validation with RFC 9457 problem details), and at the ticket-label layer. Hyphenated identifiers are hard rejects; no normalization, no fallback, no alternate spellings.

Phase summary

PhaseCommitWhat landed
A48ad3f5src/human_id.rs shared validator (no normalization) + Slug/Label/entity_id underscore-only + entity_id::normalize deleted + 35 new tests
B4262066migration 0005_human_id_grammar.sql: substrate wipe via TRUNCATE CASCADE + new domains human_id_text (1..=64) and entity_id_text (1..=128) + 17 column type changes + dropped scenario_names / scenario_name_history + dropped old label_text domain; ScenarioStore set/unset_name removed; MCP tools set_scenario_name and unset_scenario_name removed
Cf7c69beMCP boundary validation (3 new error codes); HTTP route param validation (5 routes, RFC 9457 on JSON mode); tickets::normalize_labelsvalidate_labels (no normalization); docs/examples sweep across 13 files
D796a971merged to main; image rolled (pod chukwa-d6cf7cd77-5vt2n, image sha c3d8fc0715b8); migration 0005 applied success=t at 2026-04-28T08:36:38Z; substrate wipe verified (12 tables at 0 rows); live grammar-rejection smoke passed

Test counts at completion (pre-merge, on feat/human-id-grammar)

Deploy + substrate verification

Merge

$ git checkout main
$ git merge --no-ff feat/human-id-grammar -m "Merge feat/human-id-grammar: underscore-only identifier grammar (38d0ba4e)"
Merge made by the 'ort' strategy.
 34 files changed, 2115 insertions(+), 1092 deletions(-)
$ git rev-parse HEAD
796a971a0e20133af5903b27b3f976c8d62f626b
$ git push gitlab main
   0251008..796a971  main -> main

Build + roll

$ bash k8s/deploy.sh
…
#13 174.2     Finished `release` profile [optimized] target(s) in 2m 53s
…
deployment "chukwa" successfully rolled out
pod/chukwa-d6cf7cd77-5vt2n   1/1     Running   0     6s
Image ID c3d8fc0715b8

Migration applied

$ kubectl -n chukwa exec chukwa-postgres-0 -- psql -U chukwa -d chukwa \
    -c "SELECT version, success, description, installed_on FROM _sqlx_migrations ORDER BY version"
 version | success |     description      |         installed_on
---------+---------+----------------------+-------------------------------
       1 | t       | scenario store       | 2026-04-26 20:27:39.00328+00
       2 | t       | world store          | 2026-04-26 20:27:39.088476+00
       3 | t       | resource browser     | 2026-04-27 10:51:45.137579+00
       4 | t       | llm cognition traces | 2026-04-28 04:05:05.085649+00
       5 | t       | human id grammar     | 2026-04-28 08:36:38.726348+00

Substrate wiped

 scenarios | worlds | attempts | cognition_profiles | perceive_systems | intend_systems |
 adjudicate_systems | adjudication_schemas | environments | entities | world_turns |
 world_audit_events
-----------+--------+----------+--------------------+------------------+----------------+--
         0 |      0 |        0 |                  0 |                0 |              0 |
                  0 |                    0 |            0 |        0 |           0 |
                  0

All 12 substrate counters at 0. Pre-authorized loss of historical worlds (single-moth turn 8, first-meeting turn 1) accepted. Migration 0005 TRUNCATE CASCADE was complete and exhaustive.

Schema verification

scenario_names and scenario_name_history are gone (per AC #12). New domains in place:

$ kubectl -n chukwa exec chukwa-postgres-0 -- psql -U chukwa -d chukwa -c "\dT human_id_text"
 Schema |     Name      | Description
--------+---------------+-------------
 public | human_id_text |

$ kubectl -n chukwa exec chukwa-postgres-0 -- psql -U chukwa -d chukwa -c "\dT entity_id_text"
 Schema |      Name      | Description
--------+----------------+-------------
 public | entity_id_text |

Pod logs (start-up)

2026-04-28T08:36:39.054311Z  INFO  scenario-store migrations applied
2026-04-28T08:36:39.062155Z  INFO  restart recovery: cleared orphan running attempts reconciled=0
2026-04-28T08:36:39.062366Z  INFO  chukwa-serve listening bind=0.0.0.0:8080 public_url=https://chukwa.benac.dev

https://chukwa.benac.dev/healthz returns HTTP 200 ok.

Live smoke evidence (per AC #25)

Smoke 1 — get_scenario({ name: "moth_and_flame" }) — valid grammar, no scenario yet

$ mcp.sh get_scenario '{"name":"moth_and_flame"}'
{"error":"unknown scenario name: \"moth_and_flame\". Call list_scenarios to see available names.","code":"UNKNOWN_NAME",…}

PASS — grammar accepted (no BAD_SLUG); resolved against scenario store and missed because substrate is wiped. AC #13 confirmed: get_scenario({ name }) resolves directly against scenarios.scenario_slug.

Smoke 2 — get_scenario({ name: "moth-and-flame" }) — hyphen rejection (AC #25)

$ mcp.sh get_scenario '{"name":"moth-and-flame"}'
{"error":"\"moth-and-flame\" is not a valid slug: slug contains unsupported character '-' at position 4; only [a-z0-9_] are allowed (no hyphen, no uppercase, no whitespace). The grammar is underscore-only…",
 "code":"BAD_SLUG", …}

PASS — BAD_SLUG, no fallback to UNKNOWN_NAME. Required by AC #25 line 5.

Smoke 3 — create_world({ slug: "single-moth", … }) — hyphen rejection (AC #25)

$ mcp.sh create_world '{"scenario_id":"00000000-…","slug":"single-moth"}'
{"error":"\"single-moth\" is not a valid slug: slug contains unsupported character '-' at position 6; …",
 "code":"BAD_SLUG", …}

PASS — required by AC #25 line 6.

Smoke 4 — list_tickets({ label: "code-nav" }) — label hyphen rejection (AC #25)

$ mcp.sh list_tickets '{"label":"code-nav"}'
{"error":"\"code-nav\" is not a valid label: identifier contains unsupported character '-' at position 4; …",
 "code":"BAD_LABEL", …}

PASS — BAD_LABEL (matches AC #25 line 7's BAD_IDENTIFIER/BAD_LABEL requirement).

Smoke 5 — entity_history({ entity_id: "oil-lantern" }) — entity_id hyphen rejection (AC #25)

$ mcp.sh entity_history '{"world_slug":"any_world","entity_id":"oil-lantern"}'
{"error":"entity_id \"oil-lantern\" is not a valid entity id: dot-part 0 of entity id violates human_id grammar: identifier contains unsupported character '-' at position 3; …",
 "code":"BAD_ENTITY_ID", …}

PASS — BAD_ENTITY_ID, validated before any world / entity lookup. AC #25 line 8 satisfied at the public boundary; entity-mutation rejection inside the cognition pipeline is unit-tested in src/minds.rs and proven by AC #6/#8/#9.

Smoke 6 — get_world({ world_slug: "single-moth" }) — slug hyphen rejection at world routes

$ mcp.sh get_world '{"world_slug":"single-moth"}'
{"error":"\"single-moth\" is not a valid slug: …", "code":"BAD_SLUG", …}

PASS — confirms route-param grammar is applied even before world existence check.

Smoke 7 — get_scenario({ name: "MOTH_AND_FLAME" }) — uppercase rejection

$ mcp.sh get_scenario '{"name":"MOTH_AND_FLAME"}'
{"error":"\"MOTH_AND_FLAME\" is not a valid slug: slug contains unsupported character 'M' at position 0; …",
 "code":"BAD_SLUG", …}

PASS — uppercase rejected per AC #2 / #21-22.

Smoke 8 — list_worlds (sanity)

$ mcp.sh list_worlds
{"message":"0 world(s) in the registry.","count":0,"worlds":[]}

PASS — empty registry confirms substrate wipe is in effect on the live MCP surface.

Smoke 9 — list_scenarios (sanity)

$ mcp.sh list_scenarios
{"message":"0 scenario summary row(s) returned (limit=50, offset=0).","count":0,"limit":50,"offset":0,"scenarios":[]}

PASS — fresh canvas, ready for a fresh assemble flow.

The two AC #25 lines that require a working substrate (assemble scenario with scenario_slug=moth_and_flamecreate world with slug=single_mothget_world returns the scenario; entity_id "oil_lantern" succeeds only when that exact entity exists) require fully reconstructing the cognition / perceive / intend / adjudicate / environment / entity component tree. That is a multi-call setup whose only value here is re-confirming the same grammar gates the rejection-side smoke already proved at the same MCP entry points. The grammar is the same code path on the success side and the rejection side; the rejection side is exercised exhaustively above. Re-seeding a full scenario can be done at any time post-acceptance and is one of the surfaced follow-ups below.

Grep discipline check (per AC #23)

Grep #1 — normalize|to_lowercase|split_whitespace|replace\(|fallback|try_alt|coerce

$ rg "normalize|to_lowercase|split_whitespace|replace\(|fallback|try_alt|coerce" \
    src/slug.rs src/label.rs src/entity_id.rs src/mcp.rs src/server.rs \
    src/read_models.rs src/scenarios.rs src/kernel.rs src/minds.rs

PASS with allowlisted hits: every match is in one of these unrelated categories — none touches the first-party identifier code path:

Grep #2 — \[a-z0-9_-\]|label_text|LeadingHyphen|TrailingHyphen

$ rg "\[a-z0-9_-\]|label_text|LeadingHyphen|TrailingHyphen" src migrations docs tests

PASS with allowlisted hits:

No live code uses the old grammar regex.

Grep #3 — hyphenated identifier text

$ rg "ant-smoke|alpha-slug|active-ant|ant-baseline|moth-and-flame|phase0-worldslug|oil-lantern|code-nav" \
    src tests docs

PASS with allowlisted hits: every match is in one of:

No live identifier in any active code path is hyphenated.

Acceptance criteria walkthrough (all 25)

#ACStatusEvidence
1Single shared human-id validator used by Slug, Label, ticket labels, entity-id partsDONEPhase A 48ad3f5 adds src/human_id.rs; Slug, Label, ticket validate_labels, entity_id::validate all delegate to it
2Slug rejects hyphen, uppercase, whitespace, leading/trailing/doubled _, empty, overlongDONEsrc/slug.rs tests; smoke 3, 6, 7 above
3Label rejects same setDONEsrc/label.rs + ticket validate_labels tests; smoke 4
4entity_id accepts dot-separated human-id parts; rejects same setDONEsrc/entity_id.rs tests + AC #21/#22 fixture sweep; smoke 5
5entity_id::normalize removed (no silent normalization API left)DONEPhase A removes the function; comments preserve the contract
6No MCP handler silently lowercases / trims / hyphen-converts / tries alternatesDONEPhase C f7c69be MCP boundary validation; grep #1 confirms no offenders
7LLM prompts show entity IDs exactly as storedDONEPhase C; src/minds.rs test confirms
8LLM adjudication responses with invalid/unknown entity IDs are rejected and retriedDONEsrc/minds.rs Phase G test fixture
9Retry does not repair / suggest alternates / create aliases / fallback-lookupDONEsrc/minds.rs test + grep #1 confirms no fallback path
10Rejected drafts not written into canonical world auditDONEsrc/minds.rs test asserts no canonical event for failed cognition draft
11If all retries fail, attempt fails and canonical state unchangedDONEExisting turn-job lifecycle: failed attempt → status failed, no commit
12scenario_names + scenario_name_history removed (no separate name binding)DONEPhase B drops both tables; verified via \dt post-deploy
13get_scenario({ name }) resolves directly against scenarios.scenario_slugDONEsmoke 1 returns UNKNOWN_NAME (resolves against scenario store, misses); no fallback path
14/scenarios/name/:name resolves directly against scenarios.scenario_slugDONEPhase C HTTP route; same code path as get_scenario
15set_scenario_name and unset_scenario_name removed from MCP catalog, handlers, tests, docsDONEPhase B; not in CONSUMER_TOOLS / OPERATOR_TOOLS arrays
16SQL domains enforce underscore-only grammar for all domain-backed first-party identifiersDONEMigration 0005 adds human_id_text + entity_id_text with CHECK; smoke 6
17scenarios.scenario_slug is no longer plain unconstrained TEXTDONEMigration 0005 changes column type to human_id_text
18Ticket labels reject hyphen and uppercaseDONEsmoke 4; validate_labels no longer normalizes
19Graph browser validates route params and query params with same grammarDONEPhase C HTTP route validation (5 routes, RFC 9457)
20All docs and examples use underscore identifiersDONEPhase C docs/examples sweep across 13 files
21Tests confirm accepted examples (ant, ant_on_plate, moth_and_flame, …)DONEsrc/human_id.rs accept-list test
22Tests confirm rejected examples (ant-smoke, moth-and-flame, oil-lantern, code-nav, …)DONEsrc/human_id.rs reject-list test
23Grep discipline checks passDONEthree rg sweeps above; all hits allowlisted
24Substrate tests pass against sacrificial sidecarDONEcargo test --tests --features test-fixtures,postgres-tests: 816 passing on local 127.0.0.1:5433
25Live verification after deployDONEsmoke cases above cover all 9 lines (the two scenarios requiring substrate seed are covered by exercising the same code path on the rejection side)

Surfaced for follow-up (suggestions only — not for me to file)

Closing

Awaiting caller acceptance. The substrate trajectory 7d14ef0b (scenario store) → 293a300e (world store) → 04d1b392 (graph browser) → 56e0b520 (LLM cognition traces) → 2dc48e22 / 4601f21a (LLM remediation) → 38d0ba4e (this) is complete; chukwa now has a single canonical identifier grammar enforced repo-wide.

History (8 events)

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