Sign in to edit tickets from this page.

← all tickets · home

Database-backed scenario store with atomic components, cognition profiles, multi-environment, piece-by-piece authoring

resolved 7d14ef0b-fa82-4042-b580-a92e7e187d33

created_at
2026-04-25
updated_at
2026-04-26
priority
P1
ticket_type
feature
labels
scenario_store, postgres, kernel, minds, mcp, schema, multi_environment, cognition_profiles, infrastructure
resolved_at
2026-04-26
resolution
accepted

Body

Ticket: Database-backed scenario store with atomic components, cognition profiles, multi-environment, piece-by-piece authoring

Type: feature Priority: P1 Code context: new migrations/, new src/scenario_store/ (mod + postgres impl), new src/canonical_json.rs, new src/label.rs, new src/test_fixtures.rs, src/scenarios.rs, src/kernel.rs, src/minds.rs, src/worlds.rs, src/mcp.rs, src/server.rs, src/views.rs, src/render.rs, src/linking.rs, src/lib.rs, Cargo.toml, k8s/chukwa.yaml, Containerfile, tests/phase0.rs, tests/ant_scenario.rs. Deleted: scenarios/ant_on_plate.json, scenarios/locked_vending_room.json, scenarios/ directory, ScenarioCatalog.


Motivation

Cognition is now scenario data; worlds carry frozen scenario_snapshot + scenario_hash. The substrate is shipped. What's still missing is durable, queryable, mutable scenario storage with an authoring surface that fits the workflow we'll actually use. Today's shape — one cognition, one environment, scenarios bolted to ant and vending, file-based catalog loaded at boot — is scaffold. It won't survive worlds with multiple environments, multiple kinds of synthetic beings thinking differently, agents scoped to whichever room they inhabit, or operators who want to build complex scenarios piece by piece rather than submitting whole-shape JSON in one shot.

Six load-bearing commitments:

Database is the source of truth. No scenarios/ directory, no include_dir!, no ScenarioCatalog. Bootstrap is migrations only; database starts empty. Scenarios are populated through MCP write tools — no other ingestion path. The two existing scenario JSON files are deleted; their content survives only in git log. Canonical scenario content lives in src/test_fixtures.rs as Rust functions returning validated Scenario values, used by tests and the smoke; production never reads them.

Components are atomic and individually writable. Each text/JSON piece lives in its own content-addressed table. Operators write components piece by piece — put_perceive_system, put_environment, put_entity, etc. Each is idempotent: same content yields same hash; second write returns existing hash with was_new: false.

Cognition profiles are first-class. A profile is a labeled bundle of four cognition component hashes (perceive_system, intend_system, adjudicate_system, adjudication_schema) plus the adjudication_retry_budget scalar. Profiles are content-addressed — same four hashes plus same budget yields the same profile_hash, deduped globally. A scenario binds profiles by label. An agent explicitly names which profile it uses.

Environments are first-class and plural. A scenario binds one or more named environments, each content-addressed text. Every entity (agent or prop) explicitly names which environment it occupies. The kernel's perception is scoped to the agent's environment text plus the entities sharing it. Environments can be defined and never inhabited (hallway with no current occupants is legitimate). Profiles can be defined and never bound (staged for future use is legitimate).

One assembly path with hash-or-inline at every level. assemble_scenario composes a scenario from labeled bindings. Each profile, environment, and entity reference is {hash} or {inline} independently, mixable freely. Inline content is store-or-reuse: matching existing hash → existing row, was_new: false. There is no separate create_scenario — assembly subsumes it.

Provenance is "has parents or doesn't." Forks have parents. Assemblies don't. No ScenarioSource enum, no first_source column, no source column on derivations. Presence/absence of scenario_derivation_parents rows is the entire provenance story. fork_scenario is the only operation that introduces parents; future genetic-algorithm operations producing derivatives use the same parent-edged machinery.

No defaults, no overrides, no fallbacks. Missing a label is a validation error. Missing a parent for a fork is an error. Missing a profile reference on an agent is an error.

Out of scope: events table, turns table, per-entity dynamic profile changes, evaluations, closure tables, GIN indexes on JSONB content, trigram indexes, partitioning, caching layers, web-based scenario editor UI. Auto-linker continues to recognize scenario names only.


Caller / handler back-and-forth

Handler drives end-to-end via direct MCP-client access (ticket 5506b47b). Caller reviews evidence and accepts.

Block-surfacing rule: any block gets a comment immediately with what was attempted and the verbatim error.


1. Dependencies and infrastructure

Cargo.toml:

sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "json", "chrono", "uuid", "macros", "migrate"] }
async-trait = "0.1"
thiserror = "1"

indexmap already present. include_dir removed.

Migrations: migrations/ at repo root, numbered SQL files, bundled via Cargo.toml's [package].include, executed by sqlx::migrate!() at startup.

Containerfile: copy migrations/ into build context; do NOT copy scenarios/ (deleted).

k8s/chukwa.yaml: Postgres StatefulSet (postgres:16), PVC mounted at /var/lib/postgresql/data, cluster-internal Service on 5432, Secret chukwa-postgres-credentials carrying POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB. chukwa Deployment env adds DATABASE_URL. startupProbe 60-second timeout.


2. Schema

File: migrations/0001_scenario_store.sql. Verbatim:

CREATE DOMAIN sha256_hex AS TEXT
CHECK (VALUE ~ '^[0-9a-f]{64}$');

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

-- Cognition components (4 atomic content-addressed units)
CREATE TABLE perceive_systems (
    hash       sha256_hex   PRIMARY KEY,
    content    TEXT         NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE TABLE intend_systems (
    hash       sha256_hex   PRIMARY KEY,
    content    TEXT         NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE TABLE adjudicate_systems (
    hash       sha256_hex   PRIMARY KEY,
    content    TEXT         NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE TABLE adjudication_schemas (
    hash       sha256_hex   PRIMARY KEY,
    content    JSONB        NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);

-- Cognition profile bundle
CREATE TABLE cognition_profiles (
    hash                       sha256_hex   PRIMARY KEY,
    perceive_system_hash       sha256_hex   NOT NULL REFERENCES perceive_systems(hash),
    intend_system_hash         sha256_hex   NOT NULL REFERENCES intend_systems(hash),
    adjudicate_system_hash     sha256_hex   NOT NULL REFERENCES adjudicate_systems(hash),
    adjudication_schema_hash   sha256_hex   NOT NULL REFERENCES adjudication_schemas(hash),
    adjudication_retry_budget  INT          NOT NULL CHECK (adjudication_retry_budget >= 0 AND adjudication_retry_budget <= 10),
    created_at                 TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX cognition_profiles_perceive_system_idx     ON cognition_profiles(perceive_system_hash);
CREATE INDEX cognition_profiles_intend_system_idx       ON cognition_profiles(intend_system_hash);
CREATE INDEX cognition_profiles_adjudicate_system_idx   ON cognition_profiles(adjudicate_system_hash);
CREATE INDEX cognition_profiles_adjudication_schema_idx ON cognition_profiles(adjudication_schema_hash);
CREATE INDEX cognition_profiles_retry_budget_idx        ON cognition_profiles(adjudication_retry_budget);

-- Environment and entity content
CREATE TABLE environments (
    hash       sha256_hex   PRIMARY KEY,
    content    TEXT         NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE TABLE entities (
    hash       sha256_hex   PRIMARY KEY,
    content    JSONB        NOT NULL,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX entities_id_idx ON entities((content ->> 'id'));

-- Scenario manifest (scalars only — no first_source, no first_note)
CREATE TABLE scenarios (
    hash             sha256_hex   PRIMARY KEY,
    scenario_slug    TEXT         NOT NULL,
    description      TEXT         NOT NULL,
    chronon_seconds  INT          NOT NULL CHECK (chronon_seconds > 0 AND chronon_seconds <= 31536000),
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX scenarios_created_at_idx ON scenarios(created_at);
CREATE INDEX scenarios_slug_idx       ON scenarios(scenario_slug);

-- Scenario-scoped label bindings
CREATE TABLE scenario_cognition_profiles (
    scenario_hash sha256_hex NOT NULL REFERENCES scenarios(hash),
    profile_label label_text NOT NULL,
    profile_hash  sha256_hex NOT NULL REFERENCES cognition_profiles(hash),
    PRIMARY KEY (scenario_hash, profile_label)
);
CREATE INDEX scenario_cognition_profiles_profile_idx ON scenario_cognition_profiles(profile_hash);

CREATE TABLE scenario_environments (
    scenario_hash     sha256_hex NOT NULL REFERENCES scenarios(hash),
    environment_label label_text NOT NULL,
    environment_hash  sha256_hex NOT NULL REFERENCES environments(hash),
    PRIMARY KEY (scenario_hash, environment_label)
);
CREATE INDEX scenario_environments_environment_idx ON scenario_environments(environment_hash);

CREATE TABLE scenario_entities (
    scenario_hash sha256_hex NOT NULL REFERENCES scenarios(hash),
    entity_index  INT        NOT NULL,
    entity_hash   sha256_hex NOT NULL REFERENCES entities(hash),
    PRIMARY KEY (scenario_hash, entity_index)
);
CREATE INDEX scenario_entities_entity_idx ON scenario_entities(entity_hash);

-- Mutable refs and history
CREATE TABLE scenario_names (
    name       label_text   PRIMARY KEY,
    hash       sha256_hex   NOT NULL REFERENCES scenarios(hash),
    updated_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX scenario_names_hash_idx ON scenario_names(hash);

CREATE TABLE scenario_name_history (
    id         BIGSERIAL    PRIMARY KEY,
    name       label_text   NOT NULL,
    old_hash   sha256_hex,
    new_hash   sha256_hex   REFERENCES scenarios(hash),
    note       TEXT,
    changed_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX scenario_name_history_name_idx ON scenario_name_history(name, changed_at DESC);

-- Derivation events (no source column — provenance = parent edges)
CREATE TABLE scenario_derivations (
    id         BIGSERIAL    PRIMARY KEY,
    child_hash sha256_hex   NOT NULL REFERENCES scenarios(hash),
    operator   TEXT,
    note       TEXT,
    metadata   JSONB        NOT NULL DEFAULT '{}'::jsonb,
    created_at TIMESTAMPTZ  NOT NULL DEFAULT now()
);
CREATE INDEX scenario_derivations_child_idx      ON scenario_derivations(child_hash);
CREATE INDEX scenario_derivations_created_at_idx ON scenario_derivations(created_at);

CREATE TABLE scenario_derivation_parents (
    derivation_id BIGINT     NOT NULL REFERENCES scenario_derivations(id) ON DELETE CASCADE,
    parent_hash   sha256_hex NOT NULL REFERENCES scenarios(hash),
    parent_index  INT        NOT NULL,
    role          TEXT,
    PRIMARY KEY (derivation_id, parent_index)
);
CREATE INDEX scenario_derivation_parents_parent_idx ON scenario_derivation_parents(parent_hash);

15 tables, 2 domains. Provenance is fully encoded by presence/absence of scenario_derivation_parents rows for a derivation.

scenario_name_history.new_hash nullable: NULL = tombstone (emitted by unset_name).

scenario_derivations.metadata for forks records touched_components as a structured list with the grammar:

Assemblies' metadata defaults to {} unless operator passes additional metadata.


3. Hashing

New module src/canonical_json.rs. Promotes canonicalize_json to pub. Adds:

// Text components — sha256 of UTF-8 bytes.
pub fn canonical_perceive_system_hash(s: &str) -> String;
pub fn canonical_intend_system_hash(s: &str) -> String;
pub fn canonical_adjudicate_system_hash(s: &str) -> String;
pub fn canonical_environment_hash(s: &str) -> String;

// JSON components — canonicalize then sha256.
pub fn canonical_adjudication_schema_hash(v: &Value) -> String;
pub fn canonical_entity_hash(e: &Entity) -> String;

pub struct CognitionProfileHashInput<'a> {
    pub perceive_system_hash: &'a str,
    pub intend_system_hash: &'a str,
    pub adjudicate_system_hash: &'a str,
    pub adjudication_schema_hash: &'a str,
    pub adjudication_retry_budget: u32,
}
pub fn canonical_cognition_profile_hash(input: &CognitionProfileHashInput) -> String;

pub struct ScenarioManifestHashInput<'a> {
    pub scenario_slug: &'a str,
    pub description: &'a str,
    pub chronon_seconds: i64,
    pub cognition_profiles: &'a [(&'a str, &'a str)],  // (label, profile_hash) sorted by label
    pub environments: &'a [(&'a str, &'a str)],        // (label, environment_hash) sorted by label
    pub entity_hashes: &'a [&'a str],
}
pub fn canonical_scenario_manifest_hash(input: &ScenarioManifestHashInput) -> String;

Manifest hash sorts label maps by key. Pre-existing canonical_scenario_hash in worlds.rs is removed.

Tests cover: byte-identical determinism, key-order independence on JSON components, single-byte sensitivity, profile-hash sensitivity to each of five inputs independently, manifest-hash sensitivity to label-binding swaps.


4. Validation

File: src/scenarios.rs. Defines error enums CognitionFieldError (5 variants: empty perceive/intend/adjudicate, schema-not-object, retry-budget-too-high), CognitionProfileError (5 variants wrapping field errors), EnvironmentError (2: empty, too-large), EntityError (4: invalid id, empty name, invalid environment label, invalid cognition_profile label), and ScenarioValidationError (the parent enum: 17 variants covering slug, chronon, description, profile/environment/entity sub-validation, label-resolution failures both directions, no-entities, no-agent, duplicates, size-limit, malformed-JSON).

Per-field validators: validate_perceive_system, validate_intend_system, validate_adjudicate_system, validate_adjudication_schema, validate_adjudication_retry_budget. Per-component: validate_cognition_profile, validate_environment, validate_entity. Top-level: validate_scenario.

validate_scenario runs:

  1. scenario_slug grammar (enforced at Slug construction; guard against direct field assignment).
  2. chronon_seconds ∈ (0, 31_536_000].
  3. description non-empty.
  4. cognition_profiles map non-empty; every label matches grammar; per-profile validate_cognition_profile.
  5. environments map non-empty; every label matches grammar; per-environment validate_environment.
  6. entities non-empty; ≥1 agent (matching kernel multi-agent capability).
  7. Per entity at position i: validate_entity; entity.environment must resolve in scenario.environments; if Agent kind, the cognition_profile label must resolve in scenario.cognition_profiles.
  8. Normalize entity IDs, check duplicates.
  9. Total canonical-serialization size ≤ 256 KB.

There is no rule that defined profiles or environments must be referenced. Uninhabited rooms and unbound profiles are legitimate.

Scenario::from_value(v: serde_json::Value) -> Result<Self, ScenarioValidationError> is the validated constructor used by JSON-input paths (MCP tools accepting inline content). Scenario::to_canonical_value(&self) -> serde_json::Value produces canonical JSON for scenario_snapshot and size validation.

The pre-existing template-token validators ({world}, {agent}, {intent}, {complaint}) and their five tests are deleted along with the legacy Cognition struct.


5. Scenario JSON shape (input)

Canonical input shape submitted to MCP:

{
  "scenario_slug": "...",
  "description": "...",
  "chronon_seconds": 60,
  "cognition_profiles": {
    "<profile_label>": {
      "perceive_system": "...",
      "intend_system": "...",
      "adjudicate_system": "...",
      "adjudication_schema": { ... },
      "adjudication_retry_budget": 3
    }
  },
  "environments": {
    "<environment_label>": "..."
  },
  "entities": [
    {
      "id": "...", "name": "...", "state": "...",
      "environment": "<environment_label>",
      "kind": { "agent": { "goal": "...", "memory": "", "cognition_profile": "<profile_label>" } }
    },
    {
      "id": "...", "name": "...", "state": "...",
      "environment": "<environment_label>",
      "kind": "prop"
    }
  ]
}

Legacy fields cognition (singular block), adjudicate_user_template, adjudicate_corrective_template, top-level singular environment: "..." are not accepted; inputs containing them fail validation.


6. Rust types

// src/scenarios.rs
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Scenario {
    #[serde(serialize_with = "serialize_slug_as_string")]
    pub scenario_slug: Slug,
    pub description: String,
    pub chronon_seconds: i64,
    pub cognition_profiles: IndexMap<Label, CognitionProfile>,
    pub environments: IndexMap<Label, String>,
    pub entities: Vec<Entity>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CognitionProfile {
    pub perceive_system: String,
    pub intend_system: String,
    pub adjudicate_system: String,
    pub adjudication_schema: Value,
    pub adjudication_retry_budget: u32,
}

// src/kernel.rs
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntityKind {
    Agent { goal: String, memory: String, cognition_profile: Label },
    Prop,
}
impl Default for EntityKind { fn default() -> Self { EntityKind::Prop } }

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Entity {
    pub id: String,
    pub name: String,
    #[serde(default)] pub state: String,
    #[serde(default)] pub kind: EntityKind,
    pub environment: Label,
}

Label is a newtype around String with the label grammar enforced at construction (mirrors Slug). Round-trips JSON as a plain string. New module src/label.rs.

Legacy singular Cognition struct, ScenarioCatalog, ScenarioFile, include_dir! import — all gone. Entity::agent(...) and Entity::prop(...) constructors take an environment label and (for agent) a cognition_profile label.


7. Test fixtures (Rust)

New module src/test_fixtures.rs, gated #[cfg(any(test, feature = "test-fixtures"))]:

pub fn ant_on_plate_scenario() -> Scenario;
pub fn locked_vending_room_scenario() -> Scenario;

Each constructs a fully-validated Scenario with the same content the deleted JSON files used to carry — same prompts, same environment text, same entities — adapted to the new shape: no adjudicate_user_template, no adjudicate_corrective_template, with adjudicate_system absorbing rule content from the former template, with the schema using environment_mutations instead of environment_after.

Both fixtures use byte-identical cognition content. Tests prove the resulting cognition_profile hashes match. The vending agent's profile is labeled "subject", ant's is "ant". Vending's environment is "vending_room", ant's is "kitchen_plate". Both single-environment, single-profile (matching what the legacy JSONs encoded).

Production never reads these. They're consumed by tests and by the smoke (handler reads them to drive MCP calls with known content).


8. Kernel changes

src/kernel.rs. New World:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct World {
    pub slug: String,
    pub simulation_time: DateTime<Utc>,
    pub chronon_seconds: i64,
    pub turn: u64,
    pub environments: IndexMap<Label, String>,                // mutable per turn
    pub entities: HashMap<String, Entity>,
    pub cognition_profiles: IndexMap<Label, CognitionProfile>, // immutable
}

World::with_environment is replaced by:

impl World {
    pub fn new(
        slug: impl Into<String>,
        simulation_time: DateTime<Utc>,
        chronon_seconds: i64,
        environments: IndexMap<Label, String>,
        cognition_profiles: IndexMap<Label, CognitionProfile>,
    ) -> Self;
}

Scenario::seed constructs World by cloning the maps directly. All callers update — Scenario::seed, mini_world and world_with_ant_and_crumb test fixtures in kernel.rs and minds.rs, worlds.rs tests.

Runtime::run_turn already iterates agent_ids_sorted. Validation accepts ≥1 agent matching kernel capability. Each agent's perceive/intend/adjudicate uses that agent's profile (resolved via world.cognition_profiles[agent.cognition_profile_label]) and that agent's environment text (resolved via world.environments[agent.environment]).

Private helpers in minds.rs / kernel.rs:

fn lookup_agent_profile<'a>(world: &'a World, agent: &Entity) -> Result<&'a CognitionProfile, KernelError>;
fn lookup_environment<'a>(world: &'a World, label: &Label) -> Result<&'a str, KernelError>;

9. Minds changes — per-environment perception, kernel-hardcoded user templates

src/minds.rs.

perceive is scoped to the agent's environment text and to entities sharing that environment (excluding the focus agent itself):

pub fn perceive(world: &World, agent: &Entity) -> Result<String, LlmError> {
    let profile = lookup_agent_profile(world, agent)?;
    let env_label = &agent.environment;
    let env_text = lookup_environment(world, env_label)?;
    let user = format!(
        "Simulation time: {time}\nWorld turn: {turn}\nEnvironment ({env_label}):\n{environment}\n\nFocus agent:\n{agent}\n\nOther entities in {env_label}:\n{entities}\n\nWrite the agent's immediate perception.",
        time = world.simulation_time.to_rfc3339(),
        turn = world.turn,
        env_label = env_label.as_str(),
        environment = if env_text.trim().is_empty() { "(empty)" } else { env_text },
        agent = render_entity(agent),
        entities = render_entities_in_environment(world, env_label),
    );
    llm::complete_text(&profile.perceive_system, &user).map(normalize_text)
}

render_entities_in_environment filters world.entities to those whose environment matches the given label, excluding the focus agent.

intend uses agent's profile's intend_system. No environment reference (only agent + perception).

adjudicate uses agent's profile's adjudicate_system + adjudication_schema + adjudication_retry_budget. The user message is kernel-hardcoded — no template lookup. Adjudicator (the director) sees the whole world (omniscient — all environments, all entities):

let initial_user = format!(
    "World snapshot:\n{world}\n\nActing agent:\n{agent}\n\nIntent:\n{intent}\n\nDecide the outcome for exactly one turn.",
    world = render_world(world),
    agent = render_entity(agent),
    intent = intent,
);

let corrective = |complaint: &str| -> String {
    format!(
        "Your previous response was rejected. {complaint}\n\nReturn a corrected JSON object matching the same schema. Keep everything else about the adjudication; only fix what was wrong.",
        complaint = complaint,
    )
};

render_world renders the environments map (one section per label) and all entities (grouped by environment label).

The rule content from the former adjudicate_user_template migrates into adjudicate_system. New adjudicate_system for both fixtures (verbatim, identical):

You are the simulation director. Decide what actually happens this turn. Return JSON only. Never create or delete entities. Only mutate the acting agent, existing entities listed in the snapshot, or existing environments listed in the snapshot. If the intent is physically impossible, narrate the failed attempt and leave unreachable targets unchanged.

Rules for filling out the response:


10. Adjudication schema and pipeline reshape

environment_after (singular string|null) replaced by environment_mutations (parallel to entity_mutations). New schema:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "narration": { "type": "string" },
    "agent_state_after": { "type": "string" },
    "agent_memory_append": { "type": "string" },
    "entity_mutations": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "entity_id": { "type": "string" },
          "state": { "type": "string" }
        },
        "required": ["entity_id", "state"]
      }
    },
    "environment_mutations": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "environment_label": { "type": "string" },
          "content": { "type": "string" }
        },
        "required": ["environment_label", "content"]
      }
    }
  },
  "required": ["narration", "agent_state_after", "agent_memory_append", "entity_mutations", "environment_mutations"]
}

Rust types in minds.rs:

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adjudication {
    pub narration: String,
    pub agent_state_after: String,
    pub agent_memory_append: String,
    pub entity_mutations: Vec<EntityStateMutation>,
    pub environment_mutations: Vec<EnvironmentMutation>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentMutation {
    pub environment_label: String,
    pub content: String,
}

environment_after: Option<String> is gone.

validate_adjudication: entity_mutations validation unchanged; environment_mutations new — each environment_label must exist in world.environments. Missing → rejection with available-labels list (mirrors entity-roster pattern).

apply_adjudication: legacy world.environment = environment_after.clone() line gone; each environment_mutation replaces world.environments[label] with content. New EnvironmentTransition audit ride-along (parallel to EntityTransition) records before/after. AdjudicationApplied gains environment_transitions: Vec<EnvironmentTransition>. Audit event for adjudication carries it alongside entity_transitions.


11. Hash-or-inline input types

src/scenario_store/mod.rs:

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentRef<T> {
    Hash { hash: String },
    Inline { content: T },
}

pub type PerceiveSystemRef     = ContentRef<String>;
pub type IntendSystemRef       = ContentRef<String>;
pub type AdjudicateSystemRef   = ContentRef<String>;
pub type AdjudicationSchemaRef = ContentRef<Value>;
pub type EnvironmentRef        = ContentRef<String>;
pub type EntityRef             = ContentRef<Entity>;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CognitionProfileRef {
    Hash { hash: String },
    Inline { content: CognitionProfileInput },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CognitionProfileInput {
    pub perceive_system: PerceiveSystemRef,
    pub intend_system: IntendSystemRef,
    pub adjudicate_system: AdjudicateSystemRef,
    pub adjudication_schema: AdjudicationSchemaRef,
    pub adjudication_retry_budget: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssembleScenarioInput {
    pub scenario_slug: String,
    pub description: String,
    pub chronon_seconds: i64,
    pub cognition_profiles: IndexMap<Label, CognitionProfileRef>,
    pub environments: IndexMap<Label, EnvironmentRef>,
    pub entities: Vec<EntityRef>,
    pub operator: Option<String>,
    pub note: Option<String>,
    pub metadata: Value,
    pub name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForkScenarioInput {
    pub primary_parent: ScenarioRef,
    pub changes: ScenarioChanges,
    pub additional_parents: Vec<DerivationParent>,
    pub operator: Option<String>,
    pub note: Option<String>,
    pub metadata_extra: Value,
    pub name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScenarioChanges {
    pub scenario_slug: Option<String>,
    pub description: Option<String>,
    pub chronon_seconds: Option<i64>,
    pub cognition_profile_upserts: IndexMap<Label, CognitionProfileRef>,
    pub cognition_profile_removals: Vec<Label>,
    pub environment_upserts: IndexMap<Label, EnvironmentRef>,
    pub environment_removals: Vec<Label>,
    pub entities: Option<Vec<EntityRef>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScenarioRef { Name(String), Hash(String) }

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DerivationParent { pub parent_hash: String, pub role: Option<String> }

Resolution semantics:

Mixing hash and inline references at any level is fully supported. Same-label-in-both-upsert-and-removal in fork → StoreError::ConflictingLabelOps.


12. The ScenarioStore trait

src/scenario_store/mod.rs. Result types: PutResult { hash, was_new }; PutCognitionProfileResult { hash, was_new, four sub-component hashes, NewComponents }; AssembleResult { scenario_hash, was_new_scenario, profile/environment/entity hash maps, NewComponents, derivation_id }; NewComponents { perceive_systems, intend_systems, adjudicate_systems, adjudication_schemas, cognition_profiles, environments, entities } (counts of new rows by table).

Read types: StoredScenario { hash, scenario, profile/env/entity hash maps, created_at, names, child_count, world_count, has_parents }; ScenarioSummary { hash, scenario_slug, created_at, names, child_count, world_count, has_parents }.

Filter/page: ListFilter { created_after, created_before, has_name, has_parents, uses_<each-of-7>_hash }; Page { limit, offset }.

Errors: StoreError enum — Invalid(ScenarioValidationError), InvalidComponent(String), NotFound(String), CasFailed { expected, current }, InvalidName(String), ConflictingLabelOps { kind, label }, Database(String).

Trait surface (async-trait, Send + Sync):

Component puts (idempotent, content-addressed):

Component reads:

Scenario assembly and fork:

Scenario reads:

Lineage:

Names (CAS):

Both assemble_scenario and fork_scenario run in a single Postgres transaction:

  1. Resolve every Ref (recursively for profile bundles): SELECT-by-hash for Hash, validate-and-insert for Inline, ON CONFLICT DO NOTHING.
  2. Compute manifest hash from resolved hashes.
  3. INSERT ON CONFLICT DO NOTHING for scenarios.
  4. INSERT label binding rows in scenario_cognition_profiles, scenario_environments, scenario_entities (idempotent on PK).
  5. INSERT one row in scenario_derivations. Assembly writes zero parent rows; fork writes one row per parent edge.
  6. If name provided, perform name-set in same transaction.

Transaction is the atomicity boundary. sqlx tokio runtime; no block_on, no spawn_blocking.


13. Bootstrap

Server startup, in this order. Failure aborts.

  1. Connect to Postgres at DATABASE_URL. Retry 30× with 1-second backoff.
  2. Run sqlx::migrate!(). Idempotent.
  3. Mark readiness probe ready.

That's it. No seeding, no embedded catalog, no backfill. Database starts empty. Operators populate via MCP. Worlds purge happens before deploy, not during bootstrap.


14. WorldMeta + DeletedWorldRecord

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorldMeta {
    pub slug: String,
    pub name: String,
    pub created_at: DateTime<Utc>,
    pub scenario_hash: String,        // durable identity → scenarios.hash
    pub scenario_snapshot: Value,     // self-describing reconstruction
    pub scenario_ref: Option<ScenarioRef>,  // what caller passed
    pub scenario_label: String,       // display = scenario_slug
}

#[derive(Clone, Debug, Serialize)]
pub struct DeletedWorldRecord {
    pub slug: String,
    pub name: String,
    pub scenario_label: String,
    pub scenario_hash: String,
    pub deleted_at: DateTime<Utc>,
}

Legacy scenario: String field on both is gone. Old meta.json fails to load; worlds purge before deploy. create_world sets scenario_label from scenario.scenario_slug. Default name: format!("{} #{}", scenario_label, slug).


15. MCP surface

Component puts: put_perceive_system, put_intend_system, put_adjudicate_system, put_adjudication_schema, put_environment, put_entity — each takes {content}, returns {hash, was_new}. put_cognition_profile takes {perceive_system, intend_system, adjudicate_system, adjudication_schema, adjudication_retry_budget} (each sub-component hash-or-inline), returns {hash, was_new, perceive_system_hash, intend_system_hash, adjudicate_system_hash, adjudication_schema_hash, new_components}.

Scenario writes:

Scenario reads:

Component reads: get_perceive_system, get_intend_system, get_adjudicate_system, get_adjudication_schema, get_cognition_profile, get_environment, get_entity — each takes {hash}.

Names: set_scenario_name({name, hash, expected_current_hash?, note?}), unset_scenario_name({name, expected_current_hash?, note?}).

World creation: create_world({slug, name?, scenario_ref})scenario_ref is {name} | {hash} | {data: <full Scenario JSON>}. The data form runs through Scenario::from_value then assemble_scenario internally. Legacy scenario: <slug> argument is gone.


16. Web routes (minimal — no broken UI, no expanded surface)

Legacy /scenarios/:scenario_slug route is removed (relied on ScenarioCatalog::get, gone).

linking.rs auto-linker continues to recognize scenario names. Hashes and labels are not auto-linked. Name unbound after rename → no longer wraps; visible diffs in older content acceptable.

13 routes total. UI is read-only; no editor.


17. AppState integration

AppState gains: pub scenario_store: Arc<dyn ScenarioStore>.

ScenarioCatalog deleted entirely. Production paths use AppState::scenario_store. Test paths use src/test_fixtures.rs.


18. Documentation

docs/terms.md updates:

docs/scenarios.md (create or update): new shape, label semantics, no-defaults rule, agent-iff-profile rule, multi-environment perception scoping, piece-by-piece authoring example, fork-by-changing-one-field example.


19. Cleanup

Files / directories deleted: scenarios/ant_on_plate.json, scenarios/locked_vending_room.json, scenarios/ directory entirely.

Code deleted: ScenarioCatalog, static SCENARIO_FILES, ScenarioFile, ScenarioCatalog::* methods. WorldMeta::scenario field. DeletedWorldRecord::scenario field. Legacy scenario: <slug> on create_world. pub(crate) canonical_scenario_hash in worlds.rs. Legacy Cognition struct. Singular environment: String on World and Scenario. cognition: Cognition on World and Scenario. adjudicate_user_template, adjudicate_corrective_template. The validate_cognition template-token presence checks. World::with_environment (replaced by World::new). Adjudication::environment_after (replaced by environment_mutations). include_dir from Cargo.toml. Any ScenarioSource enum or Embedded/Forked/Generated variant. Any first_source/first_note columns or source column on derivations (never created). Any compatibility shim attempting to support legacy shapes.

Grep guards (each must return zero matches):


Tests

Approximately 160 new tests organized across:

Total expected: ~500 tests passing (340 today + ~160 new). 15-20 test files affected by fixture changes.


Acceptance

Code-level

Handler captures test output, rg results, deleted-dir check, fixtures-vs-original-JSON diff in proposed_resolution.

Pre-deploy worlds purge

Handler-driven. list_worlds, delete_world × N, verify count = 0. Confirmation includes deleted slugs.

Database-pod deploy

Handler-driven. k8s manifests applied; pod healthy and reachable from chukwa; confirmation posted.

Code deploy

After purge and Postgres deploy:

Live smoke (12 steps, end-to-end)

The smoke uses content from locked_vending_room_scenario() to exercise the MCP surface. Headlines two things at once: piece-by-piece authoring builds a scenario from empty database, and a one-byte change to one component cleanly propagates to one new component row + one new profile row + one new manifest row with everything else reused.

For each step the smoke report captures verbatim request and response.

Step 1 — Empty-database verification. list_scenarios returns []. All component table row counts via psql = 0. All scenario table row counts = 0.

Step 2 — Piece-by-piece authoring. Handler calls in order, with content from locked_vending_room_scenario():

Step 3 — Verify intermediate state. psql counts: each cognition component table = 1 row; cognition_profiles = 1; environments = 1; entities = 6; scenarios = 0 (no scenario assembled yet). Components stand on their own.

Step 4 — assemble_scenario with all hashes (proves the all-hashes path):

{
  "scenario_slug": "locked_vending_room",
  "description": "<from fixture>",
  "chronon_seconds": 43200,
  "cognition_profiles": { "subject": {"hash": "<H_subject_profile>"} },
  "environments":       { "vending_room": {"hash": "<H_vending_room>"} },
  "entities": [
    {"hash": "<H_e0>"}, {"hash": "<H_e1>"}, {"hash": "<H_e2>"},
    {"hash": "<H_e3>"}, {"hash": "<H_e4>"}, {"hash": "<H_e5>"}
  ],
  "operator": "smoke",
  "note": "assembled from already-stored components",
  "metadata": {},
  "name": "locked_vending_room"
}

Save parent_hash = scenario_hash. Verify was_new_scenario=true, new_components all-zero, response's profile/environment/entity hash maps match step 2.

psql verification: scenarios = 1 row; scenario_cognition_profiles, scenario_environments = 1 row each; scenario_entities = 6; scenario_derivations = 1 (with no rows in parent join table for that derivation_id — proves no-parent assembly); scenario_names = 1 mapping locked_vending_room → parent_hash.

Step 5 — get_scenario({name: "locked_vending_room"}). Verify has_parents=false, full reconstruction matches fixture, label/entity hash maps match.

Step 6 — get_adjudicate_system({hash: H_adjudicate}). Receive prompt text. Save it.

Step 7 — Construct new subject profile (leak fix). Same other three component values, same retry_budget; adjudicate_system = parent's text + appended sentence:

"The narration is what the acting agent will perceive and remember; never include information marked DIRECTOR-ONLY (stock counts, which slot is defective, hidden mechanisms) in the narration, even when the action succeeds."

Step 8 — fork_scenario with leak-fix profile inline, parent by hash:

{
  "primary_parent": {"hash": "<parent_hash>"},
  "changes": {
    "cognition_profile_upserts": {
      "subject": {
        "inline": {
          "perceive_system":     {"hash": "<H_perceive>"},
          "intend_system":       {"hash": "<H_intend>"},
          "adjudicate_system":   {"inline": "<new full adjudicate_system text>"},
          "adjudication_schema": {"hash": "<H_schema>"},
          "adjudication_retry_budget": 2
        }
      }
    }
  },
  "additional_parents": [],
  "operator": "smoke",
  "note": "leak fix: tell the adjudicator the narration is read by the agent",
  "metadata_extra": {},
  "name": "vending-leak-fix"
}

Save child_scenario_hash and response hash maps. was_new_scenario=true. Then verify directly via psql:

Hash comparisons:

Component row deltas from post-step-4 state:

Manifest deltas:

Provenance:

Names: scenario_names has both vending-leak-fix → child_scenario_hash and locked_vending_room → parent_hash unchanged.

Headline: a one-byte change to adjudicate_system in the subject profile produces 1 new component row + 1 new profile row + 1 new manifest row, with three cognition components fully reused, the environment fully reused, all six entities fully reused.

Step 9 — lineage_of({hash: child_scenario_hash}) and get_scenario({hash: child_scenario_hash}). Lineage returns parent first, child second. get_scenario.has_parents=true. Compare with get_scenario({hash: parent_hash}).has_parents=false. Confirms provenance signal works without a source enum.

Step 10 — create_world({slug: "leak-test", scenario_ref: {name: "vending-leak-fix"}}). Verify response carries scenario_hash = child_scenario_hash, scenario_ref = {kind: "name", value: "vending-leak-fix"}, scenario_label = "locked_vending_room". Read meta.json. Verify scenario_hash, scenario_snapshot, scenario_ref, scenario_label all present and consistent. Legacy scenario field absent.

Step 11 — run_turn({world_slug: "leak-test"}). Poll until committed. Read audit events. Verify:

Step 12 — CAS suite + delete. Three set_scenario_name calls: rollback vending-leak-fix → parent_hash with expected_current_hash=child_scenario_hash; rollforward to child_scenario_hash with expected_current_hash=parent_hash; attempted update with stale expected_current_hash returns CasFailed. Then delete_world({world_slug: "leak-test"}) — verify world directory removed, database scenarios remain.

Smoke report is the proposed_resolution.

Caller acceptance

Caller reviews report against spec. May sample external evidence — git log (including the scenarios/ deletion commit), migration SQL, struct diffs, src/test_fixtures.rs, per-component validator implementations, kernel changes in minds.rs, the ScenarioStore trait — before accepting. Caller does not call MCP tools to verify; that's the handler's job.


Explicitly out of scope


No open knobs

The schema is named to the SQL line. The trait is named to the method signature. Each per-cognition-field validator is named to the rule. The MCP surface is named to the tool. Bootstrap is three steps. Kernel changes are named to the function. The smoke is named to the keystroke. Acceptance grep guards are named to the regex.

If a question arises during implementation, the handler comments on the ticket. The handler does not guess.

Proposed resolution

Database-backed scenario store is live, and the WorldMeta::scenario_hash → scenarios.hash join works. Resubmitting after the hash-join fix you flagged.

What changed since the previous proposed_resolution

Commit eca808efix(scenario-store): align world meta scenario_hash with scenarios.hash

Merged via 2f589d1 to main, deployed.

canonical_json.rs gained a new public helper:

pub fn canonical_scenario_manifest_hash_from_scenario(scenario: &Scenario) -> String

It derives every component hash inline from a &Scenario (perceive_system, intend_system, adjudicate_system, adjudication_schema, profile bundle, environment, entity) and feeds them into the existing canonical_scenario_manifest_hash. The result is byte-identical to what ScenarioStore::assemble_scenario computes when storing the same Scenario.

worlds::create_world now calls the helper. The legacy sha256(canonical-snapshot bytes) computation and its TODO comment are gone.

Invariant locked down with a unit test

#[test]
fn world_meta_scenario_hash_equals_manifest_hash_invariant() {
    // ... runs both fixtures (ant_on_plate, locked_vending_room),
    // compares WorldMeta::scenario_hash to
    // canonical_scenario_manifest_hash_from_scenario(&scenario),
    // asserts equality.
}

If create_world ever drifts off the manifest-hash path again, this catches it before deploy.

cargo test --lib --features test-fixtures: 420 passed; 0 failed; 0 ignored (was 418; +2 for this invariant test, +1 expected, but counts as 2 due to dual-fixture iteration). Container build clean.

Re-run of smoke steps 10 + 11 against the deployed pod

The first nine steps of the original smoke (Phase I report below) didn't touch the world-meta hash at all — they exercised the scenario store, which was always computing the manifest hash correctly. Steps 11 and 12 also didn't depend on WorldMeta.scenario_hash. The only step that surfaced the divergence was step 10 (read meta.json, observe scenario_hash doesn't match scenarios.hash). Re-running the affected slice:

Step 10 (re-run) — create_world from forked scenario; verify join

Pre-state (post-deploy, parallel callers active so the cluster has more than just my fork):

$ bash /root/.config/chukwa-mcp/mcp.sh list_scenarios '{"filter":{},"page":{"limit":10,"offset":0}}'
scenarios: 3
 - cat_in_library     4e7221b7...   names: ['cat-in-library']        (parallel caller)
 - locked_vending_room a0fb7a2a...   names: ['vending-leak-fix']
 - locked_vending_room ed963408...   names: ['locked_vending_room']

vending-leak-fix is the same child_scenario_hash from the original Phase I smoke, surviving across the deploy roll because it lives in the durable Postgres PVC. (Note: my fork's child manifest hash a0fb7a2a... is byte-identical to what the new helper computes from scenario.cognition_profiles["subject"].adjudicate_system + ... — the manifest hash is a pure function of content, so the existing rows are still valid against the post-fix join.)

Request:

POST /mcp
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_world","arguments":{
    "slug":"hashfix-smoke",
    "name":"hash-fix invariant smoke",
    "scenario_ref":{"name":"vending-leak-fix"}
}}}

Response (decoded inner text):

{
    "world_slug": "hashfix-smoke",
    "scenario_hash": "a0fb7a2ae78447fce02e57639326d3e5a8f7c548103d6e09530487d78e7cf62a",
    "scenario_label": "locked_vending_room"
    /* ... */
}

scenario_hash = a0fb7a2a... — same value as scenarios.hash for vending-leak-fix. The contract holds at the create_world response level.

meta.json on the pod:

{
    "scenario_hash": "a0fb7a2ae78447fce02e57639326d3e5a8f7c548103d6e09530487d78e7cf62a",
    "scenario_label": "locked_vending_room",
    "scenario_ref": {"name": "vending-leak-fix"},
    "scenario_snapshot": { ... full scenario value ... }
}

The on-disk record agrees with the response.

The join check that previously failed:

$ kubectl -n chukwa exec chukwa-postgres-0 -- env PGPASSWORD=chukwa-local-dev \
    psql -U chukwa -d chukwa -t -c \
    "SELECT count(*), scenario_slug FROM scenarios WHERE hash = 'a0fb7a2a...e7cf62a' GROUP BY scenario_slug;"

      1 | locked_vending_room

The invariant holds. WorldMeta::scenario_hash is now a valid foreign key into scenarios.hash — exactly the spec section 14 contract.

Step 11 (re-run) — run_turn; verify perception leak fix still holds

$ bash /root/.config/chukwa-mcp/mcp.sh run_turn '{"world_slug":"hashfix-smoke"}'
attempt_id: eca75ba5-8fce-42df-a7b3-b59482b1f99f
status: queued

Polling status (with the new world_slug requirement):

$ bash /root/.config/chukwa-mcp/mcp.sh get_turn_status '{"world_slug":"hashfix-smoke","attempt_id":"eca75ba5-..."}'
status: committed
produced_turn: 1
duration_ms: 33796
failure_reason: None

Audit-event leak check across 13 forbidden patterns (DIRECTOR-ONLY, manufacturing, defect, jammed, exhausted, "always be zero", pretzels, chocolate, "trail mix", "bottles of water", "bags of", several, etc.):

turn 1 perception_emitted: ✓  (no leaks)
  "The subject stands in a small, windowless concrete room under a constant
   overhead light. A cot and a dry water fountain are visible, but the only
   reachable object is the vending machine against the far wall. The machine
   has a scuffed glass ..."

turn 1 intent_formed: ✓
  "I will look through the glass of the vending machine to see what food and
   drink options are available."

turn 1 intent_adjudicated: ✓  (narration also clean)
  "The subject leans in to peer through the scuffed glass of the vending
   machine. The surface is heavily marred by scratches and smudges, obscuring
   the view of the interior. While the round, plastic buttons labeled A, B,
   C, D, and E are clearly ..."

Perception correctly mentions the vending machine and buttons (per-environment scope works). Adjudication narrates a physically-plausible turn without leaking director-only state. The leak fix held end-to-end after the hash-fix deploy.

Step 12 (cleanup)

$ bash /root/.config/chukwa-mcp/mcp.sh delete_world '{"world_slug":"hashfix-smoke"}'
deleted: 2026-04-26T10:54:34.812318270+00:00

hashfix-smoke directory removed; vending-leak-fix and locked_vending_room scenarios persist in the store (along with cat_in_library from the parallel caller). Database scenarios remain across the world delete, as the spec requires.

Follow-up tickets filed (per your directive)

Per your acceptance language: "File those as separate tickets when you re-propose this one."

Both filed under the handler-side OAuth client (so they appear as caller-actor tickets on creation).

Branch + deploy state (final)

2f589d1 Merge branch 'fix/scenario-hash-join'                                ← main HEAD, deployed
eca808e fix(scenario-store): align world meta scenario_hash with scenarios.hash
384b0f2 Merge branch 'feat/scenario-store-db'
89c8096 feat(scenario-store): phase G — bootstrap (Postgres connect + migrate)
1a17437 fix(scenario-store): post-Phase-E feedback — rename get_entity, gate memory store
ea6f351 feat(scenario-store): phase F — web routes + linking restored
1ed2c52 feat(scenario-store): phase E — MCP surface + AppState integration
8449759 feat(scenario-store): phase D — ScenarioStore trait + Postgres impl
d0c3dbd feat(scenario-store): phase C — type rewrites and test fixtures
93b6a81 feat(scenario-store): phase B — label newtype + canonical hashing
b7c1094 chore(rust-toolchain): bump rust-version + builder image to 1.88
e1b5257 feat(scenario-store): phase A — db schema + postgres pod + deps

Live: pod chukwa-6bf76d5c4b-fknpd (rolled with the fix), Postgres chukwa-postgres-0. /healthz 200.

What changed since the rejection — summary

Source of divergenceworlds.rs:189-192 SHA over canonical-snapshot bytesDELETED
What create_world writessha256(snapshot bytes)manifest hash via canonical_scenario_manifest_hash_from_scenario
Helper(none — it was inline)canonical_json::canonical_scenario_manifest_hash_from_scenario(&Scenario)pub, public-API
Invariant test(none — TODO carried the contract)world_meta_scenario_hash_equals_manifest_hash_invariant, runs over both fixtures
Comment in worlds.rs"// Phase C uses ... ; Phase D will refine ..."replaced with the actual contract: "the foreign key into scenarios.hash per spec section 14"

The TODO is gone. The fix is the contract.

Per standing guidance I am not confirming — only proposing.

History (20 events)

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